From c05b13aefe2b283d5aa8ee4c7764d315dc1f1164 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 24 Aug 2026 00:06:41 +0800 Subject: [PATCH 01/97] refactor: simplify app to minimal frontend and backend modules Backend (8.9k -> 4.1k lines): - Remove WAL transaction system (journal, staged/original shadow files, crash recovery, compensation), fault-injection framework, and fs2 - Remove all file locks, optimistic concurrency checks, and backups; writes are plain sequential file operations by design - Drop hand-written JSON Schema validator; serde + JSONC parsing carry type validation instead - Collapse DDD layers (ApplicationService/UseCase/Repository/Store) into flat modules called directly by the Tauri command layer - Replace 9 error enums with a single AppError serialized as the frontend CommandError shape; keep all 25 command signatures and DTOs unchanged Frontend: - Rebuild settings UI as route-based pages (providers/models/agents/groups) backed by a typed CommandAdapter over Tauri invoke - Replace legacy stores and components with features/config modules Tests: - Replace transaction/crash-matrix suites with focused integration tests covering group switching, CRUD, reference protection, and model replacement (cargo test 8/8, npm run build green) --- .github/workflows/ci.yml | 45 +- .github/workflows/release.yml | 40 +- .gitignore | 1 + .prettierignore | 7 + .prettierrc | 14 + AGENTS.md | 13 +- DESIGN.md | 352 ----- README.md | 72 +- .../20231114T221320Z-oh-my-openagent.json | 5 - .../20231114T221321Z-oh-my-openagent.json | 5 - .../20231114T221322Z-oh-my-openagent.json | 5 - .../20231114T221323Z-oh-my-openagent.json | 5 - .../20231114T221324Z-oh-my-openagent.json | 5 - .../20231114T221325Z-oh-my-openagent.json | 5 - fixtures/compatibility/config/current.json | 41 - fixtures/compatibility/config/malformed.json | 4 - .../json/with-conflict-write-baseline.json | 21 - .../json/with-jsonc-markers.jsonc | 20 - .../json/with-undeclared-model-ref.json | 22 - .../json/with-unknown-fields.json | 18 - .../json/with-weird-key-order.json | 22 - .../ohmy/current-oh-my-openagent.json | 20 - .../ohmy/legacy-oh-my-opencode.json.bak | 15 - .../opencode/current-opencode.json | 31 - .../targets/malformed-oh-my-openagent.jsonc | 5 - .../targets/malformed-opencode.jsonc | 3 - .../targets/oh-my-openagent.jsonc | 18 - fixtures/compatibility/targets/opencode.jsonc | 15 - .../trailing-comma-oh-my-openagent.jsonc | 4 - .../.config/omo-switch/config.json | 6 - .../.config/opencode/oh-my-openagent.json | 4 - .../full/.config/omo-switch/config.json | 11 - .../.config/opencode/oh-my-openagent.json | 12 - .../full/.config/opencode/opencode.json | 14 - ...1111111-1111-1111-1111-111111111111.staged | 1 - .../omo-switch/.transaction-journal.json.tmp | 1 - .../.config/omo-switch/config.json | 6 - .../.config/opencode/oh-my-openagent.json | 4 - .../.config/opencode/opencode.json | 8 - package-lock.json | 1292 ++--------------- package.json | 14 +- playwright.config.ts | 24 - src-tauri/Cargo.lock | 252 +--- src-tauri/Cargo.toml | 16 +- src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/gen/schemas/desktop-schema.json | 42 - src-tauri/gen/schemas/windows-schema.json | 42 - src-tauri/src/agents.rs | 658 +++++++++ src-tauri/src/agents_md.rs | 367 +++++ src-tauri/src/app_shell.rs | 198 --- src-tauri/src/application.rs | 337 ----- src-tauri/src/commands.rs | 356 +++++ src-tauri/src/commands/errors.rs | 318 ---- src-tauri/src/commands/mod.rs | 136 -- src-tauri/src/commands/types.rs | 231 --- src-tauri/src/core/backup.rs | 173 --- src-tauri/src/core/document.rs | 107 -- src-tauri/src/core/error.rs | 286 ---- src-tauri/src/core/jsonc.rs | 100 -- src-tauri/src/core/mod.rs | 9 - src-tauri/src/core/models.rs | 100 -- src-tauri/src/core/paths.rs | 68 - src-tauri/src/core/projection.rs | 127 -- src-tauri/src/core/repository/mod.rs | 10 - src-tauri/src/core/repository/model.rs | 58 - src-tauri/src/core/repository/replacement.rs | 307 ---- src-tauri/src/core/repository/target.rs | 102 -- src-tauri/src/core/switching/error.rs | 113 -- src-tauri/src/core/switching/fault.rs | 85 -- src-tauri/src/core/switching/journal.rs | 302 ---- src-tauri/src/core/switching/mod.rs | 9 - src-tauri/src/core/switching/transaction.rs | 376 ----- src-tauri/src/document.rs | 87 ++ src-tauri/src/dto.rs | 259 ++++ src-tauri/src/error.rs | 78 + src-tauri/src/groups.rs | 324 +++++ src-tauri/src/jsonc.rs | 87 ++ src-tauri/src/lib.rs | 60 +- src-tauri/src/main.rs | 2 +- src-tauri/src/models.rs | 186 +++ src-tauri/src/paths.rs | 95 ++ src-tauri/src/projection.rs | 220 +++ src-tauri/src/providers.rs | 624 ++++++++ src-tauri/src/refs.rs | 322 ++++ src-tauri/src/replacement.rs | 732 ++++++++++ src-tauri/src/shell.rs | 125 -- src-tauri/tauri.conf.json | 32 +- src-tauri/tests/app.rs | 403 +++++ src-tauri/tests/backups.rs | 100 -- src-tauri/tests/command_contract.rs | 198 --- src-tauri/tests/commands.rs | 432 ------ .../tests/common/command_contract_sets.rs | 41 - src-tauri/tests/common/switching.rs | 191 --- src-tauri/tests/compatibility_fixtures.rs | 161 -- src-tauri/tests/config_paths.rs | 137 -- src-tauri/tests/config_repository.rs | 177 --- src-tauri/tests/jsonc.rs | 94 -- src-tauri/tests/legacy_path_contract.rs | 143 -- src-tauri/tests/native_runtime_gaps.rs | 190 --- src-tauri/tests/persistence_errors.rs | 179 --- src-tauri/tests/projection.rs | 197 --- src-tauri/tests/schemas.rs | 210 --- src-tauri/tests/shell_smoke.rs | 56 - src-tauri/tests/switching.rs | 216 --- src-tauri/tests/switching_transactions.rs | 250 ---- src-tauri/tests/tray_lifecycle.rs | 109 -- src/app.css | 871 +++++++++-- src/lib/appStore.test.ts | 97 -- src/lib/appStore.ts | 106 -- src/lib/appStoreTestSupport.ts | 48 - src/lib/components/QuickSwitch.svelte | 158 -- src/lib/components/QuickSwitch.test.ts | 195 --- src/lib/components/QuickSwitchTestSupport.ts | 39 - src/lib/components/app/AppHeader.svelte | 22 + src/lib/components/app/AppShell.svelte | 14 + src/lib/components/app/AppSidebar.svelte | 16 + src/lib/components/app/ConfigFeedback.svelte | 58 + src/lib/components/app/DataTable.svelte | 9 + src/lib/components/app/EmptyTableRow.svelte | 5 + src/lib/components/app/FormActions.svelte | 7 + src/lib/components/app/FormField.svelte | 17 + src/lib/components/app/GroupForm.svelte | 217 +++ .../components/app/MobileNavigation.svelte | 90 ++ src/lib/components/app/PageHead.svelte | 18 + src/lib/components/app/SearchToolbar.svelte | 20 + src/lib/components/app/StatusBadge.svelte | 8 + src/lib/components/app/navigation.ts | 13 + .../quick-switch/CurrentGroupSummary.svelte | 62 - .../quick-switch/CurrentGroupSummary.test.ts | 83 -- .../quick-switch/CurrentValues.svelte | 105 -- .../quick-switch/SwitchTargets.svelte | 181 --- .../quick-switch/SwitchTargets.test.ts | 103 -- .../components/settings/SettingsShell.svelte | 131 -- .../components/settings/SettingsShell.test.ts | 211 --- .../settings/groups/DeleteGroupDialog.svelte | 34 - .../groups/ExactModelReplacementRegion.svelte | 41 - .../settings/groups/GroupDraftEditor.svelte | 140 -- .../settings/groups/GroupDraftRegions.test.ts | 60 - .../settings/groups/GroupList.svelte | 172 --- .../groups/GroupMetadataRegion.svelte | 47 - .../settings/groups/GroupSettingsPane.svelte | 34 - .../settings/groups/GroupSettingsPane.test.ts | 94 -- .../groups/OpenCodeOverridesRegion.svelte | 52 - .../groups/StandardMappingsRegion.svelte | 46 - .../components/ui/UiPrimitivesShowcase.svelte | 158 -- .../components/ui/alert/alert-action.svelte | 29 +- .../ui/alert/alert-description.svelte | 29 +- .../components/ui/alert/alert-title.svelte | 29 +- src/lib/components/ui/alert/alert.svelte | 66 +- src/lib/components/ui/alert/index.ts | 28 +- src/lib/components/ui/badge/badge.svelte | 68 +- src/lib/components/ui/badge/index.ts | 4 +- src/lib/components/ui/button/button.svelte | 143 +- src/lib/components/ui/button/index.ts | 23 +- .../components/ui/checkbox/checkbox.svelte | 64 +- src/lib/components/ui/checkbox/index.ts | 8 +- .../context-menu/context-menu-content.svelte | 26 + .../ui/context-menu/context-menu-item.svelte | 30 + .../context-menu-separator.svelte | 18 + .../ui/context-menu/context-menu.svelte | 14 + src/lib/components/ui/context-menu/index.ts | 15 + .../components/ui/dialog/dialog-close.svelte | 8 +- .../ui/dialog/dialog-content.svelte | 88 +- .../ui/dialog/dialog-description.svelte | 18 +- .../components/ui/dialog/dialog-footer.svelte | 50 +- .../components/ui/dialog/dialog-header.svelte | 25 +- .../ui/dialog/dialog-overlay.svelte | 21 +- .../components/ui/dialog/dialog-portal.svelte | 4 +- .../components/ui/dialog/dialog-title.svelte | 18 +- .../ui/dialog/dialog-trigger.svelte | 8 +- src/lib/components/ui/dialog/dialog.svelte | 4 +- src/lib/components/ui/dialog/index.ts | 62 +- src/lib/components/ui/input/index.ts | 8 +- src/lib/components/ui/input/input.svelte | 79 +- src/lib/components/ui/separator/index.ts | 8 +- .../components/ui/separator/separator.svelte | 34 +- src/lib/components/ui/slider/index.ts | 3 + src/lib/components/ui/slider/slider.svelte | 24 + src/lib/components/ui/spinner/index.ts | 2 +- src/lib/components/ui/spinner/spinner.svelte | 36 +- src/lib/components/ui/switch/index.ts | 3 + src/lib/components/ui/switch/switch.svelte | 32 + src/lib/components/ui/textarea/index.ts | 8 +- .../components/ui/textarea/textarea.svelte | 35 +- src/lib/components/ui/toast/index.ts | 3 + src/lib/components/ui/toast/toast.svelte | 35 + src/lib/components/ui/tooltip/index.ts | 32 +- .../ui/tooltip/tooltip-content.svelte | 62 +- .../ui/tooltip/tooltip-portal.svelte | 4 +- .../ui/tooltip/tooltip-provider.svelte | 4 +- .../ui/tooltip/tooltip-trigger.svelte | 4 +- src/lib/components/ui/tooltip/tooltip.svelte | 4 +- src/lib/components/ui/ui-primitives.test.ts | 113 -- src/lib/contracts.ts | 91 -- src/lib/features/config/adapter.ts | 441 ++++++ src/lib/features/config/context.ts | 5 + src/lib/features/config/store.svelte.ts | 238 +++ src/lib/features/config/types.ts | 93 ++ src/lib/quickSwitchFixture.ts | 63 - src/lib/quickSwitchView.test.ts | 73 - src/lib/quickSwitchView.ts | 53 - src/lib/settingsFixture.ts | 101 -- src/lib/settingsGroupActions.ts | 267 ---- src/lib/settingsGroupDraft.ts | 251 ---- src/lib/settingsMatchActions.test.ts | 121 -- src/lib/settingsMatchActions.ts | 69 - src/lib/settingsState.ts | 79 - src/lib/settingsStore.test.ts | 698 --------- src/lib/settingsStore.ts | 85 -- src/lib/settingsTestSupport.ts | 56 - src/lib/tauriClient.test.ts | 21 - src/lib/tauriClient.ts | 55 - src/native-gaps.test.ts | 213 --- src/routes/+layout.svelte | 10 +- src/routes/+page.svelte | 44 +- src/routes/agents/+page.svelte | 65 + src/routes/agents/[id]/edit/+page.svelte | 312 ++++ src/routes/agents/new/+page.svelte | 202 +++ src/routes/groups/+page.svelte | 73 + src/routes/groups/[id]/edit/+page.svelte | 19 + src/routes/groups/[id]/edit/+page.ts | 3 + src/routes/groups/new/+page.svelte | 11 + src/routes/models/+page.svelte | 109 ++ src/routes/models/[id]/edit/+page.svelte | 162 +++ src/routes/models/new/+page.svelte | 137 ++ src/routes/providers/+page.svelte | 143 ++ src/routes/providers/[id]/edit/+page.svelte | 163 +++ src/routes/providers/new/+page.svelte | 154 ++ src/routes/routing.test.ts | 106 -- src/routes/settings/+page.svelte | 41 - src/ui-system.contract.test.ts | 187 --- static/favicon.svg | 2 +- static/fonts/jetbrains-mono-regular.woff2 | Bin 0 -> 31432 bytes static/fonts/jetbrains-mono-semibold.woff2 | Bin 0 -> 31432 bytes static/fonts/space-grotesk-regular.woff2 | Bin 0 -> 22288 bytes static/fonts/space-grotesk-semibold.woff2 | Bin 0 -> 22288 bytes svelte.config.js | 6 +- tests/e2e/fixture-routes.spec.ts | 78 - vite.config.ts | 6 +- vitest.config.ts | 13 - 240 files changed, 9439 insertions(+), 15129 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc delete mode 100644 DESIGN.md delete mode 100644 fixtures/compatibility/backups/oh-my-openagent/20231114T221320Z-oh-my-openagent.json delete mode 100644 fixtures/compatibility/backups/oh-my-openagent/20231114T221321Z-oh-my-openagent.json delete mode 100644 fixtures/compatibility/backups/oh-my-openagent/20231114T221322Z-oh-my-openagent.json delete mode 100644 fixtures/compatibility/backups/oh-my-openagent/20231114T221323Z-oh-my-openagent.json delete mode 100644 fixtures/compatibility/backups/oh-my-openagent/20231114T221324Z-oh-my-openagent.json delete mode 100644 fixtures/compatibility/backups/oh-my-openagent/20231114T221325Z-oh-my-openagent.json delete mode 100644 fixtures/compatibility/config/current.json delete mode 100644 fixtures/compatibility/config/malformed.json delete mode 100644 fixtures/compatibility/json/with-conflict-write-baseline.json delete mode 100644 fixtures/compatibility/json/with-jsonc-markers.jsonc delete mode 100644 fixtures/compatibility/json/with-undeclared-model-ref.json delete mode 100644 fixtures/compatibility/json/with-unknown-fields.json delete mode 100644 fixtures/compatibility/json/with-weird-key-order.json delete mode 100644 fixtures/compatibility/ohmy/current-oh-my-openagent.json delete mode 100644 fixtures/compatibility/ohmy/legacy-oh-my-opencode.json.bak delete mode 100644 fixtures/compatibility/opencode/current-opencode.json delete mode 100644 fixtures/compatibility/targets/malformed-oh-my-openagent.jsonc delete mode 100644 fixtures/compatibility/targets/malformed-opencode.jsonc delete mode 100644 fixtures/compatibility/targets/oh-my-openagent.jsonc delete mode 100644 fixtures/compatibility/targets/opencode.jsonc delete mode 100644 fixtures/compatibility/targets/trailing-comma-oh-my-openagent.jsonc delete mode 100644 fixtures/compatibility/temp-homes/conditional-missing-opencode/.config/omo-switch/config.json delete mode 100644 fixtures/compatibility/temp-homes/conditional-missing-opencode/.config/opencode/oh-my-openagent.json delete mode 100644 fixtures/compatibility/temp-homes/full/.config/omo-switch/config.json delete mode 100644 fixtures/compatibility/temp-homes/full/.config/opencode/oh-my-openagent.json delete mode 100644 fixtures/compatibility/temp-homes/full/.config/opencode/opencode.json delete mode 100644 fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/.config.json.omo-txn-11111111-1111-1111-1111-111111111111.staged delete mode 100644 fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/.transaction-journal.json.tmp delete mode 100644 fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/config.json delete mode 100644 fixtures/compatibility/temp-homes/stale-transaction/.config/opencode/oh-my-openagent.json delete mode 100644 fixtures/compatibility/temp-homes/stale-transaction/.config/opencode/opencode.json delete mode 100644 playwright.config.ts create mode 100644 src-tauri/src/agents.rs create mode 100644 src-tauri/src/agents_md.rs delete mode 100644 src-tauri/src/app_shell.rs delete mode 100644 src-tauri/src/application.rs create mode 100644 src-tauri/src/commands.rs delete mode 100644 src-tauri/src/commands/errors.rs delete mode 100644 src-tauri/src/commands/mod.rs delete mode 100644 src-tauri/src/commands/types.rs delete mode 100644 src-tauri/src/core/backup.rs delete mode 100644 src-tauri/src/core/document.rs delete mode 100644 src-tauri/src/core/error.rs delete mode 100644 src-tauri/src/core/jsonc.rs delete mode 100644 src-tauri/src/core/mod.rs delete mode 100644 src-tauri/src/core/models.rs delete mode 100644 src-tauri/src/core/paths.rs delete mode 100644 src-tauri/src/core/projection.rs delete mode 100644 src-tauri/src/core/repository/mod.rs delete mode 100644 src-tauri/src/core/repository/model.rs delete mode 100644 src-tauri/src/core/repository/replacement.rs delete mode 100644 src-tauri/src/core/repository/target.rs delete mode 100644 src-tauri/src/core/switching/error.rs delete mode 100644 src-tauri/src/core/switching/fault.rs delete mode 100644 src-tauri/src/core/switching/journal.rs delete mode 100644 src-tauri/src/core/switching/mod.rs delete mode 100644 src-tauri/src/core/switching/transaction.rs create mode 100644 src-tauri/src/document.rs create mode 100644 src-tauri/src/dto.rs create mode 100644 src-tauri/src/error.rs create mode 100644 src-tauri/src/groups.rs create mode 100644 src-tauri/src/jsonc.rs create mode 100644 src-tauri/src/models.rs create mode 100644 src-tauri/src/paths.rs create mode 100644 src-tauri/src/projection.rs create mode 100644 src-tauri/src/providers.rs create mode 100644 src-tauri/src/refs.rs create mode 100644 src-tauri/src/replacement.rs delete mode 100644 src-tauri/src/shell.rs create mode 100644 src-tauri/tests/app.rs delete mode 100644 src-tauri/tests/backups.rs delete mode 100644 src-tauri/tests/command_contract.rs delete mode 100644 src-tauri/tests/commands.rs delete mode 100644 src-tauri/tests/common/command_contract_sets.rs delete mode 100644 src-tauri/tests/common/switching.rs delete mode 100644 src-tauri/tests/compatibility_fixtures.rs delete mode 100644 src-tauri/tests/config_paths.rs delete mode 100644 src-tauri/tests/config_repository.rs delete mode 100644 src-tauri/tests/jsonc.rs delete mode 100644 src-tauri/tests/legacy_path_contract.rs delete mode 100644 src-tauri/tests/native_runtime_gaps.rs delete mode 100644 src-tauri/tests/persistence_errors.rs delete mode 100644 src-tauri/tests/projection.rs delete mode 100644 src-tauri/tests/schemas.rs delete mode 100644 src-tauri/tests/shell_smoke.rs delete mode 100644 src-tauri/tests/switching.rs delete mode 100644 src-tauri/tests/switching_transactions.rs delete mode 100644 src-tauri/tests/tray_lifecycle.rs delete mode 100644 src/lib/appStore.test.ts delete mode 100644 src/lib/appStore.ts delete mode 100644 src/lib/appStoreTestSupport.ts delete mode 100644 src/lib/components/QuickSwitch.svelte delete mode 100644 src/lib/components/QuickSwitch.test.ts delete mode 100644 src/lib/components/QuickSwitchTestSupport.ts create mode 100644 src/lib/components/app/AppHeader.svelte create mode 100644 src/lib/components/app/AppShell.svelte create mode 100644 src/lib/components/app/AppSidebar.svelte create mode 100644 src/lib/components/app/ConfigFeedback.svelte create mode 100644 src/lib/components/app/DataTable.svelte create mode 100644 src/lib/components/app/EmptyTableRow.svelte create mode 100644 src/lib/components/app/FormActions.svelte create mode 100644 src/lib/components/app/FormField.svelte create mode 100644 src/lib/components/app/GroupForm.svelte create mode 100644 src/lib/components/app/MobileNavigation.svelte create mode 100644 src/lib/components/app/PageHead.svelte create mode 100644 src/lib/components/app/SearchToolbar.svelte create mode 100644 src/lib/components/app/StatusBadge.svelte create mode 100644 src/lib/components/app/navigation.ts delete mode 100644 src/lib/components/quick-switch/CurrentGroupSummary.svelte delete mode 100644 src/lib/components/quick-switch/CurrentGroupSummary.test.ts delete mode 100644 src/lib/components/quick-switch/CurrentValues.svelte delete mode 100644 src/lib/components/quick-switch/SwitchTargets.svelte delete mode 100644 src/lib/components/quick-switch/SwitchTargets.test.ts delete mode 100644 src/lib/components/settings/SettingsShell.svelte delete mode 100644 src/lib/components/settings/SettingsShell.test.ts delete mode 100644 src/lib/components/settings/groups/DeleteGroupDialog.svelte delete mode 100644 src/lib/components/settings/groups/ExactModelReplacementRegion.svelte delete mode 100644 src/lib/components/settings/groups/GroupDraftEditor.svelte delete mode 100644 src/lib/components/settings/groups/GroupDraftRegions.test.ts delete mode 100644 src/lib/components/settings/groups/GroupList.svelte delete mode 100644 src/lib/components/settings/groups/GroupMetadataRegion.svelte delete mode 100644 src/lib/components/settings/groups/GroupSettingsPane.svelte delete mode 100644 src/lib/components/settings/groups/GroupSettingsPane.test.ts delete mode 100644 src/lib/components/settings/groups/OpenCodeOverridesRegion.svelte delete mode 100644 src/lib/components/settings/groups/StandardMappingsRegion.svelte delete mode 100644 src/lib/components/ui/UiPrimitivesShowcase.svelte create mode 100644 src/lib/components/ui/context-menu/context-menu-content.svelte create mode 100644 src/lib/components/ui/context-menu/context-menu-item.svelte create mode 100644 src/lib/components/ui/context-menu/context-menu-separator.svelte create mode 100644 src/lib/components/ui/context-menu/context-menu.svelte create mode 100644 src/lib/components/ui/context-menu/index.ts create mode 100644 src/lib/components/ui/slider/index.ts create mode 100644 src/lib/components/ui/slider/slider.svelte create mode 100644 src/lib/components/ui/switch/index.ts create mode 100644 src/lib/components/ui/switch/switch.svelte create mode 100644 src/lib/components/ui/toast/index.ts create mode 100644 src/lib/components/ui/toast/toast.svelte delete mode 100644 src/lib/components/ui/ui-primitives.test.ts delete mode 100644 src/lib/contracts.ts create mode 100644 src/lib/features/config/adapter.ts create mode 100644 src/lib/features/config/context.ts create mode 100644 src/lib/features/config/store.svelte.ts create mode 100644 src/lib/features/config/types.ts delete mode 100644 src/lib/quickSwitchFixture.ts delete mode 100644 src/lib/quickSwitchView.test.ts delete mode 100644 src/lib/quickSwitchView.ts delete mode 100644 src/lib/settingsFixture.ts delete mode 100644 src/lib/settingsGroupActions.ts delete mode 100644 src/lib/settingsGroupDraft.ts delete mode 100644 src/lib/settingsMatchActions.test.ts delete mode 100644 src/lib/settingsMatchActions.ts delete mode 100644 src/lib/settingsState.ts delete mode 100644 src/lib/settingsStore.test.ts delete mode 100644 src/lib/settingsStore.ts delete mode 100644 src/lib/settingsTestSupport.ts delete mode 100644 src/lib/tauriClient.test.ts delete mode 100644 src/lib/tauriClient.ts delete mode 100644 src/native-gaps.test.ts create mode 100644 src/routes/agents/+page.svelte create mode 100644 src/routes/agents/[id]/edit/+page.svelte create mode 100644 src/routes/agents/new/+page.svelte create mode 100644 src/routes/groups/+page.svelte create mode 100644 src/routes/groups/[id]/edit/+page.svelte create mode 100644 src/routes/groups/[id]/edit/+page.ts create mode 100644 src/routes/groups/new/+page.svelte create mode 100644 src/routes/models/+page.svelte create mode 100644 src/routes/models/[id]/edit/+page.svelte create mode 100644 src/routes/models/new/+page.svelte create mode 100644 src/routes/providers/+page.svelte create mode 100644 src/routes/providers/[id]/edit/+page.svelte create mode 100644 src/routes/providers/new/+page.svelte delete mode 100644 src/routes/routing.test.ts delete mode 100644 src/routes/settings/+page.svelte delete mode 100644 src/ui-system.contract.test.ts create mode 100644 static/fonts/jetbrains-mono-regular.woff2 create mode 100644 static/fonts/jetbrains-mono-semibold.woff2 create mode 100644 static/fonts/space-grotesk-regular.woff2 create mode 100644 static/fonts/space-grotesk-semibold.woff2 delete mode 100644 tests/e2e/fixture-routes.spec.ts delete mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10957af..2f4cdc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Node 22 uses: actions/setup-node@v4 with: - node-version: "22.23.0" + node-version: '22.23.0' cache: npm cache-dependency-path: package-lock.json @@ -39,8 +39,8 @@ jobs: - name: Install frontend dependencies run: npm ci - - name: Run frontend tests - run: npm test + - name: Check frontend formatting + run: npm run format:check - name: Build frontend run: npm run build @@ -48,22 +48,13 @@ jobs: - name: Check Rust formatting run: cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check - - name: Run Rust tests - run: cargo test --manifest-path src-tauri/Cargo.toml -- --nocapture - - - name: Assert product config, security, identity, removed scope, and CI contract - shell: pwsh - env: - OMO_CHARACTERIZE_NATIVE_GAPS: '1' - run: npm test -- --run src/native-gaps.test.ts - - name: Build Windows Tauri package run: npm run tauri build - name: Assert exact Windows NSIS artifact shell: pwsh run: | - $artifact = 'src-tauri/target/release/bundle/nsis/omo-switch_0.1.0_x64-setup.exe' + $artifact = 'src-tauri/target/release/bundle/nsis/opencode-mom_0.1.0_x64-setup.exe' if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { throw "Expected exact NSIS artifact: $artifact" } @@ -71,8 +62,8 @@ jobs: - name: Upload Windows Tauri evidence uses: actions/upload-artifact@v4 with: - name: omo-switch-tauri-windows-0.1.0 - path: src-tauri/target/release/bundle/nsis/omo-switch_0.1.0_x64-setup.exe + name: opencode-mom-tauri-windows-0.1.0 + path: src-tauri/target/release/bundle/nsis/opencode-mom_0.1.0_x64-setup.exe if-no-files-found: error tauri-macos: @@ -86,7 +77,7 @@ jobs: - name: Setup Node 22 uses: actions/setup-node@v4 with: - node-version: "22.23.0" + node-version: '22.23.0' cache: npm cache-dependency-path: package-lock.json @@ -103,8 +94,8 @@ jobs: - name: Install frontend dependencies run: npm ci - - name: Run frontend tests - run: npm test + - name: Check frontend formatting + run: npm run format:check - name: Build frontend run: npm run build @@ -112,14 +103,6 @@ jobs: - name: Check Rust formatting run: cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check - - name: Run Rust tests - run: cargo test --manifest-path src-tauri/Cargo.toml -- --nocapture - - - name: Assert product config, security, identity, removed scope, and CI contract - env: - OMO_CHARACTERIZE_NATIVE_GAPS: '1' - run: npm test -- --run src/native-gaps.test.ts - - name: Build macOS Tauri packages run: npm run tauri build @@ -127,14 +110,14 @@ jobs: shell: bash run: | set -euo pipefail - test -d 'src-tauri/target/release/bundle/macos/omo-switch.app' - test -f 'src-tauri/target/release/bundle/dmg/omo-switch_0.1.0_aarch64.dmg' + test -d 'src-tauri/target/release/bundle/macos/opencode-mom.app' + test -f 'src-tauri/target/release/bundle/dmg/opencode-mom_0.1.0_aarch64.dmg' - name: Upload macOS Tauri evidence uses: actions/upload-artifact@v4 with: - name: omo-switch-tauri-macos-0.1.0 + name: opencode-mom-tauri-macos-0.1.0 path: | - src-tauri/target/release/bundle/macos/omo-switch.app - src-tauri/target/release/bundle/dmg/omo-switch_0.1.0_aarch64.dmg + src-tauri/target/release/bundle/macos/opencode-mom.app + src-tauri/target/release/bundle/dmg/opencode-mom_0.1.0_aarch64.dmg if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1f1bbb..a76ab99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - "v*" + - 'v*' permissions: contents: read @@ -29,7 +29,7 @@ jobs: - name: Setup Node 22 uses: actions/setup-node@v4 with: - node-version: "22.23.0" + node-version: '22.23.0' cache: npm cache-dependency-path: package-lock.json @@ -50,7 +50,7 @@ jobs: - name: Assert exact Windows NSIS artifact shell: pwsh run: | - $artifact = 'src-tauri/target/release/bundle/nsis/omo-switch_0.1.0_x64-setup.exe' + $artifact = 'src-tauri/target/release/bundle/nsis/opencode-mom_0.1.0_x64-setup.exe' if (-not (Test-Path -LiteralPath $artifact -PathType Leaf)) { throw "Expected exact NSIS artifact: $artifact" } @@ -58,8 +58,8 @@ jobs: - name: Upload Windows release artifact uses: actions/upload-artifact@v4 with: - name: omo-switch-tauri-windows-0.1.0 - path: src-tauri/target/release/bundle/nsis/omo-switch_0.1.0_x64-setup.exe + name: opencode-mom-tauri-windows-0.1.0 + path: src-tauri/target/release/bundle/nsis/opencode-mom_0.1.0_x64-setup.exe if-no-files-found: error build-macos: @@ -84,7 +84,7 @@ jobs: - name: Setup Node 22 uses: actions/setup-node@v4 with: - node-version: "22.23.0" + node-version: '22.23.0' cache: npm cache-dependency-path: package-lock.json @@ -106,26 +106,26 @@ jobs: shell: bash run: | set -euo pipefail - test -d 'src-tauri/target/release/bundle/macos/omo-switch.app' - test -f 'src-tauri/target/release/bundle/dmg/omo-switch_0.1.0_aarch64.dmg' + test -d 'src-tauri/target/release/bundle/macos/opencode-mom.app' + test -f 'src-tauri/target/release/bundle/dmg/opencode-mom_0.1.0_aarch64.dmg' - name: Package macOS app bundle shell: bash run: | set -euo pipefail mkdir -p release-assets - tar -C 'src-tauri/target/release/bundle/macos' -czf 'release-assets/omo-switch_0.1.0_aarch64.app.tar.gz' 'omo-switch.app' - cp 'src-tauri/target/release/bundle/dmg/omo-switch_0.1.0_aarch64.dmg' 'release-assets/omo-switch_0.1.0_aarch64.dmg' - test -f 'release-assets/omo-switch_0.1.0_aarch64.app.tar.gz' - test -f 'release-assets/omo-switch_0.1.0_aarch64.dmg' + tar -C 'src-tauri/target/release/bundle/macos' -czf 'release-assets/opencode-mom_0.1.0_aarch64.app.tar.gz' 'opencode-mom.app' + cp 'src-tauri/target/release/bundle/dmg/opencode-mom_0.1.0_aarch64.dmg' 'release-assets/opencode-mom_0.1.0_aarch64.dmg' + test -f 'release-assets/opencode-mom_0.1.0_aarch64.app.tar.gz' + test -f 'release-assets/opencode-mom_0.1.0_aarch64.dmg' - name: Upload macOS release artifacts uses: actions/upload-artifact@v4 with: - name: omo-switch-tauri-macos-0.1.0 + name: opencode-mom-tauri-macos-0.1.0 path: | - release-assets/omo-switch_0.1.0_aarch64.app.tar.gz - release-assets/omo-switch_0.1.0_aarch64.dmg + release-assets/opencode-mom_0.1.0_aarch64.app.tar.gz + release-assets/opencode-mom_0.1.0_aarch64.dmg if-no-files-found: error publish: @@ -141,21 +141,21 @@ jobs: - name: Download Windows release artifact uses: actions/download-artifact@v4 with: - name: omo-switch-tauri-windows-0.1.0 + name: opencode-mom-tauri-windows-0.1.0 path: release-assets/windows - name: Download macOS release artifacts uses: actions/download-artifact@v4 with: - name: omo-switch-tauri-macos-0.1.0 + name: opencode-mom-tauri-macos-0.1.0 path: release-assets/macos - name: Publish GitHub release uses: softprops/action-gh-release@v2 with: files: | - release-assets/windows/omo-switch_0.1.0_x64-setup.exe - release-assets/macos/omo-switch_0.1.0_aarch64.app.tar.gz - release-assets/macos/omo-switch_0.1.0_aarch64.dmg + release-assets/windows/opencode-mom_0.1.0_x64-setup.exe + release-assets/macos/opencode-mom_0.1.0_aarch64.app.tar.gz + release-assets/macos/opencode-mom_0.1.0_aarch64.dmg fail_on_unmatched_files: true generate_release_notes: true diff --git a/.gitignore b/.gitignore index 5f3b89e..b3130b0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dist/ src-tauri/target/ .graymatter/ .slim +.slim/deepwork/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b9dd7e2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules/ +build/ +.svelte-kit/ +dist/ +src-tauri/target/ +src-tauri/gen/ +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..145e92a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "printWidth": 120, + "singleQuote": true, + "semi": true, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 9b1245b..7965f31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# omo-switch +# opencode-mom ## Project @@ -9,20 +9,21 @@ - `npm run dev` - start the Vite development server. - `npm run build` - create a production web build. -- `npm test` - run unit tests with Vitest. -- `npm run test:e2e` - build and run Playwright end-to-end tests. +- `npm run format` - format frontend code with Prettier. +- `npm run format:check` - verify frontend formatting. +- `cargo check --manifest-path src-tauri/Cargo.toml` - type-check the Rust backend. - `npm run tauri` - run Tauri CLI commands. ## Working Rules - Follow existing Svelte, TypeScript, and Tailwind patterns in the files you edit. - Keep changes scoped to the requested behavior; avoid unrelated refactors. +- Run Prettier (`npm run format`) on changed frontend files; keep all UI text and code comments in English. - Use accessible, semantic controls and preserve keyboard interaction where applicable. - Do not commit secrets, API keys, generated artifacts, or local environment files. ## Verification -- Run the narrowest relevant test or check for each change. +- Run the narrowest relevant check for each change. - Run `npm run build` for changes affecting application integration or production output. -- Run `npm test` when changing tested application logic. -- Run `npm run test:e2e` for user-facing workflow changes when appropriate. +- Run `cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check` and `cargo check --manifest-path src-tauri/Cargo.toml` for Rust changes. diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index c12d390..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,352 +0,0 @@ -# omo-switch Compact Desktop Design System - -## 1. Atmosphere & Identity - -omo-switch is a compact desktop control surface for developers and operators who switch model groups and edit a small amount of configuration repeatedly. It must feel immediate, utilitarian, and native to a small Tauri window: dense enough to keep the current task visible, quiet enough that status and destructive actions remain obvious, and stable enough that loading or long content never rearranges the interface unexpectedly. - -The signature is **bounded utility**: pale tonal changes identify hover, selection, active rows, and degraded regions inside a precise one-pixel frame. The product is not a web dashboard and not a marketing surface. It uses short labels, compact controls, restrained color, small radii, and predictable pane boundaries to keep attention on configuration state. - -This contract preserves the information architecture and surface behavior evidenced by: - -- `src/routes/+layout.svelte` -- `src/lib/components/QuickSwitch.svelte` -- `src/lib/components/settings/SettingsShell.svelte` -- `src/lib/components/settings/groups/GroupSettingsPane.svelte` -- `src-tauri/tauri.conf.json` - -The contract documents the existing compact UI. It does not authorize changes to copy, workflow order, stores, native commands, window selection, window dimensions, theme ownership, or validation behavior. - -## 2. Color - -### Canonical Token Registry - -Every existing layout token appears once in this table. Light and dark values are authoritative. The semantic alias column defines the one-way shadcn/Tailwind mapping for the later foundation work; `None` means the token remains project-specific. The two rows marked `foundation addition` are already prescribed by the migration plan and do not replace an existing layout token. - -| Category | Existing or prescribed token | Light | Dark | Semantic shadcn alias | Contract use | -| --- | --- | --- | --- | --- | --- | -| Surface | `--surface-app` | `#f6f7f8` | `#111315` | `--background` | App and backdrop reference | -| Surface | `--surface-panel` | `#ffffff` | `#181b1f` | `--card`, `--popover` | Primary window, pane, control, dialog surface | -| Surface | `--surface-muted` | `#f1f3f5` | `#20242a` | `--secondary`, `--muted` | Sidebars, toolbars, alerts, degraded sections | -| Surface | `--surface-input` | `#ffffff` | `#15181c` | None | Editable and read-only field surface | -| Surface state | `--surface-hover` | `#eceff3` | `#262b32` | `--accent` | Hovered controls and rows | -| Surface state | `--surface-active` | `#e4eaf2` | `#2b3340` | None | Selected navigation, selected rows, compact badges | -| Text | `--text-primary` | `#171a1f` | `#f2f4f7` | `--foreground`, `--card-foreground`, `--popover-foreground`, `--secondary-foreground`, `--accent-foreground` | Primary labels, values, headings | -| Text | `--text-secondary` | `#596170` | `#a7b0bf` | `--muted-foreground` | Help, descriptions, metadata | -| Text | `--text-muted` | `#858e9c` | `#707a88` | None | Inactive indicators and least-prominent text | -| Border | `--border-default` | `#d8dde5` | `#343a44` | `--border`, `--input` | Pane boundaries, controls, dialogs | -| Border | `--border-subtle` | `#e8ebf0` | `#282d35` | None | Section dividers and quiet status rows | -| Focus | `--focus-ring` | `#2f6feb` | `#6aa4ff` | `--ring` | Keyboard focus outline | -| Accent | `--accent-primary` | `#2563eb` | `#6aa4ff` | `--primary` | Primary action and active semantic emphasis | -| Accent | `--accent-hover` | `#1d4ed8` | `#8cb8ff` | None | Primary action hover | -| Status | `--status-success` | `#16843a` | `#36c26b` | None | Enabled state | -| Status | `--status-warning` | `#b76b00` | `#f0a33a` | None | Advisory, degraded, pending approval | -| Status | `--status-error` | `#c9362b` | `#ff6b5f` | `--destructive` | Errors, invalid data, destructive intent | -| Status | `--status-info` | `#2f6feb` | `#6aa4ff` | None | Informational status | -| Spacing | `--space-1` | `4px` | `4px` | None | Tight icon and label gap | -| Spacing | `--space-2` | `8px` | `8px` | None | Compact row and control gap | -| Spacing | `--space-3` | `12px` | `12px` | None | Toolbar and field-section rhythm | -| Spacing | `--space-4` | `16px` | `16px` | None | Window and section padding | -| Spacing | `--space-5` | `20px` | `20px` | None | Settings detail padding | -| Spacing | `--space-6` | `24px` | `24px` | None | Reserved larger compact interval | -| Spacing | `--space-8` | `32px` | `32px` | None | Empty-state padding | -| Radius | `--radius-sm` | `4px` | `4px` | None | Inputs and checkbox | -| Radius | `--radius-md` | `6px` | `6px` | Buttons, rows, alerts | -| Radius | `--radius-lg` | `8px` | `8px` | Dialog and degraded section | -| Radius | `--radius-pill` | `999px` | `999px` | Status dot and badge only | -| Shadow | `--shadow-popover` | `0 8px 24px rgb(15 23 42 / 14%)` | `0 8px 24px rgb(15 23 42 / 14%)` | None | Dialog, tooltip, and popover elevation only | -| Shadow | `--shadow-window` | `0 1px 2px rgb(15 23 42 / 8%)` | `0 1px 2px rgb(15 23 42 / 8%)` | None | Outer floating window treatment only | -| Line | `--border-width` | `1px` | `1px` | None | All declared borders and separators | -| Focus | `--focus-width` | `2px` | `2px` | None | Focus outline width | -| Focus | `--focus-offset` | `2px` | `2px` | None | Focus outline offset | -| State | `--disabled-opacity` | `0.55` | `0.55` | None | Disabled interactive controls only | -| Icon | `--icon-sm` | `12px` | `12px` | None | Dense inline status/action icon | -| Icon | `--icon-md` | `16px` | `16px` | None | Standard control and navigation icon | -| Icon | `--icon-lg` | `24px` | `24px` | None | Loading and empty-state icon | -| Font | `--font-primary` | `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` | Same as light | None | All product UI text | -| Font | `--font-mono` | `ui-monospace, "SFMono-Regular", "Cascadia Mono", "Segoe UI Mono", monospace` | Same as light | None | Model refs, paths, code values | -| Type | `--font-window-size` | `20px` | `20px` | None | Window title | -| Type | `--font-window-weight` | `600` | `600` | None | Window title weight | -| Type | `--font-window-line` | `28px` | `28px` | None | Window title line height | -| Type | `--font-section-size` | `15px` | `15px` | None | Section and pane heading | -| Type | `--font-section-weight` | `600` | `600` | None | Section heading weight | -| Type | `--font-section-line` | `22px` | `22px` | None | Section heading line height | -| Type | `--font-row-size` | `13px` | `13px` | None | Row and field label | -| Type | `--font-row-weight` | `500` | `500` | None | Row and field label weight | -| Type | `--font-row-line` | `18px` | `18px` | None | Row and field label line height | -| Type | `--font-body-size` | `13px` | `13px` | None | Default product text | -| Type | `--font-body-weight` | `400` | `400` | None | Default product weight | -| Type | `--font-body-line` | `20px` | `20px` | None | Default product line height | -| Type | `--font-small-size` | `12px` | `12px` | None | Help, status, descriptions | -| Type | `--font-small-line` | `17px` | `17px` | None | Small text line height | -| Type | `--font-caption-size` | `11px` | `11px` | None | Section labels and compact actions | -| Type | `--font-caption-weight` | `500` | `500` | None | Caption and compact action weight | -| Type | `--font-caption-line` | `15px` | `15px` | None | Caption line height | -| Type | `--font-code-size` | `11px` | `11px` | None | Model refs and code metadata | -| Type | `--font-code-line` | `16px` | `16px` | None | Code line height | -| Quick Switch geometry | `--quick-switch-width` | `420px` | `420px` | None | Fixed native window width | -| Quick Switch geometry | `--quick-switch-height` | `360px` | `360px` | None | Fixed native window height | -| Quick Switch geometry | `--quick-switch-min-width` | `300px` | `300px` | None | Browser fixture lower bound | -| Quick Switch geometry | `--quick-switch-min-height` | `0` | `0` | None | Flex/grid overflow safety | -| Quick Switch geometry | `--quick-switch-state-min-height` | `0` | `0` | None | Loading state overflow safety | -| Row geometry | `--target-row-min-height` | `36px` | `36px` | None | Quick Switch target row minimum | -| Row geometry | `--value-row-min-height` | `20px` | `20px` | None | Current-value row minimum | -| Control geometry | `--control-height-compact` | `28px` | `28px` | None | Default control height | -| Control geometry | `--control-height-small` | `24px` | `24px` | None | Small and icon control height | -| Content geometry | `--model-ref-max-width` | `152px` | `152px` | None | Quick Switch model-ref truncation width | -| Accessibility | `--screen-reader-box` | `1px` | `1px` | None | Visually hidden focus/label box | -| Foundation addition | `--text-on-accent` | `#ffffff` | `#111315` | `--primary-foreground` | Text and icons on the primary action fill | -| Foundation addition | `--radius` | `8px` | `8px` | shadcn radius base | Alias basis; owned primitives still use the exact 4/6/8px radii below | - -### Color Rules - -- The operating-system color scheme is authoritative. There is no theme toggle, persisted theme state, or class-driven dark mode. -- Accent color is functional: primary action, focus, active semantic emphasis, or informational status. It is never decorative. -- Status tones identify success, warning, error, and information through icon, text, and when needed a border. Color must not be the only communicated signal. -- Error status may derive a quieter border by mixing the error tone with the subtle border. No unrelated derived palette is permitted. -- Read-only fields keep normal text contrast and the input surface so preserved values remain legible and selectable. They do not use disabled opacity. -- Disabled controls use 0.55 opacity, a not-allowed cursor, and native disabled semantics. Disabled content must remain readable in both themes. -- Degraded sections retain data, explain why editing is unavailable, use the warning tone, and use a muted surface with an 8px radius and one-pixel warning border. Degraded is not destructive and must not discard or hide preserved values. - -## 3. Typography - -### Scale - -| Level | Size | Weight | Line height | Letter spacing | Usage | -| --- | --- | --- | --- | --- | --- | -| Window title | `20px` | `600` | `28px` | `0` | Settings title and full-pane title | -| Section heading | `15px` | `600` | `22px` | `0` | Group names, pane titles, field sections | -| Row label | `13px` | `500` | `18px` | `0` | Group rows and form labels | -| Body | `13px` | `400` | `20px` | `0` | Default product copy and values | -| Small | `12px` | `400` | `17px` | `0` | Help, descriptions, status details | -| Caption | `11px` | `500` | `15px` | `0` | Section labels, badges, compact actions | -| Code | `11px` | `400` | `16px` | `0` | Model refs, paths, timestamps, code values | - -### Font Rules - -- Use the system UI stack for all interface text and the system monospace stack only for machine-facing values. -- Do not add a display font, web font, serif, or third font family. -- Letter spacing is always zero. Compactness comes from the scale and layout, not compressed tracking. -- Sentence case is the default. Existing English labels and capitalization remain unchanged. -- The 11px and 12px sizes are deliberate desktop utility sizes. They are restricted to secondary information and compact controls, never long-form copy. -- Headings do not scale with viewport width. Responsive layout changes topology before changing type size. - -### Truncation and Wrapping - -- Quick Switch names and model refs use single-line ellipsis with the complete value available through a title or accessible name. -- The current-group description is limited to two lines. Omitted descriptions do not reserve blank space. -- Settings values that are intrinsically long, including paths, timestamps, and model refs, may wrap anywhere rather than widen a pane. -- Buttons normally remain one line. Below 520px, Settings toolbar buttons may wrap to multiple lines and grow in height; text must not clip, overlap, or force horizontal document overflow. -- Navigation labels may wrap only where the current responsive topology permits it. They must remain fully readable and retain their accessible name. - -## 4. Spacing & Layout - -### Base Scale - -All spacing derives from a 4px unit. Approved steps are 4, 8, 12, 16, 20, 24, and 32px. New component CSS must consume the matching token from Section 2 rather than introduce an intermediate value. - -| Step | Typical use | -| --- | --- | -| 4px | Icon-to-label, tightly related row content, field label-to-control | -| 8px | Control groups, compact rows, alert content, dialog actions | -| 12px | Toolbars, field sections, standard horizontal button padding | -| 16px | Quick Switch window padding, section boundaries, Settings footer | -| 20px | Settings editor and dialog content padding | -| 24px | Reserved larger interval when the current layout requires it | -| 32px | Empty-state breathing room only | - -### Radii and Controls - -- 4px: inputs, textareas, and checkbox boxes. -- 6px: buttons, icon buttons, list rows, target rows, alerts, and compact state blocks. -- 8px: dialogs and explicitly degraded sections. -- Pill radius: badges and status dots only. It is not a general control shape. -- 24px: small buttons and small icon buttons. -- 28px: default buttons, icon buttons, inputs, and single-line text controls. -- 32px: Settings navigation buttons. This keeps the navigation target stable without making all controls roomy. -- Composite rows retain their current minimums: 20px current-value rows, 34px group rows, 36px Quick Switch target rows, and 44px toolbars. -- Icons are 12px for dense inline actions, 16px for normal controls/navigation, and 24px for loading or empty-state emphasis. - -### Surface Geometry - -- Quick Switch is a fixed, non-resizable `420x360` native window. Its content is one vertical flow with 16px outer padding, 12px section gaps, internal scrolling when required, and no side panel. -- Settings opens at `980x620` and remains resizable. The detail region owns overflow; the whole document must not scroll horizontally. -- At widths `>=720`, Settings keeps the outer sidebar at 180-200px and the detail surface beside it. -- At the default `980x620`, the outer sidebar remains visible, while the Groups list stacks above the editor because the remaining Groups surface is below the side-by-side threshold. -- At `1280x800`, the outer sidebar remains visible and the Groups list/editor may render side by side, with the list at 220-260px and the editor taking the remaining width. -- At `680x720`, Settings uses top navigation, a stacked Groups list, then the toolbar and editor in document order. -- Below 520px, Settings navigation may place icon above label and toolbar buttons may use a two-column wrapping grid. A fifth action spans both columns. Labels wrap without clipping. -- Group list/editor topology changes below 1040px of available Groups-pane width. This is a local pane breakpoint, not a replacement for the 720px outer-shell breakpoint. -- Forms use the available width up to 680px. Port remains compact at a maximum of 160px. Multi-column mapping and replace rows collapse to one column with the stacked Groups editor. - -### Stable Geometry Rules - -- Loading indicators replace icons or occupy an already reserved state block; they do not change control height or reorder controls. -- Buttons keep stable width where an action label changes to a pending label. Icon-only controls remain square. -- Rows set `min-width: 0` on flexible text children so ellipsis and wrapping happen inside the row. -- Selection, active, focus, disabled, read-only, and degraded states must not shift neighboring content. -- Toolbars may wrap at documented breakpoints, but controls must retain DOM order and accessible focus order. -- Dialog content is at most 420px wide and must fit within viewport padding without changing the underlying pane geometry. - -## 5. Components - -Only the ten primitives below are approved for the owned shadcn-svelte layer. Product components compose them without changing store ownership, command behavior, copy, focus order, or responsive topology. - -### Button - -- **Structure**: Native `button` root with optional leading icon, text label, optional trailing icon, and an optional Spinner replacing an icon during pending work. Icon-only buttons contain one icon and require an accessible name. -- **Variants**: `default`, `secondary`, `outline`, `destructive`, and `ghost`. Default is the filled primary action. Secondary is a bordered panel action. Outline is a transparent bordered action. Destructive communicates deletion without creating a filled red dashboard button. Ghost is reserved for Settings navigation and quiet row actions. -- **Sizes and spacing**: `default` is 28px high with 12px horizontal padding; `sm` is 24px high with 8px horizontal padding; `icon` is 28px square; `icon-sm` is 24px square. Icon-label gap is 4px. Radius is 6px. -- **States**: Default uses its variant surface; hover uses the documented hover surface or primary hover tone; active uses the active surface and may expose `aria-pressed` or current-page semantics when the product owns selection; focus uses the 2px ring with 2px offset; disabled uses native `disabled`, 0.55 opacity, and no callback; loading uses `aria-busy`, disables repeat activation, preserves geometry, and shows Spinner; empty is inapplicable because an unlabeled button is invalid; error is inapplicable as an internal state, while destructive variant expresses intent and an adjacent Alert expresses failure. -- **Accessibility**: Preserve native keyboard activation, visible focus, label text or `aria-label`, and the existing DOM/focus order. Do not nest buttons. Tooltips never replace the accessible name. -- **Motion**: Color and tonal feedback may transition in at most 150ms. Loading icon rotation is delegated to Spinner. No translation, bounce, or size animation. - -### Input - -- **Structure**: Native single-line input with a visible label supplied by the product component, optional help text, and separate validation/status text. -- **Variants**: One visual variant. Semantic differences are default, invalid, read-only, and disabled attributes rather than cosmetic variants. -- **Sizes and spacing**: Minimum height 28px, 4px radius, one-pixel border, 4px vertical and 8px horizontal padding. Field label gap is 4px. -- **States**: Default uses the input surface; hover has no required visual change; active follows native text-editing behavior; focus uses the documented ring; disabled uses native disabled semantics and 0.55 opacity; loading is inapplicable to the field itself and belongs to the owning action; empty is a valid value and placeholder text remains secondary; error uses `aria-invalid`, the error tone, and associated error copy; read-only remains legible/selectable and does not use disabled opacity. -- **Accessibility**: Every input has a visible label or exact `aria-label`, preserves input mode and native editing, associates help/error text when present, and exposes read-only versus disabled accurately. -- **Motion**: Border and ring feedback may transition in at most 150ms. No width, height, or label animation. - -### Textarea - -- **Structure**: Native multiline textarea with visible label, optional help text, and separate validation/status text. -- **Variants**: One visual variant with default, invalid, read-only, and disabled semantics. -- **Sizes and spacing**: Minimum control height 28px; product rows determine any larger initial height. Radius is 4px with 4px vertical and 8px horizontal padding. Vertical resize is allowed. -- **States**: Default uses the input surface; hover has no required visual change; active is native text editing; focus uses the documented ring; disabled uses native disabled semantics and 0.55 opacity; loading is inapplicable; empty is a valid value; error uses `aria-invalid` and associated error copy; read-only remains selectable, legible, and visually distinct from disabled through semantics rather than opacity. -- **Accessibility**: Visible label, native keyboard editing, preserved rows/value, and programmatic help/error association are required. Resize must not cause horizontal overflow. -- **Motion**: Same ring/border timing as Input. Resizing is user-driven and is not animated. - -### Checkbox - -- **Structure**: A 16px square checkbox control plus a visible text label in one row. The indicator renders checked or indeterminate state inside the box. -- **Variants**: Unchecked, checked, and indeterminate. There is no switch-style variant. -- **Sizes and spacing**: 16px box, 4px radius, one-pixel border, and 8px control-to-label gap. The label row may be taller than the box but must align centrally. -- **States**: Default may be unchecked or checked; hover uses the hover surface without changing geometry; active is the pressed native/bits state; focus uses the documented ring; disabled reports disabled, blocks pointer/Space callbacks, and uses 0.55 opacity; loading is inapplicable, with pending state owned by the surrounding setting or Alert; empty is inapplicable because checked state is explicit; error is inapplicable to the primitive, with validation communicated by adjacent error text or Alert; indeterminate is supported for the primitive showcase even when no current product checkbox consumes it. -- **Accessibility**: Expose checkbox role, accessible name from the visible label, accurate checked or mixed state, Space keyboard toggling, and disabled semantics. Controlled callbacks must preserve current store behavior. -- **Motion**: Indicator opacity/scale may transition in at most 100ms. Reduced motion removes scale and leaves an immediate state change. - -### Badge - -- **Structure**: Inline non-interactive text marker. It may include a small status icon only when the icon adds meaning. -- **Variants**: `secondary`, `success`, `warning`, `destructive`, and `outline`. -- **Sizes and spacing**: Caption typography, pill radius, 8px horizontal padding, and no independent minimum height beyond the caption line. -- **States**: Default renders the assigned semantic variant; hover, active, focus, disabled, and loading are inapplicable because Badge is not interactive; empty is inapplicable because a blank badge is forbidden; error is represented by the destructive variant, but error announcements belong to Alert. -- **Accessibility**: Text must carry the state meaning; color alone is insufficient. Decorative icons are hidden from assistive technology. Do not use a badge as a button or tooltip trigger. -- **Motion**: None. Badge content changes immediately without animation or layout flourish. - -### Alert - -- **Structure**: Compact alert root with optional semantic icon, text container, optional title, optional description, and optional single AlertAction aligned without nesting controls. -- **Variants**: `default`, `success`, `warning`, and `destructive`. -- **Sizes and spacing**: One-pixel subtle border, 6px radius, muted surface, 8px padding and 8px internal gap. Text uses the small scale. An action uses the 24px Button size when space is constrained. -- **States**: Default is a neutral status row; hover, active, and focus are inapplicable to the alert root, while AlertAction owns its own interactive states; disabled is inapplicable to the root; loading is supported only when the owning region supplies `aria-busy` and a Spinner/action state; empty is inapplicable because an alert requires meaningful copy; error is the destructive variant with error role where the existing channel requires it. Success and warning remain separate tones. -- **Accessibility**: The primitive defaults to `role="alert"`, but consumers must be able to override the role. Existing warning channels use `role="status"`; non-live summaries pass no live-region role; errors remain alerts. Icons are hidden from assistive technology and actions retain accessible names. -- **Motion**: No decorative entry animation is required for inline status rows. Content appears without moving surrounding geometry. Spinner or action motion is delegated to those primitives. - -### Dialog - -- **Structure**: Root, Trigger, Portal, Overlay, Content, Header, Title, Description, Footer, and Close composition. Delete confirmation uses one destructive trigger Button, title, explanatory description, secondary cancel Button, and destructive confirm Button. No extra close icon. -- **Variants**: Default modal shell and destructive-confirmation composition. AlertDialog is not an approved substitute. -- **Sizes and spacing**: Content width is `min(100%, 420px)` inside 20px viewport padding, with 20px content padding, 12px content gap, 8px action gap, 8px radius, one-pixel default border, and overlay-only elevation. -- **States**: Default is closed; hover and active belong to Trigger/Close/confirm Buttons; focus is trapped inside open content with visible rings on controls; disabled applies to a disabled trigger or action, not the dialog root; loading may occur after confirmation but the current delete contract closes before awaiting the command; empty is inapplicable because title and description are required; error is not shown by reopening the dialog and instead appears through the store-owned status Alert; open state is modal and blocks background interaction. -- **Accessibility**: `role="dialog"`, `aria-modal="true"`, labelled title, descriptive copy, default Escape handling, focus trap, and automatic trigger-focus restoration are required. Deletion sets outside interaction to ignore. Confirm/cancel order and existing copy remain unchanged. -- **Motion**: Overlay opacity and content opacity/scale may animate for 150ms ease-out. Reduced motion removes scale and uses an immediate or opacity-only transition. No slide across the window and no background layout animation. - -### Spinner - -- **Structure**: One decorative loading icon inside a control or state block. The owning region supplies readable loading text and live/busy semantics. -- **Variants**: 12px inline, 16px standard, and 24px state-block sizes. -- **Sizes and spacing**: Uses the existing icon scale and occupies a fixed square so pending state cannot resize its owner. -- **States**: Default means actively loading; hover, active, focus, and disabled are inapplicable because Spinner is not interactive; loading is its only semantic state; empty and error are inapplicable, and error replaces loading through Alert/status content. -- **Accessibility**: The icon is hidden from assistive technology. The owning Button or region uses `aria-busy`, and a nearby live region provides text such as loading, switching, or updating. -- **Motion**: One-second linear rotation. Under reduced motion, rotation stops; the static icon and readable loading text remain. - -### Separator - -- **Structure**: One horizontal or vertical line between owned regions. It may be semantic when it separates named groups or decorative when the surrounding structure already communicates grouping. -- **Variants**: Horizontal and vertical orientations. -- **Sizes and spacing**: One-pixel line using the default or subtle border tone. Spacing is owned by the surrounding layout, not embedded as arbitrary separator margins. -- **States**: Default is visible; hover, active, focus, disabled, loading, empty, and error are all inapplicable because Separator is non-interactive and carries no state. -- **Accessibility**: Use semantic separator role and orientation only when the boundary adds structure for assistive technology; otherwise mark it decorative. -- **Motion**: None. Separators never animate, grow, or draw in. - -### Tooltip - -- **Structure**: One Provider, Root, Trigger, Portal, and Content. Trigger props are applied to the single existing interactive control, usually an icon Button; no wrapper button is introduced. -- **Variants**: One compact informational style. It names icon-only New and Copy actions and similarly unfamiliar compact controls. -- **Sizes and spacing**: Small typography, 4px vertical and 8px horizontal padding, 6px radius, one-pixel border, and overlay elevation. Provider delay is 300ms. -- **States**: Default is closed/rest; hover opens after the delay; active is inapplicable to content; focus opens from keyboard focus on the trigger; disabled must not be the only way to explain why a control is disabled and the accessible name remains present; loading, empty, and error are inapplicable. Open content must not change trigger geometry. -- **Accessibility**: Tooltip text supplements, never replaces, the trigger accessible name. Keyboard focus must reveal it. Content is concise, non-interactive, and dismisses on Escape or loss of trigger context according to the underlying primitive. -- **Motion**: Opacity plus a minimal transform may animate for at most 100ms. Reduced motion removes the transform. No bouncing, cursor-following, or delayed layout movement. - -### Primitive Consumption Matrix - -| Product surface | Button | Input | Textarea | Checkbox | Badge | Alert | Dialog | Spinner | Separator | Tooltip | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Quick Switch root | Settings | - | - | - | - | - | - | Initial loading | Footer boundary | - | -| Current group summary and values | - | - | - | - | - | - | - | - | - | - | -| Quick Switch targets | Switch action | - | - | - | Active marker only when semantics remain equivalent | Empty/status composition when required | - | Pending switch | - | - | -| Settings shell/navigation | Ghost navigation | - | - | - | - | Shell status messages | - | Initial loading | Sidebar/header boundaries | - | -| Groups list | New, select, copy | - | - | - | Active and Draft | - | - | - | List/editor boundaries | New and Copy icon actions | -| Groups editor | New, Save, Cancel, Delete, Switch, Replace, Add | Metadata and mapping fields | Description | Enabled | Counts/status where already concise | Validation and degraded feedback | Delete confirmation | Pending action only where currently represented | Section/footer boundaries | Icon-only actions only | -| Primitive QA fixture only | Every approved variant/state | Every approved state | Every approved state | All three values/states | Every tone | Every tone/action role | Closed/open/focus/Escape | Normal/reduced motion | Both orientations | Rest/hover/focus/open | - -## 6. Motion & Interaction - -### Timing - -| Interaction | Duration | Easing | Contract | -| --- | --- | --- | --- | -| Hover, pressed, focus color feedback | `100-150ms` | `ease-out` | Color, border, outline, and opacity only | -| Checkbox indicator | `100ms` | `ease-out` | Opacity and minimal scale only | -| Tooltip open/close | `100ms` | `ease-out` | Opacity and minimal transform | -| Dialog open/close | `150ms` | `ease-out` | Overlay opacity and content opacity/minimal scale | -| Spinner | `1000ms` | `linear` | Continuous rotation while busy | - -### Interaction Rules - -- Motion communicates hover, pressed, focus, opening, closing, or pending work. There is no decorative idle animation. -- Animate only transform, opacity, filter, color, border color, background color, and outline color. Never animate layout dimensions, grid tracks, padding, margin, or position. -- Hover never carries information that is unavailable through text, selection state, focus, or accessible semantics. -- Active navigation uses current-page semantics. Active groups and switch targets retain explicit text or accessible labels in addition to tonal selection. -- Pending operations expose `aria-busy`, block duplicate activation where required, retain control geometry, and preserve other visible status channels. -- Focus is never removed. Every interactive primitive uses the same 2px ring and 2px offset. Focus restoration after switching and dialog closure is part of product behavior, not optional polish. -- Disabled means unavailable and non-interactive. Read-only means inspectable/selectable but not editable. Degraded means a subsection is constrained while saved values and an explanation remain visible. -- Error, warning, success, and informational messages preserve their current live-region roles. Do not convert every status row into an assertive alert. -- Under `prefers-reduced-motion: reduce`, stop Spinner rotation, remove dialog/tooltip scale or translation, and make non-essential state transitions immediate. State, text, focus, and busy semantics remain unchanged. - -## 7. Depth & Surface - -### Strategy: Border and Tonal Shift with Overlay-Only Elevation - -The single depth strategy is a compact combination of one-pixel boundaries and adjacent tonal shifts. The app does not simulate floating web cards. Macro panes are separated by default borders; sections use subtle borders; selected, hovered, active, toolbar, sidebar, status, and degraded regions use the documented surface tones. Shadows are exceptional elevation for the outer floating window and portal-based overlays only. - -| Depth level | Treatment | Approved use | -| --- | --- | --- | -| Base | App surface behind panel surface | Native/browser fixture background | -| Panel | Panel surface with no shadow | Quick Switch and Settings detail panes | -| Muted structural surface | Muted surface plus default or subtle boundary | Sidebar, toolbar, alerts, empty state | -| Interactive tonal state | Hover or active surface, no new shadow | Rows, navigation, ghost/secondary controls | -| Degraded boundary | Muted surface, one-pixel warning border, 8px radius | Read-only OpenCode agent-mapping and unavailable operating-system sections | -| Window elevation | Existing window shadow plus one-pixel outer frame | Floating Quick Switch/settings fixture only | -| Overlay elevation | Existing popover shadow plus one-pixel default border | Dialog and Tooltip only | - -Status does not create a separate elevation system. Success, warning, error, and information use semantic icon/text/border treatment inside the same muted or panel surfaces. Dialog backdrops use a token-derived translucent app surface; they are not gradients or decorative atmosphere. - -No component may introduce an undeclared shadow, border width, border color, blur, inset highlight, glass treatment, or elevation level. New depth requires an explicit update to this section before implementation. - -### Prohibited Patterns - -- No stock shadcn dashboard composition, generic admin template, marketing hero, feature-card grid, explanatory promo copy, or decorative visual asset. -- No Card, Sidebar, Tabs, Switch, AlertDialog, Label, Formsnap, Superforms, schema-driven form layer, or registry primitive outside the ten approved Section 5 primitives. -- No cards around page sections, nested cards, floating section containers, or card-like wrappers around the existing panes. -- No gradients, decorative blobs, orbs, bokeh, noise overlays, glassmorphism, backdrop blur, or ornamental shadows. -- No radius larger than 8px except the pill radius used only for badges and status dots. -- No roomy web spacing, oversized headings, viewport-scaled type, negative letter spacing, or mobile-first landing-page proportions. -- No new color outside Section 2, no hard-coded visual spacing outside the 4px scale, and no ad hoc control height. -- No theme toggle, persisted theme preference, class-based dark mode, or product-specific override of the operating-system theme. -- No animation that ignores reduced motion, changes layout geometry, delays a command, or serves only decoration. -- No text clipping, hidden action labels, icon-only control without an accessible name, or tooltip used as the sole accessible label. -- No undeclared borders or shadows. If a surface cannot be explained by the depth table above, it is outside this contract. diff --git a/README.md b/README.md index 0b6742d..4afc6eb 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# omo-switch +# opencode-mom -omo-switch is a cross-platform desktop app for switching Oh My OpenAgent model groups. It maintains named category mappings, Oh My OpenAgent agent overrides, and optional OpenCode agent model overrides, then syncs the selected group into the matching config files. +opencode-mom is a cross-platform desktop app for switching Oh My OpenAgent model groups. It maintains named category mappings, Oh My OpenAgent agent overrides, and optional OpenCode agent model overrides, then syncs the selected group into the matching config files. -The active application is built with Tauri 2. The Svelte 5 and TypeScript frontend provides the quick-switch and settings interfaces, while the Rust backend owns config persistence, projection, backups, tray integration, and native Windows/macOS behavior. +The active application is built with Tauri 2. The Svelte 5 and TypeScript frontend provides a single-window management console, while the Rust backend owns config persistence, projection, backups, and native Windows/macOS behavior. ## Features - Model groups with category and agent mappings. -- Quick switching from the system tray or menu bar. +- Single-window desktop management console. - Conditional OpenCode agent model overrides. - Native Windows NSIS and macOS app/DMG packages. @@ -16,44 +16,46 @@ The active application is built with Tauri 2. The Svelte 5 and TypeScript fronte - Node.js 22.23.0 and npm. - Rust 1.77.2 or later with Cargo. CI and release workflows build with Rust 1.96.0. - Platform prerequisites required by Tauri 2 for Windows or macOS desktop builds. -- Oh My OpenAgent config location: `~/.config/opencode/oh-my-openagent.json`. -- Optional OpenCode config location: `~/.config/opencode/opencode.json`. +- Oh My OpenAgent config location: `~/.omo/omo.jsonc`. +- Slim config location: `~/.config/opencode/oh-my-opencode-slim.jsonc`. +- OpenCode config location: `~/.config/opencode/opencode.jsonc` (`OPENCODE_CONFIG_DIR` can override). ## Download Tagged release workflows produce these Tauri artifacts: -- GitHub Release asset: `omo-switch_0.1.0_x64-setup.exe` (Windows NSIS installer). -- GitHub Release asset: `omo-switch_0.1.0_aarch64.dmg` (macOS disk image). -- GitHub Release asset: `omo-switch_0.1.0_aarch64.app.tar.gz` (macOS app bundle archive preserving bundle metadata and executable permissions). +- GitHub Release asset: `opencode-mom_0.1.0_x64-setup.exe` (Windows NSIS installer). +- GitHub Release asset: `opencode-mom_0.1.0_aarch64.dmg` (macOS disk image). +- GitHub Release asset: `opencode-mom_0.1.0_aarch64.app.tar.gz` (macOS app bundle archive preserving bundle metadata and executable permissions). - + ## Unsigned macOS builds -Current macOS release artifacts are unsigned and not notarized. macOS may block the app or require approval in **System Settings > Privacy & Security**. After moving `omo-switch.app` into `/Applications`, the quarantine attribute can be removed manually: +Current macOS release artifacts are unsigned and not notarized. macOS may block the app or require approval in **System Settings > Privacy & Security**. After moving `opencode-mom.app` into `/Applications`, the quarantine attribute can be removed manually: ```bash -sudo xattr -dr com.apple.quarantine /Applications/omo-switch.app +sudo xattr -dr com.apple.quarantine /Applications/opencode-mom.app ``` No signing or notarization is performed by the current release workflow. ## Configuration behavior -omo-switch stores its own groups and selection state in one config file, separately from target application configs. +opencode-mom stores its own groups and selection state in one config file, separately from target application configs. -| File | Purpose | -| ----------------------------------------- | ----------------------------------------------------------------- | -| `~/.config/omo-switch/config.json` | omo-switch group definitions, selection, and write metadata. | -| `~/.config/opencode/oh-my-openagent.json` | Rewritten when switching groups or saving the active group. | -| `~/.config/opencode/opencode.json` | Patched only when effective OpenCode agent model overrides exist. | +| File | Purpose | +| ---------------------------------------------- | ----------------------------------------------------------------- | +| `~/.config/opencode-mom/config.json` | opencode-mom group definitions, selection, and write metadata. | +| `~/.omo/omo.jsonc` | Rewritten when switching groups or saving the active group. | +| `~/.config/opencode/oh-my-opencode-slim.jsonc` | Rewritten when switching Slim-type groups. | +| `~/.config/opencode/opencode.jsonc` | Patched only when effective OpenCode agent model overrides exist. | -Before rewriting target configs, omo-switch creates backups under its config directory. Only `config.json` is read or written for omo-switch data; legacy split files are ignored and are not migrated. +Before rewriting target configs, opencode-mom creates backups under its config directory. Only `config.json` is read or written for opencode-mom data; legacy split files are ignored and are not migrated. -When switching groups, omo-switch rewrites the Oh My OpenAgent projection. Saving the active group reapplies that projection immediately. OpenCode sync is skipped when no effective OpenCode overrides exist, so a missing `opencode.json` only blocks operations that actually require OpenCode changes. +When switching groups, opencode-mom rewrites the Oh My OpenAgent projection. Saving the active group reapplies that projection immediately. OpenCode sync is skipped when no effective OpenCode overrides exist, so a missing `opencode.jsonc` only blocks operations that actually require OpenCode changes. -For OpenCode, omo-switch patches only `agent..model`. Existing fields inside agent objects and unrelated top-level keys such as `$schema`, `plugin`, and `provider` are preserved. +For OpenCode, opencode-mom patches only `agent..model`. Existing fields inside agent objects and unrelated top-level keys such as `$schema`, `plugin`, and `provider` are preserved. ## Development @@ -63,36 +65,24 @@ Install dependencies: npm ci ``` -Run the frontend and contract tests: - -```bash -npm test -``` - -Run the six native delivery contract tests from PowerShell: - -```powershell -$env:OMO_CHARACTERIZE_NATIVE_GAPS='1'; npm test -- --run src/native-gaps.test.ts -``` - -Run the same six tests from a POSIX shell: +Run the Tauri app in development: ```bash -OMO_CHARACTERIZE_NATIVE_GAPS=1 npm test -- --run src/native-gaps.test.ts +npm run tauri dev ``` -Run the Tauri app in development: +Format frontend code: ```bash -npm run tauri dev +npm run format ``` -Build the frontend and Rust backend independently: +Build the frontend and check the Rust backend independently: ```bash npm run build -cargo test --manifest-path src-tauri/Cargo.toml cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check +cargo check --manifest-path src-tauri/Cargo.toml ``` Build native Tauri packages for the current platform: @@ -101,11 +91,11 @@ Build native Tauri packages for the current platform: npm run tauri build ``` -The configured package identity is `dev.seho.omo-switch`, product name `omo-switch`, version `0.1.0`. Tauri targets are Windows NSIS plus macOS app and DMG. +The configured package identity is `dev.seho.opencode-mom`, product name `opencode-mom`, version `0.1.0`. Tauri targets are Windows NSIS plus macOS app and DMG. ## Release process -Pushing a `v*` tag starts the release workflow. Each build job first requires the pushed tag to exactly equal `v` from `src-tauri/tauri.conf.json`, then uses the pinned Node and Rust versions above to build and assert the exact Tauri artifact paths. The macOS job packages `omo-switch.app` as `omo-switch_0.1.0_aarch64.app.tar.gz` with macOS `tar` so bundle metadata and executable permissions survive transfer. A least-privilege publishing job downloads each platform artifact into an explicit directory and creates or updates the GitHub Release with the exact NSIS installer, app archive, and DMG. +Pushing a `v*` tag starts the release workflow. Each build job first requires the pushed tag to exactly equal `v` from `src-tauri/tauri.conf.json`, then uses the pinned Node and Rust versions above to build and assert the exact Tauri artifact paths. The macOS job packages `opencode-mom.app` as `opencode-mom_0.1.0_aarch64.app.tar.gz` with macOS `tar` so bundle metadata and executable permissions survive transfer. A least-privilege publishing job downloads each platform artifact into an explicit directory and creates or updates the GitHub Release with the exact NSIS installer, app archive, and DMG. ## License diff --git a/fixtures/compatibility/backups/oh-my-openagent/20231114T221320Z-oh-my-openagent.json b/fixtures/compatibility/backups/oh-my-openagent/20231114T221320Z-oh-my-openagent.json deleted file mode 100644 index b857395..0000000 --- a/fixtures/compatibility/backups/oh-my-openagent/20231114T221320Z-oh-my-openagent.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agents": {}, - "categories": {}, - "snapshot": "20231114T221320Z" -} diff --git a/fixtures/compatibility/backups/oh-my-openagent/20231114T221321Z-oh-my-openagent.json b/fixtures/compatibility/backups/oh-my-openagent/20231114T221321Z-oh-my-openagent.json deleted file mode 100644 index aa3c21e..0000000 --- a/fixtures/compatibility/backups/oh-my-openagent/20231114T221321Z-oh-my-openagent.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agents": {}, - "categories": {}, - "snapshot": "20231114T221321Z" -} diff --git a/fixtures/compatibility/backups/oh-my-openagent/20231114T221322Z-oh-my-openagent.json b/fixtures/compatibility/backups/oh-my-openagent/20231114T221322Z-oh-my-openagent.json deleted file mode 100644 index 787d539..0000000 --- a/fixtures/compatibility/backups/oh-my-openagent/20231114T221322Z-oh-my-openagent.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agents": {}, - "categories": {}, - "snapshot": "20231114T221322Z" -} diff --git a/fixtures/compatibility/backups/oh-my-openagent/20231114T221323Z-oh-my-openagent.json b/fixtures/compatibility/backups/oh-my-openagent/20231114T221323Z-oh-my-openagent.json deleted file mode 100644 index 44f7012..0000000 --- a/fixtures/compatibility/backups/oh-my-openagent/20231114T221323Z-oh-my-openagent.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agents": {}, - "categories": {}, - "snapshot": "20231114T221323Z" -} diff --git a/fixtures/compatibility/backups/oh-my-openagent/20231114T221324Z-oh-my-openagent.json b/fixtures/compatibility/backups/oh-my-openagent/20231114T221324Z-oh-my-openagent.json deleted file mode 100644 index 19b8b87..0000000 --- a/fixtures/compatibility/backups/oh-my-openagent/20231114T221324Z-oh-my-openagent.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agents": {}, - "categories": {}, - "snapshot": "20231114T221324Z" -} diff --git a/fixtures/compatibility/backups/oh-my-openagent/20231114T221325Z-oh-my-openagent.json b/fixtures/compatibility/backups/oh-my-openagent/20231114T221325Z-oh-my-openagent.json deleted file mode 100644 index 9d26262..0000000 --- a/fixtures/compatibility/backups/oh-my-openagent/20231114T221325Z-oh-my-openagent.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agents": {}, - "categories": {}, - "snapshot": "20231114T221325Z" -} diff --git a/fixtures/compatibility/config/current.json b/fixtures/compatibility/config/current.json deleted file mode 100644 index 573139d..0000000 --- a/fixtures/compatibility/config/current.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "groups": [ - { - "id": "4f648c2a-1d48-44d6-b0c0-4d340816e1f3", - "name": "Primary", - "description": "Current compatibility group", - "categoryMappings": [ - { - "categoryName": "quick", - "modelRef": "cliproxyapi/gpt-5.4" - } - ], - "agentOverrides": [ - { - "agentName": "oracle", - "modelRef": "cliproxyapi/gpt-5.4-xhigh" - } - ], - "openCodeAgentOverrides": [ - { - "agentName": "reviewer", - "modelRef": "cliproxyapi/gpt-5.4" - } - ], - "isEnabled": true, - "updatedAt": "2023-11-14T22:13:20Z" - } - ], - "state": { - "selectedGroupID": "4f648c2a-1d48-44d6-b0c0-4d340816e1f3", - "selectedGroupName": "Primary", - "lastSuccessfulWrite": { - "target": "switch-group", - "wroteAt": "2023-11-14T22:21:40Z", - "backupPath": "fixtures/compatibility/backups/oh-my-openagent/20231114T221325Z-oh-my-openagent.json" - }, - "lastWarningSummary": null, - "lastErrorSummary": null, - "migrationVersion": 2 - } -} diff --git a/fixtures/compatibility/config/malformed.json b/fixtures/compatibility/config/malformed.json deleted file mode 100644 index 81e9ed2..0000000 --- a/fixtures/compatibility/config/malformed.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "groups": "not an array", - "state": {} -} diff --git a/fixtures/compatibility/json/with-conflict-write-baseline.json b/fixtures/compatibility/json/with-conflict-write-baseline.json deleted file mode 100644 index b7ab630..0000000 --- a/fixtures/compatibility/json/with-conflict-write-baseline.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "plugin": ["oh-my-openagent"], - "agent": { - "test-agent": { - "model": "cliproxyapi/gpt-5.4", - "mode": "subagent", - "prompt": "{file:./prompts/test.md}" - } - }, - "provider": { - "cliproxyapi": { - "name": "CLIProxyAPI", - "models": { - "gpt-5.4": { - "name": "gpt-5.4" - } - } - } - } -} diff --git a/fixtures/compatibility/json/with-jsonc-markers.jsonc b/fixtures/compatibility/json/with-jsonc-markers.jsonc deleted file mode 100644 index 204aacf..0000000 --- a/fixtures/compatibility/json/with-jsonc-markers.jsonc +++ /dev/null @@ -1,20 +0,0 @@ -{ - // This JSONC fixture exercises line comments. - "provider": { - "cliproxyapi": { - "name": "CLIProxyAPI", - "endpoint": "https://example.invalid/v1" - } - }, - "agents": { - "test": { - "model": "cliproxyapi/gpt-5.4" - } - }, - /* This JSONC fixture also exercises block comments. */ - "categories": { - "test": { - "model": "cliproxyapi/minimax-m2.7" - } - } -} diff --git a/fixtures/compatibility/json/with-undeclared-model-ref.json b/fixtures/compatibility/json/with-undeclared-model-ref.json deleted file mode 100644 index 372cfab..0000000 --- a/fixtures/compatibility/json/with-undeclared-model-ref.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "agents": { - "oracle": { - "model": "cliproxyapi/gpt-5.4" - } - }, - "categories": { - "deep": { - "model": "cliproxyapi/minimax-m2.7" - } - }, - "provider": { - "cliproxyapi": { - "models": { - "minimax-m2.7": { - "name": "minimax-m2.7" - }, - "note": "gpt-5.4 is intentionally not declared" - } - } - } -} diff --git a/fixtures/compatibility/json/with-unknown-fields.json b/fixtures/compatibility/json/with-unknown-fields.json deleted file mode 100644 index 5c2e4d3..0000000 --- a/fixtures/compatibility/json/with-unknown-fields.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "knownField": "value", - "unknownField": "should be preserved", - "nestedUnknown": { - "deep": "value", - "anotherUnknown": true - }, - "provider": { - "cliproxyapi": { - "name": "CLIProxyAPI" - } - }, - "_customMarker": "\u4fdd\u7559\u7684\u672a\u77e5\u5b57\u6bb5", - "someExtraData": { - "key": "value", - "numbers": [1, 2, 3] - } -} diff --git a/fixtures/compatibility/json/with-weird-key-order.json b/fixtures/compatibility/json/with-weird-key-order.json deleted file mode 100644 index 3d6b061..0000000 --- a/fixtures/compatibility/json/with-weird-key-order.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "zebra": "last source key", - "agent": { - "model": "cliproxyapi/minimax-m2.7", - "mode": "subagent", - "prompt": "{file:./prompts/test.md}" - }, - "aardvark": "first alphabetic key", - "provider": { - "cliproxyapi": { - "models": { - "betaModel": { - "name": "beta" - }, - "alphaModel": { - "name": "alpha" - } - } - } - }, - "middle": "between aardvark and zebra" -} diff --git a/fixtures/compatibility/ohmy/current-oh-my-openagent.json b/fixtures/compatibility/ohmy/current-oh-my-openagent.json deleted file mode 100644 index 6463eeb..0000000 --- a/fixtures/compatibility/ohmy/current-oh-my-openagent.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://example.invalid/oh-my-openagent.schema.json", - "agents": { - "oracle": { - "model": "cliproxyapi/gpt-5.4", - "variant": "high" - }, - "librarian": { - "model": "cliproxyapi/minimax-m2.7" - } - }, - "categories": { - "quick": { - "model": "cliproxyapi/minimax-m2.7" - }, - "unspecified-high": { - "model": "cliproxyapi/gpt-5.4" - } - } -} diff --git a/fixtures/compatibility/ohmy/legacy-oh-my-opencode.json.bak b/fixtures/compatibility/ohmy/legacy-oh-my-opencode.json.bak deleted file mode 100644 index 0336640..0000000 --- a/fixtures/compatibility/ohmy/legacy-oh-my-opencode.json.bak +++ /dev/null @@ -1,15 +0,0 @@ -{ - "agents": { - "oracle": { - "model": "cliproxyapi/gpt-5.4" - }, - "librarian": { - "model": "cliproxyapi/minimax-m2.7" - } - }, - "categories": { - "visual-engineering": { - "model": "cliproxyapi/gemini-3.1-pro-preview" - } - } -} diff --git a/fixtures/compatibility/opencode/current-opencode.json b/fixtures/compatibility/opencode/current-opencode.json deleted file mode 100644 index 82581f6..0000000 --- a/fixtures/compatibility/opencode/current-opencode.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "plugin": ["oh-my-openagent"], - "agent": { - "creative-ui-coder": { - "model": "cliproxyapi/gemini-3.1-pro-high", - "description": "UI implementation fixture agent.", - "mode": "subagent", - "prompt": "{file:./prompts/creative-ui-coder.md}" - }, - "Jenny": { - "model": "cliproxyapi/minimax-m2.7", - "description": "Specification fixture agent.", - "mode": "subagent" - }, - "karen": { - "model": "cliproxyapi/minimax-m2.7", - "description": "Completion fixture agent.", - "mode": "subagent" - } - }, - "provider": { - "cliproxyapi": { - "name": "CLIProxyAPI", - "options": { - "baseURL": "https://example.invalid/v1", - "apiKey": "SANITIZED_API_KEY_PLACEHOLDER" - } - } - } -} diff --git a/fixtures/compatibility/targets/malformed-oh-my-openagent.jsonc b/fixtures/compatibility/targets/malformed-oh-my-openagent.jsonc deleted file mode 100644 index e5e8ee3..0000000 --- a/fixtures/compatibility/targets/malformed-oh-my-openagent.jsonc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "agents": { - "oracle": - } -} diff --git a/fixtures/compatibility/targets/malformed-opencode.jsonc b/fixtures/compatibility/targets/malformed-opencode.jsonc deleted file mode 100644 index d342b07..0000000 --- a/fixtures/compatibility/targets/malformed-opencode.jsonc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "agent": "unterminated -} diff --git a/fixtures/compatibility/targets/oh-my-openagent.jsonc b/fixtures/compatibility/targets/oh-my-openagent.jsonc deleted file mode 100644 index 7c5ea27..0000000 --- a/fixtures/compatibility/targets/oh-my-openagent.jsonc +++ /dev/null @@ -1,18 +0,0 @@ -{ - // Target config preserves unrelated top-level data. - "$schema": "https://example.invalid/oh-my-openagent.schema.json", - "agents": { - "oracle": { - "model": "cliproxyapi/gpt-5.4", - "variant": "xhigh" - } - }, - "categories": { - "quick": { - "model": "cliproxyapi/gpt-5.4" - } - }, - "unrelated": { - "preserve": true - } -} diff --git a/fixtures/compatibility/targets/opencode.jsonc b/fixtures/compatibility/targets/opencode.jsonc deleted file mode 100644 index e5d8040..0000000 --- a/fixtures/compatibility/targets/opencode.jsonc +++ /dev/null @@ -1,15 +0,0 @@ -{ - /* OpenCode configuration fixture. */ - "$schema": "https://opencode.ai/config.json", - "agent": { - "reviewer": { - "model": "cliproxyapi/gpt-5.4", - "mode": "primary" - } - }, - "provider": { - "cliproxyapi": { - "name": "CLIProxyAPI" - } - } -} diff --git a/fixtures/compatibility/targets/trailing-comma-oh-my-openagent.jsonc b/fixtures/compatibility/targets/trailing-comma-oh-my-openagent.jsonc deleted file mode 100644 index 28edce6..0000000 --- a/fixtures/compatibility/targets/trailing-comma-oh-my-openagent.jsonc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "agents": {}, - "categories": {}, -} diff --git a/fixtures/compatibility/temp-homes/conditional-missing-opencode/.config/omo-switch/config.json b/fixtures/compatibility/temp-homes/conditional-missing-opencode/.config/omo-switch/config.json deleted file mode 100644 index 664c2e8..0000000 --- a/fixtures/compatibility/temp-homes/conditional-missing-opencode/.config/omo-switch/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "groups": [], - "state": { - "migrationVersion": 2 - } -} diff --git a/fixtures/compatibility/temp-homes/conditional-missing-opencode/.config/opencode/oh-my-openagent.json b/fixtures/compatibility/temp-homes/conditional-missing-opencode/.config/opencode/oh-my-openagent.json deleted file mode 100644 index 4f66fb0..0000000 --- a/fixtures/compatibility/temp-homes/conditional-missing-opencode/.config/opencode/oh-my-openagent.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "agents": {}, - "categories": {} -} diff --git a/fixtures/compatibility/temp-homes/full/.config/omo-switch/config.json b/fixtures/compatibility/temp-homes/full/.config/omo-switch/config.json deleted file mode 100644 index a9ff82e..0000000 --- a/fixtures/compatibility/temp-homes/full/.config/omo-switch/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "groups": [], - "state": { - "selectedGroupID": null, - "selectedGroupName": null, - "lastSuccessfulWrite": null, - "lastWarningSummary": null, - "lastErrorSummary": null, - "migrationVersion": 2 - } -} diff --git a/fixtures/compatibility/temp-homes/full/.config/opencode/oh-my-openagent.json b/fixtures/compatibility/temp-homes/full/.config/opencode/oh-my-openagent.json deleted file mode 100644 index fb5701c..0000000 --- a/fixtures/compatibility/temp-homes/full/.config/opencode/oh-my-openagent.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "agents": { - "oracle": { - "model": "cliproxyapi/gpt-5.4" - } - }, - "categories": { - "quick": { - "model": "cliproxyapi/minimax-m2.7" - } - } -} diff --git a/fixtures/compatibility/temp-homes/full/.config/opencode/opencode.json b/fixtures/compatibility/temp-homes/full/.config/opencode/opencode.json deleted file mode 100644 index 350c634..0000000 --- a/fixtures/compatibility/temp-homes/full/.config/opencode/opencode.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "reviewer": { - "model": "cliproxyapi/gpt-5.4", - "mode": "primary" - } - }, - "provider": { - "cliproxyapi": { - "name": "CLIProxyAPI" - } - } -} diff --git a/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/.config.json.omo-txn-11111111-1111-1111-1111-111111111111.staged b/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/.config.json.omo-txn-11111111-1111-1111-1111-111111111111.staged deleted file mode 100644 index 03bc41d..0000000 --- a/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/.config.json.omo-txn-11111111-1111-1111-1111-111111111111.staged +++ /dev/null @@ -1 +0,0 @@ -stale staged config bytes diff --git a/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/.transaction-journal.json.tmp b/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/.transaction-journal.json.tmp deleted file mode 100644 index 7e82142..0000000 --- a/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/.transaction-journal.json.tmp +++ /dev/null @@ -1 +0,0 @@ -stale journal bytes diff --git a/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/config.json b/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/config.json deleted file mode 100644 index 664c2e8..0000000 --- a/fixtures/compatibility/temp-homes/stale-transaction/.config/omo-switch/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "groups": [], - "state": { - "migrationVersion": 2 - } -} diff --git a/fixtures/compatibility/temp-homes/stale-transaction/.config/opencode/oh-my-openagent.json b/fixtures/compatibility/temp-homes/stale-transaction/.config/opencode/oh-my-openagent.json deleted file mode 100644 index 4f66fb0..0000000 --- a/fixtures/compatibility/temp-homes/stale-transaction/.config/opencode/oh-my-openagent.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "agents": {}, - "categories": {} -} diff --git a/fixtures/compatibility/temp-homes/stale-transaction/.config/opencode/opencode.json b/fixtures/compatibility/temp-homes/stale-transaction/.config/opencode/opencode.json deleted file mode 100644 index 4a4ac61..0000000 --- a/fixtures/compatibility/temp-homes/stale-transaction/.config/opencode/opencode.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "reviewer": { - "model": "cliproxyapi/gpt-5.4" - } - } -} diff --git a/package-lock.json b/package-lock.json index 34e9a5c..176c277 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "omo-switch", + "name": "opencode-mom", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "omo-switch", + "name": "opencode-mom", "version": "0.1.0", "dependencies": { "@lucide/svelte": "1.24.0", @@ -17,237 +17,18 @@ }, "devDependencies": { "@internationalized/date": "3.12.2", - "@playwright/test": "^1.61.1", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.43.5", "@sveltejs/vite-plugin-svelte": "^6.2.2", "@tailwindcss/vite": "4.3.3", "@tauri-apps/cli": "^2.10.2", - "@testing-library/svelte": "^5.2.9", "bits-ui": "2.18.1", - "jsdom": "^27.3.0", + "prettier": "^3.9.6", + "prettier-plugin-svelte": "^4.1.1", "svelte": "^5.45.2", "tailwindcss": "4.3.3", "typescript": "^5.9.3", - "vite": "^7.2.7", - "vitest": "^4.0.16" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmmirror.com/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.9", - "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", - "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.2.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" + "vite": "^7.2.7" } }, "node_modules/@esbuild/aix-ppc64": { @@ -692,24 +473,6 @@ "node": ">=18" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", @@ -802,22 +565,6 @@ "svelte": "^5" } }, - "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1515,6 +1262,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -1791,94 +1604,6 @@ "node": ">= 10" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@testing-library/svelte": { - "version": "5.4.2", - "resolved": "https://registry.npmmirror.com/@testing-library/svelte/-/svelte-5.4.2.tgz", - "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@testing-library/dom": "9.x.x || 10.x.x", - "@testing-library/svelte-core": "1.1.3" - }, - "engines": { - "node": ">= 10" - }, - "peerDependencies": { - "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", - "vite": "*", - "vitest": "*" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@testing-library/svelte-core": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", - "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmmirror.com/@types/cookie/-/cookie-0.6.0.tgz", @@ -1886,13 +1611,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", @@ -1905,162 +1623,16 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "bin": { + "acorn": "bin/acorn" }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">=0.4.0" } }, "node_modules/aria-query": { @@ -2072,16 +1644,6 @@ "node": ">= 0.4" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2091,16 +1653,6 @@ "node": ">= 0.4" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/bits-ui": { "version": "2.18.1", "resolved": "https://registry.npmmirror.com/bits-ui/-/bits-ui-2.18.1.tgz", @@ -2126,16 +1678,6 @@ "svelte": "^5.33.0" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", @@ -2145,13 +1687,6 @@ "node": ">=6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.6.0.tgz", @@ -2162,85 +1697,6 @@ "node": ">= 0.6" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-6.0.1.tgz", - "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^15.1.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls/node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", @@ -2277,13 +1733,6 @@ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.24.2", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", @@ -2298,26 +1747,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", - "dev": true, - "license": "MIT" - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", @@ -2383,26 +1812,6 @@ } } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -2443,47 +1852,6 @@ "dev": true, "license": "ISC" }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -2491,13 +1859,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz", @@ -2517,53 +1878,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "27.4.0", - "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-27.4.0.tgz", - "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.28", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.6.0", - "cssstyle": "^5.3.4", - "data-urls": "^6.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", @@ -2841,16 +2155,6 @@ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "license": "MIT" }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", @@ -2870,13 +2174,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz", @@ -2887,13 +2184,6 @@ "node": ">=10" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", @@ -2927,26 +2217,6 @@ "node": ">=12.20.0" } }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -2967,53 +2237,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", @@ -3043,46 +2266,34 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" } }, "node_modules/rollup": { @@ -3155,19 +2366,6 @@ } } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/set-cookie-parser": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", @@ -3175,13 +2373,6 @@ "dev": true, "license": "MIT" }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/sirv/-/sirv-3.0.2.tgz", @@ -3207,20 +2398,6 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, "node_modules/style-to-object": { "version": "1.0.14", "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", @@ -3279,13 +2456,6 @@ "svelte": "^5.30.2" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/tabbable/-/tabbable-6.5.0.tgz", @@ -3342,23 +2512,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3376,36 +2529,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.4.7", - "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.7.tgz", - "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.7" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.7", - "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.7.tgz", - "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/totalist/-/totalist-3.0.1.tgz", @@ -3416,32 +2539,6 @@ "node": ">=6" } }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -3567,199 +2664,6 @@ } } }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmmirror.com/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 07999b5..7b4702a 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "omo-switch", + "name": "opencode-mom", "private": true, "version": "0.1.0", "type": "module", "scripts": { - "test": "vitest run", "build": "vite build", "dev": "vite dev", + "format": "prettier --write .", + "format:check": "prettier --check .", "preview": "vite preview", - "test:e2e": "npm run build && playwright test", "tauri": "tauri" }, "dependencies": { @@ -21,19 +21,17 @@ }, "devDependencies": { "@internationalized/date": "3.12.2", - "@playwright/test": "^1.61.1", "@sveltejs/adapter-static": "^3.0.9", "@sveltejs/kit": "^2.43.5", "@sveltejs/vite-plugin-svelte": "^6.2.2", "@tailwindcss/vite": "4.3.3", "@tauri-apps/cli": "^2.10.2", - "@testing-library/svelte": "^5.2.9", "bits-ui": "2.18.1", - "jsdom": "^27.3.0", + "prettier": "^3.9.6", + "prettier-plugin-svelte": "^4.1.1", "svelte": "^5.45.2", "tailwindcss": "4.3.3", "typescript": "^5.9.3", - "vite": "^7.2.7", - "vitest": "^4.0.16" + "vite": "^7.2.7" } } diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index dc936ea..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { defineConfig } from '@playwright/test'; - -declare const process: Readonly<{ - env: Readonly<{ CI?: string }>; -}>; - -export default defineConfig({ - testDir: './tests/e2e', - fullyParallel: true, - use: { - baseURL: 'http://127.0.0.1:4173' - }, - projects: [ - { - name: 'chromium', - use: { browserName: 'chromium' } - } - ], - webServer: { - command: 'npm run preview -- --host 127.0.0.1 --port 4173', - url: 'http://127.0.0.1:4173', - reuseExistingServer: !process.env.CI - } -}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bdd8f44..4e1e968 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -17,21 +17,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -286,27 +271,6 @@ dependencies = [ "piper", ] -[[package]] -name = "brotli" -version = "8.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "bs58" version = "0.5.1" @@ -650,17 +614,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "dbus" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1174,33 +1127,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1798,6 +1724,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "jsonc-parser" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362fdb0bfedf94ebd31e4e82ff38ba705b6b82417b3b25f0c4bbb024dd11f552" +dependencies = [ + "serde_json", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -1819,55 +1754,12 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading", - "once_cell", -] - [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "pkg-config", -] - -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libredox" version = "0.1.18" @@ -2167,7 +2059,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", - "block2", "objc2", "objc2-core-foundation", ] @@ -2241,25 +2132,27 @@ dependencies = [ ] [[package]] -name = "omo-switch" +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opencode-mom" version = "0.1.0" dependencies = [ + "dirs", + "jsonc-parser", "serde", "serde_json", + "serde_yaml", "tauri", "tauri-build", - "tauri-plugin-positioner", "tauri-plugin-single-instance", "time", "uuid", ] -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - [[package]] name = "option-ext" version = "0.2.0" @@ -2721,6 +2614,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[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" @@ -2943,6 +2842,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3195,12 +3107,10 @@ dependencies = [ "core-foundation", "core-graphics", "crossbeam-channel", - "dbus", "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", - "gdkx11-sys", "gtk", "jni", "libc", @@ -3221,7 +3131,6 @@ dependencies = [ "windows", "windows-core 0.61.2", "windows-version", - "x11-dl", ] [[package]] @@ -3284,7 +3193,6 @@ dependencies = [ "tauri-utils", "thiserror 2.0.18", "tokio", - "tray-icon", "url", "webkit2gtk", "webview2-com", @@ -3320,7 +3228,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", - "brotli", "ico", "json-patch", "plist", @@ -3354,37 +3261,6 @@ dependencies = [ "tauri-utils", ] -[[package]] -name = "tauri-plugin" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "walkdir", -] - -[[package]] -name = "tauri-plugin-positioner" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686204dc3171a2d59436e470c8ea99f08c5f5bf63ef1a40f900d6d48f433b816" -dependencies = [ - "log", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", -] - [[package]] name = "tauri-plugin-single-instance" version = "2.4.2" @@ -3458,7 +3334,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", - "brotli", "cargo_metadata", "ctor", "dom_query", @@ -3840,28 +3715,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tray-icon" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.18", - "windows-sys 0.61.2", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -3944,6 +3797,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.8" @@ -4708,7 +4567,6 @@ dependencies = [ "dom_query", "dpi", "dunce", - "gdkx11", "gtk", "http", "javascriptcore-rs", @@ -4735,28 +4593,6 @@ dependencies = [ "windows", "windows-core 0.61.2", "windows-version", - "x11-dl", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a57cc59..709dc37 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,16 +1,16 @@ [package] -name = "omo-switch" +name = "opencode-mom" version = "0.1.0" description = "Cross-platform desktop model group switcher" -authors = ["omo-switch contributors"] +authors = ["opencode-mom contributors"] edition = "2021" rust-version = "1.77.2" -default-run = "omo-switch" +default-run = "opencode-mom" [package.metadata] [lib] -name = "omo_switch_tauri" +name = "opencode_mom_tauri" crate-type = ["staticlib", "cdylib", "rlib"] path = "src/lib.rs" @@ -18,12 +18,12 @@ path = "src/lib.rs" tauri-build = { version = "2.5.3", features = [] } [dependencies] -tauri = { version = "2.10.2", features = ["tray-icon"] } +tauri = { version = "2.10.2", default-features = false, features = ["wry"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tauri-plugin-positioner = { version = "2.3.0", features = ["tray-icon"] } +serde_yaml = "0.9" +jsonc-parser = { version = "=0.33.0", features = ["cst", "serde_json"] } +dirs = "6.0" tauri-plugin-single-instance = "2.3.4" time = { version = "0.3", features = ["formatting", "parsing", "serde-well-known"] } uuid = { version = "1.11", features = ["serde", "v4"] } - -[dev-dependencies] diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 3abc0cb..0eebfc4 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"positioner":{"default_permission":{"identifier":"default","description":"Allows the moveWindow and handleIconState APIs","permissions":["allow-move-window","allow-move-window-constrained","allow-set-tray-icon-state"]},"permissions":{"allow-move-window":{"identifier":"allow-move-window","description":"Enables the move_window command without any pre-configured scope.","commands":{"allow":["move_window"],"deny":[]}},"allow-move-window-constrained":{"identifier":"allow-move-window-constrained","description":"Enables the move_window_constrained command without any pre-configured scope.","commands":{"allow":["move_window_constrained"],"deny":[]}},"allow-set-tray-icon-state":{"identifier":"allow-set-tray-icon-state","description":"Enables the set_tray_icon_state command without any pre-configured scope.","commands":{"allow":["set_tray_icon_state"],"deny":[]}},"deny-move-window":{"identifier":"deny-move-window","description":"Denies the move_window command without any pre-configured scope.","commands":{"allow":[],"deny":["move_window"]}},"deny-move-window-constrained":{"identifier":"deny-move-window-constrained","description":"Denies the move_window_constrained command without any pre-configured scope.","commands":{"allow":[],"deny":["move_window_constrained"]}},"deny-set-tray-icon-state":{"identifier":"deny-set-tray-icon-state","description":"Denies the set_tray_icon_state command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tray_icon_state"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index a53325f..3286645 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -2191,48 +2191,6 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." - }, - { - "description": "Allows the moveWindow and handleIconState APIs\n#### This default permission set includes:\n\n- `allow-move-window`\n- `allow-move-window-constrained`\n- `allow-set-tray-icon-state`", - "type": "string", - "const": "positioner:default", - "markdownDescription": "Allows the moveWindow and handleIconState APIs\n#### This default permission set includes:\n\n- `allow-move-window`\n- `allow-move-window-constrained`\n- `allow-set-tray-icon-state`" - }, - { - "description": "Enables the move_window command without any pre-configured scope.", - "type": "string", - "const": "positioner:allow-move-window", - "markdownDescription": "Enables the move_window command without any pre-configured scope." - }, - { - "description": "Enables the move_window_constrained command without any pre-configured scope.", - "type": "string", - "const": "positioner:allow-move-window-constrained", - "markdownDescription": "Enables the move_window_constrained command without any pre-configured scope." - }, - { - "description": "Enables the set_tray_icon_state command without any pre-configured scope.", - "type": "string", - "const": "positioner:allow-set-tray-icon-state", - "markdownDescription": "Enables the set_tray_icon_state command without any pre-configured scope." - }, - { - "description": "Denies the move_window command without any pre-configured scope.", - "type": "string", - "const": "positioner:deny-move-window", - "markdownDescription": "Denies the move_window command without any pre-configured scope." - }, - { - "description": "Denies the move_window_constrained command without any pre-configured scope.", - "type": "string", - "const": "positioner:deny-move-window-constrained", - "markdownDescription": "Denies the move_window_constrained command without any pre-configured scope." - }, - { - "description": "Denies the set_tray_icon_state command without any pre-configured scope.", - "type": "string", - "const": "positioner:deny-set-tray-icon-state", - "markdownDescription": "Denies the set_tray_icon_state command without any pre-configured scope." } ] }, diff --git a/src-tauri/gen/schemas/windows-schema.json b/src-tauri/gen/schemas/windows-schema.json index a53325f..3286645 100644 --- a/src-tauri/gen/schemas/windows-schema.json +++ b/src-tauri/gen/schemas/windows-schema.json @@ -2191,48 +2191,6 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." - }, - { - "description": "Allows the moveWindow and handleIconState APIs\n#### This default permission set includes:\n\n- `allow-move-window`\n- `allow-move-window-constrained`\n- `allow-set-tray-icon-state`", - "type": "string", - "const": "positioner:default", - "markdownDescription": "Allows the moveWindow and handleIconState APIs\n#### This default permission set includes:\n\n- `allow-move-window`\n- `allow-move-window-constrained`\n- `allow-set-tray-icon-state`" - }, - { - "description": "Enables the move_window command without any pre-configured scope.", - "type": "string", - "const": "positioner:allow-move-window", - "markdownDescription": "Enables the move_window command without any pre-configured scope." - }, - { - "description": "Enables the move_window_constrained command without any pre-configured scope.", - "type": "string", - "const": "positioner:allow-move-window-constrained", - "markdownDescription": "Enables the move_window_constrained command without any pre-configured scope." - }, - { - "description": "Enables the set_tray_icon_state command without any pre-configured scope.", - "type": "string", - "const": "positioner:allow-set-tray-icon-state", - "markdownDescription": "Enables the set_tray_icon_state command without any pre-configured scope." - }, - { - "description": "Denies the move_window command without any pre-configured scope.", - "type": "string", - "const": "positioner:deny-move-window", - "markdownDescription": "Denies the move_window command without any pre-configured scope." - }, - { - "description": "Denies the move_window_constrained command without any pre-configured scope.", - "type": "string", - "const": "positioner:deny-move-window-constrained", - "markdownDescription": "Denies the move_window_constrained command without any pre-configured scope." - }, - { - "description": "Denies the set_tray_icon_state command without any pre-configured scope.", - "type": "string", - "const": "positioner:deny-set-tray-icon-state", - "markdownDescription": "Denies the set_tray_icon_state command without any pre-configured scope." } ] }, diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs new file mode 100644 index 0000000..b3c54b2 --- /dev/null +++ b/src-tauri/src/agents.rs @@ -0,0 +1,658 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::agents_md::MarkdownAgentDocument; +use crate::document::{read_text, write_file, JsoncDoc, OPENCODE_SCHEMA}; +use crate::error::{AppError, ErrorCode}; +use crate::providers::ModelRef; + +const BUILT_IN_AGENT_IDS: &[&str] = &["build", "plan", "general", "explore"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentStorage { + Inline, + GlobalMarkdown, + ProjectMarkdown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentSource { + Inline, + Markdown, + Both, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentReferenceKind { + DefaultAgent, + Command, + PermissionTask, + Group, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentReference { + pub kind: AgentReferenceKind, + pub owner: String, +} + +pub type AgentReferenceIndex = BTreeMap>; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InlineAgentSource { + pub raw: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarkdownAgentSource { + pub storage: AgentStorage, + pub path: PathBuf, + pub raw: String, + pub frontmatter: Map, + pub prompt: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentFieldOverride { + pub field: String, + pub inline: Value, + pub markdown: Value, + pub effective: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentDefinition { + pub id: String, + pub source: AgentSource, + pub inline: Option, + pub markdown: Vec, + pub effective: Value, + pub overrides: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentMutation { + #[serde(default)] + pub fields: Map, + pub prompt: Option, + #[serde(default)] + pub clear_fields: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCreate { + pub id: String, + pub storage: AgentStorage, + #[serde(default)] + pub fields: Map, + pub prompt: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentDeleteResult { + pub id: String, + pub deleted_storage: AgentStorage, + pub replacement: Option, + pub another_source_takes_over: bool, +} + +fn invalid(detail: impl Into) -> AppError { + AppError::validation(detail) +} + +fn not_found(id: &str) -> AppError { + AppError::not_found(format!("agent '{id}' was not found")) +} + +/// Lists every agent definition merged from inline (OpenCode config) and Markdown sources. +pub fn list(paths: &crate::paths::ConfigPaths) -> Result, AppError> { + let document = JsoncDoc::read(&paths.opencode_file(), OPENCODE_SCHEMA)?; + let inline = inline_agents(document.raw())?; + let markdown = scan_markdown(paths)?; + let ids = inline + .keys() + .chain(markdown.keys()) + .cloned() + .collect::>(); + + ids.into_iter() + .map(|id| definition_from_sources(&id, inline.get(&id), markdown.get(&id))) + .collect() +} + +pub fn get(paths: &crate::paths::ConfigPaths, id: &str) -> Result { + validate_agent_id(id)?; + list(paths)? + .into_iter() + .find(|definition| definition.id == id) + .ok_or_else(|| not_found(id)) +} + +/// Scans all agent sources for `model` references used by deletion protection. +pub fn model_references( + paths: &crate::paths::ConfigPaths, +) -> Result, AppError> { + let document = JsoncDoc::read(&paths.opencode_file(), OPENCODE_SCHEMA)?; + let inline = inline_agents(document.raw())?; + let markdown = scan_markdown(paths)?; + let mut references = Vec::new(); + for (id, raw) in inline { + if let Some(model) = raw + .get("model") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + references.push(crate::refs::ModelReference { + source: crate::refs::ReferenceSource::OpenCodeInlineAgent, + location: format!("agent.{id}.model"), + model_ref: ModelRef::parse(model).map_err(|_| invalid_model_ref(model))?, + }); + } + } + for (id, sources) in markdown { + for source in sources { + if let Some(model) = source + .frontmatter + .get("model") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + references.push(crate::refs::ModelReference { + source: crate::refs::ReferenceSource::MarkdownAgent, + location: format!("{}:{id}.model", source.path.display()), + model_ref: ModelRef::parse(model).map_err(|_| invalid_model_ref(model))?, + }); + } + } + } + references.sort(); + Ok(references) +} + +pub fn create( + paths: &crate::paths::ConfigPaths, + request: AgentCreate, +) -> Result { + validate_agent_id(&request.id)?; + validate_model(&request.fields)?; + let (fields, prompt, _) = normalize_mutation(&request.fields, request.prompt, Vec::new())?; + + match request.storage { + AgentStorage::Inline => create_inline(paths, &request.id, fields, prompt)?, + AgentStorage::GlobalMarkdown | AgentStorage::ProjectMarkdown => { + let path = markdown_path(paths, request.storage, &request.id)?; + if path.exists() { + return Err(invalid(format!( + "agent '{}' already exists at {}", + request.id, + path.display() + ))); + } + let mut document = MarkdownAgentDocument::empty(); + document.apply_fields(&fields, prompt, false)?; + write_file(&path, document.serialize().as_bytes())?; + } + } + get(paths, &request.id) +} + +pub fn update( + paths: &crate::paths::ConfigPaths, + id: &str, + storage: AgentStorage, + mutation: AgentMutation, +) -> Result { + validate_agent_id(id)?; + validate_model(&mutation.fields)?; + let (fields, prompt, clear_fields) = + normalize_mutation(&mutation.fields, mutation.prompt, mutation.clear_fields)?; + + match storage { + AgentStorage::Inline => update_inline(paths, id, fields, prompt, clear_fields)?, + AgentStorage::GlobalMarkdown | AgentStorage::ProjectMarkdown => { + let path = markdown_path(paths, storage, id)?; + if !path.exists() { + return Err(not_found(id)); + } + let content = + read_text(&path)?.ok_or_else(|| AppError::io("read", &path, fs_not_found()))?; + let mut document = MarkdownAgentDocument::parse(&content)?; + document.apply_fields(&fields, prompt, false)?; + document.remove_fields(&clear_fields); + if clear_fields.iter().any(|field| field == "prompt") { + document.prompt.clear(); + } + write_file(&path, document.serialize().as_bytes())?; + } + } + get(paths, id) +} + +fn fs_not_found() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "file disappeared while editing", + ) +} + +pub fn delete( + paths: &crate::paths::ConfigPaths, + id: &str, + storage: AgentStorage, + references: &AgentReferenceIndex, +) -> Result { + validate_agent_id(id)?; + if BUILT_IN_AGENT_IDS.contains(&id) { + return Err(AppError::references( + format!( + "built-in agent '{id}' cannot be physically deleted; use an overlay or disable it" + ), + "built-in agents are protected", + )); + } + if let Some(agent_references) = references.get(id) { + if !agent_references.is_empty() { + let locations = agent_references + .iter() + .map(|reference| format!("{:?} at {}", reference.kind, reference.owner)) + .collect::>() + .join(", "); + return Err(AppError::references( + format!("agent '{id}' is still referenced"), + locations, + )); + } + } + + match storage { + AgentStorage::Inline => delete_inline(paths, id)?, + AgentStorage::GlobalMarkdown | AgentStorage::ProjectMarkdown => { + let path = markdown_path(paths, storage, id)?; + if !path.exists() { + return Err(not_found(id)); + } + fs::remove_file(&path).map_err(|error| AppError::io("remove", &path, error))?; + } + } + + let replacement = match get(paths, id) { + Ok(definition) => Some(definition), + Err(error) if error.code == ErrorCode::NotFound => None, + Err(error) => return Err(error), + }; + Ok(AgentDeleteResult { + id: id.to_owned(), + deleted_storage: storage, + another_source_takes_over: replacement.is_some(), + replacement, + }) +} + +fn create_inline( + paths: &crate::paths::ConfigPaths, + id: &str, + fields: Map, + prompt: Option, +) -> Result<(), AppError> { + let opencode_file = paths.opencode_file(); + let mut document = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; + let agents = inline_agents(document.raw())?; + if agents.contains_key(id) { + return Err(invalid(format!("inline agent '{id}' already exists"))); + } + let mut value = fields; + value.remove("tools"); + if let Some(prompt) = prompt { + value.insert("prompt".to_owned(), Value::String(prompt)); + } + document.patch(&["agent", id], Some(Value::Object(value)))?; + document.save(&opencode_file) +} + +fn update_inline( + paths: &crate::paths::ConfigPaths, + id: &str, + fields: Map, + prompt: Option, + clear_fields: Vec, +) -> Result<(), AppError> { + let opencode_file = paths.opencode_file(); + let mut document = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; + let agents = inline_agents(document.raw())?; + let existing = agents.get(id).ok_or_else(|| not_found(id))?; + if !existing.is_object() { + return Err(invalid(format!("inline agent '{id}' must be an object"))); + } + let mut values = fields; + let prompt_is_set = prompt.is_some(); + if let Some(prompt) = prompt { + values.insert("prompt".to_owned(), Value::String(prompt)); + } + values.remove("tools"); + for (key, value) in values { + document.patch(&["agent", id, &key], Some(value))?; + } + for key in clear_fields { + if key == "prompt" && prompt_is_set { + continue; + } + document.patch(&["agent", id, &key], None)?; + } + document.save(&opencode_file) +} + +fn delete_inline(paths: &crate::paths::ConfigPaths, id: &str) -> Result<(), AppError> { + let opencode_file = paths.opencode_file(); + let mut document = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; + if !inline_agents(document.raw())?.contains_key(id) { + return Err(not_found(id)); + } + document.patch(&["agent", id], None)?; + document.save(&opencode_file) +} + +fn inline_agents(document: &Map) -> Result<&Map, AppError> { + match document.get("agent") { + None => Ok(empty_object()), + Some(Value::Object(agents)) => Ok(agents), + Some(_) => Err(invalid("top-level 'agent' must be an object")), + } +} + +fn empty_object() -> &'static Map { + static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); + EMPTY.get_or_init(Map::new) +} + +fn scan_markdown( + paths: &crate::paths::ConfigPaths, +) -> Result>, AppError> { + let mut results = BTreeMap::>::new(); + scan_markdown_root( + AgentStorage::GlobalMarkdown, + &paths.global_agents_dir(), + &mut results, + )?; + if let Some(project_agents_dir) = paths.project_agents_dir() { + scan_markdown_root( + AgentStorage::ProjectMarkdown, + &project_agents_dir, + &mut results, + )?; + } + Ok(results) +} + +fn scan_markdown_root( + storage: AgentStorage, + root: &Path, + results: &mut BTreeMap>, +) -> Result<(), AppError> { + if !root.exists() { + return Ok(()); + } + let mut files = Vec::new(); + collect_markdown_files(root, &mut files)?; + files.sort(); + for path in files { + let relative = path.strip_prefix(root).map_err(|error| { + AppError::configuration(format!("resolve {}: {error}", path.display())) + })?; + let id = markdown_id(relative)?; + let raw = read_text(&path)?.unwrap_or_default(); + let document = MarkdownAgentDocument::parse(&raw)?; + results.entry(id).or_default().push(MarkdownAgentSource { + storage, + path, + raw, + frontmatter: document.frontmatter, + prompt: document.prompt, + }); + } + Ok(()) +} + +fn markdown_path( + paths: &crate::paths::ConfigPaths, + storage: AgentStorage, + id: &str, +) -> Result { + let root = match storage { + AgentStorage::Inline => { + return Err(invalid("inline storage does not have a Markdown path")) + } + AgentStorage::GlobalMarkdown => paths.global_agents_dir(), + AgentStorage::ProjectMarkdown => paths + .project_agents_dir() + .ok_or_else(|| invalid("project Markdown agents directory was not configured"))?, + }; + Ok(root.join(id).with_extension("md")) +} + +/// Lists every `.md` file under the given agents roots. Used by model replacement. +pub fn collect_markdown_files_in_roots( + paths: &crate::paths::ConfigPaths, +) -> Result, AppError> { + let mut files = Vec::new(); + collect_markdown_files(&paths.global_agents_dir(), &mut files)?; + if let Some(project_agents_dir) = paths.project_agents_dir() { + collect_markdown_files(&project_agents_dir, &mut files)?; + } + files.sort(); + Ok(files) +} + +fn definition_from_sources( + id: &str, + inline: Option<&Value>, + markdown: Option<&Vec>, +) -> Result { + let inline = inline.cloned().map(|raw| InlineAgentSource { raw }); + let markdown = markdown.cloned().unwrap_or_default(); + let source = match (inline.is_some(), markdown.is_empty()) { + (true, true) => AgentSource::Inline, + (false, false) => AgentSource::Markdown, + (true, false) => AgentSource::Both, + (false, true) => return Err(not_found(id)), + }; + let mut effective = inline + .as_ref() + .map(|source| source.raw.clone()) + .unwrap_or_else(|| Value::Object(Map::new())); + if !effective.is_object() { + return Err(invalid(format!("inline agent '{id}' must be an object"))); + } + let mut overrides = Vec::new(); + for markdown_source in &markdown { + let fields = markdown_effective_fields(markdown_source); + if let (Some(inline), Value::Object(markdown)) = (inline.as_ref(), &fields) { + let Value::Object(inline) = &inline.raw else { + unreachable!("checked above") + }; + for (field, markdown_value) in markdown { + if let Some(inline_value) = inline.get(field) { + overrides.push(AgentFieldOverride { + field: field.clone(), + inline: inline_value.clone(), + markdown: markdown_value.clone(), + effective: markdown_value.clone(), + }); + } + } + } + deep_merge(&mut effective, fields); + } + Ok(AgentDefinition { + id: id.to_owned(), + source, + inline, + markdown, + effective, + overrides, + }) +} + +fn markdown_effective_fields(source: &MarkdownAgentSource) -> Value { + let mut fields = source.frontmatter.clone(); + fields.insert("prompt".to_owned(), Value::String(source.prompt.clone())); + Value::Object(fields) +} + +fn deep_merge(destination: &mut Value, overlay: Value) { + match (destination, overlay) { + (Value::Object(destination), Value::Object(overlay)) => { + for (key, value) in overlay { + match destination.get_mut(&key) { + Some(existing) => deep_merge(existing, value), + None => { + destination.insert(key, value); + } + } + } + } + (destination, overlay) => *destination = overlay, + } +} + +fn collect_markdown_files(root: &Path, files: &mut Vec) -> Result<(), AppError> { + if !root.exists() { + return Ok(()); + } + for entry in fs::read_dir(root).map_err(|error| AppError::io("read", root, error))? { + let entry = entry.map_err(|error| AppError::io("read", root, error))?; + let path = entry.path(); + if path.is_dir() { + collect_markdown_files(&path, files)?; + } else if path.extension().is_some_and(|extension| extension == "md") { + files.push(path); + } + } + Ok(()) +} + +fn markdown_id(relative: &Path) -> Result { + if relative.extension().is_none() + || relative + .extension() + .is_some_and(|extension| extension != "md") + { + return Err(invalid(format!( + "Markdown agent path '{}' does not end with .md", + relative.display() + ))); + } + let without_extension = relative.with_extension(""); + let components = without_extension + .components() + .map(|component| match component { + Component::Normal(value) => value.to_string_lossy().into_owned(), + _ => String::new(), + }) + .collect::>(); + if components.is_empty() || components.iter().any(String::is_empty) { + return Err(invalid(format!( + "invalid Markdown agent path '{}'", + relative.display() + ))); + } + Ok(components.join("/")) +} + +fn validate_agent_id(id: &str) -> Result<(), AppError> { + if id.is_empty() + || id.starts_with('/') + || id.ends_with('/') + || id.contains('\\') + || id.split('/').any(|segment| { + segment.is_empty() + || matches!(segment, "." | "..") + || !segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + }) + { + return Err(invalid(format!("invalid agent id '{id}'"))); + } + Ok(()) +} + +fn validate_model(fields: &Map) -> Result<(), AppError> { + let Some(model) = fields.get("model") else { + return Ok(()); + }; + let valid = model.as_str().is_some_and(|model| { + model.is_empty() + || (model.matches('/').count() == 1 + && model.split_once('/').is_some_and(|(provider, name)| { + !provider.is_empty() + && !name.is_empty() + && !provider.contains(char::is_whitespace) + && !name.contains(char::is_whitespace) + })) + }); + if valid { + Ok(()) + } else { + Err(invalid( + "agent model must be empty or a complete provider/model reference", + )) + } +} + +fn invalid_model_ref(model: &str) -> AppError { + invalid(format!( + "agent model must be a complete provider/model reference: {model}" + )) +} + +fn normalize_mutation( + fields: &Map, + prompt: Option, + clear_fields: Vec, +) -> Result<(Map, Option, Vec), AppError> { + let mut fields = fields.clone(); + let frontmatter_prompt = fields.remove("prompt"); + fields.remove("tools"); + let prompt = match (prompt, frontmatter_prompt) { + (Some(prompt), _) => Some(prompt), + (None, Some(Value::String(prompt))) => Some(prompt), + (None, Some(_)) => { + return Err(invalid("agent prompt must be a string")); + } + (None, None) => None, + }; + let clear_fields = clear_fields + .into_iter() + .collect::>() + .into_iter() + .collect::>(); + if let Some(field) = clear_fields.iter().find(|field| field.trim().is_empty()) { + return Err(invalid(format!( + "agent field to clear must not be empty: '{field}'" + ))); + } + if let Some(field) = clear_fields + .iter() + .find(|field| fields.contains_key(*field)) + { + return Err(invalid(format!( + "agent field '{field}' cannot be set and cleared together" + ))); + } + Ok((fields, prompt, clear_fields)) +} diff --git a/src-tauri/src/agents_md.rs b/src-tauri/src/agents_md.rs new file mode 100644 index 0000000..34f6ca2 --- /dev/null +++ b/src-tauri/src/agents_md.rs @@ -0,0 +1,367 @@ +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; + +/// A parsed Markdown agent document. Serialization preserves untouched frontmatter +/// sections verbatim so user formatting survives edits. +#[derive(Debug, Clone, PartialEq)] +pub struct MarkdownAgentDocument { + pub frontmatter: Map, + pub prompt: String, + original: String, + original_frontmatter: Option, + original_prompt: String, + key_order: Vec, + changed_fields: BTreeSet, +} + +impl MarkdownAgentDocument { + pub fn empty() -> Self { + Self { + frontmatter: Map::new(), + prompt: String::new(), + original: "---\n---\n".to_owned(), + original_frontmatter: Some(String::new()), + original_prompt: String::new(), + key_order: Vec::new(), + changed_fields: BTreeSet::new(), + } + } + + pub fn parse(input: &str) -> Result { + let input = input.strip_prefix('\u{feff}').unwrap_or(input); + let Some((frontmatter, prompt)) = split_frontmatter(input) else { + return Ok(Self { + frontmatter: Map::new(), + prompt: input.to_owned(), + original: input.to_owned(), + original_frontmatter: None, + original_prompt: input.to_owned(), + key_order: Vec::new(), + changed_fields: BTreeSet::new(), + }); + }; + let original_frontmatter = frontmatter.to_owned(); + let frontmatter_value = if frontmatter.trim().is_empty() { + serde_yaml::Value::Mapping(Default::default()) + } else { + serde_yaml::from_str(frontmatter).map_err(|error| { + crate::error::AppError::validation(format!("invalid YAML frontmatter: {error}")) + })? + }; + let Value::Object(frontmatter) = yaml_to_json(frontmatter_value)? else { + return Err(crate::error::AppError::validation( + "Markdown frontmatter must be an object", + )); + }; + + Ok(Self { + key_order: frontmatter_key_order(&original_frontmatter, frontmatter.keys()), + frontmatter, + prompt: prompt.to_owned(), + original: input.to_owned(), + original_frontmatter: Some(original_frontmatter), + original_prompt: prompt.to_owned(), + changed_fields: BTreeSet::new(), + }) + } + + pub fn apply_fields( + &mut self, + fields: &Map, + prompt: Option, + allow_existing_tools: bool, + ) -> Result<(), crate::error::AppError> { + for (key, value) in fields { + if key == "prompt" { + continue; + } + if key == "tools" && !allow_existing_tools && !self.frontmatter.contains_key(key) { + continue; + } + if !self.frontmatter.contains_key(key) { + self.key_order.push(key.clone()); + } + self.frontmatter.insert(key.clone(), value.clone()); + self.changed_fields.insert(key.clone()); + } + if let Some(prompt) = prompt { + self.prompt = prompt; + } + Ok(()) + } + + pub fn remove_fields(&mut self, fields: &[String]) { + for field in fields { + if field != "prompt" && self.frontmatter.remove(field).is_some() { + self.changed_fields.insert(field.clone()); + } + } + } + + /// Renders the document. Unchanged documents round-trip to their original text. + pub fn serialize(&self) -> String { + if self.prompt == self.original_prompt + && self.original_frontmatter.as_ref().is_some_and(|original| { + parse_frontmatter_object(original).is_ok_and(|fields| fields == self.frontmatter) + }) + { + return self.original.clone(); + } + + let mut output = String::from("---\n"); + output.push_str(&render_frontmatter( + &self.frontmatter, + &self.key_order, + self.original_frontmatter.as_deref(), + &self.changed_fields, + )); + output.push_str("---\n"); + output.push_str(&self.prompt); + output + } +} + +fn split_frontmatter(input: &str) -> Option<(&str, &str)> { + let Some(after_open) = input + .strip_prefix("---\n") + .or_else(|| input.strip_prefix("---\r\n")) + else { + return None; + }; + let start_offset = input.len() - after_open.len(); + let mut offset = start_offset; + for line in after_open.split_inclusive(['\n']) { + let trimmed = line.trim_end_matches(['\r', '\n']); + if matches!(trimmed, "---" | "...") { + return Some((&input[start_offset..offset], &input[offset + line.len()..])); + } + offset += line.len(); + } + None +} + +fn parse_frontmatter_object(source: &str) -> Result, crate::error::AppError> { + let yaml: serde_yaml::Value = if source.trim().is_empty() { + serde_yaml::Value::Mapping(Default::default()) + } else { + serde_yaml::from_str(source).map_err(|error| { + crate::error::AppError::validation(format!("invalid YAML frontmatter: {error}")) + })? + }; + let Value::Object(fields) = yaml_to_json(yaml)? else { + return Err(crate::error::AppError::validation( + "Markdown frontmatter must be an object", + )); + }; + Ok(fields) +} + +fn yaml_to_json(value: serde_yaml::Value) -> Result { + match value { + serde_yaml::Value::Null => Ok(Value::Null), + serde_yaml::Value::Bool(value) => Ok(Value::Bool(value)), + serde_yaml::Value::Number(value) => serde_json::to_value(value) + .map_err(|error| crate::error::AppError::validation(error.to_string())), + serde_yaml::Value::String(value) => Ok(Value::String(value)), + serde_yaml::Value::Sequence(values) => values + .into_iter() + .map(yaml_to_json) + .collect::, _>>() + .map(Value::Array), + serde_yaml::Value::Mapping(values) => { + let mut object = Map::new(); + for (key, value) in values { + let serde_yaml::Value::String(key) = key else { + return Err(crate::error::AppError::validation( + "Markdown frontmatter must be an object", + )); + }; + object.insert(key, yaml_to_json(value)?); + } + Ok(Value::Object(object)) + } + serde_yaml::Value::Tagged(value) => yaml_to_json(value.value), + } +} + +fn frontmatter_key_order<'a>(source: &str, keys: impl Iterator) -> Vec { + let available = keys.cloned().collect::>(); + let mut ordered = Vec::new(); + for line in source.lines() { + if line.starts_with([' ', '\t', '-']) { + continue; + } + let Some((key, _)) = line.split_once(':') else { + continue; + }; + let key = key.trim(); + if available.contains(key) && !ordered.iter().any(|known| known == key) { + ordered.push(key.to_owned()); + } + } + for key in available { + if !ordered.contains(&key) { + ordered.push(key); + } + } + ordered +} + +fn render_frontmatter( + fields: &Map, + order: &[String], + original: Option<&str>, + changed_fields: &BTreeSet, +) -> String { + match original { + Some(original) => render_frontmatter_preserving(original, fields, order, changed_fields), + None => render_all_frontmatter(fields, order), + } +} + +fn render_frontmatter_preserving( + original: &str, + fields: &Map, + order: &[String], + changed_fields: &BTreeSet, +) -> String { + let mut output = String::new(); + let mut emitted = BTreeSet::new(); + for section in frontmatter_sections(original) { + if section.key.is_empty() { + output.push_str(§ion.source); + continue; + } + let Some(value) = fields.get(§ion.key) else { + continue; + }; + emitted.insert(section.key.clone()); + if changed_fields.contains(§ion.key) { + render_key_value(&mut output, §ion.key, value, 0); + } else { + output.push_str(§ion.source); + } + } + for key in order.iter().chain(fields.keys()) { + if emitted.insert(key.clone()) { + if let Some(value) = fields.get(key) { + render_key_value(&mut output, key, value, 0); + } + } + } + output +} + +fn render_all_frontmatter(fields: &Map, order: &[String]) -> String { + let mut output = String::new(); + let mut emitted = BTreeSet::new(); + for key in order.iter().chain(fields.keys()) { + if !emitted.insert(key.clone()) { + continue; + } + if let Some(value) = fields.get(key) { + render_key_value(&mut output, key, value, 0); + } + } + output +} + +struct FrontmatterSection { + key: String, + source: String, +} + +fn frontmatter_sections(source: &str) -> Vec { + let mut sections = Vec::new(); + let mut current_key = String::new(); + let mut current_source = String::new(); + for line in source.split_inclusive('\n') { + if let Some(key) = top_level_key(line) { + if !current_source.is_empty() { + sections.push(FrontmatterSection { + key: current_key, + source: current_source, + }); + } + current_key = key; + current_source = line.to_owned(); + } else { + current_source.push_str(line); + } + } + if !current_source.is_empty() { + sections.push(FrontmatterSection { + key: current_key, + source: current_source, + }); + } + sections +} + +fn top_level_key(line: &str) -> Option { + if line.starts_with([' ', '\t', '#', '-']) { + return None; + } + let (key, _) = line.split_once(':')?; + let key = key.trim(); + (!key.is_empty() + && key + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))) + .then(|| key.to_owned()) +} + +fn render_key_value(output: &mut String, key: &str, value: &Value, indent: usize) { + let prefix = " ".repeat(indent); + match value { + Value::Array(values) if values.is_empty() => { + output.push_str(&format!("{prefix}{key}: []\n")) + } + Value::Object(values) if values.is_empty() => { + output.push_str(&format!("{prefix}{key}: {{}}\n")) + } + Value::Array(_) | Value::Object(_) => { + output.push_str(&format!("{prefix}{key}:\n")); + render_value(output, value, indent + 2); + } + _ => output.push_str(&format!("{prefix}{key}: {}\n", render_scalar(value))), + } +} + +fn render_value(output: &mut String, value: &Value, indent: usize) { + match value { + Value::Array(values) => { + for value in values { + let prefix = " ".repeat(indent); + match value { + Value::Array(_) | Value::Object(_) => { + output.push_str(&format!("{prefix}-\n")); + render_value(output, value, indent + 2); + } + _ => output.push_str(&format!("{prefix}- {}\n", render_scalar(value))), + } + } + } + Value::Object(values) => { + for (key, value) in values { + render_key_value(output, key, value, indent); + } + } + _ => output.push_str(&format!("{}{}\n", " ".repeat(indent), render_scalar(value))), + } +} + +fn render_scalar(value: &Value) -> String { + match value { + Value::Null => "null".to_owned(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => { + serde_json::to_string(value).expect("string serialization is infallible") + } + Value::Array(_) | Value::Object(_) => { + unreachable!("complex YAML values are rendered structurally") + } + } +} diff --git a/src-tauri/src/app_shell.rs b/src-tauri/src/app_shell.rs deleted file mode 100644 index 0c910c3..0000000 --- a/src-tauri/src/app_shell.rs +++ /dev/null @@ -1,198 +0,0 @@ -use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}; -use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::{ - App, AppHandle, Manager, Runtime, State, WebviewUrl, WebviewWindow, WebviewWindowBuilder, - WindowEvent, -}; -use tauri_plugin_positioner::{Position, WindowExt}; - -use crate::application::GroupApplicationService; -use crate::shell::{resolve_shell_action, ShellAction, ShellStateSnapshot}; - -pub fn build_tray(app: &mut App) -> tauri::Result<()> { - let state = app.state::(); - let menu = build_menu(app, &state)?; - - TrayIconBuilder::with_id("omo-switch-tray") - .tooltip("omo-switch") - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| { - if let Some(action) = resolve_shell_action(event.id().as_ref()) { - dispatch_shell_action(app, action); - } - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - dispatch_shell_action(tray.app_handle(), ShellAction::OpenSettings); - } - }) - .build(app)?; - - Ok(()) -} - -pub fn dispatch_shell_action(app: &AppHandle, action: ShellAction) { - match action { - ShellAction::ShowQuickSwitch => { - report_shell_error(restore_window(app, "quick-switch", Position::TrayCenter)) - } - ShellAction::OpenSettings => { - if app.get_webview_window("settings").is_some() { - report_shell_error(open_settings(app)); - } else { - let app = app.clone(); - std::thread::spawn(move || report_shell_error(open_settings(&app))); - } - } - ShellAction::ReloadAppState => refresh_tray(app), - ShellAction::Quit => app.exit(0), - ShellAction::SwitchGroup(group_id) => { - if let Some(application) = app.try_state::() { - let _ = application.switch_group(group_id); - } - refresh_tray(app); - } - } -} - -pub fn open_settings(app: &AppHandle) -> tauri::Result<()> { - if app.get_webview_window("settings").is_none() { - let window = - WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("/settings".into())) - .title("omo-switch") - .inner_size(980.0, 620.0) - .visible(false) - .resizable(true) - .build()?; - install_hide_on_close(&window); - } - - restore_window(app, "settings", Position::Center) -} - -pub fn install_window_lifecycle(app: &App) { - for label in ["quick-switch", "settings"] { - if let Some(window) = app.get_webview_window(label) { - install_hide_on_close(&window); - } - } -} - -fn install_hide_on_close(window: &WebviewWindow) { - let label = window.label().to_owned(); - window.on_window_event({ - let window = window.clone(); - move |event| { - if let WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - if let Err(error) = window.hide() { - eprintln!("failed to hide {label} window: {error}"); - } - } - } - }); -} - -pub fn refresh_tray(app: &AppHandle) { - let Some(tray) = app.tray_by_id("omo-switch-tray") else { - return; - }; - let Some(application) = app.try_state::() else { - return; - }; - if let Ok(menu) = build_menu(app, &application) { - let _ = tray.set_menu(Some(menu)); - } -} - -fn build_menu>( - manager: &M, - application: &State<'_, GroupApplicationService>, -) -> tauri::Result> { - let model = load_shell_state(application).menu_model(); - let current_group = MenuItem::with_id( - manager, - "current_group", - model.current_group_label, - false, - None::<&str>, - )?; - let menu = Menu::with_items(manager, &[¤t_group])?; - let separator = PredefinedMenuItem::separator(manager)?; - menu.append(&separator)?; - - for group in model.group_items { - let item = CheckMenuItem::with_id( - manager, - group.action.id(), - group.title, - true, - group.active, - None::<&str>, - )?; - menu.append(&item)?; - } - - let separator = PredefinedMenuItem::separator(manager)?; - menu.append(&separator)?; - for item in model.shell_items { - let menu_item = - MenuItem::with_id(manager, item.action.id(), item.title, true, None::<&str>)?; - menu.append(&menu_item)?; - } - Ok(menu) -} - -fn load_shell_state(application: &GroupApplicationService) -> ShellStateSnapshot { - match application.load_app_state() { - Ok(response) => ShellStateSnapshot { - groups: response.groups, - app_state: response.app_state, - }, - Err(_) => ShellStateSnapshot { - groups: Vec::new(), - app_state: crate::core::models::AppSelectionState::default(), - }, - } -} - -fn restore_window( - app: &AppHandle, - label: &str, - position: Position, -) -> tauri::Result<()> { - let Some(window) = app.get_webview_window(label) else { - return Ok(()); - }; - - activate_app(app)?; - if window.is_minimized()? { - window.unminimize()?; - } - window.move_window_constrained(position)?; - window.show()?; - window.set_focus() -} - -#[cfg(target_os = "macos")] -fn activate_app(app: &AppHandle) -> tauri::Result<()> { - app.set_activation_policy(tauri::ActivationPolicy::Accessory)?; - app.show() -} - -#[cfg(not(target_os = "macos"))] -fn activate_app(_app: &AppHandle) -> tauri::Result<()> { - Ok(()) -} - -fn report_shell_error(result: tauri::Result<()>) { - if let Err(error) = result { - eprintln!("shell lifecycle action failed: {error}"); - } -} diff --git a/src-tauri/src/application.rs b/src-tauri/src/application.rs deleted file mode 100644 index 780a248..0000000 --- a/src-tauri/src/application.rs +++ /dev/null @@ -1,337 +0,0 @@ -use std::fmt; -use std::path::Path; - -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::core::models::{AppSelectionState, ModelGroup}; -use crate::core::paths::{ConfigPathError, ConfigPaths, HomeEnv}; -use crate::core::repository::{ - ConfigRepository, OhMyOpenAgentConfigRepository, OpenCodeConfigError, OpenCodeConfigRepository, - RepositoryError, -}; -use crate::core::switching::{ - recover_pending_transaction, SwitchError, SwitchGroupRepositories, SwitchGroupUseCase, - SwitchOutcome, -}; - -#[derive(Debug)] -pub enum ApplicationError { - ConfigPath(ConfigPathError), - LoadGroups(RepositoryError), - SaveGroups(RepositoryError), - LoadAppState(RepositoryError), - SaveAppState(RepositoryError), - GroupNotFound, - DuplicateGroupName, - Switch(SwitchError), -} - -impl fmt::Display for ApplicationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ConfigPath(error) => write!(formatter, "{error}"), - Self::LoadGroups(error) => write!(formatter, "failed to load groups: {error}"), - Self::SaveGroups(error) => write!(formatter, "failed to save groups: {error}"), - Self::LoadAppState(error) => write!(formatter, "failed to load app state: {error}"), - Self::SaveAppState(error) => write!(formatter, "failed to save app state: {error}"), - Self::GroupNotFound => formatter.write_str("group not found"), - Self::DuplicateGroupName => formatter.write_str("duplicate group name"), - Self::Switch(error) => write!(formatter, "{error}"), - } - } -} - -impl std::error::Error for ApplicationError {} - -#[derive(Debug, Clone, PartialEq)] -pub struct LoadedAppState { - pub groups: Vec, - pub app_state: AppSelectionState, - pub discovered_open_code_agent_names: Vec, - pub open_code_agent_discovery_error: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct GroupMutation { - pub group: ModelGroup, - pub groups: Vec, - pub app_state: AppSelectionState, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GroupSwitchOutcome { - Success, - NoOp, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct GroupSwitch { - pub outcome: GroupSwitchOutcome, - pub warnings: Vec, - pub app_state: AppSelectionState, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct OpenCodeAgentDiscovery { - pub agent_names: Vec, - pub error: Option, -} - -#[derive(Debug, Clone)] -pub struct GroupApplicationService { - paths: ConfigPaths, -} - -impl GroupApplicationService { - pub fn live() -> Result { - ConfigPaths::from_environment() - .map(|paths| Self { paths }) - .map_err(ApplicationError::ConfigPath) - } - - pub fn for_home(home: &Path) -> Result { - ConfigPaths::from_home_env(HomeEnv::new(Some(home), None)) - .map(|paths| Self { paths }) - .map_err(ApplicationError::ConfigPath) - } - - pub fn load_app_state(&self) -> Result { - let config = self.config().load().map_err(ApplicationError::LoadGroups)?; - let discovery = self.discovered_open_code_agents(); - Ok(LoadedAppState { - groups: config.groups, - app_state: config.state, - discovered_open_code_agent_names: discovery.agent_names, - open_code_agent_discovery_error: discovery.error, - }) - } - - pub fn save_group(&self, group: ModelGroup) -> Result { - let group_id = group.id; - let mut config = self.config().load().map_err(ApplicationError::LoadGroups)?; - if config.groups.iter().any(|candidate| { - candidate.id != group_id - && candidate - .name - .trim() - .eq_ignore_ascii_case(group.name.trim()) - }) { - return Err(ApplicationError::DuplicateGroupName); - } - match config - .groups - .iter_mut() - .find(|candidate| candidate.id == group_id) - { - Some(existing) => *existing = group.clone(), - None => config.groups.push(group.clone()), - } - if config.state.selected_group_id == Some(group_id) { - self.switch_use_case() - .save_active_group_projection_for_config(config.clone()) - .map_err(ApplicationError::Switch)?; - } else { - self.config() - .save(&config) - .map_err(ApplicationError::SaveGroups)?; - } - let app_state = self - .config() - .load() - .map_err(ApplicationError::LoadAppState)? - .state; - Ok(GroupMutation { - group, - groups: config.groups, - app_state, - }) - } - - pub fn copy_group(&self, id: Uuid) -> Result { - let mut config = self.config().load().map_err(ApplicationError::LoadGroups)?; - let source = config - .groups - .iter() - .find(|group| group.id == id) - .cloned() - .ok_or(ApplicationError::GroupNotFound)?; - let copied_name = unique_copy_group_name(&source.name, &config.groups); - let copied = ModelGroup { - id: Uuid::new_v4(), - name: copied_name, - updated_at: OffsetDateTime::now_utc(), - ..source - }; - config.groups.push(copied.clone()); - self.config() - .save(&config) - .map_err(ApplicationError::SaveGroups)?; - Ok(GroupMutation { - group: copied, - groups: config.groups, - app_state: config.state, - }) - } - - pub fn delete_group(&self, id: Uuid) -> Result { - let mut config = self.config().load().map_err(ApplicationError::LoadGroups)?; - let deleted = config - .groups - .iter() - .find(|group| group.id == id) - .cloned() - .ok_or(ApplicationError::GroupNotFound)?; - let remaining = config - .groups - .iter() - .cloned() - .into_iter() - .filter(|group| group.id != id) - .collect::>(); - config.groups = remaining.clone(); - if config.state.selected_group_id == Some(id) { - config.state.selected_group_id = None; - config.state.selected_group_name = None; - } - self.config() - .save(&config) - .map_err(ApplicationError::SaveGroups)?; - Ok(GroupMutation { - group: deleted, - groups: remaining, - app_state: config.state, - }) - } - - pub fn switch_group(&self, id: Uuid) -> Result { - let outcome = self - .switch_use_case() - .switch_to(id) - .map_err(ApplicationError::Switch)?; - let app_state = self - .config() - .load() - .map_err(ApplicationError::LoadAppState)? - .state; - match outcome { - SwitchOutcome::Success { warnings, .. } => Ok(GroupSwitch { - outcome: GroupSwitchOutcome::Success, - warnings, - app_state, - }), - SwitchOutcome::NoOp => Ok(GroupSwitch { - outcome: GroupSwitchOutcome::NoOp, - warnings: vec!["Already using this group.".to_owned()], - app_state, - }), - } - } - - pub fn discover_open_code_agents(&self) -> OpenCodeAgentDiscovery { - let discovery = self.discovered_open_code_agents(); - OpenCodeAgentDiscovery { - agent_names: discovery.agent_names, - error: discovery.error, - } - } - - pub(crate) fn recover_pending_transaction(&self) -> std::io::Result<()> { - recover_pending_transaction(&self.paths.omo_switch_dir()) - } - - fn switch_use_case(&self) -> SwitchGroupUseCase OffsetDateTime> { - SwitchGroupUseCase::new( - SwitchGroupRepositories { - config: self.config(), - backups_root: self.paths.omo_switch_dir(), - opencode: self.opencode(), - oh_my_openagent: self.oh_my_openagent(), - }, - OffsetDateTime::now_utc, - ) - } - - fn config(&self) -> ConfigRepository { - ConfigRepository::new(self.paths.config_file()) - } - - fn opencode(&self) -> OpenCodeConfigRepository { - OpenCodeConfigRepository::new(self.paths.opencode_file()) - } - - fn oh_my_openagent(&self) -> OhMyOpenAgentConfigRepository { - OhMyOpenAgentConfigRepository::new(self.paths.oh_my_openagent_file()) - } - - fn discovered_open_code_agents(&self) -> DiscoveredOpenCodeAgentNames { - match self.opencode().load() { - Ok(document) => { - if document - .raw() - .get("agent") - .is_some_and(serde_json::Value::is_object) - { - let mut agent_names = document.agents().keys().cloned().collect::>(); - agent_names.sort_by_key(|name| name.to_lowercase()); - DiscoveredOpenCodeAgentNames { - agent_names, - error: None, - } - } else { - DiscoveredOpenCodeAgentNames { - agent_names: Vec::new(), - error: Some( - "OpenCode config has no valid top-level agent object.".to_owned(), - ), - } - } - } - Err(OpenCodeConfigError::FileNotFound { .. }) => DiscoveredOpenCodeAgentNames { - agent_names: Vec::new(), - error: Some("OpenCode config not found.".to_owned()), - }, - Err( - OpenCodeConfigError::ReadFailed { .. } - | OpenCodeConfigError::MalformedConfig { .. } - | OpenCodeConfigError::WriteFailed { .. }, - ) => DiscoveredOpenCodeAgentNames { - agent_names: Vec::new(), - error: Some("OpenCode config is malformed.".to_owned()), - }, - } - } -} - -fn unique_copy_group_name(source_name: &str, groups: &[ModelGroup]) -> String { - let source_name = source_name.trim(); - let copy_name = if source_name.is_empty() { - "Copy".to_owned() - } else { - format!("{source_name} Copy") - }; - - if !group_name_exists(©_name, groups) { - return copy_name; - } - - let mut copy_number = 2; - loop { - let candidate = format!("{copy_name} {copy_number}"); - if !group_name_exists(&candidate, groups) { - return candidate; - } - copy_number += 1; - } -} - -fn group_name_exists(candidate: &str, groups: &[ModelGroup]) -> bool { - groups - .iter() - .any(|group| group.name.trim().eq_ignore_ascii_case(candidate.trim())) -} - -struct DiscoveredOpenCodeAgentNames { - agent_names: Vec, - error: Option, -} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..3ce1db1 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,356 @@ +use serde_json::Value; +use tauri::State; +use uuid::Uuid; + +use crate::agents::{ + self, AgentCreate, AgentDefinition, AgentMutation, AgentReferenceIndex, AgentStorage, +}; +use crate::document::{JsoncDoc, OMO_SCHEMA, OPENCODE_SCHEMA, SLIM_SCHEMA}; +use crate::dto::{AgentDefinitionDto, AgentStorageDto, AppStateResponse}; +use crate::error::AppError; +use crate::groups; +use crate::models::{self, ModelDef, ModelGroup, ProviderDef}; +use crate::paths::ConfigPaths; +use crate::providers::{self, ModelRef}; +use crate::refs; + +type CommandResult = Result; + +fn paths(state: &State<'_, ConfigPaths>) -> ConfigPaths { + state.inner().clone() +} + +#[tauri::command] +pub fn load_app_state(state: State<'_, ConfigPaths>) -> CommandResult { + let paths = paths(&state); + let config = models::load_config(&paths.config_file())?; + config.validate()?; + Ok(AppStateResponse { + providers: refs::list_providers_redacted(&paths)?, + agents: agents::list(&paths)? + .into_iter() + .map(Into::into) + .collect::>(), + groups: config.groups, + diagnostics: config_diagnostics(&paths), + }) +} + +#[tauri::command] +pub fn list_providers(state: State<'_, ConfigPaths>) -> CommandResult> { + refs::list_providers_redacted(&paths(&state)) +} + +#[tauri::command] +pub fn reveal_provider_option( + state: State<'_, ConfigPaths>, + provider_id: String, + key: String, +) -> CommandResult { + providers::reveal_sensitive_option(&paths(&state).opencode_file(), &provider_id, &key) +} + +#[tauri::command] +pub fn create_provider( + state: State<'_, ConfigPaths>, + provider: ProviderDef, +) -> CommandResult { + providers::create_provider(&paths(&state).opencode_file(), provider) + .map(providers::redact_provider_secrets) +} + +#[tauri::command] +pub fn update_provider( + state: State<'_, ConfigPaths>, + provider: ProviderDef, +) -> CommandResult { + providers::update_provider(&paths(&state).opencode_file(), provider) + .map(providers::redact_provider_secrets) +} + +#[tauri::command] +pub fn delete_provider(state: State<'_, ConfigPaths>, id: String) -> CommandResult<()> { + let paths = paths(&state); + let references = refs::provider_is_referenced(&paths, &id)?; + if !references.is_empty() { + return Err(AppError::references( + format!("{id} is referenced by {} location(s)", references.len()), + refs::model_reference_locations(&references), + )); + } + providers::delete_provider(&paths.opencode_file(), &id) +} + +#[tauri::command] +pub fn list_models(state: State<'_, ConfigPaths>) -> CommandResult> { + refs::list_providers_redacted(&paths(&state)) +} + +#[tauri::command] +pub fn create_model( + state: State<'_, ConfigPaths>, + provider_id: String, + model: ModelDef, +) -> CommandResult { + providers::create_model(&paths(&state).opencode_file(), &provider_id, model) +} + +#[tauri::command] +pub fn update_model( + state: State<'_, ConfigPaths>, + provider_id: String, + model: ModelDef, +) -> CommandResult { + let model_ref = ModelRef::new(&provider_id, &model.id)?; + providers::update_model(&paths(&state).opencode_file(), &model_ref, model) +} + +#[tauri::command] +pub fn delete_model(state: State<'_, ConfigPaths>, model_ref: String) -> CommandResult<()> { + let paths = paths(&state); + let model_ref = ModelRef::parse(&model_ref)?; + let references = refs::model_is_referenced(&paths, &model_ref)?; + if !references.is_empty() { + return Err(AppError::references( + format!( + "{} is referenced by {} location(s)", + model_ref.as_str(), + references.len() + ), + refs::model_reference_locations(&references), + )); + } + providers::delete_model(&paths.opencode_file(), &model_ref) +} + +#[tauri::command] +pub fn replace_model_references( + state: State<'_, ConfigPaths>, + from: String, + to: String, +) -> CommandResult<()> { + let paths = paths(&state); + let from = ModelRef::parse(&from)?; + let to = ModelRef::parse(&to)?; + crate::replacement::replace(&paths, &from, &to).map(|_| ()) +} + +#[tauri::command] +pub fn rename_model( + state: State<'_, ConfigPaths>, + from: String, + new_model_id: String, +) -> CommandResult<()> { + let paths = paths(&state); + let from = ModelRef::parse(&from)?; + crate::replacement::rename_model(&paths, &from, &new_model_id) +} + +#[tauri::command] +pub fn rename_provider( + state: State<'_, ConfigPaths>, + old_id: String, + new_id: String, +) -> CommandResult<()> { + crate::replacement::rename_provider(&paths(&state), &old_id, &new_id) +} + +#[tauri::command] +pub fn list_agents(state: State<'_, ConfigPaths>) -> CommandResult> { + let agents = agents::list(&paths(&state))?; + Ok(agents.into_iter().map(Into::into).collect()) +} + +#[tauri::command] +pub fn get_agent(state: State<'_, ConfigPaths>, id: String) -> CommandResult { + agents::get(&paths(&state), &id).map(Into::into) +} + +#[tauri::command] +pub fn create_agent( + state: State<'_, ConfigPaths>, + agent: AgentDefinitionDto, +) -> CommandResult { + let paths = paths(&state); + let storage = agent.storage.map(Into::into).ok_or_else(|| { + AppError::validation("agent creation requires an explicit storage target") + })?; + let mutation = agent.mutation.clone().unwrap_or_default(); + validate_agent_model_reference(&paths, &mutation.fields)?; + agents::create( + &paths, + AgentCreate { + id: agent.id, + storage, + fields: mutation.fields, + prompt: mutation.prompt, + }, + ) + .map(Into::into) +} + +#[tauri::command] +pub fn update_agent( + state: State<'_, ConfigPaths>, + agent: AgentDefinitionDto, +) -> CommandResult { + let paths = paths(&state); + let storage = agent + .storage + .map(Into::into) + .ok_or_else(|| AppError::validation("agent update requires an explicit storage target"))?; + let mutation = agent.mutation.clone().unwrap_or_default(); + validate_agent_model_reference(&paths, &mutation.fields)?; + agents::update( + &paths, + &agent.id, + storage, + AgentMutation { + fields: mutation.fields, + prompt: mutation.prompt, + clear_fields: mutation.clear_fields, + }, + ) + .map(Into::into) +} + +#[tauri::command] +pub fn delete_agent( + state: State<'_, ConfigPaths>, + id: String, + storage: Option, +) -> CommandResult<()> { + let paths = paths(&state); + let storage: AgentStorage = match storage { + Some(storage) => storage.into(), + None => { + let current: AgentDefinition = agents::get(&paths, &id)?; + match current.source { + crate::agents::AgentSource::Inline => AgentStorage::Inline, + crate::agents::AgentSource::Both => { + return Err(AppError::validation( + "agent has multiple sources; provide an explicit storage target", + )); + } + crate::agents::AgentSource::Markdown => { + let mut storages = current.markdown.iter().map(|source| source.storage); + let first = storages.next().ok_or_else(|| { + AppError::validation( + "agent has multiple sources; provide an explicit storage target", + ) + })?; + if storages.all(|candidate| candidate == first) { + first + } else { + return Err(AppError::validation( + "agent has multiple sources; provide an explicit storage target", + )); + } + } + } + } + }; + let references: AgentReferenceIndex = refs::collect_agent_references(&paths)?; + agents::delete(&paths, &id, storage, &references).map(|_| ()) +} + +#[tauri::command] +pub fn save_group(state: State<'_, ConfigPaths>, group: ModelGroup) -> CommandResult { + let (group, _) = groups::save_group(&paths(&state), group)?; + Ok(group) +} + +#[tauri::command] +pub fn copy_group( + state: State<'_, ConfigPaths>, + id: Uuid, + name: Option, +) -> CommandResult { + let (group, _) = groups::copy_group(&paths(&state), id, name)?; + Ok(group) +} + +#[tauri::command] +pub fn delete_group(state: State<'_, ConfigPaths>, id: Uuid) -> CommandResult<()> { + groups::delete_group(&paths(&state), id).map(|_| ()) +} + +#[tauri::command] +pub fn switch_group(state: State<'_, ConfigPaths>, id: Uuid) -> CommandResult<()> { + groups::switch_group(&paths(&state), id).map(|_| ()) +} + +#[tauri::command] +pub fn load_config_diagnostics(state: State<'_, ConfigPaths>) -> CommandResult> { + Ok(config_diagnostics(&paths(&state))) +} + +#[tauri::command] +pub fn validate_config(state: State<'_, ConfigPaths>) -> CommandResult> { + let paths = paths(&state); + let config = models::load_config(&paths.config_file())?; + config.validate()?; + // Parsing every managed document surfaces malformed JSONC as a validation error. + JsoncDoc::read(&paths.opencode_file(), OPENCODE_SCHEMA)?; + JsoncDoc::read(&paths.slim_file(), SLIM_SCHEMA)?; + JsoncDoc::read(&paths.omo_file(), OMO_SCHEMA)?; + agents::list(&paths)?; + refs::collect_model_references(&paths)?; + refs::collect_agent_references(&paths)?; + Ok(config_diagnostics(&paths)) +} + +fn validate_agent_model_reference( + paths: &ConfigPaths, + fields: &serde_json::Map, +) -> CommandResult<()> { + let Some(model) = fields.get("model") else { + return Ok(()); + }; + let model = model + .as_str() + .ok_or_else(|| AppError::validation("agent model must be a string when provided"))?; + if model.is_empty() { + return Ok(()); + } + let model_ref = ModelRef::parse(model).map_err(|error| { + AppError::validation(format!("agent model must be provider/model: {error}")) + })?; + let provider = providers::get_provider(&paths.opencode_file(), &model_ref.provider_id) + .map_err(|error| { + AppError::validation(format!( + "agent model '{}' does not exist: {error}", + model_ref.as_str() + )) + })?; + if provider.models.contains_key(&model_ref.model_id) { + Ok(()) + } else { + Err(AppError::validation(format!( + "agent model '{}' does not exist", + model_ref.as_str() + ))) + } +} + +/// Surfaces legacy configuration files that the application deliberately ignores. +fn config_diagnostics(paths: &ConfigPaths) -> Vec { + let mut diagnostics = Vec::new(); + let opencode_directory = paths + .opencode_file() + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_default(); + for filename in [ + "oh-my-openagent.json", + "oh-my-openagent.jsonc", + "oh-my-opencode.json", + "oh-my-opencode.jsonc", + ] { + let path = opencode_directory.join(filename); + if path.exists() { + diagnostics.push(format!("ignored legacy configuration: {}", path.display())); + } + } + diagnostics +} diff --git a/src-tauri/src/commands/errors.rs b/src-tauri/src/commands/errors.rs deleted file mode 100644 index 227ac64..0000000 --- a/src-tauri/src/commands/errors.rs +++ /dev/null @@ -1,318 +0,0 @@ -use crate::application::ApplicationError; -use crate::commands::types::{CommandError, CommandErrorCode}; -use crate::core::paths::ConfigPathError; -use crate::core::repository::RepositoryError; -use crate::core::switching::SwitchError; - -pub(crate) fn command_error_from_application(error: ApplicationError) -> CommandError { - match error { - ApplicationError::ConfigPath(error) => command_error_from_config_path(error), - ApplicationError::LoadGroups(error) => load_groups_error(error), - ApplicationError::SaveGroups(error) => save_groups_error(error), - ApplicationError::LoadAppState(error) => load_app_state_error(error), - ApplicationError::SaveAppState(error) => save_app_state_error(error), - ApplicationError::GroupNotFound => group_not_found_error(), - ApplicationError::DuplicateGroupName => duplicate_group_name_error(), - ApplicationError::Switch(error) => command_error_from_switch(error), - } -} - -pub(crate) fn command_error_from_config_path(error: ConfigPathError) -> CommandError { - match error { - ConfigPathError::MissingHome => { - CommandError::new(CommandErrorCode::MissingHome, error.to_string()) - } - } -} - -pub(crate) fn command_error_from_switch(error: SwitchError) -> CommandError { - match error { - SwitchError::LoadGroupsFailed { source } => load_groups_error(source), - SwitchError::GroupNotFound => group_not_found_error(), - SwitchError::GroupDisabled => { - CommandError::new(CommandErrorCode::GroupDisabled, "Group is disabled.") - } - SwitchError::LoadAppStateFailed { source } => load_app_state_error(source), - SwitchError::LoadOpenCodeConfigFailed => CommandError::new( - CommandErrorCode::MissingOpenCodeConfig, - "OpenCode config not found or malformed.", - ), - SwitchError::LoadOhMyConfigFailed => CommandError::new( - CommandErrorCode::LoadOhMyConfigFailed, - "Failed to load Oh My OpenAgent config.", - ), - SwitchError::BackupFailed { source } => CommandError::with_detail( - CommandErrorCode::BackupFailed, - "Failed to create backup.", - source.to_string(), - ), - SwitchError::WriteFailed { target, source } => CommandError::with_detail( - CommandErrorCode::WriteFailed, - format!("Failed to write {target}."), - source, - ), - SwitchError::RollbackFailed { source } => CommandError::with_detail( - CommandErrorCode::RollbackFailed, - "Rollback failed.", - source.to_string(), - ), - SwitchError::SaveAppStateFailed { source } => save_app_state_error(source), - SwitchError::TransactionFailed { stage, source } => CommandError::with_detail( - CommandErrorCode::WriteFailed, - "Transactional config write failed.", - format!("{stage}: {source}"), - ), - SwitchError::CompensationFailed { primary, source } => CommandError::with_detail( - CommandErrorCode::RollbackFailed, - "Transactional rollback failed; recovery evidence was retained.", - format!("primary={primary}; compensation={source}"), - ), - SwitchError::Interrupted { stage } => CommandError::with_detail( - CommandErrorCode::WriteFailed, - "Transactional config write was interrupted.", - stage, - ), - } -} - -pub(crate) fn load_groups_error(source: RepositoryError) -> CommandError { - CommandError::with_detail( - CommandErrorCode::LoadGroupsFailed, - "Failed to load groups.", - source.to_string(), - ) -} - -pub(crate) fn save_groups_error(source: RepositoryError) -> CommandError { - CommandError::with_detail( - CommandErrorCode::SaveGroupsFailed, - "Failed to save groups.", - source.to_string(), - ) -} - -pub(crate) fn load_app_state_error(source: RepositoryError) -> CommandError { - CommandError::with_detail( - CommandErrorCode::LoadAppStateFailed, - "Failed to load app state.", - source.to_string(), - ) -} - -pub(crate) fn save_app_state_error(source: RepositoryError) -> CommandError { - CommandError::with_detail( - CommandErrorCode::SaveAppStateFailed, - "Failed to save app state.", - source.to_string(), - ) -} - -pub(crate) fn group_not_found_error() -> CommandError { - CommandError::new(CommandErrorCode::GroupNotFound, "Group not found.") -} - -pub(crate) fn duplicate_group_name_error() -> CommandError { - CommandError::new( - CommandErrorCode::DuplicateGroupName, - "A group with this name already exists.", - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io; - use std::path::PathBuf; - - use crate::core::backup::BackupError; - use crate::core::error::PersistenceOperation; - - #[test] - fn application_errors_map_every_variant_to_its_stable_wire_code() { - let cases = vec![ - ( - "config path", - ApplicationError::ConfigPath(ConfigPathError::MissingHome), - CommandErrorCode::MissingHome, - ), - ( - "load groups", - ApplicationError::LoadGroups(repository_error("groups")), - CommandErrorCode::LoadGroupsFailed, - ), - ( - "save groups", - ApplicationError::SaveGroups(repository_error("groups")), - CommandErrorCode::SaveGroupsFailed, - ), - ( - "load app state", - ApplicationError::LoadAppState(repository_error("state")), - CommandErrorCode::LoadAppStateFailed, - ), - ( - "save app state", - ApplicationError::SaveAppState(repository_error("state")), - CommandErrorCode::SaveAppStateFailed, - ), - ( - "group not found", - ApplicationError::GroupNotFound, - CommandErrorCode::GroupNotFound, - ), - ( - "duplicate group name", - ApplicationError::DuplicateGroupName, - CommandErrorCode::DuplicateGroupName, - ), - ( - "switch", - ApplicationError::Switch(SwitchError::GroupDisabled), - CommandErrorCode::GroupDisabled, - ), - ]; - - for (name, error, expected_code) in cases { - assert_eq!( - command_error_from_application(error).code, - expected_code, - "{name}" - ); - } - } - - #[test] - fn switch_errors_map_every_variant_to_its_stable_wire_code_and_diagnostics() { - let cases = vec![ - ( - "load groups", - SwitchError::LoadGroupsFailed { - source: repository_error("groups"), - }, - CommandErrorCode::LoadGroupsFailed, - None, - ), - ( - "group not found", - SwitchError::GroupNotFound, - CommandErrorCode::GroupNotFound, - None, - ), - ( - "group disabled", - SwitchError::GroupDisabled, - CommandErrorCode::GroupDisabled, - None, - ), - ( - "load app state", - SwitchError::LoadAppStateFailed { - source: repository_error("state"), - }, - CommandErrorCode::LoadAppStateFailed, - None, - ), - ( - "load opencode config", - SwitchError::LoadOpenCodeConfigFailed, - CommandErrorCode::MissingOpenCodeConfig, - None, - ), - ( - "load oh-my-openagent config", - SwitchError::LoadOhMyConfigFailed, - CommandErrorCode::LoadOhMyConfigFailed, - None, - ), - ( - "backup", - SwitchError::BackupFailed { - source: BackupError::Io(io::Error::other("backup filesystem unavailable")), - }, - CommandErrorCode::BackupFailed, - Some(( - "Failed to create backup.", - "backup filesystem error: backup filesystem unavailable", - )), - ), - ( - "write", - SwitchError::WriteFailed { - target: "opencode", - source: "disk full".to_owned(), - }, - CommandErrorCode::WriteFailed, - Some(("Failed to write opencode.", "disk full")), - ), - ( - "rollback", - SwitchError::RollbackFailed { - source: io::Error::other("restore failed"), - }, - CommandErrorCode::RollbackFailed, - Some(("Rollback failed.", "restore failed")), - ), - ( - "save app state", - SwitchError::SaveAppStateFailed { - source: repository_error("state"), - }, - CommandErrorCode::SaveAppStateFailed, - None, - ), - ( - "transaction", - SwitchError::TransactionFailed { - stage: "commit", - source: io::Error::other("rename failed"), - }, - CommandErrorCode::WriteFailed, - Some(( - "Transactional config write failed.", - "commit: rename failed", - )), - ), - ( - "compensation", - SwitchError::CompensationFailed { - primary: "opencode write failed".to_owned(), - source: io::Error::other("compensation rename failed"), - }, - CommandErrorCode::RollbackFailed, - Some(( - "Transactional rollback failed; recovery evidence was retained.", - "primary=opencode write failed; compensation=compensation rename failed", - )), - ), - ( - "interrupted", - SwitchError::Interrupted { - stage: "after-config-rename", - }, - CommandErrorCode::WriteFailed, - Some(( - "Transactional config write was interrupted.", - "after-config-rename", - )), - ), - ]; - - for (name, error, expected_code, diagnostic) in cases { - let mapped = command_error_from_switch(error); - - assert_eq!(mapped.code, expected_code, "{name}"); - if let Some((message, detail)) = diagnostic { - assert_eq!(mapped.message, message, "{name}"); - assert_eq!(mapped.detail.as_deref(), Some(detail), "{name}"); - } - } - } - - fn repository_error(name: &str) -> RepositoryError { - RepositoryError::WriteFailed { - path: PathBuf::from(format!("{name}.json")), - operation: PersistenceOperation::Write, - source: io::Error::other(format!("{name} write failed")), - } - } -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs deleted file mode 100644 index c7cfa02..0000000 --- a/src-tauri/src/commands/mod.rs +++ /dev/null @@ -1,136 +0,0 @@ -mod errors; -mod types; - -pub use types::{ - AppStateResponse, CommandError, CommandErrorCode, CopyGroupRequest, DeleteGroupRequest, - DiscoverOpenCodeAgentsRequest, DiscoverOpenCodeAgentsResponse, GroupMutationResponse, - SaveGroupRequest, SwitchGroupOutcome, SwitchGroupRequest, SwitchGroupResponse, -}; - -use crate::application::{ - GroupApplicationService, GroupMutation, GroupSwitch, GroupSwitchOutcome, LoadedAppState, - OpenCodeAgentDiscovery, -}; -use crate::commands::errors::command_error_from_application; -use crate::commands::types::open_code_agent_mapping_presentation; -use tauri::{AppHandle, State}; - -#[tauri::command] -pub fn load_app_state( - application: State<'_, GroupApplicationService>, -) -> Result { - application - .load_app_state() - .map(app_state_response) - .map_err(command_error_from_application) -} - -#[tauri::command] -pub fn save_group( - app: AppHandle, - application: State<'_, GroupApplicationService>, - request: SaveGroupRequest, -) -> Result { - let response = application - .save_group(request.group) - .map(group_mutation_response) - .map_err(command_error_from_application)?; - crate::app_shell::refresh_tray(&app); - Ok(response) -} - -#[tauri::command] -pub fn copy_group( - app: AppHandle, - application: State<'_, GroupApplicationService>, - request: CopyGroupRequest, -) -> Result { - let response = application - .copy_group(request.id) - .map(group_mutation_response) - .map_err(command_error_from_application)?; - crate::app_shell::refresh_tray(&app); - Ok(response) -} - -#[tauri::command] -pub fn delete_group( - app: AppHandle, - application: State<'_, GroupApplicationService>, - request: DeleteGroupRequest, -) -> Result { - let response = application - .delete_group(request.id) - .map(group_mutation_response) - .map_err(command_error_from_application)?; - crate::app_shell::refresh_tray(&app); - Ok(response) -} - -#[tauri::command] -pub fn switch_group( - app: AppHandle, - application: State<'_, GroupApplicationService>, - request: SwitchGroupRequest, -) -> Result { - let response = application - .switch_group(request.id) - .map(switch_group_response) - .map_err(command_error_from_application)?; - crate::app_shell::refresh_tray(&app); - Ok(response) -} - -#[tauri::command] -pub fn discover_open_code_agents( - application: State<'_, GroupApplicationService>, - request: DiscoverOpenCodeAgentsRequest, -) -> Result { - let saved_overrides = request.saved_overrides; - let discovery = application.discover_open_code_agents(); - let presentation = open_code_agent_mapping_presentation( - &saved_overrides, - &discovery.agent_names, - discovery.error.as_deref(), - ); - Ok(discover_open_code_agents_response(discovery, presentation)) -} - -fn app_state_response(state: LoadedAppState) -> AppStateResponse { - AppStateResponse { - groups: state.groups, - app_state: state.app_state, - discovered_open_code_agent_names: state.discovered_open_code_agent_names, - open_code_agent_discovery_error: state.open_code_agent_discovery_error, - } -} - -fn group_mutation_response(mutation: GroupMutation) -> GroupMutationResponse { - GroupMutationResponse { - group: mutation.group, - groups: mutation.groups, - app_state: mutation.app_state, - } -} - -fn switch_group_response(switch: GroupSwitch) -> SwitchGroupResponse { - SwitchGroupResponse { - outcome: match switch.outcome { - GroupSwitchOutcome::Success => SwitchGroupOutcome::Success, - GroupSwitchOutcome::NoOp => SwitchGroupOutcome::NoOp, - }, - warnings: switch.warnings, - app_state: switch.app_state, - } -} - -fn discover_open_code_agents_response( - discovery: OpenCodeAgentDiscovery, - presentation: types::OpenCodeAgentMappingPresentation, -) -> DiscoverOpenCodeAgentsResponse { - DiscoverOpenCodeAgentsResponse { - agent_names: discovery.agent_names, - error: discovery.error, - presentation, - } -} diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs deleted file mode 100644 index 5d75afb..0000000 --- a/src-tauri/src/commands/types.rs +++ /dev/null @@ -1,231 +0,0 @@ -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::core::models::{AppSelectionState, ModelGroup, ModelGroupAgentOverride}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AppStateResponse { - pub groups: Vec, - pub app_state: AppSelectionState, - pub discovered_open_code_agent_names: Vec, - pub open_code_agent_discovery_error: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GroupMutationResponse { - pub group: ModelGroup, - pub groups: Vec, - pub app_state: AppSelectionState, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CopyGroupRequest { - pub id: Uuid, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteGroupRequest { - pub id: Uuid, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SaveGroupRequest { - pub group: ModelGroup, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SwitchGroupRequest { - pub id: Uuid, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SwitchGroupResponse { - pub outcome: SwitchGroupOutcome, - pub warnings: Vec, - pub app_state: AppSelectionState, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum SwitchGroupOutcome { - Success, - NoOp, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DiscoverOpenCodeAgentsRequest { - pub saved_overrides: Vec, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DiscoverOpenCodeAgentsResponse { - pub agent_names: Vec, - pub error: Option, - pub presentation: OpenCodeAgentMappingPresentation, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenCodeAgentMappingPresentation { - pub discovered_rows: Vec, - pub stale_overrides: Vec, - pub preserved_overrides: Vec, - pub discovery_error: Option, - pub is_read_only: bool, - pub allows_custom_agent_creation: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenCodeAgentDiscoveredRow { - pub id: String, - pub agent_name: String, - pub model_ref: String, - pub is_editable: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenCodeAgentOverrideInfoRow { - pub id: String, - pub agent_name: String, - pub model_ref: String, - pub status: String, - pub message: String, -} - -pub(super) fn open_code_agent_mapping_presentation( - overrides: &[ModelGroupAgentOverride], - discovered_agent_names: &[String], - discovery_error: Option<&str>, -) -> OpenCodeAgentMappingPresentation { - match discovery_error { - Some(message) => degraded_presentation(overrides, message), - None => discovered_presentation(overrides, discovered_agent_names), - } -} - -fn degraded_presentation( - overrides: &[ModelGroupAgentOverride], - message: &str, -) -> OpenCodeAgentMappingPresentation { - OpenCodeAgentMappingPresentation { - discovered_rows: Vec::new(), - stale_overrides: Vec::new(), - preserved_overrides: overrides - .iter() - .map(|agent_override| OpenCodeAgentOverrideInfoRow { - id: format!("preserved:{}", agent_override.agent_name), - agent_name: agent_override.agent_name.clone(), - model_ref: agent_override.model_ref.clone(), - status: "Preserved".to_owned(), - message: "Editing disabled until OpenCode agent discovery succeeds.".to_owned(), - }) - .collect(), - discovery_error: Some(message.to_owned()), - is_read_only: true, - allows_custom_agent_creation: false, - } -} - -fn discovered_presentation( - overrides: &[ModelGroupAgentOverride], - discovered_agent_names: &[String], -) -> OpenCodeAgentMappingPresentation { - let discovered_agent_names = unique_names_preserving_order(discovered_agent_names); - - OpenCodeAgentMappingPresentation { - discovered_rows: discovered_agent_names - .into_iter() - .map(|name| OpenCodeAgentDiscoveredRow { - id: format!("discovered:{name}"), - agent_name: name.clone(), - model_ref: model_ref_for(&name, overrides).to_owned(), - is_editable: true, - }) - .collect(), - stale_overrides: Vec::new(), - preserved_overrides: Vec::new(), - discovery_error: None, - is_read_only: false, - allows_custom_agent_creation: false, - } -} - -fn model_ref_for<'a>(agent_name: &str, overrides: &'a [ModelGroupAgentOverride]) -> &'a str { - overrides - .iter() - .find(|agent_override| agent_override.agent_name == agent_name) - .map_or("", |agent_override| agent_override.model_ref.as_str()) -} - -fn unique_names_preserving_order(names: &[String]) -> Vec { - let mut seen = HashSet::new(); - let mut unique = Vec::new(); - for name in names { - if seen.insert(name.as_str()) { - unique.push(name.clone()); - } - } - unique -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CommandErrorCode { - MissingHome, - LoadGroupsFailed, - SaveGroupsFailed, - LoadAppStateFailed, - SaveAppStateFailed, - GroupNotFound, - DuplicateGroupName, - GroupDisabled, - MissingOpenCodeConfig, - MalformedOpenCodeConfig, - LoadOhMyConfigFailed, - BackupFailed, - WriteFailed, - RollbackFailed, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CommandError { - pub code: CommandErrorCode, - pub message: String, - pub detail: Option, -} - -impl CommandError { - pub fn new(code: CommandErrorCode, message: impl Into) -> Self { - Self { - code, - message: message.into(), - detail: None, - } - } - - pub fn with_detail( - code: CommandErrorCode, - message: impl Into, - detail: impl Into, - ) -> Self { - Self { - code, - message: message.into(), - detail: Some(detail.into()), - } - } -} diff --git a/src-tauri/src/core/backup.rs b/src-tauri/src/core/backup.rs deleted file mode 100644 index f14871c..0000000 --- a/src-tauri/src/core/backup.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; - -use time::format_description::FormatItem; -use time::macros::format_description; -use time::OffsetDateTime; - -const BACKUP_TIMESTAMP_FORMAT: &[FormatItem<'_>] = - format_description!("[year][month][day]T[hour][minute][second]Z"); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BackupArtifact { - pub target: String, - pub file_path: PathBuf, - pub created_at: Option, -} - -#[derive(Debug)] -pub enum BackupError { - Io(io::Error), - Timestamp(time::error::Format), -} - -impl PartialEq for BackupError { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::Io(left), Self::Io(right)) => left.kind() == right.kind(), - (Self::Timestamp(_), Self::Timestamp(_)) => true, - (Self::Io(_), Self::Timestamp(_)) | (Self::Timestamp(_), Self::Io(_)) => false, - } - } -} - -impl Eq for BackupError {} - -impl std::fmt::Display for BackupError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(error) => write!(formatter, "backup filesystem error: {error}"), - Self::Timestamp(error) => write!(formatter, "backup timestamp error: {error}"), - } - } -} - -impl std::error::Error for BackupError {} - -impl From for BackupError { - fn from(error: io::Error) -> Self { - Self::Io(error) - } -} - -impl From for BackupError { - fn from(error: time::error::Format) -> Self { - Self::Timestamp(error) - } -} - -#[derive(Clone)] -pub struct BackupRepository -where - C: Fn() -> OffsetDateTime + Clone, -{ - backups_root: PathBuf, - now: C, -} - -impl BackupRepository -where - C: Fn() -> OffsetDateTime + Clone, -{ - pub fn new(config_root: PathBuf, now: C) -> Self { - Self { - backups_root: config_root.join("backups"), - now, - } - } - - pub fn create_backup( - &self, - target: &str, - source_file: &Path, - contents: &[u8], - ) -> Result { - let sanitized_target = sanitized(target); - let directory = self.backups_root.join(&sanitized_target); - fs::create_dir_all(&directory)?; - let timestamp = (self.now)().format(BACKUP_TIMESTAMP_FORMAT)?; - let base_name = source_file - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| sanitized_target.clone()); - let file_path = directory.join(format!("{timestamp}-{base_name}")); - fs::write(&file_path, contents)?; - - Ok(BackupArtifact { - target: sanitized_target, - file_path, - created_at: Some((self.now)()), - }) - } - - pub fn list_backups(&self, target: Option<&str>) -> Result, BackupError> { - if !self.backups_root.exists() { - return Ok(Vec::new()); - } - match target { - Some(target) => { - self.list_backups_in(&self.backups_root.join(sanitized(target)), target) - } - None => { - let mut artifacts = Vec::new(); - for entry in fs::read_dir(&self.backups_root)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - let target = entry.file_name().to_string_lossy().into_owned(); - artifacts.extend(self.list_backups_in(&entry.path(), &target)?); - } - } - artifacts.sort_by(|left, right| right.file_path.cmp(&left.file_path)); - Ok(artifacts) - } - } - } - - pub fn cleanup(&self, target: &str, keeping_latest: usize) -> Result<(), BackupError> { - let backups = self.list_backups(Some(target))?; - for backup in backups.iter().skip(keeping_latest) { - fs::remove_file(&backup.file_path)?; - } - Ok(()) - } - - fn list_backups_in( - &self, - directory: &Path, - target: &str, - ) -> Result, BackupError> { - if !directory.exists() { - return Ok(Vec::new()); - } - let mut artifacts = Vec::new(); - for entry in fs::read_dir(directory)? { - let entry = entry?; - if entry.file_type()?.is_file() { - artifacts.push(BackupArtifact { - target: sanitized(target), - file_path: entry.path(), - created_at: None, - }); - } - } - artifacts.sort_by(|left, right| right.file_path.cmp(&left.file_path)); - Ok(artifacts) - } -} - -fn sanitized(value: &str) -> String { - let mut output = String::new(); - for character in value.trim().chars() { - match character { - 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-' => output.push(character), - _ => output.push('-'), - } - } - if output.is_empty() { - "unknown-target".to_owned() - } else { - output - } -} diff --git a/src-tauri/src/core/document.rs b/src-tauri/src/core/document.rs deleted file mode 100644 index 9cb5cf3..0000000 --- a/src-tauri/src/core/document.rs +++ /dev/null @@ -1,107 +0,0 @@ -use serde_json::{Map, Value}; - -pub use crate::core::error::DocumentError; -use crate::core::jsonc::{parse_jsonc_object, JsoncError}; - -const OH_MY_BOOTSTRAP_SCHEMA: &str = - "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"; - -impl From for DocumentError { - fn from(error: JsoncError) -> Self { - match error { - JsoncError::MalformedJson => Self::MalformedJson, - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct OhMyOpenAgentDocument { - raw: Map, -} - -impl OhMyOpenAgentDocument { - pub fn from_raw(raw: Map) -> Self { - Self { raw } - } - - pub fn bootstrap() -> Self { - let mut raw = Map::new(); - raw.insert( - "$schema".to_owned(), - Value::String(OH_MY_BOOTSTRAP_SCHEMA.to_owned()), - ); - raw.insert("agents".to_owned(), Value::Object(Map::new())); - raw.insert("categories".to_owned(), Value::Object(Map::new())); - Self { raw } - } - - pub fn parse_jsonc(input: &str) -> Result { - Ok(Self { - raw: parse_jsonc_object(input)?, - }) - } - - pub fn serialize(&self) -> Result { - serde_json::to_string_pretty(&self.raw) - .map(|json| json.replace("\\/", "/")) - .map_err(|_| DocumentError::MalformedJson) - } - - pub const fn raw(&self) -> &Map { - &self.raw - } - - pub fn agents(&self) -> &Map { - object_field(&self.raw, "agents") - } - - pub fn categories(&self) -> &Map { - object_field(&self.raw, "categories") - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct OpenCodeDocument { - raw: Map, -} - -impl OpenCodeDocument { - pub fn from_raw(raw: Map) -> Self { - Self { raw } - } - - pub fn parse_jsonc(input: &str) -> Result { - Ok(Self { - raw: parse_jsonc_object(input)?, - }) - } - - pub fn serialize(&self) -> Result { - serde_json::to_string_pretty(&self.raw) - .map(|json| json.replace("\\/", "/")) - .map_err(|_| DocumentError::MalformedJson) - } - - pub const fn raw(&self) -> &Map { - &self.raw - } - - pub fn agents(&self) -> &Map { - object_field(&self.raw, "agent") - } -} - -fn object_field<'a>(raw: &'a Map, key: &str) -> &'a Map { - match raw.get(key) { - Some(Value::Object(object)) => object, - Some( - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) | Value::Array(_), - ) - | None => empty_object(), - } -} - -fn empty_object() -> &'static Map { - static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); - EMPTY.get_or_init(Map::new) -} diff --git a/src-tauri/src/core/error.rs b/src-tauri/src/core/error.rs deleted file mode 100644 index d2adf74..0000000 --- a/src-tauri/src/core/error.rs +++ /dev/null @@ -1,286 +0,0 @@ -use std::fmt; -use std::io; -use std::path::{Path, PathBuf}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfigPathError { - MissingHome, -} - -impl ConfigPathError { - pub const fn code(&self) -> &'static str { - match self { - Self::MissingHome => "missingHome", - } - } - - pub const fn detail(&self) -> &'static str { - match self { - Self::MissingHome => "HOME or USERPROFILE must be set", - } - } -} - -impl fmt::Display for ConfigPathError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.detail()) - } -} - -impl std::error::Error for ConfigPathError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DocumentError { - MalformedJson, -} - -impl fmt::Display for DocumentError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MalformedJson => formatter.write_str("malformed JSON document"), - } - } -} - -impl std::error::Error for DocumentError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PersistenceOperation { - Read, - Parse, - Serialize, - CreateDirectory, - CreateTemporaryFile, - Write, - Flush, - Sync, - Rename, -} - -impl PersistenceOperation { - pub const fn as_str(self) -> &'static str { - match self { - Self::Read => "read", - Self::Parse => "parse", - Self::Serialize => "serialize", - Self::CreateDirectory => "create directory", - Self::CreateTemporaryFile => "create temporary file", - Self::Write => "write", - Self::Flush => "flush", - Self::Sync => "sync", - Self::Rename => "rename", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RepositoryErrorCode { - ReadFailed, - MalformedJson, - WriteFailed, -} - -impl RepositoryErrorCode { - pub const fn as_str(self) -> &'static str { - match self { - Self::ReadFailed => "readFailed", - Self::MalformedJson => "malformedJson", - Self::WriteFailed => "writeFailed", - } - } -} - -#[derive(Debug)] -pub enum RepositoryError { - ReadFailed { - path: PathBuf, - source: io::Error, - }, - MalformedJson { - path: PathBuf, - operation: PersistenceOperation, - source: serde_json::Error, - }, - WriteFailed { - path: PathBuf, - operation: PersistenceOperation, - source: io::Error, - }, -} - -impl RepositoryError { - pub const fn code(&self) -> RepositoryErrorCode { - match self { - Self::ReadFailed { .. } => RepositoryErrorCode::ReadFailed, - Self::MalformedJson { .. } => RepositoryErrorCode::MalformedJson, - Self::WriteFailed { .. } => RepositoryErrorCode::WriteFailed, - } - } - - pub const fn operation(&self) -> PersistenceOperation { - match self { - Self::ReadFailed { .. } => PersistenceOperation::Read, - Self::MalformedJson { operation, .. } | Self::WriteFailed { operation, .. } => { - *operation - } - } - } - - pub fn path(&self) -> &Path { - match self { - Self::ReadFailed { path, .. } - | Self::MalformedJson { path, .. } - | Self::WriteFailed { path, .. } => path, - } - } - - pub fn detail(&self) -> String { - match self { - Self::ReadFailed { path, source } => format!("read {}: {source}", path.display()), - Self::MalformedJson { - path, - operation, - source, - } => { - format!("{} {}: {source}", operation.as_str(), path.display()) - } - Self::WriteFailed { - path, - operation, - source, - } => { - format!("{} {}: {source}", operation.as_str(), path.display()) - } - } - } -} - -impl fmt::Display for RepositoryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}: {}", self.code().as_str(), self.detail()) - } -} - -impl std::error::Error for RepositoryError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::ReadFailed { source, .. } | Self::WriteFailed { source, .. } => Some(source), - Self::MalformedJson { source, .. } => Some(source), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TargetConfigErrorCode { - FileNotFound, - ReadFailed, - MalformedConfig, - WriteFailed, -} - -impl TargetConfigErrorCode { - pub const fn as_str(self) -> &'static str { - match self { - Self::FileNotFound => "fileNotFound", - Self::ReadFailed => "readFailed", - Self::MalformedConfig => "malformedConfig", - Self::WriteFailed => "writeFailed", - } - } -} - -#[derive(Debug)] -pub enum TargetConfigError { - FileNotFound { - path: PathBuf, - }, - ReadFailed { - path: PathBuf, - source: io::Error, - }, - MalformedConfig { - path: PathBuf, - }, - WriteFailed { - path: PathBuf, - operation: PersistenceOperation, - source: io::Error, - }, -} - -impl TargetConfigError { - pub const fn code(&self) -> TargetConfigErrorCode { - match self { - Self::FileNotFound { .. } => TargetConfigErrorCode::FileNotFound, - Self::ReadFailed { .. } => TargetConfigErrorCode::ReadFailed, - Self::MalformedConfig { .. } => TargetConfigErrorCode::MalformedConfig, - Self::WriteFailed { .. } => TargetConfigErrorCode::WriteFailed, - } - } - - pub const fn operation(&self) -> PersistenceOperation { - match self { - Self::FileNotFound { .. } | Self::ReadFailed { .. } => PersistenceOperation::Read, - Self::MalformedConfig { .. } => PersistenceOperation::Parse, - Self::WriteFailed { operation, .. } => *operation, - } - } - - pub fn path(&self) -> &Path { - match self { - Self::FileNotFound { path } - | Self::ReadFailed { path, .. } - | Self::MalformedConfig { path } - | Self::WriteFailed { path, .. } => path, - } - } - - pub fn detail(&self) -> String { - match self { - Self::FileNotFound { path } => format!("read {}: file not found", path.display()), - Self::ReadFailed { path, source } => format!("read {}: {source}", path.display()), - Self::MalformedConfig { path } => format!("parse {}: malformed config", path.display()), - Self::WriteFailed { - path, - operation, - source, - } => { - format!("{} {}: {source}", operation.as_str(), path.display()) - } - } - } -} - -impl PartialEq for TargetConfigError { - fn eq(&self, other: &Self) -> bool { - self.code() == other.code() - && self.operation() == other.operation() - && self.path() == other.path() - && io_kind(self) == io_kind(other) - } -} - -impl Eq for TargetConfigError {} - -impl fmt::Display for TargetConfigError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.detail()) - } -} - -impl std::error::Error for TargetConfigError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::FileNotFound { .. } | Self::MalformedConfig { .. } => None, - Self::ReadFailed { source, .. } | Self::WriteFailed { source, .. } => Some(source), - } - } -} - -fn io_kind(error: &TargetConfigError) -> Option { - match error { - TargetConfigError::FileNotFound { .. } | TargetConfigError::MalformedConfig { .. } => None, - TargetConfigError::ReadFailed { source, .. } - | TargetConfigError::WriteFailed { source, .. } => Some(source.kind()), - } -} diff --git a/src-tauri/src/core/jsonc.rs b/src-tauri/src/core/jsonc.rs deleted file mode 100644 index 42d27d0..0000000 --- a/src-tauri/src/core/jsonc.rs +++ /dev/null @@ -1,100 +0,0 @@ -use serde_json::{Map, Value}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum JsoncError { - MalformedJson, -} - -impl std::fmt::Display for JsoncError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::MalformedJson => formatter.write_str("malformed JSONC"), - } - } -} - -impl std::error::Error for JsoncError {} - -pub fn parse_jsonc_object(input: &str) -> Result, JsoncError> { - let stripped = strip_comments(input); - let value: Value = serde_json::from_str(&stripped).map_err(|_| JsoncError::MalformedJson)?; - match value { - Value::Object(object) => Ok(object), - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) | Value::Array(_) => { - Err(JsoncError::MalformedJson) - } - } -} - -fn strip_comments(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - let mut in_string = false; - let mut escaped = false; - - while let Some(character) = chars.next() { - if in_string { - output.push(character); - if escaped { - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == '"' { - in_string = false; - } - continue; - } - - if character == '"' { - in_string = true; - output.push(character); - continue; - } - - if character == '/' { - match chars.peek().copied() { - Some('/') => { - chars.next(); - for next in chars.by_ref() { - if next == '\n' { - output.push('\n'); - break; - } - } - } - Some('*') => { - chars.next(); - let mut previous = '\0'; - for next in chars.by_ref() { - if previous == '*' && next == '/' { - break; - } - previous = next; - } - } - Some(_) | None => output.push(character), - } - continue; - } - - output.push(character); - } - - output -} - -#[cfg(test)] -mod tests { - use super::strip_comments; - - #[test] - fn strip_comments_when_markers_are_inside_strings_preserves_them() { - let input = r#"{"url":"https://example.invalid","code":"a // b","block":"/* c */"}"#; - - let stripped = strip_comments(input); - - assert!(stripped.contains("https://example.invalid")); - assert!(stripped.contains("a // b")); - assert!(stripped.contains("/* c */")); - } -} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs deleted file mode 100644 index ad7e1e9..0000000 --- a/src-tauri/src/core/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod backup; -pub mod document; -pub mod error; -pub mod jsonc; -pub mod models; -pub mod paths; -pub mod projection; -pub mod repository; -pub mod switching; diff --git a/src-tauri/src/core/models.rs b/src-tauri/src/core/models.rs deleted file mode 100644 index e1ed43c..0000000 --- a/src-tauri/src/core/models.rs +++ /dev/null @@ -1,100 +0,0 @@ -use serde::{Deserialize, Serialize}; -use time::OffsetDateTime; -use uuid::Uuid; - -const fn default_migration_version() -> u16 { - 1 -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelGroupCategoryMapping { - pub category_name: String, - pub model_ref: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelGroupAgentOverride { - pub agent_name: String, - pub model_ref: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelGroup { - pub id: Uuid, - pub name: String, - pub description: Option, - pub category_mappings: Vec, - pub agent_overrides: Vec, - #[serde(default)] - pub open_code_agent_overrides: Vec, - pub is_enabled: bool, - #[serde(with = "time::serde::rfc3339")] - pub updated_at: OffsetDateTime, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AppSelectionState { - #[serde(rename = "selectedGroupID")] - pub selected_group_id: Option, - pub selected_group_name: Option, - pub last_successful_write: Option, - pub last_warning_summary: Option, - pub last_error_summary: Option, - #[serde(default = "default_migration_version")] - pub migration_version: u16, -} - -impl AppSelectionState { - pub const CURRENT_SCHEMA_VERSION: u16 = 2; -} - -impl Default for AppSelectionState { - fn default() -> Self { - Self { - selected_group_id: None, - selected_group_name: None, - last_successful_write: None, - last_warning_summary: None, - last_error_summary: None, - migration_version: Self::CURRENT_SCHEMA_VERSION, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OmoSwitchConfig { - #[serde(default)] - pub groups: Vec, - #[serde(default)] - pub state: AppSelectionState, -} - -impl Default for OmoSwitchConfig { - fn default() -> Self { - Self { - groups: Vec::new(), - state: AppSelectionState::default(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LastSuccessfulWriteMetadata { - pub target: String, - #[serde(with = "time::serde::rfc3339")] - pub wrote_at: OffsetDateTime, - pub backup_path: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectionIssueSummary { - pub message: String, - pub count: u32, -} diff --git a/src-tauri/src/core/paths.rs b/src-tauri/src/core/paths.rs deleted file mode 100644 index 405cb49..0000000 --- a/src-tauri/src/core/paths.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::env; -use std::path::{Path, PathBuf}; - -pub use crate::core::error::ConfigPathError; - -#[derive(Debug, Clone, Copy)] -pub struct HomeEnv<'a> { - home: Option<&'a Path>, - userprofile: Option<&'a Path>, -} - -impl<'a> HomeEnv<'a> { - pub const fn new(home: Option<&'a Path>, userprofile: Option<&'a Path>) -> Self { - Self { home, userprofile } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConfigPaths { - home: PathBuf, -} - -impl ConfigPaths { - pub fn from_environment() -> Result { - let home = env::var_os("HOME").map(PathBuf::from); - let userprofile = env::var_os("USERPROFILE").map(PathBuf::from); - Self::from_owned_home(home, userprofile) - } - - pub fn from_home_env(env: HomeEnv<'_>) -> Result { - let home = env - .home - .or(env.userprofile) - .ok_or(ConfigPathError::MissingHome)?; - Ok(Self { - home: home.to_path_buf(), - }) - } - - pub fn omo_switch_dir(&self) -> PathBuf { - self.home.join(".config").join("omo-switch") - } - - pub fn opencode_dir(&self) -> PathBuf { - self.home.join(".config").join("opencode") - } - - pub fn config_file(&self) -> PathBuf { - self.omo_switch_dir().join("config.json") - } - - pub fn oh_my_openagent_file(&self) -> PathBuf { - self.opencode_dir().join("oh-my-openagent.json") - } - - pub fn opencode_file(&self) -> PathBuf { - self.opencode_dir().join("opencode.json") - } - - fn from_owned_home( - home: Option, - userprofile: Option, - ) -> Result { - home.or(userprofile) - .map(|home| Self { home }) - .ok_or(ConfigPathError::MissingHome) - } -} diff --git a/src-tauri/src/core/projection.rs b/src-tauri/src/core/projection.rs deleted file mode 100644 index 28d08c3..0000000 --- a/src-tauri/src/core/projection.rs +++ /dev/null @@ -1,127 +0,0 @@ -use serde_json::{Map, Value}; - -use crate::core::document::{OhMyOpenAgentDocument, OpenCodeDocument}; -use crate::core::models::ModelGroup; - -#[derive(Debug, Clone, PartialEq)] -pub struct ProjectionResult { - pub document: D, - pub warnings: Vec, -} - -pub struct OhMyOpenAgentProjectionService; - -impl OhMyOpenAgentProjectionService { - pub fn project( - group: &ModelGroup, - existing_document: &OhMyOpenAgentDocument, - ) -> ProjectionResult { - let mut raw = existing_document.raw().clone(); - let agents = project_section( - existing_document.agents(), - group.agent_overrides.iter().filter_map(|override_row| { - let model_ref = override_row.model_ref.trim(); - (!model_ref.is_empty()).then(|| (override_row.agent_name.as_str(), model_ref)) - }), - ); - let categories = project_section( - existing_document.categories(), - group.category_mappings.iter().filter_map(|mapping| { - let model_ref = mapping.model_ref.trim(); - (!model_ref.is_empty()).then(|| (mapping.category_name.as_str(), model_ref)) - }), - ); - - raw.insert("agents".to_owned(), Value::Object(agents)); - raw.insert("categories".to_owned(), Value::Object(categories)); - - ProjectionResult { - document: OhMyOpenAgentDocument::from_raw(raw), - warnings: Vec::new(), - } - } -} - -pub struct OpenCodeProjectionService; - -impl OpenCodeProjectionService { - pub fn project( - group: &ModelGroup, - existing_document: &OpenCodeDocument, - ) -> ProjectionResult { - let effective_overrides: Vec<(&str, &str)> = group - .open_code_agent_overrides - .iter() - .filter_map(|override_row| { - let model_ref = override_row.model_ref.trim(); - (!model_ref.is_empty()).then(|| (override_row.agent_name.as_str(), model_ref)) - }) - .collect(); - let mut raw = existing_document.raw().clone(); - let Some(Value::Object(existing_agents)) = raw.get("agent") else { - let warnings = effective_overrides - .iter() - .map(|(agent_name, _model_ref)| { - format!( - "OpenCode config has no valid top-level 'agent' object; skipped model override for '{agent_name}'." - ) - }) - .collect(); - return ProjectionResult { - document: existing_document.clone(), - warnings, - }; - }; - - let mut agents = existing_agents.clone(); - let mut warnings = Vec::new(); - for (agent_name, model_ref) in effective_overrides { - match agents.get(agent_name) { - Some(Value::Object(entry)) => { - let mut projected_entry = entry.clone(); - projected_entry.insert("model".to_owned(), Value::String(model_ref.to_owned())); - agents.insert(agent_name.to_owned(), Value::Object(projected_entry)); - } - Some( - Value::Null - | Value::Bool(_) - | Value::Number(_) - | Value::String(_) - | Value::Array(_), - ) - | None => warnings.push(format!( - "OpenCode agent '{agent_name}' was not found; skipped model override." - )), - } - } - - raw.insert("agent".to_owned(), Value::Object(agents)); - ProjectionResult { - document: OpenCodeDocument::from_raw(raw), - warnings, - } - } -} - -fn project_section<'a>( - existing_section: &Map, - selected_models: impl Iterator, -) -> Map { - let mut projected = Map::new(); - for (name, model_ref) in selected_models { - let mut entry = match existing_section.get(name) { - Some(Value::Object(object)) => object.clone(), - Some( - Value::Null - | Value::Bool(_) - | Value::Number(_) - | Value::String(_) - | Value::Array(_), - ) - | None => Map::new(), - }; - entry.insert("model".to_owned(), Value::String(model_ref.to_owned())); - projected.insert(name.to_owned(), Value::Object(entry)); - } - projected -} diff --git a/src-tauri/src/core/repository/mod.rs b/src-tauri/src/core/repository/mod.rs deleted file mode 100644 index 941b655..0000000 --- a/src-tauri/src/core/repository/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -mod model; -mod replacement; -mod target; - -pub use crate::core::error::{RepositoryError, RepositoryErrorCode, TargetConfigError}; -pub use model::ConfigRepository; -pub use target::{OhMyOpenAgentConfigRepository, OpenCodeConfigRepository}; - -pub type OhMyOpenAgentConfigError = TargetConfigError; -pub type OpenCodeConfigError = TargetConfigError; diff --git a/src-tauri/src/core/repository/model.rs b/src-tauri/src/core/repository/model.rs deleted file mode 100644 index 5a122b0..0000000 --- a/src-tauri/src/core/repository/model.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -use crate::core::error::{PersistenceOperation, RepositoryError}; -use crate::core::models::OmoSwitchConfig; - -use super::replacement::replace_file; - -#[derive(Debug, Clone)] -pub struct ConfigRepository { - config_file: PathBuf, -} - -impl ConfigRepository { - pub fn new(config_file: PathBuf) -> Self { - Self { config_file } - } - - pub fn load(&self) -> Result { - if !self.config_file.exists() { - return Ok(OmoSwitchConfig::default()); - } - - let data = fs::read_to_string(&self.config_file).map_err(|source| { - RepositoryError::ReadFailed { - path: self.config_file.clone(), - source, - } - })?; - serde_json::from_str(&data).map_err(|source| RepositoryError::MalformedJson { - path: self.config_file.clone(), - operation: PersistenceOperation::Parse, - source, - }) - } - - pub fn save(&self, config: &OmoSwitchConfig) -> Result<(), RepositoryError> { - let data = serde_json::to_string_pretty(config).map_err(|source| { - RepositoryError::MalformedJson { - path: self.config_file.clone(), - operation: PersistenceOperation::Serialize, - source, - } - })?; - replace_file(&self.config_file, data.as_bytes()).map_err(|error| { - let (path, operation, source) = error.into_parts(); - RepositoryError::WriteFailed { - path, - operation, - source, - } - }) - } - - pub fn config_file(&self) -> &std::path::Path { - &self.config_file - } -} diff --git a/src-tauri/src/core/repository/replacement.rs b/src-tauri/src/core/repository/replacement.rs deleted file mode 100644 index a0b93cc..0000000 --- a/src-tauri/src/core/repository/replacement.rs +++ /dev/null @@ -1,307 +0,0 @@ -use std::ffi::{OsStr, OsString}; -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use crate::core::error::PersistenceOperation; - -const TEMPORARY_FILE_ATTEMPTS: usize = 16; - -static NEXT_TEMPORARY_ID: AtomicU64 = AtomicU64::new(0); - -#[derive(Debug)] -pub(super) struct ReplacementError { - path: PathBuf, - operation: PersistenceOperation, - source: io::Error, -} - -impl ReplacementError { - pub(super) fn into_parts(self) -> (PathBuf, PersistenceOperation, io::Error) { - (self.path, self.operation, self.source) - } -} - -/// Replaces an ordinary repository file with a fully written adjacent temporary file. -/// -/// This is a conservative replacement path, not a universal power-loss or parent-directory -/// durability guarantee. -pub(super) fn replace_file(path: &Path, bytes: &[u8]) -> Result<(), ReplacementError> { - let directory = parent_directory(path); - if let Some(parent) = directory { - fs::create_dir_all(parent).map_err(|source| ReplacementError { - path: parent.to_path_buf(), - operation: PersistenceOperation::CreateDirectory, - source, - })?; - } - - let directory = directory.unwrap_or_else(|| Path::new(".")); - let (temporary_path, mut temporary_file) = - create_temporary_file(directory, path).map_err(|source| { - replacement_failure(path, PersistenceOperation::CreateTemporaryFile, source) - })?; - - let write_result = write_and_sync(&mut temporary_file, bytes); - // File::drop does not expose close errors; sync_all above captures the observable durability - // failure before the handle is closed for the Windows rename path. - drop(temporary_file); - if let Err((operation, source)) = write_result { - cleanup_temporary_file(&temporary_path); - return Err(replacement_failure(path, operation, source)); - } - - #[cfg(test)] - let rename_result = - fail_if_injected(ReplacementStep::Rename).and_then(|()| fs::rename(&temporary_path, path)); - #[cfg(not(test))] - let rename_result = fs::rename(&temporary_path, path); - if let Err(source) = rename_result { - cleanup_temporary_file(&temporary_path); - return Err(replacement_failure( - path, - PersistenceOperation::Rename, - source, - )); - } - - Ok(()) -} - -fn parent_directory(path: &Path) -> Option<&Path> { - path.parent() - .filter(|parent| !parent.as_os_str().is_empty()) -} - -fn create_temporary_file(directory: &Path, destination: &Path) -> io::Result<(PathBuf, File)> { - for _ in 0..TEMPORARY_FILE_ATTEMPTS { - let temporary_path = temporary_path(directory, destination); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary_path) - { - Ok(file) => return Ok((temporary_path, file)), - Err(source) if source.kind() == io::ErrorKind::AlreadyExists => continue, - Err(source) => return Err(source), - } - } - - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not allocate a unique adjacent temporary file", - )) -} - -fn temporary_path(directory: &Path, destination: &Path) -> PathBuf { - let file_name = destination - .file_name() - .unwrap_or_else(|| OsStr::new("repository")); - let temporary_id = NEXT_TEMPORARY_ID.fetch_add(1, Ordering::Relaxed); - let mut temporary_name = OsString::from("."); - temporary_name.push(file_name); - temporary_name.push(format!( - ".omo-switch-{}-{temporary_id}.tmp", - std::process::id() - )); - directory.join(temporary_name) -} - -fn write_and_sync( - temporary_file: &mut File, - bytes: &[u8], -) -> Result<(), (PersistenceOperation, io::Error)> { - #[cfg(test)] - fail_if_injected(ReplacementStep::Write) - .map_err(|source| (PersistenceOperation::Write, source))?; - temporary_file - .write_all(bytes) - .map_err(|source| (PersistenceOperation::Write, source))?; - - #[cfg(test)] - fail_if_injected(ReplacementStep::Flush) - .map_err(|source| (PersistenceOperation::Flush, source))?; - temporary_file - .flush() - .map_err(|source| (PersistenceOperation::Flush, source))?; - - #[cfg(test)] - fail_if_injected(ReplacementStep::Sync) - .map_err(|source| (PersistenceOperation::Sync, source))?; - temporary_file - .sync_all() - .map_err(|source| (PersistenceOperation::Sync, source)) -} - -fn cleanup_temporary_file(path: &Path) { - let _ = fs::remove_file(path); -} - -fn replacement_failure( - path: &Path, - operation: PersistenceOperation, - source: io::Error, -) -> ReplacementError { - ReplacementError { - path: path.to_path_buf(), - operation, - source, - } -} - -#[cfg(test)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ReplacementStep { - Write, - Flush, - Sync, - Rename, -} - -#[cfg(test)] -fn fail_if_injected(_step: ReplacementStep) -> io::Result<()> { - let should_fail = TEST_FAILURE.with(|failure| failure.get() == Some(_step)); - if should_fail { - return Err(io::Error::new( - io::ErrorKind::Other, - format!("injected {_step:?} failure"), - )); - } - - Ok(()) -} - -#[cfg(test)] -thread_local! { - static TEST_FAILURE: std::cell::Cell> = std::cell::Cell::new(None); -} - -#[cfg(test)] -struct InjectedFailureGuard { - previous: Option, -} - -#[cfg(test)] -impl Drop for InjectedFailureGuard { - fn drop(&mut self) { - TEST_FAILURE.with(|failure| failure.set(self.previous)); - } -} - -#[cfg(test)] -fn inject_failure(step: ReplacementStep) -> InjectedFailureGuard { - let previous = TEST_FAILURE.with(|failure| { - let previous = failure.get(); - failure.set(Some(step)); - previous - }); - InjectedFailureGuard { previous } -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::path::{Path, PathBuf}; - use std::sync::atomic::{AtomicU64, Ordering}; - - use super::*; - - static NEXT_TEST_DIRECTORY_ID: AtomicU64 = AtomicU64::new(0); - - #[test] - fn replacement_when_destination_exists_overwrites_it_on_the_same_directory() { - let directory = temporary_directory("existing-overwrite"); - let destination = directory.join("config.json"); - fs::write(&destination, b"old bytes").expect("Given: original bytes persist"); - - replace_file(&destination, b"new bytes").expect("When: replacement succeeds"); - - assert_eq!( - fs::read(&destination).expect("Then: replaced bytes read"), - b"new bytes" - ); - assert!(temporary_files(&directory).is_empty()); - remove_temporary_directory(&directory); - } - - #[test] - fn replacement_when_parent_creation_fails_preserves_current_error_context_without_temp_file() { - let directory = temporary_directory("parent-failure"); - let blocked_parent = directory.join("blocked"); - fs::write(&blocked_parent, b"not a directory").expect("Given: blocking file persists"); - let destination = blocked_parent.join("config.json"); - - let error = replace_file(&destination, b"new bytes") - .expect_err("When: parent directory cannot be created"); - let (path, operation, _) = error.into_parts(); - - assert_eq!(path, blocked_parent); - assert_eq!(operation, PersistenceOperation::CreateDirectory); - assert!(temporary_files(&directory).is_empty()); - remove_temporary_directory(&directory); - } - - #[test] - fn replacement_when_write_flush_sync_or_rename_fails_keeps_original_bytes_and_cleans_temp() { - for (step, expected_operation, label) in [ - (ReplacementStep::Write, PersistenceOperation::Write, "write"), - (ReplacementStep::Flush, PersistenceOperation::Flush, "flush"), - (ReplacementStep::Sync, PersistenceOperation::Sync, "sync"), - ( - ReplacementStep::Rename, - PersistenceOperation::Rename, - "rename", - ), - ] { - let directory = temporary_directory(label); - let destination = directory.join("config.json"); - fs::write(&destination, b"original bytes") - .expect("Given: original destination bytes persist"); - let _failure = inject_failure(step); - - let error = replace_file(&destination, b"replacement bytes") - .expect_err("When: a replacement step fails"); - let (path, operation, _) = error.into_parts(); - - assert_eq!(path, destination, "{label}"); - assert_eq!(operation, expected_operation, "{label}"); - assert_eq!( - fs::read(&destination).expect("Then: original bytes read"), - b"original bytes", - "{label}" - ); - assert!(temporary_files(&directory).is_empty(), "{label}"); - drop(_failure); - remove_temporary_directory(&directory); - } - } - - fn temporary_directory(label: &str) -> PathBuf { - let id = NEXT_TEST_DIRECTORY_ID.fetch_add(1, Ordering::Relaxed); - let directory = std::env::temp_dir().join(format!( - "omo-switch-replacement-{label}-{}-{id}", - std::process::id() - )); - let _ = fs::remove_dir_all(&directory); - fs::create_dir_all(&directory).expect("Given: temporary directory is created"); - directory - } - - fn temporary_files(directory: &Path) -> Vec { - fs::read_dir(directory) - .expect("Then: temporary directory reads") - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.contains(".omo-switch-")) - }) - .collect() - } - - fn remove_temporary_directory(directory: &Path) { - let _ = fs::remove_dir_all(directory); - } -} diff --git a/src-tauri/src/core/repository/target.rs b/src-tauri/src/core/repository/target.rs deleted file mode 100644 index 6dfa397..0000000 --- a/src-tauri/src/core/repository/target.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::core::document::{OhMyOpenAgentDocument, OpenCodeDocument}; -use crate::core::error::{DocumentError, TargetConfigError}; - -use super::replacement::replace_file; - -#[derive(Debug, Clone)] -pub struct OhMyOpenAgentConfigRepository { - config_file: PathBuf, -} - -impl OhMyOpenAgentConfigRepository { - pub fn new(config_file: PathBuf) -> Self { - Self { config_file } - } - - pub fn config_file(&self) -> &Path { - &self.config_file - } - - pub fn load(&self) -> Result { - if !self.config_file.exists() { - return Ok(OhMyOpenAgentDocument::bootstrap()); - } - - let data = fs::read_to_string(&self.config_file).map_err(|source| { - TargetConfigError::ReadFailed { - path: self.config_file.clone(), - source, - } - })?; - OhMyOpenAgentDocument::parse_jsonc(&data) - .map_err(|error| malformed_document(&self.config_file, error)) - } - - pub fn save(&self, document: &OhMyOpenAgentDocument) -> Result<(), TargetConfigError> { - let data = document - .serialize() - .map_err(|error| malformed_document(&self.config_file, error))?; - write_document(&self.config_file, &data) - } -} - -#[derive(Debug, Clone)] -pub struct OpenCodeConfigRepository { - config_file: PathBuf, -} - -impl OpenCodeConfigRepository { - pub fn new(config_file: PathBuf) -> Self { - Self { config_file } - } - - pub fn config_file(&self) -> &Path { - &self.config_file - } - - pub fn load(&self) -> Result { - if !self.config_file.exists() { - return Err(TargetConfigError::FileNotFound { - path: self.config_file.clone(), - }); - } - - let data = fs::read_to_string(&self.config_file).map_err(|source| { - TargetConfigError::ReadFailed { - path: self.config_file.clone(), - source, - } - })?; - OpenCodeDocument::parse_jsonc(&data) - .map_err(|error| malformed_document(&self.config_file, error)) - } - - pub fn save(&self, document: &OpenCodeDocument) -> Result<(), TargetConfigError> { - let data = document - .serialize() - .map_err(|error| malformed_document(&self.config_file, error))?; - write_document(&self.config_file, &data) - } -} - -fn write_document(path: &Path, data: &str) -> Result<(), TargetConfigError> { - replace_file(path, data.as_bytes()).map_err(|error| { - let (path, operation, source) = error.into_parts(); - TargetConfigError::WriteFailed { - path, - operation, - source, - } - }) -} - -fn malformed_document(path: &Path, error: DocumentError) -> TargetConfigError { - match error { - DocumentError::MalformedJson => TargetConfigError::MalformedConfig { - path: path.to_path_buf(), - }, - } -} diff --git a/src-tauri/src/core/switching/error.rs b/src-tauri/src/core/switching/error.rs deleted file mode 100644 index caa8e30..0000000 --- a/src-tauri/src/core/switching/error.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::io; - -use crate::core::backup::BackupError; -use crate::core::repository::RepositoryError; - -#[derive(Debug)] -pub enum SwitchError { - LoadGroupsFailed { - source: RepositoryError, - }, - GroupNotFound, - GroupDisabled, - LoadAppStateFailed { - source: RepositoryError, - }, - LoadOpenCodeConfigFailed, - LoadOhMyConfigFailed, - BackupFailed { - source: BackupError, - }, - WriteFailed { - target: &'static str, - source: String, - }, - RollbackFailed { - source: io::Error, - }, - SaveAppStateFailed { - source: RepositoryError, - }, - TransactionFailed { - stage: &'static str, - source: io::Error, - }, - CompensationFailed { - primary: String, - source: io::Error, - }, - Interrupted { - stage: &'static str, - }, -} - -impl PartialEq for SwitchError { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::LoadGroupsFailed { .. }, Self::LoadGroupsFailed { .. }) - | (Self::GroupNotFound, Self::GroupNotFound) - | (Self::GroupDisabled, Self::GroupDisabled) - | (Self::LoadAppStateFailed { .. }, Self::LoadAppStateFailed { .. }) - | (Self::LoadOpenCodeConfigFailed, Self::LoadOpenCodeConfigFailed) - | (Self::LoadOhMyConfigFailed, Self::LoadOhMyConfigFailed) - | (Self::BackupFailed { .. }, Self::BackupFailed { .. }) - | (Self::RollbackFailed { .. }, Self::RollbackFailed { .. }) - | (Self::SaveAppStateFailed { .. }, Self::SaveAppStateFailed { .. }) - | (Self::TransactionFailed { .. }, Self::TransactionFailed { .. }) - | (Self::CompensationFailed { .. }, Self::CompensationFailed { .. }) - | (Self::Interrupted { .. }, Self::Interrupted { .. }) => true, - ( - Self::WriteFailed { - target: left_target, - .. - }, - Self::WriteFailed { - target: right_target, - .. - }, - ) => left_target == right_target, - _ => false, - } - } -} - -impl Eq for SwitchError {} - -impl std::fmt::Display for SwitchError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::LoadGroupsFailed { source } => { - write!(formatter, "failed to load groups: {source}") - } - Self::GroupNotFound => formatter.write_str("group not found"), - Self::GroupDisabled => formatter.write_str("group is disabled"), - Self::LoadAppStateFailed { source } => { - write!(formatter, "failed to load app state: {source}") - } - Self::LoadOpenCodeConfigFailed => formatter.write_str("failed to load opencode config"), - Self::LoadOhMyConfigFailed => { - formatter.write_str("failed to load oh-my-openagent config") - } - Self::BackupFailed { source } => write!(formatter, "failed to create backup: {source}"), - Self::WriteFailed { target, source } => { - write!(formatter, "failed to write {target}: {source}") - } - Self::RollbackFailed { source } => write!(formatter, "rollback failed: {source}"), - Self::SaveAppStateFailed { source } => { - write!(formatter, "config written but state save failed: {source}") - } - Self::TransactionFailed { stage, source } => { - write!(formatter, "transaction failed at {stage}: {source}") - } - Self::CompensationFailed { primary, source } => { - write!( - formatter, - "transaction failed ({primary}); compensation failed: {source}" - ) - } - Self::Interrupted { stage } => write!(formatter, "transaction interrupted at {stage}"), - } - } -} - -impl std::error::Error for SwitchError {} diff --git a/src-tauri/src/core/switching/fault.rs b/src-tauri/src/core/switching/fault.rs deleted file mode 100644 index 1823708..0000000 --- a/src-tauri/src/core/switching/fault.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::collections::VecDeque; -use std::io; -use std::sync::{Arc, Mutex}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TransactionFault { - StageCreate, - StageWrite, - StageFlush, - JournalCreate, - JournalWrite, - JournalFlush, - FirstTargetRename, - SecondTargetRename, - ConfigRename, - CompensationRename, - AfterJournalSync, - AfterFirstTargetRename, - AfterSecondTargetRename, - AfterConfigRename, -} - -impl TransactionFault { - pub const fn label(self) -> &'static str { - match self { - Self::StageCreate => "stage-create", - Self::StageWrite => "stage-write", - Self::StageFlush => "stage-flush", - Self::JournalCreate => "journal-create", - Self::JournalWrite => "journal-write", - Self::JournalFlush => "journal-flush", - Self::FirstTargetRename => "first-target-rename", - Self::SecondTargetRename => "second-target-rename", - Self::ConfigRename => "config-rename", - Self::CompensationRename => "compensation-rename", - Self::AfterJournalSync => "after-journal-sync", - Self::AfterFirstTargetRename => "after-first-target-rename", - Self::AfterSecondTargetRename => "after-second-target-rename", - Self::AfterConfigRename => "after-config-rename", - } - } -} - -pub(crate) fn is_interruption(error: &io::Error) -> bool { - error.to_string().starts_with("after-") -} - -pub(crate) fn interruption_stage(error: &io::Error) -> &'static str { - match error.to_string().as_str() { - "after-journal-sync" => "after-journal-sync", - "after-first-target-rename" => "after-first-target-rename", - "after-second-target-rename" => "after-second-target-rename", - "after-config-rename" => "after-config-rename", - _ => "unknown-interruption", - } -} - -#[derive(Debug, Clone, Default)] -pub struct SwitchTestControl { - faults: Arc>>, -} - -impl SwitchTestControl { - pub fn fail_once(fault: TransactionFault) -> Self { - Self::fail_sequence(&[fault]) - } - - pub fn fail_sequence(faults: &[TransactionFault]) -> Self { - Self { - faults: Arc::new(Mutex::new(faults.iter().copied().collect())), - } - } - - pub(crate) fn inject(&self, stage: TransactionFault) -> io::Result<()> { - let mut faults = self - .faults - .lock() - .map_err(|_| io::Error::other("transaction fault lock poisoned"))?; - if faults.iter().position(|fault| *fault == stage) == Some(0) { - faults.pop_front(); - return Err(io::Error::other(stage.label())); - } - Ok(()) - } -} diff --git a/src-tauri/src/core/switching/journal.rs b/src-tauri/src/core/switching/journal.rs deleted file mode 100644 index 31ea1b7..0000000 --- a/src-tauri/src/core/switching/journal.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::fault::TransactionFault; - -const JOURNAL_FILE: &str = "transaction-journal.json"; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum CommitPhase { - Prepared, - FirstTargetCommitted, - TargetsCommitted, - ConfigCommitted, - Compensated, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct JournalEntry { - pub(crate) name: String, - pub(crate) target_path: PathBuf, - pub(crate) staged_path: PathBuf, - pub(crate) original_path: PathBuf, - pub(crate) original_existed: bool, - pub(crate) expected_bytes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct TransactionJournal { - pub(crate) transaction_id: Uuid, - pub(crate) phase: CommitPhase, - pub(crate) ordered_targets: Vec, - pub(crate) entries: Vec, -} - -pub(crate) fn restore(entry: &JournalEntry) -> io::Result<()> { - if entry.original_existed { - fs::copy(&entry.original_path, &entry.target_path)?; - sync_path(&entry.target_path)?; - sync_parent(&entry.target_path) - } else { - remove_if_exists(&entry.target_path) - } -} - -pub(crate) fn sibling(target: &Path, id: &Uuid, suffix: &str) -> io::Result { - let name = target - .file_name() - .ok_or_else(|| io::Error::other("transaction target has no file name"))? - .to_string_lossy(); - Ok(target.with_file_name(format!(".{name}.omo-txn-{id}.{suffix}"))) -} - -pub(crate) fn replace(source: &Path, target: &Path) -> io::Result<()> { - remove_if_exists(target)?; - fs::rename(source, target) -} - -pub(crate) fn sync_path(path: &Path) -> io::Result<()> { - OpenOptions::new() - .read(true) - .write(true) - .open(path)? - .sync_all() -} - -pub(crate) fn sync_parent(_path: &Path) -> io::Result<()> { - #[cfg(not(windows))] - if let Some(parent) = _path.parent() { - File::open(parent)?.sync_all()?; - } - Ok(()) -} - -pub(crate) fn remove_if_exists(path: &Path) -> io::Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -pub(crate) fn io_context(operation: &str, path: &Path, error: io::Error) -> io::Error { - io::Error::new( - error.kind(), - format!("{operation} {}: {error}", path.display()), - ) -} - -pub(crate) struct TransactionFiles { - root: PathBuf, - journal_path: PathBuf, - journal: TransactionJournal, -} - -impl TransactionFiles { - pub(crate) fn prepare( - root: &Path, - writes: &[(&str, &Path, &[u8])], - mut inject: impl FnMut(TransactionFault) -> io::Result<()>, - ) -> io::Result { - fs::create_dir_all(root)?; - let transaction_id = Uuid::new_v4(); - let mut entries = Vec::with_capacity(writes.len()); - for (name, target, bytes) in writes { - let staged_path = sibling(target, &transaction_id, "staged")?; - let original_path = sibling(target, &transaction_id, "original")?; - let original_existed = target.exists(); - if original_existed { - fs::copy(target, &original_path) - .map_err(|error| io_context("copy original", &original_path, error))?; - sync_path(&original_path) - .map_err(|error| io_context("sync original", &original_path, error))?; - } - inject(TransactionFault::StageCreate)?; - let mut staged = OpenOptions::new() - .create_new(true) - .write(true) - .open(&staged_path) - .map_err(|error| io_context("create staged", &staged_path, error))?; - inject(TransactionFault::StageWrite)?; - staged - .write_all(bytes) - .map_err(|error| io_context("write staged", &staged_path, error))?; - inject(TransactionFault::StageFlush)?; - staged - .sync_all() - .map_err(|error| io_context("sync staged", &staged_path, error))?; - entries.push(JournalEntry { - name: (*name).to_owned(), - target_path: (*target).to_path_buf(), - staged_path, - original_path, - original_existed, - expected_bytes: bytes.to_vec(), - }); - } - let journal = TransactionJournal { - transaction_id, - phase: CommitPhase::Prepared, - ordered_targets: entries.iter().map(|entry| entry.name.clone()).collect(), - entries, - }; - let mut files = Self { - root: root.to_path_buf(), - journal_path: root.join(JOURNAL_FILE), - journal, - }; - inject(TransactionFault::JournalCreate)?; - files.sync_journal(&mut inject)?; - Ok(files) - } - - pub(crate) fn commit( - &mut self, - mut inject: impl FnMut(TransactionFault) -> io::Result<()>, - ) -> io::Result<()> { - inject(TransactionFault::AfterJournalSync)?; - for index in 0..self.journal.entries.len() { - let rename_fault = match index { - 0 => TransactionFault::FirstTargetRename, - 1 => TransactionFault::SecondTargetRename, - _ => TransactionFault::ConfigRename, - }; - inject(rename_fault)?; - replace( - &self.journal.entries[index].staged_path, - &self.journal.entries[index].target_path, - )?; - sync_parent(&self.journal.entries[index].target_path)?; - self.journal.phase = match index { - 0 => CommitPhase::FirstTargetCommitted, - 1 if self.journal.entries.len() == 3 => CommitPhase::TargetsCommitted, - _ => CommitPhase::ConfigCommitted, - }; - self.sync_journal(&mut inject)?; - let interruption = match index { - 0 => TransactionFault::AfterFirstTargetRename, - 1 if self.journal.entries.len() == 3 => TransactionFault::AfterSecondTargetRename, - _ => TransactionFault::AfterConfigRename, - }; - inject(interruption)?; - } - self.verify_new()?; - self.cleanup() - } - - pub(crate) fn compensate( - &mut self, - mut inject: impl FnMut(TransactionFault) -> io::Result<()>, - ) -> io::Result<()> { - for entry in self.journal.entries.iter().rev() { - inject(TransactionFault::CompensationRename)?; - restore(entry)?; - } - self.journal.phase = CommitPhase::Compensated; - self.sync_journal(&mut inject)?; - self.verify_original()?; - self.cleanup() - } - - fn sync_journal( - &mut self, - inject: &mut impl FnMut(TransactionFault) -> io::Result<()>, - ) -> io::Result<()> { - let temporary = self.root.join(format!(".{JOURNAL_FILE}.tmp")); - inject(TransactionFault::JournalWrite)?; - let bytes = serde_json::to_vec_pretty(&self.journal).map_err(io::Error::other)?; - let mut file = File::create(&temporary)?; - file.write_all(&bytes)?; - inject(TransactionFault::JournalFlush)?; - file.sync_all()?; - drop(file); - replace(&temporary, &self.journal_path) - .map_err(|error| io_context("replace journal", &self.journal_path, error))?; - sync_parent(&self.journal_path) - } - - fn verify_new(&self) -> io::Result<()> { - for entry in &self.journal.entries { - if fs::read(&entry.target_path)? != entry.expected_bytes { - return Err(io::Error::other("committed bytes differ from staged bytes")); - } - } - Ok(()) - } - - fn verify_original(&self) -> io::Result<()> { - for entry in &self.journal.entries { - if entry.original_existed - && fs::read(&entry.target_path)? != fs::read(&entry.original_path)? - { - return Err(io::Error::other( - "compensated bytes differ from original bytes", - )); - } - if !entry.original_existed && entry.target_path.exists() { - return Err(io::Error::other( - "compensation retained newly created target", - )); - } - } - Ok(()) - } - - fn cleanup(&self) -> io::Result<()> { - for entry in &self.journal.entries { - remove_if_exists(&entry.staged_path)?; - remove_if_exists(&entry.original_path)?; - } - remove_if_exists(&self.journal_path)?; - remove_if_exists(&self.root.join(format!(".{JOURNAL_FILE}.tmp"))) - } -} - -pub fn recover_pending_transaction(root: &Path) -> io::Result<()> { - let journal_path = root.join(JOURNAL_FILE); - if !journal_path.exists() { - cleanup_stale_artifacts(root)?; - return Ok(()); - } - let journal: TransactionJournal = - serde_json::from_slice(&fs::read(&journal_path)?).map_err(io::Error::other)?; - let mut files = TransactionFiles { - root: root.to_path_buf(), - journal_path, - journal, - }; - match files.journal.phase { - CommitPhase::ConfigCommitted => { - files.verify_new()?; - files.cleanup() - } - CommitPhase::Prepared - | CommitPhase::FirstTargetCommitted - | CommitPhase::TargetsCommitted - | CommitPhase::Compensated => files.compensate(|_| Ok(())), - } -} - -fn cleanup_stale_artifacts(root: &Path) -> io::Result<()> { - if !root.exists() { - return Ok(()); - } - for entry in fs::read_dir(root)? { - let path = entry?.path(); - let name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or_default(); - if name.contains(".omo-txn-") || name == format!(".{JOURNAL_FILE}.tmp") { - remove_if_exists(&path)?; - } - } - Ok(()) -} diff --git a/src-tauri/src/core/switching/mod.rs b/src-tauri/src/core/switching/mod.rs deleted file mode 100644 index 530e244..0000000 --- a/src-tauri/src/core/switching/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod error; -mod fault; -mod journal; -mod transaction; - -pub use error::SwitchError; -pub use fault::{SwitchTestControl, TransactionFault}; -pub use journal::recover_pending_transaction; -pub use transaction::{SwitchGroupRepositories, SwitchGroupUseCase, SwitchOutcome}; diff --git a/src-tauri/src/core/switching/transaction.rs b/src-tauri/src/core/switching/transaction.rs deleted file mode 100644 index 55c0a53..0000000 --- a/src-tauri/src/core/switching/transaction.rs +++ /dev/null @@ -1,376 +0,0 @@ -use std::fs; -use std::io; -use std::path::PathBuf; - -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::core::backup::{BackupArtifact, BackupError, BackupRepository}; -use crate::core::document::{OhMyOpenAgentDocument, OpenCodeDocument}; -use crate::core::models::{ - AppSelectionState, LastSuccessfulWriteMetadata, ModelGroup, OmoSwitchConfig, - ProjectionIssueSummary, -}; -use crate::core::projection::{ - OhMyOpenAgentProjectionService, OpenCodeProjectionService, ProjectionResult, -}; -use crate::core::repository::{ - ConfigRepository, OhMyOpenAgentConfigRepository, OpenCodeConfigRepository, -}; - -use super::error::SwitchError; -use super::fault::{interruption_stage, is_interruption, SwitchTestControl, TransactionFault}; -use super::journal::{recover_pending_transaction, TransactionFiles}; - -const BACKUP_RETENTION_LIMIT: usize = 5; - -#[derive(Debug, Clone)] -pub struct SwitchGroupRepositories { - pub config: ConfigRepository, - pub backups_root: PathBuf, - pub opencode: OpenCodeConfigRepository, - pub oh_my_openagent: OhMyOpenAgentConfigRepository, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum SwitchOutcome { - Success { - oh_my_document: OhMyOpenAgentDocument, - warnings: Vec, - }, - NoOp, -} - -pub struct SwitchBackups { - pub opencode: Option, - pub oh_my: Option, -} - -pub fn has_effective_opencode_overrides(group: &ModelGroup) -> bool { - group - .open_code_agent_overrides - .iter() - .any(|override_row| !override_row.model_ref.trim().is_empty()) -} - -pub fn create_backup_if_source_exists( - backup_repository: &BackupRepository, - target: &str, - source_file: &std::path::Path, -) -> Result, SwitchError> -where - C: Fn() -> OffsetDateTime + Clone, -{ - if !source_file.exists() { - return Ok(None); - } - let contents = fs::read(source_file).map_err(|source| SwitchError::BackupFailed { - source: BackupError::Io(source), - })?; - backup_repository - .create_backup(target, source_file, &contents) - .map(Some) - .map_err(|source| SwitchError::BackupFailed { source }) -} - -pub fn merge_warnings( - opencode_projection: Option<&ProjectionResult>, - oh_my_projection: &ProjectionResult, -) -> Vec { - opencode_projection - .into_iter() - .flat_map(|projection| projection.warnings.iter().cloned()) - .chain(oh_my_projection.warnings.iter().cloned()) - .collect() -} - -pub fn warning_summary(warnings: &[String]) -> Option { - if warnings.is_empty() { - return None; - } - Some(ProjectionIssueSummary { - message: warnings.join("; "), - count: warnings.len().try_into().unwrap_or(u32::MAX), - }) -} - -pub fn backup_summary( - opencode_backup: Option<&BackupArtifact>, - oh_my_backup: Option<&BackupArtifact>, -) -> String { - format!( - "opencode:{};oh-my-openagent:{}", - opencode_backup - .map(|backup| backup.file_path.display().to_string()) - .unwrap_or_else(|| "none".to_owned()), - oh_my_backup - .map(|backup| backup.file_path.display().to_string()) - .unwrap_or_else(|| "none".to_owned()) - ) -} - -#[derive(Clone)] -pub struct SwitchGroupUseCase -where - C: Fn() -> OffsetDateTime + Clone, -{ - repositories: SwitchGroupRepositories, - now: C, - test_control: SwitchTestControl, -} - -impl SwitchGroupUseCase -where - C: Fn() -> OffsetDateTime + Clone, -{ - pub fn new(repositories: SwitchGroupRepositories, now: C) -> Self { - Self { - repositories, - now, - test_control: SwitchTestControl::default(), - } - } - - pub fn with_test_control(mut self, test_control: SwitchTestControl) -> Self { - self.test_control = test_control; - self - } - - pub fn fail_next_oh_my_save_for_test(&mut self) { - self.test_control = SwitchTestControl::fail_once(TransactionFault::SecondTargetRename); - } - - pub fn switch_to(&self, group_id: Uuid) -> Result { - self.recover_pending()?; - let (group, current_config) = self.load_context(group_id)?; - if current_config.state.selected_group_id == Some(group_id) { - return Ok(SwitchOutcome::NoOp); - } - self.persist_projection(&group, current_config) - } - - pub fn save_active_group_projection( - &self, - group_id: Uuid, - ) -> Result { - self.recover_pending()?; - let (group, current_config) = self.load_context(group_id)?; - if current_config.state.selected_group_id != Some(group_id) { - return Ok(SwitchOutcome::NoOp); - } - self.persist_projection(&group, current_config) - } - - pub fn save_active_group_projection_for_config( - &self, - config: OmoSwitchConfig, - ) -> Result { - self.recover_pending()?; - let group_id = config - .state - .selected_group_id - .ok_or(SwitchError::GroupNotFound)?; - let group = config - .groups - .iter() - .find(|group| group.id == group_id) - .cloned() - .ok_or(SwitchError::GroupNotFound)?; - if !group.is_enabled { - return Err(SwitchError::GroupDisabled); - } - self.persist_projection(&group, config) - } - - fn load_context(&self, group_id: Uuid) -> Result<(ModelGroup, OmoSwitchConfig), SwitchError> { - let config = self - .repositories - .config - .load() - .map_err(|source| SwitchError::LoadGroupsFailed { source })?; - let group = config - .groups - .iter() - .cloned() - .find(|group| group.id == group_id) - .ok_or(SwitchError::GroupNotFound)?; - if !group.is_enabled { - return Err(SwitchError::GroupDisabled); - } - Ok((group, config)) - } - - fn persist_projection( - &self, - group: &ModelGroup, - mut current_config: OmoSwitchConfig, - ) -> Result { - let should_write_opencode = has_effective_opencode_overrides(group); - let opencode_document = self.load_opencode_if_needed(should_write_opencode)?; - let oh_my_document = self - .repositories - .oh_my_openagent - .load() - .map_err(|_| SwitchError::LoadOhMyConfigFailed)?; - let backups = self.create_backups(should_write_opencode)?; - let opencode_projection = opencode_document - .as_ref() - .map(|document| OpenCodeProjectionService::project(group, document)); - let oh_my_projection = OhMyOpenAgentProjectionService::project(group, &oh_my_document); - - let warnings = merge_warnings(opencode_projection.as_ref(), &oh_my_projection); - current_config.state = self.success_state(current_config.state, group, &backups, &warnings); - self.save_transaction( - opencode_projection.as_ref(), - &oh_my_projection, - ¤t_config, - )?; - self.cleanup_backups(should_write_opencode); - - Ok(SwitchOutcome::Success { - oh_my_document: oh_my_projection.document, - warnings, - }) - } - - fn load_opencode_if_needed( - &self, - should_write_opencode: bool, - ) -> Result, SwitchError> { - if !should_write_opencode { - return Ok(None); - } - self.repositories - .opencode - .load() - .map(Some) - .map_err(|_| SwitchError::LoadOpenCodeConfigFailed) - } - - fn create_backups(&self, should_write_opencode: bool) -> Result { - let backup_repository = - BackupRepository::new(self.repositories.backups_root.clone(), self.now.clone()); - let opencode = if should_write_opencode { - create_backup_if_source_exists( - &backup_repository, - "opencode", - self.repositories.opencode.config_file(), - )? - } else { - None - }; - let oh_my = create_backup_if_source_exists( - &backup_repository, - "oh-my-openagent", - self.repositories.oh_my_openagent.config_file(), - )?; - Ok(SwitchBackups { opencode, oh_my }) - } - - fn save_transaction( - &self, - opencode_projection: Option<&ProjectionResult>, - oh_my_projection: &ProjectionResult, - config: &OmoSwitchConfig, - ) -> Result<(), SwitchError> { - let opencode_bytes = opencode_projection - .map(|projection| projection.document.serialize()) - .transpose() - .map_err(|_| SwitchError::LoadOpenCodeConfigFailed)?; - let oh_my_bytes = oh_my_projection - .document - .serialize() - .map_err(|_| SwitchError::LoadOhMyConfigFailed)?; - let config_bytes = - serde_json::to_vec_pretty(config).map_err(|source| SwitchError::TransactionFailed { - stage: "config-serialize", - source: io::Error::other(source), - })?; - let mut writes = Vec::with_capacity(if opencode_bytes.is_some() { 3 } else { 2 }); - if let Some(bytes) = opencode_bytes.as_deref() { - writes.push(( - "opencode", - self.repositories.opencode.config_file(), - bytes.as_bytes(), - )); - } - writes.push(( - "oh-my-openagent", - self.repositories.oh_my_openagent.config_file(), - oh_my_bytes.as_bytes(), - )); - writes.push(( - "config", - self.repositories.config.config_file(), - config_bytes.as_slice(), - )); - let mut transaction = - TransactionFiles::prepare(&self.repositories.backups_root, &writes, |stage| { - self.test_control.inject(stage) - }) - .map_err(|source| self.transaction_error(source))?; - match transaction.commit(|stage| self.test_control.inject(stage)) { - Ok(()) => Ok(()), - Err(source) if is_interruption(&source) => Err(SwitchError::Interrupted { - stage: interruption_stage(&source), - }), - Err(source) => { - let primary = source.to_string(); - transaction - .compensate(|stage| self.test_control.inject(stage)) - .map_err(|source| SwitchError::CompensationFailed { primary, source })?; - Err(SwitchError::TransactionFailed { - stage: "commit", - source, - }) - } - } - } - - fn success_state( - &self, - mut state: AppSelectionState, - group: &ModelGroup, - backups: &SwitchBackups, - warnings: &[String], - ) -> AppSelectionState { - state.selected_group_id = Some(group.id); - state.selected_group_name = Some(group.name.clone()); - state.last_successful_write = Some(LastSuccessfulWriteMetadata { - target: "switch-group".to_owned(), - wrote_at: (self.now)(), - backup_path: Some(backup_summary( - backups.opencode.as_ref(), - backups.oh_my.as_ref(), - )), - }); - state.last_error_summary = None; - state.last_warning_summary = warning_summary(warnings); - state - } - - fn transaction_error(&self, source: io::Error) -> SwitchError { - SwitchError::TransactionFailed { - stage: "prepare", - source, - } - } - - fn recover_pending(&self) -> Result<(), SwitchError> { - recover_pending_transaction(&self.repositories.backups_root).map_err(|source| { - SwitchError::TransactionFailed { - stage: "startup-recovery", - source, - } - }) - } - - fn cleanup_backups(&self, should_write_opencode: bool) { - let backup_repository = - BackupRepository::new(self.repositories.backups_root.clone(), self.now.clone()); - if should_write_opencode { - let _ = backup_repository.cleanup("opencode", BACKUP_RETENTION_LIMIT); - } - let _ = backup_repository.cleanup("oh-my-openagent", BACKUP_RETENTION_LIMIT); - } -} diff --git a/src-tauri/src/document.rs b/src-tauri/src/document.rs new file mode 100644 index 0000000..66f35f2 --- /dev/null +++ b/src-tauri/src/document.rs @@ -0,0 +1,87 @@ +use std::fs; +use std::path::Path; + +use serde_json::{Map, Value}; + +use crate::error::AppError; +use crate::jsonc::{parse_jsonc_object, patch}; + +pub const OPENCODE_SCHEMA: &str = "https://opencode.ai/config.json"; +pub const SLIM_SCHEMA: &str = + "https://unpkg.com/oh-my-opencode-slim@latest/oh-my-opencode-slim.schema.json"; +pub const OMO_SCHEMA: &str = + "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json"; + +/// Writes bytes to a file, creating parent directories as needed. Plain sequential write, +/// no atomicity, locking, or backups by design. +pub fn write_file(path: &Path, bytes: &[u8]) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|error| AppError::io("create", parent, error))?; + } + } + fs::write(path, bytes).map_err(|error| AppError::io("write", path, error)) +} + +/// Reads a file as UTF-8 text; `None` when the file does not exist. +pub fn read_text(path: &Path) -> Result, AppError> { + match fs::read_to_string(path) { + Ok(content) => Ok(Some(content)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(AppError::io("read", path, error)), + } +} + +/// A parsed JSONC document that retains its original CST text. Mutations always use JSONC +/// paths, so comments and formatting outside changed nodes remain untouched. +#[derive(Debug, Clone, PartialEq)] +pub struct JsoncDoc { + content: String, + raw: Map, +} + +impl JsoncDoc { + pub fn parse(input: &str) -> Result { + let raw = parse_jsonc_object(input) + .map_err(|_| AppError::validation("malformed JSONC document"))?; + Ok(Self { + content: input.to_owned(), + raw, + }) + } + + /// An empty document carrying only the `$schema` marker for the given target file. + pub fn bootstrap(schema: &str) -> Self { + let content = format!("{{\n \"$schema\": \"{schema}\"\n}}\n"); + Self::parse(&content).expect("constant JSONC bootstrap must parse") + } + + /// Reads and parses a document from disk. A missing file yields an in-memory bootstrap + /// document that is only persisted on the next write. + pub fn read(path: &Path, schema: &str) -> Result { + match read_text(path)? { + Some(content) => JsoncDoc::parse(&content), + None => Ok(Self::bootstrap(schema)), + } + } + + pub fn patch(&mut self, path: &[&str], value: Option) -> Result<(), AppError> { + self.content = patch(&self.content, path, value) + .map_err(|_| AppError::validation("malformed JSONC document"))?; + self.raw = parse_jsonc_object(&self.content) + .map_err(|_| AppError::validation("malformed JSONC document"))?; + Ok(()) + } + + pub fn save(&self, path: &Path) -> Result<(), AppError> { + write_file(path, self.content.as_bytes()) + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn raw(&self) -> &Map { + &self.raw + } +} diff --git a/src-tauri/src/dto.rs b/src-tauri/src/dto.rs new file mode 100644 index 0000000..04a9d88 --- /dev/null +++ b/src-tauri/src/dto.rs @@ -0,0 +1,259 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::agents::{ + AgentDefinition as CoreAgentDefinition, AgentFieldOverride, AgentSource as CoreAgentSource, + AgentStorage, +}; + +/// The single serialized response shape for `load_app_state`. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppStateResponse { + pub providers: Vec, + pub agents: Vec, + pub groups: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSourceDto { + Inline, + Markdown, + Both, +} + +impl From for AgentSourceDto { + fn from(source: CoreAgentSource) -> Self { + match source { + CoreAgentSource::Inline => Self::Inline, + CoreAgentSource::Markdown => Self::Markdown, + CoreAgentSource::Both => Self::Both, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStorageDto { + Inline, + GlobalMarkdown, + ProjectMarkdown, +} + +impl From for AgentStorage { + fn from(storage: AgentStorageDto) -> Self { + match storage { + AgentStorageDto::Inline => Self::Inline, + AgentStorageDto::GlobalMarkdown => Self::GlobalMarkdown, + AgentStorageDto::ProjectMarkdown => Self::ProjectMarkdown, + } + } +} + +impl From for AgentStorageDto { + fn from(storage: AgentStorage) -> Self { + match storage { + AgentStorage::Inline => Self::Inline, + AgentStorage::GlobalMarkdown => Self::GlobalMarkdown, + AgentStorage::ProjectMarkdown => Self::ProjectMarkdown, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentDefinitionDto { + pub id: String, + pub source: AgentSourceDto, + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option, + #[serde(default)] + pub sources: Vec, + #[serde(default)] + pub overrides: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub mutation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inline: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub markdown: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub effective: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hidden: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub variant: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "top_p")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub steps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentMutationDto { + #[serde(default)] + pub fields: Map, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(default)] + pub clear_fields: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSourcePreviewDto { + pub storage: AgentStorageDto, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + pub raw: Value, + pub fields: Map, + pub prompt: Option, +} + +impl From for AgentDefinitionDto { + fn from(agent: CoreAgentDefinition) -> Self { + let storage = match agent.source { + CoreAgentSource::Inline => Some(AgentStorageDto::Inline), + CoreAgentSource::Markdown => unique_markdown_storage(&agent), + CoreAgentSource::Both => None, + }; + let inline_source = agent.inline.clone(); + let inline = inline_source.clone().and_then(|source| object(source.raw)); + let markdown = markdown_fields(&agent.markdown); + let effective = object(agent.effective); + let fields = effective.as_ref(); + Self { + id: agent.id, + source: agent.source.into(), + storage, + sources: source_previews(inline_source, &agent.markdown), + overrides: agent.overrides, + mutation: None, + inline, + markdown, + model_ref: string_field(fields, "model"), + mode: string_field(fields, "mode"), + description: string_field(fields, "description"), + disable: bool_field(fields, "disable"), + hidden: bool_field(fields, "hidden"), + color: string_field(fields, "color"), + variant: string_field(fields, "variant"), + prompt: string_field(fields, "prompt"), + temperature: number_field(fields, "temperature"), + top_p: number_field(fields, "top_p"), + steps: unsigned_field(fields, "steps"), + permission: object_field(fields, "permission"), + tools: bool_map_field(fields, "tools"), + options: object_field(fields, "options"), + effective, + } + } +} + +fn source_previews( + inline: Option, + markdown: &[crate::agents::MarkdownAgentSource], +) -> Vec { + let mut sources = Vec::new(); + if let Some(inline) = inline { + sources.push(AgentSourcePreviewDto { + storage: AgentStorageDto::Inline, + path: None, + fields: inline.raw.as_object().cloned().unwrap_or_default(), + raw: inline.raw, + prompt: None, + }); + } + sources.extend(markdown.iter().map(|source| { + let mut fields = source.frontmatter.clone(); + fields.remove("prompt"); + AgentSourcePreviewDto { + storage: source.storage.into(), + path: Some(source.path.display().to_string()), + raw: Value::String(source.raw.clone()), + fields, + prompt: Some(source.prompt.clone()), + } + })); + sources +} + +fn unique_markdown_storage(agent: &CoreAgentDefinition) -> Option { + let mut storages = agent.markdown.iter().map(|source| source.storage); + let storage = storages.next()?; + storages + .all(|candidate| candidate == storage) + .then_some(storage.into()) +} + +fn markdown_fields(markdown: &[crate::agents::MarkdownAgentSource]) -> Option> { + let mut fields = Map::new(); + for source in markdown { + fields.extend(source.frontmatter.clone()); + fields.insert("prompt".to_owned(), Value::String(source.prompt.clone())); + } + (!fields.is_empty()).then_some(fields) +} + +fn object(value: Value) -> Option> { + match value { + Value::Object(value) => Some(value), + _ => None, + } +} + +fn string_field(fields: Option<&Map>, key: &str) -> Option { + fields?.get(key)?.as_str().map(ToOwned::to_owned) +} + +fn bool_field(fields: Option<&Map>, key: &str) -> Option { + fields?.get(key)?.as_bool() +} + +fn number_field(fields: Option<&Map>, key: &str) -> Option { + fields?.get(key)?.as_f64() +} + +fn unsigned_field(fields: Option<&Map>, key: &str) -> Option { + fields?.get(key)?.as_u64() +} + +fn object_field(fields: Option<&Map>, key: &str) -> Option> { + fields?.get(key)?.as_object().cloned() +} + +fn bool_map_field( + fields: Option<&Map>, + key: &str, +) -> Option> { + fields? + .get(key)? + .as_object()? + .iter() + .map(|(key, value)| Some((key.clone(), value.as_bool()?))) + .collect() +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..07b638b --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,78 @@ +use serde::Serialize; + +/// Error codes exposed to the frontend. `busy` and `conflict` no longer occur because the +/// backend performs plain sequential file writes, but the codes stay reserved for the +/// frontend type contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + NotFound, + ReferencesBlocked, + ValidationFailed, + ConfigurationFailed, +} + +/// The single error type for every backend operation. It serializes directly into the +/// frontend `CommandError` shape (`{ code, message, detail? }`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AppError { + pub code: ErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl AppError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + detail: None, + } + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(ErrorCode::NotFound, message) + } + + pub fn validation(message: impl Into) -> Self { + Self::new(ErrorCode::ValidationFailed, message) + } + + pub fn configuration(message: impl Into) -> Self { + Self::new(ErrorCode::ConfigurationFailed, message) + } + + pub fn references(message: impl Into, detail: impl Into) -> Self { + Self { + code: ErrorCode::ReferencesBlocked, + message: message.into(), + detail: Some(detail.into()), + } + } + + /// Wraps an IO error with a short operation context and the involved path. + pub fn io(context: &str, path: &std::path::Path, error: std::io::Error) -> Self { + Self::new( + ErrorCode::ConfigurationFailed, + format!("{context} {}: {error}", path.display()), + ) + } +} + +impl std::fmt::Display for AppError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.detail { + Some(detail) => write!(formatter, "{} ({detail})", self.message), + None => formatter.write_str(&self.message), + } + } +} + +impl std::error::Error for AppError {} + +impl From for AppError { + fn from(error: std::io::Error) -> Self { + Self::new(ErrorCode::ConfigurationFailed, error.to_string()) + } +} diff --git a/src-tauri/src/groups.rs b/src-tauri/src/groups.rs new file mode 100644 index 0000000..d3f0962 --- /dev/null +++ b/src-tauri/src/groups.rs @@ -0,0 +1,324 @@ +use uuid::Uuid; + +use crate::document::{JsoncDoc, OMO_SCHEMA, OPENCODE_SCHEMA, SLIM_SCHEMA}; +use crate::error::AppError; +use crate::models::{self, AppConfig, AppSelectionState, GroupType, ModelGroup}; +use crate::paths::ConfigPaths; +use crate::projection; + +fn load(paths: &ConfigPaths) -> Result { + let config = models::load_config(&paths.config_file())?; + config.validate()?; + Ok(config) +} + +fn find_enabled_group<'a>( + config: &'a AppConfig, + group_id: Uuid, +) -> Result<&'a ModelGroup, AppError> { + let group = config + .groups + .iter() + .find(|group| group.id == group_id) + .ok_or_else(|| AppError::not_found("group not found"))?; + if !group.is_enabled { + return Err(AppError::validation("group is disabled")); + } + Ok(group) +} + +/// Saves a group and re-projects Slim/OMO/OpenCode files when the group is selected or +/// was selected before. Plain sequential writes. +pub fn save_group( + paths: &ConfigPaths, + mut group: ModelGroup, +) -> Result<(ModelGroup, AppConfig), AppError> { + let mut config = load(paths)?; + if config + .groups + .iter() + .any(|item| item.id != group.id && item.name.trim().eq_ignore_ascii_case(group.name.trim())) + { + return Err(AppError::validation("duplicate group name")); + } + validate_group_drafts(paths, &config, &group)?; + let previous = config + .groups + .iter() + .find(|item| item.id == group.id) + .cloned(); + group.updated_at = time::OffsetDateTime::now_utc(); + + if config.state.selected_group_id == Some(group.id) && !group.is_enabled { + return Err(AppError::validation( + "the selected group cannot be disabled; select another group first", + )); + } + + if let Some(existing) = config.groups.iter_mut().find(|item| item.id == group.id) { + *existing = group.clone(); + } else { + config.groups.push(group.clone()); + } + if config.state.selected_group_id == Some(group.id) { + config.state.selected_group_name = Some(group.name.clone()); + } + config.validate()?; + + persist_group_lifecycle(paths, previous.as_ref(), &group, config)?; + let config = load(paths)?; + Ok((group, config)) +} + +pub fn copy_group( + paths: &ConfigPaths, + id: Uuid, + name: Option, +) -> Result<(ModelGroup, AppConfig), AppError> { + let mut config = load(paths)?; + let source = config + .groups + .iter() + .find(|item| item.id == id) + .cloned() + .ok_or_else(|| AppError::not_found("group not found"))?; + let copied = ModelGroup { + id: Uuid::new_v4(), + name: match name { + Some(name) => { + let name = name.trim().to_owned(); + if name.is_empty() + || config + .groups + .iter() + .any(|group| group.name.trim().eq_ignore_ascii_case(&name)) + { + return Err(AppError::validation("duplicate group name")); + } + name + } + None => unique_copy_group_name(&source.name, &config.groups), + }, + updated_at: time::OffsetDateTime::now_utc(), + ..source + }; + config.groups.push(copied.clone()); + config.validate()?; + models::save_config(&paths.config_file(), &config)?; + Ok((copied, config)) +} + +pub fn delete_group(paths: &ConfigPaths, id: Uuid) -> Result { + let mut config = load(paths)?; + let index = config + .groups + .iter() + .position(|item| item.id == id) + .ok_or_else(|| AppError::not_found("group not found"))?; + let deleted = config.groups.remove(index); + let selected = config.state.selected_group_id == Some(deleted.id); + if selected { + config.state = AppSelectionState::default(); + } + config.validate()?; + if deleted.group_type == GroupType::Slim { + let slim_file = paths.slim_file(); + let source = JsoncDoc::read(&slim_file, SLIM_SCHEMA)?; + let document = projection::delete_slim_preset(&source, &deleted.name)?; + document.save(&slim_file)?; + } + if selected && deleted.group_type == GroupType::OhMyOpenagent { + let omo_file = paths.omo_file(); + let source = JsoncDoc::read(&omo_file, OMO_SCHEMA)?; + let document = projection::clear_omo(&source)?; + document.save(&omo_file)?; + } + + models::save_config(&paths.config_file(), &config)?; + Ok(config) +} + +/// Switches the active selection and projects the group into the managed target files. +/// Switching to the already-selected group is a no-op. +pub fn switch_group(paths: &ConfigPaths, group_id: Uuid) -> Result<(), AppError> { + let mut config = load(paths)?; + let group = find_enabled_group(&config, group_id)?.clone(); + if config.state.selected_group_id == Some(group_id) { + return Ok(()); + } + + if group.group_type == GroupType::Slim { + let slim_file = paths.slim_file(); + let source = JsoncDoc::read(&slim_file, SLIM_SCHEMA)?; + let document = projection::project_slim_active(&group, &source)?; + document.save(&slim_file)?; + } + if group.group_type == GroupType::OhMyOpenagent { + let omo_file = paths.omo_file(); + let source = JsoncDoc::read(&omo_file, OMO_SCHEMA)?; + let document = projection::project_omo(&group, &source)?; + document.save(&omo_file)?; + } + if projection::has_effective_opencode_overrides(&group) { + let opencode_file = paths.opencode_file(); + let source = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; + let (document, warnings) = projection::project_opencode(&group, &source)?; + document.save(&opencode_file)?; + for warning in warnings { + eprintln!("warning: {warning}"); + } + } + + config.state = AppSelectionState { + selected_group_id: Some(group.id), + selected_group_name: Some(group.name.clone()), + }; + models::save_config(&paths.config_file(), &config) +} + +/// Persists a group mutation plus all dependent projections in plain sequential writes. +/// Order matters only cosmetically: every write is independent. +fn persist_group_lifecycle( + paths: &ConfigPaths, + previous: Option<&ModelGroup>, + group: &ModelGroup, + config: AppConfig, +) -> Result<(), AppError> { + let selected = config.state.selected_group_id == Some(group.id); + + if group.group_type == GroupType::Slim || switches_away_from_slim(previous, group) { + let slim_file = paths.slim_file(); + let source = JsoncDoc::read(&slim_file, SLIM_SCHEMA)?; + let mut document = source.clone(); + if group.group_type == GroupType::Slim { + if let Some(previous) = previous.filter(|item| item.group_type == GroupType::Slim) { + if previous.name != group.name { + document = + projection::rename_slim_preset(&document, &previous.name, &group.name)?; + } + } + document = projection::update_slim_preset( + &document, + &group.name, + group.slim_agent_overrides.as_deref(), + )?; + if selected { + document = projection::project_slim_active(group, &document)?; + } + } else if let Some(previous) = previous.filter(|item| item.group_type == GroupType::Slim) { + document = projection::delete_slim_preset(&document, &previous.name)?; + } + document.save(&slim_file)?; + } + + let switched_from_omo = selected + && previous.is_some_and(|item| item.group_type == GroupType::OhMyOpenagent) + && group.group_type != GroupType::OhMyOpenagent; + if (group.group_type == GroupType::OhMyOpenagent && selected) || switched_from_omo { + let omo_file = paths.omo_file(); + let source = JsoncDoc::read(&omo_file, OMO_SCHEMA)?; + let document = if group.group_type == GroupType::OhMyOpenagent && selected { + projection::project_omo(group, &source)? + } else { + projection::clear_omo(&source)? + }; + document.save(&omo_file)?; + } + + if selected && projection::has_effective_opencode_overrides(group) { + let opencode_file = paths.opencode_file(); + let source = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; + let (document, _) = projection::project_opencode(group, &source)?; + document.save(&opencode_file)?; + } + + models::save_config(&paths.config_file(), &config) +} + +fn switches_away_from_slim(previous: Option<&ModelGroup>, group: &ModelGroup) -> bool { + previous.is_some_and(|item| item.group_type == GroupType::Slim) + && group.group_type != GroupType::Slim +} + +fn validate_group_drafts( + paths: &ConfigPaths, + _config: &AppConfig, + group: &ModelGroup, +) -> Result<(), AppError> { + let providers = crate::providers::list_providers(&paths.opencode_file())?; + for (field, name, model) in group_model_references(group) { + let model_ref = crate::providers::ModelRef::parse(model).map_err(|error| { + AppError::validation(format!( + "invalid model reference in group '{}', {field} '{name}': {error}", + group.name + )) + })?; + let exists = providers + .iter() + .find(|provider| provider.id == model_ref.provider_id) + .is_some_and(|provider| provider.models.contains_key(&model_ref.model_id)); + if !exists { + return Err(AppError::validation(format!( + "model '{}' does not exist for group '{}', {field} '{name}'", + model_ref.as_str(), + group.name + ))); + } + } + Ok(()) +} + +fn group_model_references<'a>(group: &'a ModelGroup) -> Vec<(&'static str, &'a str, &'a str)> { + let mut references = Vec::new(); + references.extend(group.open_code_agent_overrides.iter().map(|binding| { + ( + "openCodeAgentOverrides", + binding.agent_name.as_str(), + binding.model_ref.as_str(), + ) + })); + references.extend(group.slim_agent_overrides.iter().flatten().map(|binding| { + ( + "slimAgentOverrides", + binding.agent_name.as_str(), + binding.model_ref.as_str(), + ) + })); + references.extend(group.omo_agent_overrides.iter().flatten().map(|binding| { + ( + "omoAgentOverrides", + binding.agent_name.as_str(), + binding.model_ref.as_str(), + ) + })); + references.extend(group.omo_category_mappings.iter().flatten().map(|mapping| { + ( + "omoCategoryMappings", + mapping.category_name.as_str(), + mapping.model_ref.as_str(), + ) + })); + references +} + +fn unique_copy_group_name(source: &str, groups: &[ModelGroup]) -> String { + let base = if source.trim().is_empty() { + "Copy".to_owned() + } else { + format!("{} Copy", source.trim()) + }; + if !groups + .iter() + .any(|group| group.name.trim().eq_ignore_ascii_case(&base)) + { + return base; + } + (2..) + .map(|number| format!("{base} {number}")) + .find(|candidate| { + !groups + .iter() + .any(|group| group.name.trim().eq_ignore_ascii_case(candidate)) + }) + .expect("unbounded copy suffix iterator") +} diff --git a/src-tauri/src/jsonc.rs b/src-tauri/src/jsonc.rs new file mode 100644 index 0000000..bcd2ddb --- /dev/null +++ b/src-tauri/src/jsonc.rs @@ -0,0 +1,87 @@ +use jsonc_parser::cst::{CstInputValue, CstObject, CstRootNode}; +use jsonc_parser::ParseOptions; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsoncError { + MalformedJson, +} +impl std::fmt::Display for JsoncError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("malformed JSONC") + } +} +impl std::error::Error for JsoncError {} + +pub fn parse_jsonc_object(input: &str) -> Result, JsoncError> { + let root = CstRootNode::parse(input, &options()).map_err(|_| JsoncError::MalformedJson)?; + match root.object_value().and_then(|value| value.to_serde_value()) { + Some(Value::Object(object)) => Ok(object), + _ => Err(JsoncError::MalformedJson), + } +} + +pub fn patch(input: &str, path: &[&str], value: Option) -> Result { + if path.is_empty() || path.iter().any(|segment| segment.is_empty()) { + return Err(JsoncError::MalformedJson); + } + let root = CstRootNode::parse(input, &options()).map_err(|_| JsoncError::MalformedJson)?; + let object = root.object_value().ok_or(JsoncError::MalformedJson)?; + patch_object(&object, path, value); + let output = root.to_string(); + parse_jsonc_object(&output)?; + Ok(output) +} + +fn patch_object(object: &CstObject, path: &[&str], value: Option) { + if path.len() == 1 { + match (object.get(path[0]), value) { + (Some(prop), Some(value)) => prop.set_value(to_cst(value)), + (Some(prop), None) => prop.remove(), + (None, Some(value)) => { + object.append(path[0], to_cst(value)); + } + (None, None) => {} + } + return; + } + match value { + Some(value) => { + let next = object.object_value_or_set(path[0]); + patch_object(&next, &path[1..], Some(value)); + } + None => { + if let Some(next) = object.object_value(path[0]) { + patch_object(&next, &path[1..], None); + } + } + } +} + +fn to_cst(value: Value) -> CstInputValue { + match value { + Value::Null => CstInputValue::Null, + Value::Bool(value) => CstInputValue::Bool(value), + Value::Number(value) => CstInputValue::Number(value.to_string()), + Value::String(value) => CstInputValue::String(value), + Value::Array(values) => CstInputValue::Array(values.into_iter().map(to_cst).collect()), + Value::Object(values) => CstInputValue::Object( + values + .into_iter() + .map(|(key, value)| (key, to_cst(value))) + .collect(), + ), + } +} + +fn options() -> ParseOptions { + ParseOptions { + allow_comments: true, + allow_loose_object_property_names: false, + allow_trailing_commas: true, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 71a8659..b912966 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,39 +1,61 @@ -mod app_shell; -pub mod application; +pub mod agents; +pub mod agents_md; pub mod commands; -pub mod core; -pub mod shell; +pub mod document; +pub mod dto; +pub mod error; +pub mod groups; +pub mod jsonc; +pub mod models; +pub mod paths; +pub mod projection; +pub mod providers; +pub mod refs; +pub mod replacement; use tauri::Manager; pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - app_shell::dispatch_shell_action(app, shell::ShellAction::OpenSettings); + if let Some(window) = app.get_webview_window("main") { + if window.is_minimized().unwrap_or(false) { + let _ = window.unminimize(); + } + let _ = window.show(); + let _ = window.set_focus(); + } })) - .plugin(tauri_plugin_positioner::init()) .setup(|app| { - #[cfg(target_os = "macos")] - app.set_activation_policy(tauri::ActivationPolicy::Accessory); - let application = application::GroupApplicationService::live() - .map_err(|error| std::io::Error::other(error.to_string()))?; - application.recover_pending_transaction().map_err(|error| { - std::io::Error::other(format!( - "Pending configuration transaction recovery failed during startup: {error}" - )) - })?; - app.manage(application); - app_shell::install_window_lifecycle(app); - app_shell::build_tray(app)?; + let paths = paths::ConfigPaths::from_environment()?; + app.manage(paths); Ok(()) }) .invoke_handler(tauri::generate_handler![ commands::load_app_state, + commands::list_providers, + commands::reveal_provider_option, + commands::create_provider, + commands::update_provider, + commands::delete_provider, + commands::list_models, + commands::create_model, + commands::update_model, + commands::delete_model, + commands::replace_model_references, + commands::rename_model, + commands::rename_provider, + commands::list_agents, + commands::get_agent, + commands::create_agent, + commands::update_agent, + commands::delete_agent, commands::save_group, commands::copy_group, commands::delete_group, commands::switch_group, - commands::discover_open_code_agents + commands::load_config_diagnostics, + commands::validate_config ]) .run(tauri::generate_context!()) .expect("failed to run omo-switch Tauri shell") diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2f0d229..aaa3669 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,3 +1,3 @@ fn main() { - omo_switch_tauri::run(); + opencode_mom_tauri::run(); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..af6be79 --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,186 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::error::AppError; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderDef { + pub id: String, + pub name: Option, + pub npm: Option, + pub api: Option, + pub env: Option>, + pub whitelist: Option>, + pub blacklist: Option>, + pub options: Option>, + #[serde(default)] + pub models: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ModelDef { + pub id: String, + pub name: Option, + pub family: Option, + pub release_date: Option, + pub status: Option, + pub reasoning: Option, + pub temperature: Option, + pub tool_call: Option, + pub attachment: Option, + pub interleaved: Option, + pub cost: Option, + pub limit: Option, + pub modalities: Option, + pub experimental: Option, + pub options: Option>, + pub headers: Option>, + pub variants: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ModelStatus { + Alpha, + Beta, + Deprecated, + Active, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ModelCost { + pub input: Option, + pub output: Option, + pub cache_read: Option, + pub cache_write: Option, + pub context_over_200k: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ModelLimit { + pub context: Option, + pub input: Option, + pub output: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelModalities { + pub input: Option>, + pub output: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ModelVariant { + pub disabled: Option, + pub options: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentModelBinding { + pub agent_name: String, + pub model_ref: String, + pub variant: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OmoCategoryMapping { + pub category_name: String, + pub model_ref: String, + pub variant: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GroupType { + #[serde(rename = "opencode")] + OpenCode, + #[serde(rename = "slim")] + Slim, + #[serde(rename = "oh-my-openagent")] + OhMyOpenagent, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelGroup { + pub id: Uuid, + pub name: String, + pub description: String, + #[serde(rename = "type")] + pub group_type: GroupType, + #[serde(default)] + pub open_code_agent_overrides: Vec, + #[serde(default)] + pub slim_agent_overrides: Option>, + #[serde(default)] + pub omo_agent_overrides: Option>, + #[serde(default)] + pub omo_category_mappings: Option>, + pub is_enabled: bool, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AppSelectionState { + #[serde(rename = "selectedGroupID")] + pub selected_group_id: Option, + pub selected_group_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AppConfig { + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub state: AppSelectionState, +} + +impl AppConfig { + /// Lightweight structural validation. Type checking is already done by serde; this only + /// rejects values serde cannot see. + pub fn validate(&self) -> Result<(), AppError> { + for group in &self.groups { + if group.name.trim().is_empty() { + return Err(AppError::validation(format!( + "group '{}' has an empty name", + group.id + ))); + } + } + Ok(()) + } +} + +/// Reads the application config file. A missing file is an empty default configuration. +pub fn load_config(path: &Path) -> Result { + if !path.exists() { + return Ok(AppConfig::default()); + } + let content = fs::read_to_string(path).map_err(|error| AppError::io("read", path, error))?; + let config = serde_json::from_str(&content).map_err(|error| { + AppError::validation(format!("application config: invalid JSON: {error}")) + })?; + Ok(config) +} + +/// Serializes the application config and writes it in one plain write. +pub fn save_config(path: &Path, config: &AppConfig) -> Result<(), AppError> { + let data = serde_json::to_vec_pretty(config) + .map_err(|error| AppError::validation(format!("application config: {error}")))?; + crate::document::write_file(path, &data) +} diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs new file mode 100644 index 0000000..efff262 --- /dev/null +++ b/src-tauri/src/paths.rs @@ -0,0 +1,95 @@ +use std::env; +use std::path::{Path, PathBuf}; + +use crate::error::AppError; + +/// Resolved locations of every file the application manages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigPaths { + home: PathBuf, + user_config_dir: PathBuf, + opencode: PathBuf, + project_root: Option, +} + +impl ConfigPaths { + pub fn from_environment() -> Result { + let home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| { + AppError::validation("HOME or USERPROFILE environment variable is not set") + })?; + let config = env::var_os("OPENCODE_CONFIG").map(PathBuf::from); + let directory = env::var_os("OPENCODE_CONFIG_DIR").map(PathBuf::from); + let project_root = env::var_os("OPENCODE_PROJECT_ROOT").map(PathBuf::from); + let user_config_dir = dirs::config_dir().unwrap_or_else(|| home.join(".config")); + let opencode = config.unwrap_or_else(|| { + directory + .unwrap_or_else(|| user_config_dir.join("opencode")) + .join("opencode.jsonc") + }); + Ok(Self { + home, + user_config_dir, + opencode, + project_root, + }) + } + + pub fn for_home(home: &Path) -> Self { + Self::with_parts(home.to_path_buf(), None, None) + } + + pub fn with_parts(home: PathBuf, config: Option, directory: Option) -> Self { + let user_config_dir = home.join(".config"); + let opencode = config.unwrap_or_else(|| { + directory + .unwrap_or_else(|| user_config_dir.join("opencode")) + .join("opencode.jsonc") + }); + Self { + home, + user_config_dir, + opencode, + project_root: None, + } + } + + pub fn with_project_root(mut self, project_root: impl Into) -> Self { + self.project_root = Some(project_root.into()); + self + } + + pub fn app_config_dir(&self) -> PathBuf { + self.home.join(".config").join("opencode-mom") + } + + pub fn config_file(&self) -> PathBuf { + self.app_config_dir().join("config.json") + } + + pub fn opencode_file(&self) -> PathBuf { + self.opencode.clone() + } + + pub fn slim_file(&self) -> PathBuf { + self.user_config_dir + .join("opencode") + .join("oh-my-opencode-slim.jsonc") + } + + pub fn omo_file(&self) -> PathBuf { + self.home.join(".omo").join("omo.jsonc") + } + + pub fn global_agents_dir(&self) -> PathBuf { + self.user_config_dir.join("opencode").join("agents") + } + + pub fn project_agents_dir(&self) -> Option { + self.project_root + .as_ref() + .map(|root| root.join(".opencode").join("agents")) + } +} diff --git a/src-tauri/src/projection.rs b/src-tauri/src/projection.rs new file mode 100644 index 0000000..9b3c624 --- /dev/null +++ b/src-tauri/src/projection.rs @@ -0,0 +1,220 @@ +use serde_json::{Map, Value}; + +use crate::document::JsoncDoc; +use crate::models::{AgentModelBinding, GroupType, ModelGroup, OmoCategoryMapping}; + +/// Projects the active Slim group: replaces the preset and activates it. +pub fn project_slim_active( + group: &ModelGroup, + source: &JsoncDoc, +) -> Result { + let mut document = source.clone(); + if group.group_type != GroupType::Slim { + return Ok(document); + } + replace_slim_preset( + &mut document, + &group.name, + group.slim_agent_overrides.as_deref(), + ); + document.patch(&["preset"], Some(Value::String(group.name.clone())))?; + Ok(document) +} + +pub fn update_slim_preset( + source: &JsoncDoc, + name: &str, + overrides: Option<&[AgentModelBinding]>, +) -> Result { + let mut document = source.clone(); + replace_slim_preset(&mut document, name, overrides); + Ok(document) +} + +pub fn rename_slim_preset( + source: &JsoncDoc, + old_name: &str, + new_name: &str, +) -> Result { + let mut document = source.clone(); + if slim_preset(source, old_name).is_some() { + let preset = slim_preset(source, old_name).expect("preset existence checked above"); + document.patch(&["presets", new_name], Some(Value::Object(preset.clone())))?; + document.patch(&["presets", old_name], None)?; + } + if active_slim_preset(source) == Some(old_name) { + document.patch(&["preset"], Some(Value::String(new_name.to_owned())))?; + } + Ok(document) +} + +pub fn delete_slim_preset( + source: &JsoncDoc, + name: &str, +) -> Result { + let mut document = source.clone(); + document.patch(&["presets", name], None)?; + if active_slim_preset(source) == Some(name) { + document.patch(&["preset"], None)?; + } + Ok(document) +} + +fn slim_preset<'a>(document: &'a JsoncDoc, name: &str) -> Option<&'a Map> { + document + .raw() + .get("presets") + .and_then(Value::as_object) + .and_then(|presets| presets.get(name)) + .and_then(Value::as_object) +} + +fn active_slim_preset(document: &JsoncDoc) -> Option<&str> { + document.raw().get("preset").and_then(Value::as_str) +} + +fn replace_slim_preset( + document: &mut JsoncDoc, + name: &str, + overrides: Option<&[AgentModelBinding]>, +) { + let agents = overrides + .unwrap_or_default() + .iter() + .filter(|binding| { + !binding.agent_name.trim().is_empty() && !binding.model_ref.trim().is_empty() + }) + .map(|binding| (binding.agent_name.clone(), binding_value(binding))) + .collect(); + document + .patch(&["presets", name], Some(Value::Object(agents))) + .expect("a parsed JSONC document accepts a preset replacement"); +} + +fn binding_value(binding: &AgentModelBinding) -> Value { + model_value(&binding.model_ref, binding.variant.as_deref()) +} + +/// Replaces the freeform `opencode` agent/category mappings of an active OMO group. +/// Existing entries not represented by the group are deliberately removed. +pub fn project_omo( + group: &ModelGroup, + source: &JsoncDoc, +) -> Result { + let mut document = source.clone(); + if group.group_type != GroupType::OhMyOpenagent { + return Ok(document); + } + let agents = mapping_values(group.omo_agent_overrides.as_deref().unwrap_or_default()); + let categories = category_values(group.omo_category_mappings.as_deref().unwrap_or_default()); + document.patch(&["opencode", "agents"], Some(Value::Object(agents)))?; + document.patch(&["opencode", "categories"], Some(Value::Object(categories)))?; + Ok(document) +} + +/// Removes mappings owned by the currently selected OMO group while preserving all other +/// OMO configuration. +pub fn clear_omo(source: &JsoncDoc) -> Result { + let mut document = source.clone(); + document.patch(&["opencode", "agents"], Some(Value::Object(Map::new())))?; + document.patch(&["opencode", "categories"], Some(Value::Object(Map::new())))?; + Ok(document) +} + +pub fn has_effective_opencode_overrides(group: &ModelGroup) -> bool { + group + .open_code_agent_overrides + .iter() + .any(is_effective_binding) +} + +/// Patches native OpenCode agent models/variants for the group. Never creates agents; +/// missing ones produce warnings. +pub fn project_opencode( + group: &ModelGroup, + source: &JsoncDoc, +) -> Result<(JsoncDoc, Vec), crate::error::AppError> { + let mut document = source.clone(); + let mut warnings = Vec::new(); + if !document.raw().get("agent").is_some_and(Value::is_object) { + for binding in group + .open_code_agent_overrides + .iter() + .filter(|binding| is_effective_binding(binding)) + { + warnings.push(format!( + "OpenCode config has no valid top-level 'agent' object; skipped model override for '{}'.", + binding.agent_name + )); + } + return Ok((document, warnings)); + } + for binding in group + .open_code_agent_overrides + .iter() + .filter(|binding| is_effective_binding(binding)) + { + let agents = document.raw().get("agent").and_then(Value::as_object); + if !agents + .and_then(|agents| agents.get(&binding.agent_name)) + .is_some_and(Value::is_object) + { + warnings.push(format!( + "OpenCode agent '{}' was not found; skipped model override.", + binding.agent_name + )); + continue; + } + let path = ["agent", binding.agent_name.as_str(), "model"]; + document.patch(&path, Some(Value::String(binding.model_ref.clone())))?; + let variant_path = ["agent", binding.agent_name.as_str(), "variant"]; + let variant = binding + .variant + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| Value::String(value.to_owned())); + document.patch(&variant_path, variant)?; + } + Ok((document, warnings)) +} + +fn is_effective_binding(binding: &AgentModelBinding) -> bool { + !binding.agent_name.trim().is_empty() && !binding.model_ref.trim().is_empty() +} + +fn mapping_values(bindings: &[AgentModelBinding]) -> Map { + bindings + .iter() + .filter(|binding| is_effective_binding(binding)) + .map(|binding| { + ( + binding.agent_name.clone(), + model_value(&binding.model_ref, binding.variant.as_deref()), + ) + }) + .collect() +} + +fn category_values(bindings: &[OmoCategoryMapping]) -> Map { + bindings + .iter() + .filter(|binding| { + !binding.category_name.trim().is_empty() && !binding.model_ref.trim().is_empty() + }) + .map(|binding| { + ( + binding.category_name.clone(), + model_value(&binding.model_ref, binding.variant.as_deref()), + ) + }) + .collect() +} + +fn model_value(model: &str, variant: Option<&str>) -> Value { + let mut value = Map::new(); + value.insert("model".to_owned(), Value::String(model.to_owned())); + if let Some(variant) = variant.filter(|value| !value.trim().is_empty()) { + value.insert("variant".to_owned(), Value::String(variant.to_owned())); + } + Value::Object(value) +} diff --git a/src-tauri/src/providers.rs b/src-tauri/src/providers.rs new file mode 100644 index 0000000..0bcd97e --- /dev/null +++ b/src-tauri/src/providers.rs @@ -0,0 +1,624 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; + +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::document::{JsoncDoc, OPENCODE_SCHEMA}; +use crate::error::AppError; +use crate::models::{ModelDef, ProviderDef}; + +/// A stable OpenCode model reference. Only `provider/model` is valid. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ModelRef { + pub provider_id: String, + pub model_id: String, +} + +impl ModelRef { + pub fn new( + provider_id: impl Into, + model_id: impl Into, + ) -> Result { + let provider_id = provider_id.into(); + let model_id = model_id.into(); + validate_id(&provider_id, IdKind::Provider)?; + validate_id(&model_id, IdKind::Model)?; + Ok(Self { + provider_id, + model_id, + }) + } + + pub fn parse(value: &str) -> Result { + let (provider_id, model_id) = value + .split_once('/') + .filter(|(_, model_id)| !model_id.contains('/')) + .ok_or_else(|| invalid_model_ref(value))?; + Self::new(provider_id, model_id) + } + + pub fn as_str(&self) -> String { + format!("{}/{}", self.provider_id, self.model_id) + } +} + +impl fmt::Display for ModelRef { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.as_str()) + } +} + +fn invalid_model_ref(value: &str) -> AppError { + AppError::validation(format!("invalid model reference: {value}")) +} + +/// The public read projection for an option containing a credential. The value is +/// deliberately structural rather than a magic string, so a user cannot accidentally submit +/// it as a secret. +pub fn masked_secret_value() -> Value { + Value::Object(Map::from_iter([( + "configured".to_owned(), + Value::Bool(true), + )])) +} + +pub fn redact_provider_secrets(mut provider: ProviderDef) -> ProviderDef { + if let Some(options) = provider.options.as_mut() { + for (key, value) in options { + if is_sensitive_option_key(key) { + *value = masked_secret_value(); + } + } + } + provider +} + +pub fn is_sensitive_option_key(key: &str) -> bool { + let key = key.to_ascii_lowercase(); + ["apikey", "key", "token", "secret", "password"] + .into_iter() + .any(|needle| key.contains(needle)) +} + +fn is_masked_secret_value(value: &Value) -> bool { + value == &masked_secret_value() +} + +pub fn list_providers(opencode_file: &Path) -> Result, AppError> { + providers_from_root( + &JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)? + .raw() + .clone(), + ) +} + +pub fn get_provider(opencode_file: &Path, provider_id: &str) -> Result { + validate_id(provider_id, IdKind::Provider)?; + list_providers(opencode_file)? + .into_iter() + .find(|provider| provider.id == provider_id) + .ok_or_else(|| AppError::not_found(format!("provider not found: {provider_id}"))) +} + +/// Returns one stored sensitive option after an explicit user-initiated reveal request. +pub fn reveal_sensitive_option( + opencode_file: &Path, + provider_id: &str, + key: &str, +) -> Result { + if !is_sensitive_option_key(key) { + return Err(AppError::validation(format!( + "provider option is not a sensitive key: {key}" + ))); + } + let provider = get_provider(opencode_file, provider_id)?; + provider + .options + .as_ref() + .and_then(|options| options.get(key)) + .cloned() + .ok_or_else(|| { + AppError::not_found(format!("provider option not found: {provider_id}.{key}")) + }) +} + +pub fn create_provider( + opencode_file: &Path, + provider: ProviderDef, +) -> Result { + validate_provider(&provider)?; + reject_masked_secrets(&provider)?; + let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; + let providers = providers_from_root(&document.raw().clone())?; + if providers.iter().any(|item| item.id == provider.id) { + return Err(AppError::validation(format!( + "provider already exists: {}", + provider.id + ))); + } + document.patch(&["provider", &provider.id], Some(provider_value(&provider)))?; + document.save(opencode_file)?; + Ok(provider) +} + +pub fn update_provider( + opencode_file: &Path, + mut provider: ProviderDef, +) -> Result { + validate_provider(&provider)?; + let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; + let providers = providers_from_root(&document.raw().clone())?; + let current = providers + .iter() + .find(|item| item.id == provider.id) + .ok_or_else(|| AppError::not_found(format!("provider not found: {}", provider.id)))?; + if current.models != provider.models { + return Err(AppError::validation(format!( + "provider updates cannot change models directly: provider.{}.models", + provider.id + ))); + } + preserve_masked_secrets(&mut provider, current)?; + apply_patches(&mut document, provider_field_patches(&provider))?; + document.save(opencode_file)?; + Ok(provider) +} + +pub fn delete_provider(opencode_file: &Path, provider_id: &str) -> Result<(), AppError> { + validate_id(provider_id, IdKind::Provider)?; + let provider = get_provider(opencode_file, provider_id)?; + if !provider.models.is_empty() { + return Err(AppError::validation(format!( + "provider still contains models: {provider_id}" + ))); + } + let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; + document.patch(&["provider", provider_id], None)?; + document.save(opencode_file)?; + Ok(()) +} + +pub fn create_model( + opencode_file: &Path, + provider_id: &str, + model: ModelDef, +) -> Result { + validate_id(provider_id, IdKind::Provider)?; + validate_id(&model.id, IdKind::Model)?; + let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; + let providers = providers_from_root(&document.raw().clone())?; + let provider = providers + .iter() + .find(|item| item.id == provider_id) + .ok_or_else(|| AppError::not_found(format!("provider not found: {provider_id}")))?; + if provider.models.contains_key(&model.id) { + return Err(AppError::validation(format!( + "model already exists: {}/{}", + provider_id, model.id + ))); + } + document.patch( + &["provider", provider_id, "models", &model.id], + Some(model_value(&model)), + )?; + document.save(opencode_file)?; + Ok(model) +} + +pub fn update_model( + opencode_file: &Path, + model_ref: &ModelRef, + model: ModelDef, +) -> Result { + validate_id(&model.id, IdKind::Model)?; + if model.id != model_ref.model_id { + return Err(AppError::validation(format!( + "invalid model ID: {}", + model.id + ))); + } + let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; + ensure_model_exists(&document.raw(), model_ref)?; + apply_patches(&mut document, model_field_patches(model_ref, &model))?; + document.save(opencode_file)?; + Ok(model) +} + +pub fn delete_model(opencode_file: &Path, model_ref: &ModelRef) -> Result<(), AppError> { + let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; + ensure_model_exists(&document.raw(), model_ref)?; + let path = model_path_vec(model_ref); + let segments: Vec<&str> = path.iter().map(String::as_str).collect(); + document.patch(&segments, None)?; + document.save(opencode_file)?; + Ok(()) +} + +fn apply_patches( + document: &mut JsoncDoc, + patches: Vec<(Vec, Option)>, +) -> Result<(), AppError> { + for (path, value) in patches { + let segments: Vec<_> = path.iter().map(String::as_str).collect(); + document.patch(&segments, value)?; + } + Ok(()) +} + +fn preserve_masked_secrets( + provider: &mut ProviderDef, + current: &ProviderDef, +) -> Result<(), AppError> { + let Some(options) = provider.options.as_mut() else { + return Ok(()); + }; + let existing = current.options.as_ref(); + for (key, value) in options { + if !is_sensitive_option_key(key) || !is_masked_secret_value(value) { + continue; + } + let Some(original) = existing.and_then(|options| options.get(key)) else { + return Err(AppError::validation(format!( + "masked secret value is not valid for option: {key}" + ))); + }; + *value = original.clone(); + } + Ok(()) +} + +fn reject_masked_secrets(provider: &ProviderDef) -> Result<(), AppError> { + if let Some(options) = &provider.options { + if let Some((key, _)) = options + .iter() + .find(|(key, value)| is_sensitive_option_key(key) && is_masked_secret_value(value)) + { + return Err(AppError::validation(format!( + "masked secret value is not valid for option: {key}" + ))); + } + } + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderPayload { + name: Option, + npm: Option, + api: Option, + env: Option>, + whitelist: Option>, + blacklist: Option>, + options: Option>, + #[serde(default)] + models: BTreeMap, +} + +fn providers_from_root(root: &Map) -> Result, AppError> { + let providers = match root.get("provider") { + None => return Ok(Vec::new()), + Some(Value::Object(providers)) => providers, + Some(_) => { + return Err(AppError::validation( + "invalid OpenCode provider shape at: provider", + )) + } + }; + let mut parsed = providers + .iter() + .map(|(id, value)| provider_from_value(id, value)) + .collect::, _>>()?; + parsed.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(parsed) +} + +pub fn provider_from_root_value( + root: &Map, + provider_id: &str, +) -> Result { + root.get("provider") + .and_then(Value::as_object) + .and_then(|providers| providers.get(provider_id)) + .ok_or_else(|| AppError::not_found(format!("provider not found: {provider_id}"))) + .and_then(|value| provider_from_value(provider_id, value)) +} + +fn provider_from_value(id: &str, value: &Value) -> Result { + validate_id(id, IdKind::Provider)?; + let payload: ProviderPayload = serde_json::from_value(value.clone()).map_err(|_| { + AppError::validation(format!("invalid OpenCode provider shape at: provider.{id}")) + })?; + let mut models = BTreeMap::new(); + for (model_id, model_value) in payload.models { + let model = model_from_value(&model_id, model_value)?; + models.insert(model_id, model); + } + Ok(ProviderDef { + id: id.to_owned(), + name: payload.name, + npm: payload.npm, + api: payload.api, + env: payload.env, + whitelist: payload.whitelist, + blacklist: payload.blacklist, + options: payload.options, + models, + }) +} + +pub fn model_from_value(id: &str, value: Value) -> Result { + validate_id(id, IdKind::Model)?; + #[derive(Deserialize)] + #[serde(rename_all = "snake_case")] + struct ModelPayload { + name: Option, + family: Option, + release_date: Option, + status: Option, + reasoning: Option, + temperature: Option, + tool_call: Option, + attachment: Option, + interleaved: Option, + cost: Option, + limit: Option, + modalities: Option, + experimental: Option, + options: Option>, + headers: Option>, + variants: Option>, + } + let payload: ModelPayload = serde_json::from_value(value).map_err(|_| { + AppError::validation(format!( + "invalid OpenCode provider shape at: provider.*.models.{id}" + )) + })?; + Ok(ModelDef { + id: id.to_owned(), + name: payload.name, + family: payload.family, + release_date: payload.release_date, + status: payload.status, + reasoning: payload.reasoning, + temperature: payload.temperature, + tool_call: payload.tool_call, + attachment: payload.attachment, + interleaved: payload.interleaved, + cost: payload.cost, + limit: payload.limit, + modalities: payload.modalities, + experimental: payload.experimental, + options: payload.options, + headers: payload.headers, + variants: payload.variants, + }) +} + +fn provider_value(provider: &ProviderDef) -> Value { + let mut value = Map::new(); + put_optional(&mut value, "name", provider.name.clone().map(Value::String)); + put_optional(&mut value, "npm", provider.npm.clone().map(Value::String)); + put_optional(&mut value, "api", provider.api.clone().map(Value::String)); + put_optional(&mut value, "env", provider.env.clone().and_then(to_value)); + put_optional( + &mut value, + "whitelist", + provider.whitelist.clone().and_then(to_value), + ); + put_optional( + &mut value, + "blacklist", + provider.blacklist.clone().and_then(to_value), + ); + put_optional( + &mut value, + "options", + provider.options.clone().and_then(to_value), + ); + let models = provider + .models + .iter() + .map(|(id, model)| (id.clone(), model_value(model))) + .collect(); + value.insert("models".to_owned(), Value::Object(models)); + Value::Object(value) +} + +pub fn model_value(model: &ModelDef) -> Value { + let mut value = Map::new(); + put_optional(&mut value, "name", model.name.clone().map(Value::String)); + put_optional( + &mut value, + "family", + model.family.clone().map(Value::String), + ); + put_optional( + &mut value, + "release_date", + model.release_date.clone().map(Value::String), + ); + put_optional(&mut value, "status", model.status.and_then(to_value)); + put_optional(&mut value, "reasoning", model.reasoning.map(Value::Bool)); + put_optional( + &mut value, + "temperature", + model + .temperature + .and_then(|number| serde_json::Number::from_f64(number).map(Value::Number)), + ); + put_optional(&mut value, "tool_call", model.tool_call.map(Value::Bool)); + put_optional(&mut value, "attachment", model.attachment.map(Value::Bool)); + put_optional( + &mut value, + "interleaved", + model.interleaved.map(Value::Bool), + ); + put_optional(&mut value, "cost", model.cost.clone().and_then(to_value)); + put_optional(&mut value, "limit", model.limit.clone().and_then(to_value)); + put_optional( + &mut value, + "modalities", + model.modalities.clone().and_then(to_value), + ); + put_optional( + &mut value, + "experimental", + model.experimental.map(Value::Bool), + ); + put_optional( + &mut value, + "options", + model.options.clone().and_then(to_value), + ); + put_optional( + &mut value, + "headers", + model.headers.clone().and_then(to_value), + ); + put_optional( + &mut value, + "variants", + model.variants.clone().and_then(to_value), + ); + Value::Object(value) +} + +fn provider_field_patches(provider: &ProviderDef) -> Vec<(Vec, Option)> { + let base = vec!["provider".to_owned(), provider.id.clone()]; + [ + ("name", provider.name.clone().map(Value::String)), + ("npm", provider.npm.clone().map(Value::String)), + ("api", provider.api.clone().map(Value::String)), + ("env", provider.env.clone().and_then(to_value)), + ("whitelist", provider.whitelist.clone().and_then(to_value)), + ("blacklist", provider.blacklist.clone().and_then(to_value)), + ("options", provider.options.clone().and_then(to_value)), + ] + .into_iter() + .map(|(field, value)| { + let mut path = base.clone(); + path.push(field.to_owned()); + (path, value) + }) + .collect() +} + +const MODEL_FIELDS: &[&str] = &[ + "name", + "family", + "release_date", + "status", + "reasoning", + "temperature", + "tool_call", + "attachment", + "interleaved", + "cost", + "limit", + "modalities", + "experimental", + "options", + "headers", + "variants", +]; + +fn model_field_patches( + model_ref: &ModelRef, + model: &ModelDef, +) -> Vec<(Vec, Option)> { + let base = model_path_vec(model_ref); + let updated = model_value(model) + .as_object() + .expect("model value is an object") + .clone(); + updated + .iter() + .map(|(field, value)| { + let mut path = base.clone(); + path.push(field.clone()); + (path, Some(value.clone())) + }) + .chain( + MODEL_FIELDS + .iter() + .filter(|field| !updated.contains_key(**field)) + .map(|field| { + let mut path = base.clone(); + path.push((*field).to_owned()); + (path, None) + }), + ) + .collect() +} + +pub fn model_path_vec(model_ref: &ModelRef) -> Vec { + vec![ + "provider".to_owned(), + model_ref.provider_id.clone(), + "models".to_owned(), + model_ref.model_id.clone(), + ] +} + +fn put_optional(target: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + target.insert(key.to_owned(), value); + } +} + +fn to_value(value: T) -> Option { + serde_json::to_value(value).ok() +} + +fn validate_provider(provider: &ProviderDef) -> Result<(), AppError> { + validate_id(&provider.id, IdKind::Provider)?; + for (id, model) in &provider.models { + if id != &model.id { + return Err(AppError::validation(format!( + "invalid model ID: {}", + model.id + ))); + } + validate_id(&model.id, IdKind::Model)?; + } + Ok(()) +} + +enum IdKind { + Provider, + Model, +} + +fn validate_id(value: &str, kind: IdKind) -> Result<(), AppError> { + if !value.is_empty() + && value.trim() == value + && !value.contains('/') + && !value.chars().any(char::is_whitespace) + { + return Ok(()); + } + Err(match kind { + IdKind::Provider => AppError::validation(format!("invalid provider ID: {value}")), + IdKind::Model => AppError::validation(format!("invalid model ID: {value}")), + }) +} + +fn ensure_model_exists(root: &Map, model_ref: &ModelRef) -> Result<(), AppError> { + let exists = provider_from_root_value(root, &model_ref.provider_id) + .map(|provider| provider.models.contains_key(&model_ref.model_id)) + .or_else(|error| match error.code { + crate::error::ErrorCode::NotFound => Ok(false), + _ => Err(error), + })?; + if exists { + Ok(()) + } else { + Err(AppError::not_found(format!( + "model not found: {}", + model_ref.as_str() + ))) + } +} diff --git a/src-tauri/src/refs.rs b/src-tauri/src/refs.rs new file mode 100644 index 0000000..26c99a1 --- /dev/null +++ b/src-tauri/src/refs.rs @@ -0,0 +1,322 @@ +use serde_json::Value; + +use crate::agents::{self, AgentReference, AgentReferenceIndex, AgentReferenceKind}; +use crate::error::AppError; +use crate::models::AppConfig; +use crate::paths::ConfigPaths; +use crate::providers::{list_providers, ModelRef}; + +/// Where a model reference was found. Displayed to users when deletions are blocked. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ReferenceSource { + OpenCodeInlineAgent, + MarkdownAgent, + ApplicationGroup, + SlimPreset, + OmoAgent, + OmoCategory, +} + +impl ReferenceSource { + pub const fn as_str(self) -> &'static str { + match self { + Self::OpenCodeInlineAgent => "opencode-inline-agent", + Self::MarkdownAgent => "markdown-agent", + Self::ApplicationGroup => "application-group", + Self::SlimPreset => "slim-preset", + Self::OmoAgent => "omo-agent", + Self::OmoCategory => "omo-category", + } + } +} + +/// One real model binding with a displayable location. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ModelReference { + pub source: ReferenceSource, + pub location: String, + pub model_ref: ModelRef, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ModelReferenceIndex { + references: Vec, +} + +impl ModelReferenceIndex { + pub fn new(references: impl IntoIterator) -> Self { + let mut references: Vec<_> = references.into_iter().collect(); + references.sort(); + references.dedup(); + Self { references } + } + + pub fn for_model(&self, model_ref: &ModelRef) -> Vec { + self.references + .iter() + .filter(|reference| &reference.model_ref == model_ref) + .cloned() + .collect() + } + + pub fn for_provider(&self, provider_id: &str) -> Vec { + self.references + .iter() + .filter(|reference| reference.model_ref.provider_id == provider_id) + .cloned() + .collect() + } +} + +/// Builds the index used to protect models from deletion while still referenced. +pub fn collect_model_references(paths: &ConfigPaths) -> Result { + let mut references = agents::model_references(paths)?; + let config = crate::models::load_config(&paths.config_file())?; + for group in config.groups { + let group_id = group.id; + let group_name = group.name.clone(); + add_group_references( + &mut references, + group.open_code_agent_overrides.into_iter(), + group_id, + &group_name, + "openCodeAgentOverrides", + )?; + if let Some(bindings) = group.slim_agent_overrides { + add_group_references( + &mut references, + bindings.into_iter(), + group_id, + &group_name, + "slimAgentOverrides", + )?; + } + if let Some(bindings) = group.omo_agent_overrides { + add_group_references( + &mut references, + bindings.into_iter(), + group_id, + &group_name, + "omoAgentOverrides", + )?; + } + if let Some(mappings) = group.omo_category_mappings { + for mapping in mappings { + references.push(ModelReference { + source: ReferenceSource::ApplicationGroup, + location: format!( + "group.{group_id}.omoCategoryMappings.{}.model", + mapping.category_name + ), + model_ref: ModelRef::parse(&mapping.model_ref).map_err(|error| { + AppError::validation(format!( + "invalid model reference in group '{}', category '{}': {error}", + group_name, mapping.category_name + )) + })?, + }); + } + } + } + references.extend(crate::replacement::scan_plugin_references(paths)?); + Ok(ModelReferenceIndex::new(references)) +} + +fn add_group_references( + references: &mut Vec, + bindings: impl IntoIterator, + group_id: uuid::Uuid, + group_name: &str, + field: &str, +) -> Result<(), AppError> { + for binding in bindings { + references.push(ModelReference { + source: ReferenceSource::ApplicationGroup, + location: format!("group.{group_id}.{field}.{}.model", binding.agent_name), + model_ref: ModelRef::parse(&binding.model_ref).map_err(|error| { + AppError::validation(format!( + "invalid model reference in group '{group_name}', field '{field}', agent '{}': {error}", + binding.agent_name + )) + })?, + }); + } + Ok(()) +} + +/// Formats the locations of blocking model references into a single detail string. +pub fn model_reference_locations(references: &[ModelReference]) -> String { + references + .iter() + .map(|reference| format!("{} at {}", reference.source.as_str(), reference.location)) + .collect::>() + .join(", ") +} + +/// Collects every agent name referenced by groups and the native OpenCode config. +pub fn collect_agent_references(paths: &ConfigPaths) -> Result { + let mut references = AgentReferenceIndex::new(); + for definition in agents::list(paths)? { + if let Some(inline) = definition.inline { + scan_permission_task_references( + &mut references, + &inline.raw, + &format!("agent.{}.inline", definition.id), + ); + } + for markdown in definition.markdown { + scan_permission_task_references( + &mut references, + &Value::Object(markdown.frontmatter), + &format!("{}:{}", markdown.path.display(), definition.id), + ); + } + } + let config: AppConfig = crate::models::load_config(&paths.config_file())?; + for group in config.groups { + let owner_prefix = format!("group.{}", group.id); + add_group_agent_references( + &mut references, + &group.open_code_agent_overrides, + &format!("{owner_prefix}.openCodeAgentOverrides"), + ); + if let Some(bindings) = &group.slim_agent_overrides { + add_group_agent_references( + &mut references, + bindings, + &format!("{owner_prefix}.slimAgentOverrides"), + ); + } + if let Some(bindings) = &group.omo_agent_overrides { + add_group_agent_references( + &mut references, + bindings, + &format!("{owner_prefix}.omoAgentOverrides"), + ); + } + } + + let document = + crate::document::JsoncDoc::read(&paths.opencode_file(), crate::document::OPENCODE_SCHEMA)?; + let opencode = document.raw(); + if let Some(agent) = opencode.get("default_agent").and_then(Value::as_str) { + add_agent_reference( + &mut references, + agent, + AgentReferenceKind::DefaultAgent, + "default_agent".to_owned(), + ); + } + if let Some(commands) = opencode.get("command").and_then(Value::as_object) { + for (name, command) in commands { + if let Some(agent) = command + .as_object() + .and_then(|command| command.get("agent")) + .and_then(Value::as_str) + { + add_agent_reference( + &mut references, + agent, + AgentReferenceKind::Command, + format!("command.{name}.agent"), + ); + } + } + } + scan_permission_task_references(&mut references, &Value::Object(document.raw().clone()), ""); + Ok(references) +} + +fn add_group_agent_references( + references: &mut AgentReferenceIndex, + bindings: &[crate::models::AgentModelBinding], + owner_prefix: &str, +) { + for binding in bindings { + add_agent_reference( + references, + &binding.agent_name, + AgentReferenceKind::Group, + format!("{owner_prefix}.{}.agentName", binding.agent_name), + ); + } +} + +fn add_agent_reference( + references: &mut AgentReferenceIndex, + agent_id: &str, + kind: AgentReferenceKind, + owner: String, +) { + if agent_id.is_empty() { + return; + } + references + .entry(agent_id.to_owned()) + .or_default() + .push(AgentReference { kind, owner }); +} + +fn scan_permission_task_references( + references: &mut AgentReferenceIndex, + value: &Value, + owner_prefix: &str, +) { + let Some(object) = value.as_object() else { + if let Some(values) = value.as_array() { + for nested in values { + scan_permission_task_references(references, nested, owner_prefix); + } + } + return; + }; + if let Some(tasks) = object + .get("permission") + .and_then(Value::as_object) + .and_then(|permission| permission.get("task")) + .and_then(Value::as_object) + { + for agent in tasks.keys() { + let owner = if owner_prefix.is_empty() { + format!("permission.task.{agent}") + } else { + format!("{owner_prefix}.permission.task.{agent}") + }; + add_agent_reference(references, agent, AgentReferenceKind::PermissionTask, owner); + } + } + for (key, nested) in object { + let nested_prefix = if owner_prefix.is_empty() { + key.clone() + } else { + format!("{owner_prefix}.{key}") + }; + scan_permission_task_references(references, nested, &nested_prefix); + } +} + +/// Convenience wrapper used by provider deletion protection. +pub fn provider_is_referenced( + paths: &ConfigPaths, + provider_id: &str, +) -> Result, AppError> { + Ok(collect_model_references(paths)?.for_provider(provider_id)) +} + +/// Convenience wrapper used by model deletion protection. +pub fn model_is_referenced( + paths: &ConfigPaths, + model_ref: &ModelRef, +) -> Result, AppError> { + Ok(collect_model_references(paths)?.for_model(model_ref)) +} + +/// Lists providers without exposing stored secrets. +pub fn list_providers_redacted( + paths: &ConfigPaths, +) -> Result, AppError> { + Ok(list_providers(&paths.opencode_file())? + .into_iter() + .map(crate::providers::redact_provider_secrets) + .collect()) +} diff --git a/src-tauri/src/replacement.rs b/src-tauri/src/replacement.rs new file mode 100644 index 0000000..a28d3e0 --- /dev/null +++ b/src-tauri/src/replacement.rs @@ -0,0 +1,732 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde_json::Value; +use time::OffsetDateTime; + +use crate::agents_md::MarkdownAgentDocument; +use crate::document::JsoncDoc; +use crate::error::AppError; +use crate::models::AppConfig; +use crate::paths::ConfigPaths; +use crate::providers::ModelRef; + +// OMO stores OpenCode-specific mappings under the literal `opencode` object. +const OMO_OPENCODE_BLOCK: &str = "opencode"; + +#[derive(Debug, Clone)] +enum OpenCodeKeyRename { + Model { from: ModelRef, to: ModelRef }, + Provider { old_id: String, new_id: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum JsoncRole { + OpenCode, + Slim, + Omo, +} + +/// Scans Slim and OMO files for model references used by deletion protection. +pub fn scan_plugin_references( + paths: &ConfigPaths, +) -> Result, AppError> { + let mut references = Vec::new(); + if let Some(content) = crate::document::read_text(&paths.slim_file())? { + let document = JsoncDoc::parse(&content)?; + scan_slim(&document, &mut references)?; + } + if let Some(content) = crate::document::read_text(&paths.omo_file())? { + let document = JsoncDoc::parse(&content)?; + scan_omo(&document, &mut references)?; + } + references.sort(); + Ok(references) +} + +/// Replaces every `from` reference with `to` across OpenCode/Slim/OMO files, Markdown +/// agents, and application groups. Returns the number of replacements. +pub fn replace(paths: &ConfigPaths, from: &ModelRef, to: &ModelRef) -> Result { + let (opencode_bytes, opencode) = + required_document(&paths.opencode_file(), "OpenCode configuration")?; + ensure_models_exist(&opencode, from, to)?; + if from == to { + return Ok(0); + } + + let mut replacements = 0; + let mut targets: BTreeMap = BTreeMap::new(); + add_jsonc_target( + &mut targets, + paths.opencode_file(), + opencode_bytes.clone(), + JsoncRole::OpenCode, + ); + if let Some((original, document)) = optional_document(&paths.slim_file(), "Slim configuration")? + { + add_jsonc_target(&mut targets, paths.slim_file(), original, JsoncRole::Slim); + drop(document); + } + if let Some((original, document)) = optional_document(&paths.omo_file(), "OMO configuration")? { + add_jsonc_target(&mut targets, paths.omo_file(), original, JsoncRole::Omo); + drop(document); + } + + for target in targets.into_values() { + let content = String::from_utf8(target.original).map_err(|error| { + AppError::configuration(format!("read {}: {error}", target.path.display())) + })?; + let mapping = (from.clone(), to.clone()); + let mut updated = content.clone(); + let mut replacements_for_target = 0; + for role in &target.roles { + let (next, changed) = + patch_jsonc_role(*role, &updated, std::slice::from_ref(&mapping), None)?; + updated = next; + replacements_for_target += changed; + } + replacements += replacements_for_target; + if updated != content { + JsoncDoc::parse(&updated)?.save(&target.path)?; + } + } + + for path in agents_markdown_files(paths)? { + let raw = crate::document::read_text(&path)?.unwrap_or_default(); + let mut document = MarkdownAgentDocument::parse(&raw).map_err(|error| { + AppError::validation(format!("parse Markdown agent {}: {error}", path.display())) + })?; + let Some(model) = document.frontmatter.get("model").and_then(Value::as_str) else { + continue; + }; + if model != from.as_str() { + continue; + } + document.apply_fields( + &serde_json::Map::from_iter([("model".to_owned(), Value::String(to.as_str()))]), + None, + true, + )?; + crate::document::write_file(&path, document.serialize().as_bytes())?; + replacements += 1; + } + + let config_path = paths.config_file(); + if config_path.exists() { + let original = crate::document::read_text(&config_path)?.unwrap_or_default(); + let mut config: AppConfig = serde_json::from_str(&original).map_err(|error| { + AppError::validation(format!("application config: invalid JSON: {error}")) + })?; + let changed = patch_groups(&mut config, from, to); + if changed > 0 { + crate::models::save_config(&config_path, &config)?; + replacements += changed; + } + } + Ok(replacements) +} + +pub fn rename_model( + paths: &ConfigPaths, + from: &ModelRef, + new_model_id: &str, +) -> Result<(), AppError> { + let to = ModelRef::new(&from.provider_id, new_model_id)?; + rename_with_mappings( + paths, + vec![(from.clone(), to.clone())], + OpenCodeKeyRename::Model { + from: from.clone(), + to, + }, + ) +} + +pub fn rename_provider(paths: &ConfigPaths, old_id: &str, new_id: &str) -> Result<(), AppError> { + validate_rename_id(old_id, "provider")?; + validate_rename_id(new_id, "provider")?; + rename_with_mappings( + paths, + Vec::new(), + OpenCodeKeyRename::Provider { + old_id: old_id.to_owned(), + new_id: new_id.to_owned(), + }, + ) +} + +fn rename_with_mappings( + paths: &ConfigPaths, + mappings: Vec<(ModelRef, ModelRef)>, + key_rename: OpenCodeKeyRename, +) -> Result<(), AppError> { + let (opencode_bytes, opencode) = + required_document(&paths.opencode_file(), "OpenCode configuration")?; + validate_key_rename(&opencode, &key_rename)?; + let mappings = match &key_rename { + OpenCodeKeyRename::Provider { old_id, new_id } => { + provider_model_mappings(&opencode, old_id, new_id)? + } + OpenCodeKeyRename::Model { .. } => mappings, + }; + + let mut targets: BTreeMap = BTreeMap::new(); + add_jsonc_target( + &mut targets, + paths.opencode_file(), + opencode_bytes, + JsoncRole::OpenCode, + ); + if let Some((original, _)) = optional_document(&paths.slim_file(), "Slim configuration")? { + add_jsonc_target(&mut targets, paths.slim_file(), original, JsoncRole::Slim); + } + if let Some((original, _)) = optional_document(&paths.omo_file(), "OMO configuration")? { + add_jsonc_target(&mut targets, paths.omo_file(), original, JsoncRole::Omo); + } + + for target in targets.into_values() { + let content = String::from_utf8(target.original).map_err(|error| { + AppError::configuration(format!("read {}: {error}", target.path.display())) + })?; + let mut updated = content.clone(); + for role in &target.roles { + let (next, _) = patch_jsonc_role(*role, &updated, &mappings, Some(&key_rename))?; + updated = next; + } + if updated != content { + JsoncDoc::parse(&updated)?.save(&target.path)?; + } + } + + for path in agents_markdown_files(paths)? { + let raw = crate::document::read_text(&path)?.unwrap_or_default(); + let mut document = MarkdownAgentDocument::parse(&raw).map_err(|error| { + AppError::validation(format!("parse Markdown agent {}: {error}", path.display())) + })?; + let Some(model) = document.frontmatter.get("model").and_then(Value::as_str) else { + continue; + }; + let Some(to) = replacement_for(model, &mappings) else { + continue; + }; + document.apply_fields( + &serde_json::Map::from_iter([("model".to_owned(), Value::String(to.as_str()))]), + None, + true, + )?; + crate::document::write_file(&path, document.serialize().as_bytes())?; + } + + let config_path = paths.config_file(); + if config_path.exists() { + let original = crate::document::read_text(&config_path)?.unwrap_or_default(); + let mut config: AppConfig = serde_json::from_str(&original).map_err(|error| { + AppError::validation(format!("application config: invalid JSON: {error}")) + })?; + let changed = patch_groups_mappings(&mut config, &mappings); + if changed > 0 { + crate::models::save_config(&config_path, &config)?; + } + } + Ok(()) +} + +struct JsoncTarget { + path: PathBuf, + original: Vec, + roles: Vec, +} + +fn add_jsonc_target( + targets: &mut BTreeMap, + path: PathBuf, + original: Vec, + role: JsoncRole, +) { + match targets.get_mut(&path) { + Some(target) => target.roles.push(role), + None => { + targets.insert( + path.clone(), + JsoncTarget { + path, + original, + roles: vec![role], + }, + ); + } + } +} + +/// Applies model mappings (and optionally an OpenCode key rename) to one document role. +fn patch_jsonc_role( + role: JsoncRole, + source: &str, + mappings: &[(ModelRef, ModelRef)], + key_rename: Option<&OpenCodeKeyRename>, +) -> Result<(String, usize), AppError> { + let mut document = parse_role(role, source)?; + let mut changed = 0; + for (from, to) in mappings { + changed += match role { + JsoncRole::OpenCode => patch_document_opencode(&mut document, from, to)?, + JsoncRole::Slim => patch_document_slim(&mut document, from, to)?, + JsoncRole::Omo => patch_document_omo(&mut document, from, to)?, + }; + } + if let (JsoncRole::OpenCode, Some(rename)) = (role, key_rename) { + patch_opencode_key(&mut document, rename)?; + } + Ok((document.content().to_owned(), changed)) +} + +fn parse_role(role: JsoncRole, source: &str) -> Result { + let label = match role { + JsoncRole::OpenCode => "OpenCode", + JsoncRole::Slim => "Slim", + JsoncRole::Omo => "OMO", + }; + JsoncDoc::parse(source) + .map_err(|error| AppError::validation(format!("parse {label} configuration: {error}"))) +} + +fn patch_opencode_key(document: &mut JsoncDoc, rename: &OpenCodeKeyRename) -> Result<(), AppError> { + let (create_path, remove_path, value): (Vec<&str>, Vec<&str>, Option) = match rename { + OpenCodeKeyRename::Model { from, to } => ( + vec![ + "provider", + from.provider_id.as_str(), + "models", + to.model_id.as_str(), + ], + vec![ + "provider", + from.provider_id.as_str(), + "models", + from.model_id.as_str(), + ], + document + .raw() + .get("provider") + .and_then(Value::as_object) + .and_then(|providers| providers.get(&from.provider_id)) + .and_then(Value::as_object) + .and_then(|provider| provider.get("models")) + .and_then(Value::as_object) + .and_then(|models| models.get(&from.model_id)) + .cloned(), + ), + OpenCodeKeyRename::Provider { old_id, new_id } => ( + vec!["provider", new_id.as_str()], + vec!["provider", old_id.as_str()], + document + .raw() + .get("provider") + .and_then(Value::as_object) + .and_then(|providers| providers.get(old_id)) + .cloned(), + ), + }; + let value = value + .ok_or_else(|| AppError::validation("OpenCode rename source disappeared while planning"))?; + document.patch(&create_path, Some(value))?; + document.patch(&remove_path, None) +} + +fn validate_key_rename(document: &JsoncDoc, rename: &OpenCodeKeyRename) -> Result<(), AppError> { + let providers = document + .raw() + .get("provider") + .and_then(Value::as_object) + .ok_or_else(|| AppError::validation("OpenCode provider must be an object"))?; + match rename { + OpenCodeKeyRename::Model { from, to } => { + let models = providers + .get(&from.provider_id) + .and_then(Value::as_object) + .and_then(|provider| provider.get("models")) + .and_then(Value::as_object) + .ok_or_else(|| { + AppError::validation(format!("provider '{}' does not exist", from.provider_id)) + })?; + if !models.contains_key(&from.model_id) { + return Err(AppError::validation(format!( + "model '{}' does not exist", + from.as_str() + ))); + } + if models.contains_key(&to.model_id) { + return Err(AppError::validation(format!( + "model '{}' already exists", + to.as_str() + ))); + } + } + OpenCodeKeyRename::Provider { old_id, new_id } => { + if !providers.contains_key(old_id) { + return Err(AppError::validation(format!( + "provider '{old_id}' does not exist" + ))); + } + if providers.contains_key(new_id) { + return Err(AppError::validation(format!( + "provider '{new_id}' already exists" + ))); + } + } + } + Ok(()) +} + +fn provider_model_mappings( + document: &JsoncDoc, + old_id: &str, + new_id: &str, +) -> Result, AppError> { + let models = document + .raw() + .get("provider") + .and_then(Value::as_object) + .and_then(|providers| providers.get(old_id)) + .and_then(Value::as_object) + .and_then(|provider| provider.get("models")) + .and_then(Value::as_object) + .ok_or_else(|| { + AppError::validation(format!("provider '{old_id}'.models must be an object")) + })? + .clone(); + models + .keys() + .map(|model_id| { + Ok(( + ModelRef::new(old_id, model_id)?, + ModelRef::new(new_id, model_id)?, + )) + }) + .collect() +} + +fn validate_rename_id(value: &str, kind: &str) -> Result<(), AppError> { + ModelRef::new(value, "placeholder") + .map(|_| ()) + .map_err(|error| AppError::validation(format!("invalid {kind} ID '{value}': {error}"))) +} + +fn required_document(path: &std::path::Path, label: &str) -> Result<(Vec, JsoncDoc), AppError> { + let bytes = std::fs::read(path).map_err(|error| AppError::io("read", path, error))?; + let content = String::from_utf8(bytes.clone()) + .map_err(|error| AppError::validation(format!("read {label}: {error}")))?; + let document = JsoncDoc::parse(&content) + .map_err(|error| AppError::validation(format!("parse {label}: {error}")))?; + Ok((bytes, document)) +} + +fn optional_document( + path: &std::path::Path, + label: &str, +) -> Result, JsoncDoc)>, AppError> { + if !path.exists() { + return Ok(None); + } + required_document(path, label).map(Some) +} + +fn ensure_models_exist( + document: &JsoncDoc, + from: &ModelRef, + to: &ModelRef, +) -> Result<(), AppError> { + let providers = document + .raw() + .get("provider") + .and_then(Value::as_object) + .ok_or_else(|| AppError::validation("OpenCode provider must be an object"))?; + for model_ref in [from, to] { + let models = providers + .get(&model_ref.provider_id) + .and_then(Value::as_object) + .and_then(|provider| provider.get("models")) + .and_then(Value::as_object) + .ok_or_else(|| { + AppError::validation(format!( + "provider '{}'.models must be an object", + model_ref.provider_id + )) + })?; + if !models.contains_key(&model_ref.model_id) { + return Err(AppError::not_found(format!( + "model '{}' does not exist", + model_ref.as_str() + ))); + } + } + Ok(()) +} + +fn patch_document_opencode( + document: &mut JsoncDoc, + from: &ModelRef, + to: &ModelRef, +) -> Result { + let Some(agents) = document.raw().get("agent") else { + return Ok(0); + }; + let agents = agents + .as_object() + .ok_or_else(|| AppError::validation("OpenCode agent must be an object"))?; + let mut ids = Vec::new(); + for (id, agent) in agents { + let agent = agent.as_object().ok_or_else(|| { + AppError::validation(format!("OpenCode agent.{id} must be an object")) + })?; + if let Some(model) = agent.get("model") { + let model = model.as_str().ok_or_else(|| { + AppError::validation(format!("OpenCode agent.{id}.model must be a string")) + })?; + if model == from.as_str() { + ids.push(id.clone()); + } + } + } + for id in &ids { + document.patch(&["agent", id, "model"], Some(Value::String(to.as_str())))?; + } + Ok(ids.len()) +} + +fn patch_groups(config: &mut AppConfig, from: &ModelRef, to: &ModelRef) -> usize { + let mut replacements = 0; + for group in &mut config.groups { + let mut changed = false; + for binding in &mut group.open_code_agent_overrides { + changed |= replace_binding(&mut binding.model_ref, from, to, &mut replacements); + } + for binding in group.slim_agent_overrides.iter_mut().flatten() { + changed |= replace_binding(&mut binding.model_ref, from, to, &mut replacements); + } + for binding in group.omo_agent_overrides.iter_mut().flatten() { + changed |= replace_binding(&mut binding.model_ref, from, to, &mut replacements); + } + for mapping in group.omo_category_mappings.iter_mut().flatten() { + changed |= replace_binding(&mut mapping.model_ref, from, to, &mut replacements); + } + if changed { + group.updated_at = OffsetDateTime::now_utc(); + } + } + replacements +} + +fn patch_groups_mappings(config: &mut AppConfig, mappings: &[(ModelRef, ModelRef)]) -> usize { + let mut replacements = 0; + for group in &mut config.groups { + let mut changed = false; + for binding in &mut group.open_code_agent_overrides { + changed |= replace_mapped_binding(&mut binding.model_ref, mappings, &mut replacements); + } + for binding in group.slim_agent_overrides.iter_mut().flatten() { + changed |= replace_mapped_binding(&mut binding.model_ref, mappings, &mut replacements); + } + for binding in group.omo_agent_overrides.iter_mut().flatten() { + changed |= replace_mapped_binding(&mut binding.model_ref, mappings, &mut replacements); + } + for mapping in group.omo_category_mappings.iter_mut().flatten() { + changed |= replace_mapped_binding(&mut mapping.model_ref, mappings, &mut replacements); + } + if changed { + group.updated_at = OffsetDateTime::now_utc(); + } + } + replacements +} + +fn replace_mapped_binding( + value: &mut String, + mappings: &[(ModelRef, ModelRef)], + count: &mut usize, +) -> bool { + let Some(to) = replacement_for(value, mappings) else { + return false; + }; + *value = to.as_str(); + *count += 1; + true +} + +fn replacement_for<'a>(value: &str, mappings: &'a [(ModelRef, ModelRef)]) -> Option<&'a ModelRef> { + mappings + .iter() + .find(|(from, _)| value == from.as_str()) + .map(|(_, to)| to) +} + +fn replace_binding(value: &mut String, from: &ModelRef, to: &ModelRef, count: &mut usize) -> bool { + if *value == from.as_str() { + *value = to.as_str(); + *count += 1; + true + } else { + false + } +} + +fn patch_document_slim( + document: &mut JsoncDoc, + from: &ModelRef, + to: &ModelRef, +) -> Result { + let Some(presets) = document.raw().get("presets") else { + return Ok(0); + }; + let presets = presets + .as_object() + .ok_or_else(|| AppError::validation("Slim presets must be an object"))?; + let mut paths = Vec::new(); + for (preset_name, preset) in presets { + let preset = preset.as_object().ok_or_else(|| { + AppError::validation(format!("Slim presets.{preset_name} must be an object")) + })?; + for (agent_name, agent) in preset { + let agent = agent.as_object().ok_or_else(|| { + AppError::validation(format!( + "Slim presets.{preset_name}.{agent_name} must be an object" + )) + })?; + if let Some(model) = agent.get("model") { + let model = model.as_str().ok_or_else(|| { + AppError::validation(format!( + "Slim presets.{preset_name}.{agent_name}.model must be a string" + )) + })?; + if model == from.as_str() { + paths.push((preset_name.clone(), agent_name.clone())); + } + } + } + } + for (preset, agent) in &paths { + document.patch( + &["presets", preset, agent, "model"], + Some(Value::String(to.as_str())), + )?; + } + Ok(paths.len()) +} + +fn patch_document_omo( + document: &mut JsoncDoc, + from: &ModelRef, + to: &ModelRef, +) -> Result { + let Some(opencode) = document.raw().get(OMO_OPENCODE_BLOCK) else { + return Ok(0); + }; + let opencode = opencode + .as_object() + .ok_or_else(|| AppError::validation("OMO opencode must be an object"))?; + let mut paths = Vec::new(); + for kind in ["agents", "categories"] { + let Some(entries) = opencode.get(kind) else { + continue; + }; + let entries = entries.as_object().ok_or_else(|| { + AppError::validation(format!("OMO opencode.{kind} must be an object")) + })?; + for (name, entry) in entries { + let entry = entry.as_object().ok_or_else(|| { + AppError::validation(format!("OMO opencode.{kind}.{name} must be an object")) + })?; + if let Some(model) = entry.get("model") { + let model = model.as_str().ok_or_else(|| { + AppError::validation(format!( + "OMO opencode.{kind}.{name}.model must be a string" + )) + })?; + if model == from.as_str() { + paths.push((kind.to_owned(), name.clone())); + } + } + } + } + for (kind, name) in &paths { + document.patch( + &[OMO_OPENCODE_BLOCK, kind, name, "model"], + Some(Value::String(to.as_str())), + )?; + } + Ok(paths.len()) +} + +fn scan_slim( + document: &JsoncDoc, + references: &mut Vec, +) -> Result<(), AppError> { + let Some(presets) = document.raw().get("presets") else { + return Ok(()); + }; + let presets = presets + .as_object() + .ok_or_else(|| AppError::validation("Slim presets must be an object"))?; + for (preset, entries) in presets { + let entries = entries.as_object().ok_or_else(|| { + AppError::validation(format!("Slim presets.{preset} must be an object")) + })?; + for (agent, entry) in entries { + let entry = entry.as_object().ok_or_else(|| { + AppError::validation(format!("Slim presets.{preset}.{agent} must be an object")) + })?; + if let Some(model) = entry.get("model").and_then(Value::as_str) { + if !model.is_empty() { + references.push(crate::refs::ModelReference { + source: crate::refs::ReferenceSource::SlimPreset, + location: format!("presets.{preset}.{agent}.model"), + model_ref: ModelRef::parse(model)?, + }); + } + } + } + } + Ok(()) +} + +fn scan_omo( + document: &JsoncDoc, + references: &mut Vec, +) -> Result<(), AppError> { + let Some(opencode) = document.raw().get(OMO_OPENCODE_BLOCK) else { + return Ok(()); + }; + let opencode = opencode + .as_object() + .ok_or_else(|| AppError::validation("OMO opencode must be an object"))?; + for (key, source) in [ + ("agents", crate::refs::ReferenceSource::OmoAgent), + ("categories", crate::refs::ReferenceSource::OmoCategory), + ] { + let Some(entries) = opencode.get(key) else { + continue; + }; + let entries = entries + .as_object() + .ok_or_else(|| AppError::validation(format!("OMO opencode.{key} must be an object")))?; + for (name, entry) in entries { + let entry = entry.as_object().ok_or_else(|| { + AppError::validation(format!("OMO opencode.{key}.{name} must be an object")) + })?; + if let Some(model) = entry.get("model").and_then(Value::as_str) { + if !model.is_empty() { + references.push(crate::refs::ModelReference { + source, + location: format!("{OMO_OPENCODE_BLOCK}.{key}.{name}.model"), + model_ref: ModelRef::parse(model)?, + }); + } + } + } + } + Ok(()) +} + +fn agents_markdown_files(paths: &ConfigPaths) -> Result, AppError> { + crate::agents::collect_markdown_files_in_roots(paths) +} diff --git a/src-tauri/src/shell.rs b/src-tauri/src/shell.rs deleted file mode 100644 index 02ec4a7..0000000 --- a/src-tauri/src/shell.rs +++ /dev/null @@ -1,125 +0,0 @@ -use uuid::Uuid; - -use crate::core::models::{AppSelectionState, ModelGroup}; - -pub const SHOW_QUICK_SWITCH: &str = "show_quick_switch"; -pub const OPEN_SETTINGS: &str = "open_settings"; -pub const RELOAD_APP_STATE: &str = "reload_app_state"; -pub const QUIT: &str = "quit"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ShellAction { - ShowQuickSwitch, - OpenSettings, - ReloadAppState, - Quit, - SwitchGroup(Uuid), -} - -impl ShellAction { - pub fn id(self) -> String { - match self { - Self::ShowQuickSwitch => SHOW_QUICK_SWITCH.to_owned(), - Self::OpenSettings => OPEN_SETTINGS.to_owned(), - Self::ReloadAppState => RELOAD_APP_STATE.to_owned(), - Self::Quit => QUIT.to_owned(), - Self::SwitchGroup(group_id) => format!("switch_group:{group_id}"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ShellMenuItemKind { - Reload, - QuickSwitch, - Quit, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ShellMenuItem { - pub kind: ShellMenuItemKind, - pub title: String, - pub action: ShellAction, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ShellGroupMenuItem { - pub id: Uuid, - pub title: String, - pub active: bool, - pub action: ShellAction, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ShellMenuModel { - pub current_group_label: String, - pub group_items: Vec, - pub shell_items: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ShellStateSnapshot { - pub groups: Vec, - pub app_state: AppSelectionState, -} - -impl ShellStateSnapshot { - pub fn menu_model(&self) -> ShellMenuModel { - let active_group = self - .app_state - .selected_group_id - .and_then(|id| self.groups.iter().find(|group| group.id == id)); - let active_group_id = active_group.map(|group| group.id); - let current_group_name = active_group - .map(|group| group.name.as_str()) - .unwrap_or("None"); - ShellMenuModel { - current_group_label: format!("Current Group: {current_group_name}"), - group_items: self - .groups - .iter() - .filter(|group| group.is_enabled) - .map(|group| ShellGroupMenuItem { - id: group.id, - title: group.name.clone(), - active: Some(group.id) == active_group_id, - action: ShellAction::SwitchGroup(group.id), - }) - .collect(), - shell_items: shell_items(), - } - } -} - -pub fn resolve_shell_action(id: &str) -> Option { - match id { - SHOW_QUICK_SWITCH => Some(ShellAction::ShowQuickSwitch), - OPEN_SETTINGS => Some(ShellAction::OpenSettings), - RELOAD_APP_STATE => Some(ShellAction::ReloadAppState), - QUIT => Some(ShellAction::Quit), - _ => id - .strip_prefix("switch_group:") - .and_then(|group_id| Uuid::parse_str(group_id).ok()) - .map(ShellAction::SwitchGroup), - } -} - -fn shell_items() -> Vec { - vec![ - ShellMenuItem { - kind: ShellMenuItemKind::Reload, - title: "Reload".to_owned(), - action: ShellAction::ReloadAppState, - }, - ShellMenuItem { - kind: ShellMenuItemKind::QuickSwitch, - title: "Quick Switch".to_owned(), - action: ShellAction::ShowQuickSwitch, - }, - ShellMenuItem { - kind: ShellMenuItemKind::Quit, - title: "Quit".to_owned(), - action: ShellAction::Quit, - }, - ] -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d0322d3..024aad0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "omo-switch", + "productName": "opencode-mom", "version": "0.1.0", - "identifier": "dev.seho.omo-switch", + "identifier": "dev.seho.opencode-mom", "build": { "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build", @@ -12,19 +12,13 @@ "app": { "windows": [ { - "label": "quick-switch", - "title": "omo-switch", - "width": 420, - "height": 360, - "visible": false, - "resizable": false - }, - { - "label": "settings", - "title": "omo-switch", - "url": "/settings", - "width": 980, - "height": 620, + "label": "main", + "title": "opencode-mom", + "url": "/", + "width": 1280, + "height": 800, + "minWidth": 960, + "minHeight": 640, "visible": true, "resizable": true } @@ -36,12 +30,6 @@ "bundle": { "active": true, "targets": ["nsis", "app", "dmg"], - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ] + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"] } } diff --git a/src-tauri/tests/app.rs b/src-tauri/tests/app.rs new file mode 100644 index 0000000..8337202 --- /dev/null +++ b/src-tauri/tests/app.rs @@ -0,0 +1,403 @@ +use std::fs; +use std::path::PathBuf; + +use opencode_mom_tauri::agents::{AgentCreate, AgentMutation, AgentStorage}; +use opencode_mom_tauri::document::write_file; +use opencode_mom_tauri::error::ErrorCode; +use opencode_mom_tauri::groups; +use opencode_mom_tauri::models::{ + AgentModelBinding, AppConfig, AppSelectionState, GroupType, ModelDef, ModelGroup, + OmoCategoryMapping, ProviderDef, +}; +use opencode_mom_tauri::paths::ConfigPaths; +use opencode_mom_tauri::providers; +use time::OffsetDateTime; +use uuid::Uuid; + +struct TestHome { + root: PathBuf, + paths: ConfigPaths, +} + +impl Drop for TestHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn temp_home(name: &str) -> TestHome { + let root = std::env::temp_dir().join(format!( + "opencode-mom-test-{name}-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + fs::create_dir_all(&root).unwrap(); + let paths = ConfigPaths::for_home(&root); + TestHome { root, paths } +} + +fn provider(id: &str, models: &[&str]) -> ProviderDef { + ProviderDef { + id: id.to_owned(), + name: Some(id.to_owned()), + npm: None, + api: None, + env: None, + whitelist: None, + blacklist: None, + options: None, + models: models + .iter() + .map(|model_id| ((*model_id).to_owned(), model_def(model_id))) + .collect(), + } +} + +fn model_def(id: &str) -> ModelDef { + serde_json::from_value(serde_json::json!({ "id": id })).unwrap() +} + +fn binding(agent: &str, model: &str) -> AgentModelBinding { + AgentModelBinding { + agent_name: agent.to_owned(), + model_ref: model.to_owned(), + variant: None, + } +} + +fn group(group_type: GroupType, name: &str) -> ModelGroup { + ModelGroup { + id: Uuid::new_v4(), + name: name.to_owned(), + description: String::new(), + group_type, + open_code_agent_overrides: Vec::new(), + slim_agent_overrides: None, + omo_agent_overrides: None, + omo_category_mappings: None, + is_enabled: true, + updated_at: OffsetDateTime::now_utc(), + } +} + +fn load_config(home: &TestHome) -> AppConfig { + let content = fs::read_to_string(home.paths.config_file()).unwrap(); + serde_json::from_str(&content).unwrap() +} + +fn write_opencode(home: &TestHome) { + let path = home.paths.opencode_file(); + write_file( + &path, + br#"{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "reviewer": { "model": "acme/old" } + }, + "provider": { + "acme": { + "name": "Acme", + "models": { + "old": { "name": "Old" }, + "new": { "name": "New" }, + "fast": { "name": "Fast" }, + "slow": { "name": "Slow" } + } + } + } +} +"#, + ) + .unwrap(); +} + +#[test] +fn provider_crud_round_trip() { + let home = temp_home("providers"); + let opencode = home.paths.opencode_file(); + + let created = + providers::create_provider(&opencode, provider("acme", &["fast", "slow"])).unwrap(); + assert_eq!(created.id, "acme"); + let listed = providers::list_providers(&opencode).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].models.len(), 2); + + // Duplicate create fails. + let error = providers::create_provider(&opencode, provider("acme", &[])).unwrap_err(); + assert_eq!(error.code, ErrorCode::ValidationFailed); + + // Model CRUD. + let mut model = model_def("turbo"); + model.name = Some("Turbo".to_owned()); + providers::create_model(&opencode, "acme", model).unwrap(); + let mut updated = model_def("turbo"); + updated.name = Some("Turbo v2".to_owned()); + providers::update_model( + &opencode, + &providers::ModelRef::new("acme", "turbo").unwrap(), + updated, + ) + .unwrap(); + let acme = providers::get_provider(&opencode, "acme").unwrap(); + assert_eq!(acme.models["turbo"].name.as_deref(), Some("Turbo v2")); + + providers::delete_model( + &opencode, + &providers::ModelRef::new("acme", "turbo").unwrap(), + ); + assert!(providers::get_provider(&opencode, "acme") + .unwrap() + .models + .get("turbo") + .is_none()); + + // Delete with models is blocked. + let error = providers::delete_provider(&opencode, "acme").unwrap_err(); + assert_eq!(error.code, ErrorCode::ValidationFailed); + + // Sensitive options are redacted on read and preserved on masked update. + // Sensitive options are redacted on read and revealed on demand. + let mut secret = provider("secretco", &[]); + let mut options = std::collections::BTreeMap::new(); + options.insert("apiKey".to_owned(), serde_json::json!("super-secret")); + secret.options = Some(options); + providers::create_provider(&opencode, secret).unwrap(); + let redacted = + providers::redact_provider_secrets(providers::get_provider(&opencode, "secretco").unwrap()); + assert_eq!( + redacted.options.as_ref().unwrap()["apiKey"], + providers::masked_secret_value() + ); + let revealed = providers::reveal_sensitive_option(&opencode, "secretco", "apiKey").unwrap(); + assert_eq!(revealed, serde_json::json!("super-secret")); +} + +#[test] +fn agent_inline_and_markdown_lifecycle() { + let home = temp_home("agents"); + write_opencode(&home); + + // Inline create/update/delete through the OpenCode config file. + let mut fields = serde_json::Map::new(); + fields.insert( + "model".to_owned(), + serde_json::Value::String("acme/fast".to_owned()), + ); + let created = opencode_mom_tauri::agents::create( + &home.paths, + AgentCreate { + id: "helper".to_owned(), + storage: AgentStorage::Inline, + fields: fields.clone(), + prompt: Some("do things".to_owned()), + }, + ) + .unwrap(); + assert_eq!( + created.source, + opencode_mom_tauri::agents::AgentSource::Inline + ); + + let listed = opencode_mom_tauri::agents::list(&home.paths).unwrap(); + assert!(listed.iter().any(|agent| agent.id == "helper")); + + opencode_mom_tauri::agents::update( + &home.paths, + "helper", + AgentStorage::Inline, + AgentMutation { + fields: serde_json::Map::new(), + prompt: Some("updated prompt".to_owned()), + clear_fields: vec!["model".to_owned()], + }, + ) + .unwrap(); + + // Markdown create writes an agents/*.md file with frontmatter. + let created_md = opencode_mom_tauri::agents::create( + &home.paths, + AgentCreate { + id: "docs-writer".to_owned(), + storage: AgentStorage::GlobalMarkdown, + fields: fields.clone(), + prompt: Some("write docs".to_owned()), + }, + ) + .unwrap(); + let markdown_path = home.paths.global_agents_dir().join("docs-writer.md"); + assert!(markdown_path.exists()); + let raw = fs::read_to_string(&markdown_path).unwrap(); + assert!(raw.starts_with("---")); + assert_eq!( + created_md.source, + opencode_mom_tauri::agents::AgentSource::Markdown + ); + + // Built-in deletion protection. + let references = Default::default(); + let error = + opencode_mom_tauri::agents::delete(&home.paths, "build", AgentStorage::Inline, &references) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::ReferencesBlocked); + + // Give the helper a second (markdown) source so deleting the inline one leaves the + // markdown source as replacement. + opencode_mom_tauri::agents::create( + &home.paths, + AgentCreate { + id: "helper".to_owned(), + storage: AgentStorage::GlobalMarkdown, + fields: serde_json::Map::new(), + prompt: Some("markdown prompt".to_owned()), + }, + ) + .unwrap(); + let result = opencode_mom_tauri::agents::delete( + &home.paths, + "helper", + AgentStorage::Inline, + &references, + ) + .unwrap(); + assert!(result.another_source_takes_over); + let replacement = result.replacement.unwrap(); + assert_eq!(replacement.id, "helper"); + assert_eq!( + replacement.source, + opencode_mom_tauri::agents::AgentSource::Markdown + ); +} + +#[test] +fn group_switch_projects_targets_and_state() { + let home = temp_home("switch-slim"); + write_opencode(&home); + + let mut slim_group = group(GroupType::Slim, "work"); + slim_group.slim_agent_overrides = Some(vec![binding("reviewer", "acme/slow")]); + let (saved, _) = groups::save_group(&home.paths, slim_group).unwrap(); + + groups::switch_group(&home.paths, saved.id).unwrap(); + + let config = load_config(&home); + assert_eq!(config.state.selected_group_id, Some(saved.id)); + + let slim_content = fs::read_to_string(home.paths.slim_file()).unwrap(); + assert!(slim_content.contains("\"preset\": \"work\"")); + assert!(slim_content.contains("acme/slow")); +} + +#[test] +fn group_delete_clears_selected_omo_mappings() { + let home = temp_home("delete-omo"); + write_opencode(&home); + + let mut omo_group = group(GroupType::OhMyOpenagent, "omo-main"); + omo_group.omo_agent_overrides = Some(vec![binding("planner", "acme/slow")]); + omo_group.omo_category_mappings = Some(vec![OmoCategoryMapping { + category_name: "coding".to_owned(), + model_ref: "acme/fast".to_owned(), + variant: None, + }]); + let (saved, _) = groups::save_group(&home.paths, omo_group).unwrap(); + groups::switch_group(&home.paths, saved.id).unwrap(); + + let omo_path = home.paths.omo_file(); + assert!(omo_path.exists()); + let content = fs::read_to_string(&omo_path).unwrap(); + assert!(content.contains("acme/slow")); + + groups::delete_group(&home.paths, saved.id).unwrap(); + + let config = load_config(&home); + assert_eq!(config.state.selected_group_id, None); + assert!(!config + .groups + .iter() + .any(|candidate| candidate.id == saved.id)); + let content = fs::read_to_string(&omo_path).unwrap(); + assert!(!content.contains("acme/slow")); +} + +#[test] +fn save_group_validates_model_references_and_names() { + let home = temp_home("group-validation"); + write_opencode(&home); + + let mut invalid = group(GroupType::OpenCode, "bad-refs"); + invalid.open_code_agent_overrides = vec![binding("ghost", "missing/model")]; + let error = groups::save_group(&home.paths, invalid).unwrap_err(); + assert_eq!(error.code, ErrorCode::ValidationFailed); + + let first = group(GroupType::OpenCode, "dup"); + groups::save_group(&home.paths, first).unwrap(); + let second = group(GroupType::OpenCode, "DUP"); + let error = groups::save_group(&home.paths, second).unwrap_err(); + assert_eq!(error.code, ErrorCode::ValidationFailed); +} + +#[test] +fn replace_model_references_across_files() { + let home = temp_home("replace"); + write_opencode(&home); + + // Seed a slim preset and a selected OMO group referencing the old model. + let mut slim_group = group(GroupType::Slim, "preset-a"); + slim_group.slim_agent_overrides = Some(vec![binding("reviewer", "acme/old")]); + let (slim_saved, config) = groups::save_group(&home.paths, slim_group).unwrap(); + drop(config); + groups::switch_group(&home.paths, slim_saved.id).unwrap(); + + let replacements = opencode_mom_tauri::replacement::replace( + &home.paths, + &providers::ModelRef::new("acme", "old").unwrap(), + &providers::ModelRef::new("acme", "new").unwrap(), + ) + .unwrap(); + + assert!(replacements >= 3); + let opencode = fs::read_to_string(home.paths.opencode_file()).unwrap(); + let slim = fs::read_to_string(home.paths.slim_file()).unwrap(); + assert!(opencode.contains("acme/new")); + assert!(!opencode.contains("acme/old")); + assert!(slim.contains("acme/new")); + + let config = load_config(&home); + assert!(config.groups.iter().all(|candidate| candidate + .slim_agent_overrides + .iter() + .flatten() + .all(|entry| entry.model_ref == "acme/new"))); +} + +#[test] +fn rename_provider_updates_keys_everywhere() { + let home = temp_home("rename-provider"); + write_opencode(&home); + + opencode_mom_tauri::replacement::rename_provider(&home.paths, "acme", "acme2").unwrap(); + + let opencode = fs::read_to_string(home.paths.opencode_file()).unwrap(); + assert!(opencode.contains("\"acme2\"")); + assert!(!opencode.contains("acme/old")); + assert!(opencode.contains("acme2/old")); +} + +#[test] +fn selection_state_survives_save_of_unselected_group() { + let home = temp_home("state"); + let selected = group(GroupType::OpenCode, "selected"); + let (selected_saved, _) = groups::save_group(&home.paths, selected).unwrap(); + groups::switch_group(&home.paths, selected_saved.id).unwrap(); + + let other = group(GroupType::Slim, "other"); + let (_, config) = groups::save_group(&home.paths, other).unwrap(); + assert_eq!( + config.state, + AppSelectionState { + selected_group_id: Some(selected_saved.id), + selected_group_name: Some("selected".to_owned()), + } + ); +} diff --git a/src-tauri/tests/backups.rs b/src-tauri/tests/backups.rs deleted file mode 100644 index de700d8..0000000 --- a/src-tauri/tests/backups.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use omo_switch_tauri::core::backup::BackupRepository; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; - -static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); - -fn temp_root(label: &str) -> PathBuf { - let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("task-8-temp") - .join(format!("omo-switch-{label}-{}-{id}", std::process::id())); - let _ = fs::remove_dir_all(&path); - fs::create_dir_all(&path).expect("Given: temp root directory is created"); - path -} - -fn remove_temp(path: &Path) { - let _ = fs::remove_dir_all(path); -} - -fn instant(value: &str) -> OffsetDateTime { - OffsetDateTime::parse(value, &Rfc3339).expect("Given: RFC3339 fixture parses") -} - -#[test] -fn backups_when_source_exists_create_timestamped_target_specific_artifact() { - // Given: a target file has current bytes that must be protected before switching. - let root = temp_root("create"); - let source = root.join("opencode.json"); - fs::write(&source, "original opencode").expect("Given: source writes"); - let repository = - BackupRepository::new(root.join("omo-switch"), || instant("2023-11-14T22:13:20Z")); - - // When: Rust creates a backup for the OpenCode target. - let backup = repository - .create_backup("opencode", &source, b"original opencode") - .expect("When: backup creates"); - - // Then: the path and contents match established backup repository semantics. - assert_eq!(backup.target, "opencode"); - assert!(backup.file_path.ends_with("20231114T221320Z-opencode.json")); - assert_eq!( - fs::read_to_string(&backup.file_path).expect("Then: backup reads"), - "original opencode" - ); - println!("created backup: {}", backup.file_path.display()); - - remove_temp(&root); -} - -#[test] -fn backups_when_more_than_five_exist_cleanup_keeps_latest_five_by_filename() { - // Given: six dated backups exist for the same target. - let root = temp_root("cleanup"); - let repository = - BackupRepository::new(root.join("omo-switch"), || instant("2023-11-14T22:13:20Z")); - let source = root.join("oh-my-openagent.json"); - fs::write(&source, "source").expect("Given: source writes"); - for index in 0..6 { - let now = instant(&format!("2023-11-14T22:13:2{index}Z")); - let repo = BackupRepository::new(root.join("omo-switch"), move || now); - repo.create_backup( - "oh-my-openagent", - &source, - format!("backup-{index}").as_bytes(), - ) - .expect("Given: backup creates"); - } - - // When: cleanup runs with the established retention limit used by switching. - repository - .cleanup("oh-my-openagent", 5) - .expect("When: cleanup succeeds"); - - // Then: only the latest five backup filenames remain. - let backups = repository - .list_backups(Some("oh-my-openagent")) - .expect("Then: backups list"); - let names: Vec = backups - .iter() - .map(|backup| { - backup - .file_path - .file_name() - .expect("Then: backup has filename") - .to_string_lossy() - .into_owned() - }) - .collect(); - assert_eq!(backups.len(), 5); - assert!(names[0].starts_with("20231114T221325Z")); - assert!(names[4].starts_with("20231114T221321Z")); - - remove_temp(&root); -} diff --git a/src-tauri/tests/command_contract.rs b/src-tauri/tests/command_contract.rs deleted file mode 100644 index adef83c..0000000 --- a/src-tauri/tests/command_contract.rs +++ /dev/null @@ -1,198 +0,0 @@ -#[path = "common/command_contract_sets.rs"] -mod command_contract_sets; -#[path = "common/switching.rs"] -mod switching; - -use std::fs; -use std::path::Path; - -use command_contract_sets::{exact_set_mismatch, runtime_commands, BACKEND_COMMANDS}; -use omo_switch_tauri::application::{ApplicationError, GroupApplicationService}; -use omo_switch_tauri::commands::AppStateResponse; -use omo_switch_tauri::core::models::ModelGroup; -use omo_switch_tauri::core::switching::SwitchError; -use switching::{ - category_row, group, override_row, paths, remove_temp, seed_groups, seed_opencode, seed_state, - temp_home, -}; - -#[test] -fn commands_when_saving_duplicate_name_returns_stable_error() { - let home = temp_home("commands-duplicate-name"); - let first = dual_target_group(); - let second = group( - "55555555-5555-4555-8555-555555555555", - "Second", - true, - Vec::new(), - Vec::new(), - Vec::new(), - ); - seed_groups(&home, &[first.clone(), second.clone()]); - seed_state(&home, None, None); - let service = GroupApplicationService::for_home(&home).expect("Given: fake HOME resolves"); - let mut duplicate = second; - duplicate.name = first.name; - - let error = service - .save_group(duplicate) - .expect_err("When: duplicate group name is saved"); - - assert!(matches!(error, ApplicationError::DuplicateGroupName)); - remove_temp(&home); -} - -#[test] -fn commands_when_active_group_save_reapply_fails_returns_transaction_error() { - let home = temp_home("commands-active-save-reapply"); - let target = dual_target_group(); - seed_groups(&home, &[target.clone()]); - seed_state(&home, Some(target.id), Some(&target.name)); - seed_opencode(&home); - let target_paths = paths(&home); - fs::create_dir_all(target_paths.opencode_dir()).expect("Given: target directory exists"); - fs::write(target_paths.oh_my_openagent_file(), "{ malformed: ") - .expect("Given: active target is malformed"); - let service = GroupApplicationService::for_home(&home).expect("Given: fake HOME resolves"); - let mut edited = target; - edited.description = Some("Requires active reapply".to_owned()); - - let error = service - .save_group(edited) - .expect_err("When: active group save attempts transactional reapply"); - - assert!(matches!( - error, - ApplicationError::Switch(SwitchError::LoadOhMyConfigFailed) - )); - remove_temp(&home); -} - -#[test] -fn commands_when_serializing_app_state_does_not_expose_backend_paths() { - let home = temp_home("commands-owned-dto"); - seed_groups(&home, &[]); - seed_state(&home, None, None); - let service = GroupApplicationService::for_home(&home).expect("Given: fake HOME resolves"); - - let state = service - .load_app_state() - .expect("When: app state loads through application service"); - let serialized = serde_json::to_value(AppStateResponse { - groups: state.groups, - app_state: state.app_state, - discovered_open_code_agent_names: state.discovered_open_code_agent_names, - open_code_agent_discovery_error: state.open_code_agent_discovery_error, - }) - .expect("Then: command response serializes"); - - assert!(serialized.get("paths").is_none()); - remove_temp(&home); -} - -#[test] -fn commands_runtime_handler_matches_approved_command_set() { - let actual = runtime_commands(include_str!("../src/lib.rs")); - - if let Some(mismatch) = exact_set_mismatch("runtime handler", &actual, BACKEND_COMMANDS) { - panic!("{mismatch}"); - } -} - -#[test] -fn commands_exact_contract_rejects_unknown_and_missing_names() { - let actual = vec![ - "load_app_state".to_owned(), - "injected_unknown_command".to_owned(), - ]; - - let mismatch = exact_set_mismatch( - "mutation probe", - &actual, - &["load_app_state", "switch_group"], - ) - .expect("Then: an injected unknown command cannot produce a success result"); - - assert!(mismatch.contains("missing=[\"switch_group\"]")); - assert!(mismatch.contains("unexpected=[\"injected_unknown_command\"]")); -} - -#[test] -fn commands_use_tauri_defaults_without_application_acl_configuration() { - let tauri_root = Path::new(env!("CARGO_MANIFEST_DIR")); - let tauri_config: serde_json::Value = serde_json::from_str( - &fs::read_to_string(tauri_root.join("tauri.conf.json")) - .expect("Given: tauri.conf.json reads"), - ) - .expect("Given: tauri.conf.json is valid JSON"); - - assert_eq!( - tauri_config - .pointer("/app/windows") - .and_then(serde_json::Value::as_array) - .expect("tauri.conf.json must configure application windows") - .iter() - .filter_map(|window| window.get("label")) - .filter_map(serde_json::Value::as_str) - .collect::>(), - ["quick-switch", "settings"], - "tauri.conf.json must configure exactly the quick-switch and settings window labels" - ); - assert!( - tauri_config.pointer("/app/security/csp").is_some(), - "tauri.conf.json must retain its CSP" - ); - assert!( - tauri_config.pointer("/app/security/capabilities").is_none(), - "tauri.conf.json must not configure application capabilities" - ); - assert!( - configuration_files(&tauri_root.join("capabilities"), "json").is_empty(), - "application capability config files must not exist" - ); - assert!( - configuration_files(&tauri_root.join("permissions"), "toml").is_empty(), - "application permission manifest/config files must not exist" - ); -} - -fn configuration_files(directory: &Path, extension: &str) -> Vec { - if !directory.exists() { - return Vec::new(); - } - - fs::read_dir(directory) - .expect("Given: application ACL configuration directory reads") - .map(|entry| { - entry - .expect("Given: application ACL configuration entry reads") - .path() - }) - .flat_map(|path| { - if path.is_dir() { - configuration_files(&path, extension) - } else if path.extension().and_then(|value| value.to_str()) == Some(extension) { - vec![path] - } else { - Vec::new() - } - }) - .collect() -} - -fn dual_target_group() -> ModelGroup { - group( - "44444444-4444-4444-8444-444444444444", - "Dual Target", - true, - vec![category_row( - "unspecified-high", - "cliproxyapi/gpt-5.4-xhigh", - )], - Vec::new(), - vec![ - override_row("creative-ui-coder", "cliproxyapi/gpt-5.4"), - override_row("Jenny", "cliproxyapi/gpt-5.4-xhigh"), - ], - ) -} diff --git a/src-tauri/tests/commands.rs b/src-tauri/tests/commands.rs deleted file mode 100644 index 0866062..0000000 --- a/src-tauri/tests/commands.rs +++ /dev/null @@ -1,432 +0,0 @@ -#[path = "common/switching.rs"] -mod switching; - -mod core { - pub mod models { - pub use omo_switch_tauri::core::models::*; - } -} - -#[allow(dead_code)] -#[path = "../src/commands/types.rs"] -mod command_types; - -use std::fs; - -use omo_switch_tauri::application::{ - ApplicationError, GroupApplicationService, GroupSwitchOutcome, -}; -use omo_switch_tauri::commands::AppStateResponse; -use omo_switch_tauri::core::models::ModelGroup; -use omo_switch_tauri::core::switching::SwitchError; -use switching::{ - category_row, group, override_row, paths, remove_temp, seed_groups, seed_oh_my, seed_opencode, - seed_state, temp_home, -}; -use uuid::Uuid; - -#[test] -fn commands_when_loading_app_state_returns_groups_state_discovery_and_literal_config_paths() { - let home = temp_home("commands-load-state"); - let target = dual_target_group(); - seed_groups(&home, &[target.clone()]); - seed_state(&home, Some(target.id), Some("Dual Target")); - seed_oh_my(&home); - seed_opencode(&home); - - let backend = GroupApplicationService::for_home(&home) - .expect("Given: command backend resolves fake HOME"); - let state = backend.load_app_state().expect("When: app state loads"); - - assert_eq!(state.groups, vec![target]); - assert_eq!( - state.app_state.selected_group_name.as_deref(), - Some("Dual Target") - ); - assert!(state - .discovered_open_code_agent_names - .contains(&"Jenny".to_owned())); - assert_eq!(state.open_code_agent_discovery_error, None); - remove_temp(&home); -} - -#[test] -fn commands_when_groups_are_saved_copied_and_deleted_persist_typed_results() { - let home = temp_home("commands-group-crud"); - let target = dual_target_group(); - seed_groups(&home, &[target.clone()]); - seed_state(&home, Some(target.id), Some("Dual Target")); - seed_oh_my(&home); - seed_opencode(&home); - - let backend = GroupApplicationService::for_home(&home) - .expect("Given: command backend resolves fake HOME"); - let mut renamed = target.clone(); - renamed.name = "Renamed".to_owned(); - - let saved = backend - .save_group(renamed.clone()) - .expect("When: group saves"); - assert_eq!(saved.group.name, "Renamed"); - assert_eq!(saved.groups, vec![renamed.clone()]); - - let copied = backend.copy_group(renamed.id).expect("When: group copies"); - assert_eq!(copied.group.name, "Renamed Copy"); - assert_ne!(copied.group.id, renamed.id); - assert_eq!(copied.groups.len(), 2); - - let remaining = backend - .delete_group(renamed.id) - .expect("When: active group deletes"); - assert_eq!(remaining.groups, vec![copied.group]); - assert_eq!(remaining.app_state.selected_group_id, None); - - remove_temp(&home); -} - -#[test] -fn commands_when_copying_group_repeatedly_increments_the_copy_suffix() { - let home = temp_home("commands-copy-sequence"); - let source = group( - "88888888-8888-4888-8888-888888888888", - "Name", - true, - Vec::new(), - Vec::new(), - Vec::new(), - ); - seed_groups(&home, &[source.clone()]); - seed_state(&home, None, None); - let backend = GroupApplicationService::for_home(&home) - .expect("Given: command backend resolves fake HOME"); - - let first = backend - .copy_group(source.id) - .expect("When: the first group copy saves"); - let second = backend - .copy_group(source.id) - .expect("When: the second group copy saves"); - let third = backend - .copy_group(source.id) - .expect("When: the third group copy saves"); - - assert_eq!(first.group.name, "Name Copy"); - assert_eq!(second.group.name, "Name Copy 2"); - assert_eq!(third.group.name, "Name Copy 3"); - - remove_temp(&home); -} - -#[test] -fn commands_when_copying_group_treats_trimmed_ascii_case_insensitive_names_as_conflicts() { - let home = temp_home("commands-copy-normalized-conflicts"); - let source = group( - "99999999-9999-4999-8999-999999999999", - "Name", - true, - Vec::new(), - Vec::new(), - Vec::new(), - ); - let existing_copy = group( - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - " nAmE cOpY ", - true, - Vec::new(), - Vec::new(), - Vec::new(), - ); - let existing_second_copy = group( - "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - " NAME COPY 2 ", - true, - Vec::new(), - Vec::new(), - Vec::new(), - ); - seed_groups( - &home, - &[source.clone(), existing_copy, existing_second_copy], - ); - seed_state(&home, None, None); - let backend = GroupApplicationService::for_home(&home) - .expect("Given: command backend resolves fake HOME"); - - let copied = backend - .copy_group(source.id) - .expect("When: normalized copy-name conflicts exist"); - - assert_eq!(copied.group.name, "Name Copy 3"); - - remove_temp(&home); -} - -#[test] -fn commands_when_switching_missing_disabled_or_missing_opencode_returns_typed_errors() { - let home = temp_home("commands-switch-errors"); - let enabled = dual_target_group(); - let disabled = group( - "77777777-7777-4777-8777-777777777777", - "Disabled", - false, - vec![category_row( - "unspecified-high", - "cliproxyapi/gpt-5.4-xhigh", - )], - Vec::new(), - Vec::new(), - ); - seed_groups(&home, &[enabled.clone(), disabled.clone()]); - seed_state(&home, None, None); - seed_oh_my(&home); - - let backend = GroupApplicationService::for_home(&home) - .expect("Given: command backend resolves fake HOME"); - let missing = backend - .switch_group(Uuid::nil()) - .expect_err("When: missing group switch fails"); - assert!(matches!( - missing, - ApplicationError::Switch(SwitchError::GroupNotFound) - )); - - let disabled_error = backend - .switch_group(disabled.id) - .expect_err("When: disabled group switch fails"); - assert!(matches!( - disabled_error, - ApplicationError::Switch(SwitchError::GroupDisabled) - )); - - let missing_opencode = backend - .switch_group(enabled.id) - .expect_err("When: OpenCode-required switch lacks opencode.json"); - assert!(matches!( - missing_opencode, - ApplicationError::Switch(SwitchError::LoadOpenCodeConfigFailed) - )); - - remove_temp(&home); -} - -#[test] -fn commands_when_switching_valid_group_persists_state_and_warnings() { - let home = temp_home("commands-switch-success"); - let target = dual_target_group(); - seed_groups(&home, &[target.clone()]); - seed_state(&home, None, None); - seed_oh_my(&home); - seed_opencode(&home); - - let backend = GroupApplicationService::for_home(&home) - .expect("Given: command backend resolves fake HOME"); - let result = backend - .switch_group(target.id) - .expect("When: switch command runs"); - - assert_eq!(result.outcome, GroupSwitchOutcome::Success); - assert_eq!(result.app_state.selected_group_id, Some(target.id)); - assert!(result.warnings.is_empty()); - - remove_temp(&home); -} - -#[test] -fn commands_when_config_state_with_unknown_fields_is_loaded_and_group_switched_drops_them() { - let home = temp_home("commands-config-state-unknown-fields"); - let target = dual_target_group(); - seed_groups(&home, &[target.clone()]); - seed_oh_my(&home); - seed_opencode(&home); - let target_paths = paths(&home); - fs::write( - target_paths.config_file(), - r#"{ - "groups": [ - { - "id": "44444444-4444-4444-8444-444444444444", - "name": "Dual Target", - "description": null, - "categoryMappings": [{ "categoryName": "unspecified-high", "modelRef": "cliproxyapi/gpt-5.4-xhigh" }], - "agentOverrides": [], - "openCodeAgentOverrides": [{ "agentName": "creative-ui-coder", "modelRef": "cliproxyapi/gpt-5.4" }, { "agentName": "Jenny", "modelRef": "cliproxyapi/gpt-5.4-xhigh" }], - "isEnabled": true, - "updatedAt": "2023-11-14T22:13:20Z" - } - ], - "state": { - "selectedGroupID": "4f648c2a-1d48-44d6-b0c0-4d340816e1f3", - "selectedGroupName": "Primary", - "launchAtLoginEnabled": true, - "openCodeServeConfig": { - "port": 4096, - "hostname": "127.0.0.1", - "mdns": false, - "mdnsDomain": "opencode.local", - "cors": [], - "executablePath": null, - "autoStart": false - }, - "lastSuccessfulWrite": { - "target": "switch-group", - "wroteAt": "2023-11-14T22:21:40Z", - "backupPath": "legacy-backup.json" - }, - "lastWarningSummary": null, - "lastErrorSummary": null, - "migrationVersion": 2 - } -}"#, - ) - .expect("Given: config persists"); - let backend = GroupApplicationService::for_home(&home) - .expect("Given: command backend resolves fake HOME"); - - let loaded = backend - .load_app_state() - .expect("When: config app state loads"); - assert_eq!(loaded.groups, vec![target.clone()]); - let response = serde_json::to_value(AppStateResponse { - groups: loaded.groups, - app_state: loaded.app_state, - discovered_open_code_agent_names: loaded.discovered_open_code_agent_names, - open_code_agent_discovery_error: loaded.open_code_agent_discovery_error, - }) - .expect("Then: command response serializes"); - let response_state = response - .get("appState") - .expect("Then: command response includes app state"); - assert!(response_state.get("launchAtLoginEnabled").is_none()); - assert!(response_state.get("openCodeServeConfig").is_none()); - - let switched = backend - .switch_group(target.id) - .expect("When: group switch persists the retained state"); - assert_eq!(switched.outcome, GroupSwitchOutcome::Success); - let persisted_config: serde_json::Value = serde_json::from_str( - &fs::read_to_string(target_paths.config_file()).expect("Then: persisted config reads"), - ) - .expect("Then: persisted config JSON parses"); - let persisted_state = &persisted_config["state"]; - assert!(persisted_state.get("launchAtLoginEnabled").is_none()); - assert!(persisted_state.get("openCodeServeConfig").is_none()); - assert_eq!(persisted_state["selectedGroupID"], target.id.to_string()); - assert_eq!(persisted_state["selectedGroupName"], target.name); - - let reloaded = backend - .load_app_state() - .expect("Then: retained app state reloads"); - assert_eq!(reloaded.groups, vec![target.clone()]); - assert_eq!(reloaded.app_state.selected_group_id, Some(target.id)); - assert_eq!( - reloaded.app_state.selected_group_name.as_deref(), - Some(target.name.as_str()) - ); - - remove_temp(&home); -} - -#[test] -fn commands_when_discovering_opencode_handles_success_missing_and_malformed_config() { - let missing_home = temp_home("commands-discovery-missing"); - let missing_backend = - GroupApplicationService::for_home(&missing_home).expect("Given: missing backend resolves"); - let missing = discover_open_code_agents_response(&missing_backend, Vec::new()); - assert!(missing.agent_names.is_empty()); - assert_eq!(missing.error.as_deref(), Some("OpenCode config not found.")); - assert!(missing.presentation.is_read_only); - let missing_json = serde_json::to_value(&missing).expect("Then: response serializes as JSON"); - assert_eq!(missing_json["presentation"]["isReadOnly"], true); - assert_eq!( - missing_json["presentation"]["allowsCustomAgentCreation"], - false - ); - remove_temp(&missing_home); - - let malformed_home = temp_home("commands-discovery-malformed"); - let malformed_paths = paths(&malformed_home); - fs::create_dir_all(malformed_paths.opencode_dir()).expect("Given: opencode dir exists"); - fs::write(malformed_paths.opencode_file(), "{ agent: ") - .expect("Given: malformed config writes"); - let malformed_backend = - GroupApplicationService::for_home(&malformed_home).expect("Given: backend resolves"); - let malformed = discover_open_code_agents_response( - &malformed_backend, - vec![override_row("legacy", "model")], - ); - assert_eq!( - malformed.error.as_deref(), - Some("OpenCode config is malformed.") - ); - assert!(malformed.presentation.is_read_only); - assert_eq!( - malformed.presentation.preserved_overrides[0].agent_name, - "legacy" - ); - let malformed_json = - serde_json::to_value(&malformed).expect("Then: response serializes as JSON"); - assert_eq!( - malformed_json["presentation"]["preservedOverrides"][0]["agentName"], - "legacy" - ); - remove_temp(&malformed_home); - - let success_home = temp_home("commands-discovery-success"); - seed_opencode(&success_home); - let success_backend = - GroupApplicationService::for_home(&success_home).expect("Given: backend resolves"); - let success = discover_open_code_agents_response(&success_backend, Vec::new()); - assert_eq!(success.error, None); - assert!(success.agent_names.contains(&"Jenny".to_owned())); - assert!(!success.presentation.is_read_only); - assert!(success.presentation.stale_overrides.is_empty()); - assert!(!success.presentation.allows_custom_agent_creation); - assert!(success - .presentation - .discovered_rows - .iter() - .all(|row| row.is_editable)); - let success_json = serde_json::to_value(&success).expect("Then: response serializes as JSON"); - assert_eq!(success_json["presentation"]["isReadOnly"], false); - assert!(success_json["presentation"]["discoveredRows"] - .as_array() - .expect("Then: discovered rows serialize as an array") - .iter() - .all(|row| row["isEditable"] == true)); - remove_temp(&success_home); -} - -fn discover_open_code_agents_response( - application: &GroupApplicationService, - saved_overrides: Vec, -) -> command_types::DiscoverOpenCodeAgentsResponse { - let discovery = application.discover_open_code_agents(); - let presentation = command_types::open_code_agent_mapping_presentation( - &saved_overrides, - &discovery.agent_names, - discovery.error.as_deref(), - ); - - command_types::DiscoverOpenCodeAgentsResponse { - agent_names: discovery.agent_names, - error: discovery.error, - presentation, - } -} - -fn dual_target_group() -> ModelGroup { - group( - "44444444-4444-4444-8444-444444444444", - "Dual Target", - true, - vec![category_row( - "unspecified-high", - "cliproxyapi/gpt-5.4-xhigh", - )], - Vec::new(), - vec![ - override_row("creative-ui-coder", "cliproxyapi/gpt-5.4"), - override_row("Jenny", "cliproxyapi/gpt-5.4-xhigh"), - ], - ) -} diff --git a/src-tauri/tests/common/command_contract_sets.rs b/src-tauri/tests/common/command_contract_sets.rs deleted file mode 100644 index 4248750..0000000 --- a/src-tauri/tests/common/command_contract_sets.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::collections::BTreeSet; - -pub const BACKEND_COMMANDS: &[&str] = &[ - "copy_group", - "delete_group", - "discover_open_code_agents", - "load_app_state", - "save_group", - "switch_group", -]; - -pub fn runtime_commands(source: &str) -> Vec { - let section = source - .split_once("tauri::generate_handler![") - .and_then(|(_, suffix)| suffix.split_once("])")) - .map(|(section, _)| section) - .expect("Given: Tauri handler registration delimiters exist"); - - section - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(|line| line.trim_end_matches(',')) - .filter_map(|line| line.rsplit("::").next()) - .map(str::to_owned) - .collect() -} - -pub fn exact_set_mismatch(surface: &str, actual: &[String], expected: &[&str]) -> Option { - let actual_set: BTreeSet<&str> = actual.iter().map(String::as_str).collect(); - let expected_set: BTreeSet<&str> = expected.iter().copied().collect(); - let missing: Vec<&str> = expected_set.difference(&actual_set).copied().collect(); - let unexpected: Vec<&str> = actual_set.difference(&expected_set).copied().collect(); - if missing.is_empty() && unexpected.is_empty() { - None - } else { - Some(format!( - "{surface} command contract mismatch; missing={missing:?}; unexpected={unexpected:?}" - )) - } -} diff --git a/src-tauri/tests/common/switching.rs b/src-tauri/tests/common/switching.rs deleted file mode 100644 index 2d04a65..0000000 --- a/src-tauri/tests/common/switching.rs +++ /dev/null @@ -1,191 +0,0 @@ -#![allow(dead_code)] - -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use omo_switch_tauri::core::models::{ - AppSelectionState, ModelGroup, ModelGroupAgentOverride, ModelGroupCategoryMapping, -}; -use omo_switch_tauri::core::paths::{ConfigPaths, HomeEnv}; -use omo_switch_tauri::core::repository::{ - ConfigRepository, OhMyOpenAgentConfigRepository, OpenCodeConfigRepository, -}; -use omo_switch_tauri::core::switching::SwitchOutcome; -use omo_switch_tauri::core::switching::{SwitchGroupRepositories, SwitchGroupUseCase}; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; -use uuid::Uuid; - -static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); - -pub fn temp_home(label: &str) -> PathBuf { - let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("task-8-temp") - .join(format!("omo-switch-{label}-{}-{id}", std::process::id())); - let _ = fs::remove_dir_all(&path); - fs::create_dir_all(&path).expect("Given: temp home directory is created"); - path -} - -pub fn remove_temp(path: &Path) { - let _ = fs::remove_dir_all(path); -} - -pub fn paths(home: &Path) -> ConfigPaths { - ConfigPaths::from_home_env(HomeEnv::new(Some(home), None)).expect("Given: HOME path resolves") -} - -pub fn category_row(category_name: &str, model_ref: &str) -> ModelGroupCategoryMapping { - ModelGroupCategoryMapping { - category_name: category_name.to_owned(), - model_ref: model_ref.to_owned(), - } -} - -pub fn override_row(agent_name: &str, model_ref: &str) -> ModelGroupAgentOverride { - ModelGroupAgentOverride { - agent_name: agent_name.to_owned(), - model_ref: model_ref.to_owned(), - } -} - -pub fn group( - id: &str, - name: &str, - is_enabled: bool, - category_mappings: Vec, - agent_overrides: Vec, - open_code_agent_overrides: Vec, -) -> ModelGroup { - ModelGroup { - id: Uuid::parse_str(id).expect("Given: UUID fixture parses"), - name: name.to_owned(), - description: None, - category_mappings, - agent_overrides, - open_code_agent_overrides, - is_enabled, - updated_at: instant("2023-11-14T22:13:20Z"), - } -} - -pub fn use_case(home: &Path) -> SwitchGroupUseCase OffsetDateTime> { - let resolved = paths(home); - let repositories = SwitchGroupRepositories { - config: ConfigRepository::new(resolved.config_file()), - backups_root: resolved.omo_switch_dir(), - opencode: OpenCodeConfigRepository::new(resolved.opencode_file()), - oh_my_openagent: OhMyOpenAgentConfigRepository::new(resolved.oh_my_openagent_file()), - }; - SwitchGroupUseCase::new(repositories, fixed_now) -} - -pub fn seed_groups(home: &Path, groups: &[ModelGroup]) { - let repo = ConfigRepository::new(paths(home).config_file()); - let mut config = repo.load().expect("Given: config loads"); - config.groups = groups.to_vec(); - repo.save(&config).expect("Given: groups save"); -} - -pub fn seed_state(home: &Path, selected_group_id: Option, selected_group_name: Option<&str>) { - let repo = ConfigRepository::new(paths(home).config_file()); - let mut config = repo.load().expect("Given: config loads"); - config.state = AppSelectionState { - selected_group_id, - selected_group_name: selected_group_name.map(str::to_owned), - ..AppSelectionState::default() - }; - repo.save(&config).expect("Given: app state saves"); -} - -pub fn seed_oh_my(home: &Path) { - let resolved = paths(home); - fs::create_dir_all(resolved.opencode_dir()).expect("Given: opencode dir exists"); - fs::write( - resolved.oh_my_openagent_file(), - fixture("ohmy/current-oh-my-openagent.json"), - ) - .expect("Given: Oh My fixture writes"); -} - -pub fn seed_opencode(home: &Path) { - let resolved = paths(home); - fs::create_dir_all(resolved.opencode_dir()).expect("Given: opencode dir exists"); - fs::write( - resolved.opencode_file(), - fixture("opencode/current-opencode.json"), - ) - .expect("Given: OpenCode fixture writes"); -} - -pub fn assert_dual_target_success(home: &Path, target_group: &ModelGroup, outcome: SwitchOutcome) { - let resolved = paths(home); - let oh_my = OhMyOpenAgentConfigRepository::new(resolved.oh_my_openagent_file()) - .load() - .expect("Then: Oh My loads"); - let opencode = OpenCodeConfigRepository::new(resolved.opencode_file()) - .load() - .expect("Then: OpenCode loads"); - assert!(matches!(outcome, SwitchOutcome::Success { .. })); - assert_eq!( - oh_my.agents()["oracle"]["model"], - "cliproxyapi/gpt-5.4-xhigh" - ); - assert_eq!( - oh_my.categories()["unspecified-high"]["model"], - "cliproxyapi/gpt-5.4-xhigh" - ); - assert_eq!( - opencode.agents()["creative-ui-coder"]["model"], - "cliproxyapi/gpt-5.4" - ); - assert_eq!( - opencode.agents()["Jenny"]["model"], - "cliproxyapi/gpt-5.4-xhigh" - ); - assert!(opencode.raw().get("$schema").is_some()); - assert!(opencode.raw().get("plugin").is_some()); - assert!(opencode.raw().get("provider").is_some()); - assert!(opencode.agents().get("karen").is_some()); - let state = ConfigRepository::new(resolved.config_file()) - .load() - .expect("Then: config loads") - .state; - assert_eq!(state.selected_group_id, Some(target_group.id)); - assert_eq!(state.selected_group_name.as_deref(), Some("Dual Target")); - assert_eq!( - state - .last_successful_write - .as_ref() - .map(|write| write.target.as_str()), - Some("switch-group") - ); - assert!(state - .last_successful_write - .as_ref() - .and_then(|write| write.backup_path.as_ref()) - .is_some_and( - |summary| summary.contains("opencode:") && summary.contains("oh-my-openagent:") - )); -} - -pub fn instant(value: &str) -> OffsetDateTime { - OffsetDateTime::parse(value, &Rfc3339).expect("Given: RFC3339 fixture parses") -} - -fn fixed_now() -> OffsetDateTime { - instant("2023-11-14T22:21:40Z") -} - -fn fixture(relative_path: &str) -> String { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("Given: src-tauri has workspace parent") - .join("fixtures") - .join("compatibility") - .join(relative_path); - fs::read_to_string(path).expect("Given: fixture reads") -} diff --git a/src-tauri/tests/compatibility_fixtures.rs b/src-tauri/tests/compatibility_fixtures.rs deleted file mode 100644 index 94dafbe..0000000 --- a/src-tauri/tests/compatibility_fixtures.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use omo_switch_tauri::core::backup::BackupRepository; -use omo_switch_tauri::core::document::{OhMyOpenAgentDocument, OpenCodeDocument}; -use omo_switch_tauri::core::models::OmoSwitchConfig; -use omo_switch_tauri::core::paths::{ConfigPaths, HomeEnv}; -use omo_switch_tauri::core::repository::ConfigRepository; -use omo_switch_tauri::core::switching::recover_pending_transaction; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; - -fn fixture(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("Given: src-tauri has workspace parent") - .join("fixtures") - .join("compatibility") - .join(relative_path) -} - -fn fixture_text(relative_path: &str) -> String { - fs::read_to_string(fixture(relative_path)).expect("Given: compatibility fixture reads") -} - -fn fixed_now() -> OffsetDateTime { - OffsetDateTime::parse("2023-11-14T22:21:40Z", &Rfc3339) - .expect("Given: fixed fixture timestamp parses") -} - -#[test] -fn compatibility_fixtures_when_config_schema_loads_pin_current_defaults() { - // Given: a current language-neutral single-file config payload. - let config: OmoSwitchConfig = - serde_json::from_str(&fixture_text("config/current.json")).expect("Given: config parses"); - - // Then: UUID/date encodings and defaults remain compatible. - let current_group = &config.groups[0]; - assert_eq!(current_group.name, "Primary"); - assert_eq!( - current_group.updated_at, - OffsetDateTime::parse("2023-11-14T22:13:20Z", &Rfc3339) - .expect("Then: group timestamp parses") - ); - assert_eq!(config.state.selected_group_name.as_deref(), Some("Primary")); - assert!( - serde_json::from_str::(&fixture_text("config/malformed.json")).is_err() - ); -} - -#[test] -fn compatibility_fixtures_when_temp_home_is_resolved_pin_literal_config_paths() { - // Given: a full fixture home and a conditional home without opencode.json. - let full_home = fixture("temp-homes/full"); - let conditional_home = fixture("temp-homes/conditional-missing-opencode"); - - // When: current path and repository seams load those homes. - let full_paths = ConfigPaths::from_home_env(HomeEnv::new(Some(&full_home), None)) - .expect("When: full HOME resolves"); - let conditional_paths = ConfigPaths::from_home_env(HomeEnv::new(Some(&conditional_home), None)) - .expect("When: conditional HOME resolves"); - let config = ConfigRepository::new(full_paths.config_file()) - .load() - .expect("When: config loads"); - - // Then: paths stay under HOME/.config and conditional OpenCode absence is represented on disk. - assert!(config.groups.is_empty()); - assert_eq!(config.state.migration_version, 2); - assert!(full_paths.opencode_file().exists()); - assert!(conditional_paths.oh_my_openagent_file().exists()); - assert!(!conditional_paths.opencode_file().exists()); - assert!(full_paths - .config_file() - .ends_with(".config/omo-switch/config.json")); -} - -#[test] -fn compatibility_fixtures_when_jsonc_is_parsed_pin_structural_projection_inputs() { - // Given: comments, trailing commas, unrelated keys, and malformed target fixtures. - let oh_my = OhMyOpenAgentDocument::parse_jsonc(&fixture_text("targets/oh-my-openagent.jsonc")) - .expect("When: Oh My JSONC parses"); - let opencode = OpenCodeDocument::parse_jsonc(&fixture_text("targets/opencode.jsonc")) - .expect("When: OpenCode JSONC parses"); - - // When: malformed variants are parsed through the same document boundaries. - let malformed_oh_my = OhMyOpenAgentDocument::parse_jsonc(&fixture_text( - "targets/malformed-oh-my-openagent.jsonc", - )); - let malformed_opencode = - OpenCodeDocument::parse_jsonc(&fixture_text("targets/malformed-opencode.jsonc")); - let trailing_comma = OhMyOpenAgentDocument::parse_jsonc(&fixture_text( - "targets/trailing-comma-oh-my-openagent.jsonc", - )); - - // Then: valid structure is preserved and malformed inputs are rejected before writes. - assert_eq!(oh_my.agents()["oracle"]["variant"], "xhigh"); - assert_eq!(oh_my.raw()["unrelated"]["preserve"], true); - assert_eq!(opencode.agents()["reviewer"]["mode"], "primary"); - assert!(opencode.raw().get("provider").is_some()); - assert!(malformed_oh_my.is_err()); - assert!(malformed_opencode.is_err()); - assert!(trailing_comma.is_err()); -} - -#[test] -fn compatibility_fixtures_when_backup_directory_is_loaded_pin_retention_order() { - // Given: six deterministic target-specific backup files. - let fixture_root = fixture(""); - let repository = BackupRepository::new(fixture_root, fixed_now); - - // When: current backup metadata listing reads the committed directory. - let backups = repository - .list_backups(Some("oh-my-openagent")) - .expect("When: backup fixtures list"); - - // Then: metadata is newest-first and exposes all pre-cleanup retention inputs. - assert_eq!(backups.len(), 6); - assert!(backups[0] - .file_path - .ends_with("20231114T221325Z-oh-my-openagent.json")); - assert!(backups[5] - .file_path - .ends_with("20231114T221320Z-oh-my-openagent.json")); -} - -#[test] -fn transaction_guarantee_when_stale_artifacts_exist_recovers_on_startup() { - let home = fixture("temp-homes/stale-transaction"); - let copy = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/task-7-fixture-stale"); - let _ = fs::remove_dir_all(©); - copy_tree(&home, ©); - let root = copy.join(".config/omo-switch"); - let config_before = fs::read(root.join("config.json")).expect("Given: config fixture reads"); - let stale_paths = [ - root.join(".config.json.omo-txn-11111111-1111-1111-1111-111111111111.staged"), - root.join(".transaction-journal.json.tmp"), - ]; - assert!(stale_paths.iter().all(|path| path.exists())); - - recover_pending_transaction(&root).expect("When: stale artifacts recover on startup"); - - assert!(stale_paths.iter().all(|path| !path.exists())); - assert_eq!( - fs::read(root.join("config.json")).expect("Then: config fixture reads"), - config_before - ); - let _ = fs::remove_dir_all(copy); -} - -fn copy_tree(source: &Path, destination: &Path) { - fs::create_dir_all(destination).expect("Given: fixture copy directory creates"); - for entry in fs::read_dir(source).expect("Given: fixture directory reads") { - let entry = entry.expect("Given: fixture entry reads"); - let target = destination.join(entry.file_name()); - if entry.path().is_dir() { - copy_tree(&entry.path(), &target); - } else { - fs::copy(entry.path(), target).expect("Given: fixture file copies"); - } - } -} diff --git a/src-tauri/tests/config_paths.rs b/src-tauri/tests/config_paths.rs deleted file mode 100644 index ddb6fc3..0000000 --- a/src-tauri/tests/config_paths.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use omo_switch_tauri::core::paths::{ConfigPaths, HomeEnv}; - -static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); - -fn temp_home(label: &str) -> PathBuf { - let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("task-6-temp") - .join(format!("omo-switch-{label}-{}-{id}", std::process::id())); - let _ = fs::remove_dir_all(&path); - fs::create_dir_all(&path).expect("Given: temp home directory is created"); - path -} - -fn remove_temp(path: &Path) { - let _ = fs::remove_dir_all(path); -} - -#[test] -fn config_paths_when_home_is_available_resolve_exact_dot_config_locations() { - // Given: a fake HOME directory controls path resolution. - let home = temp_home("home-paths"); - - // When: committed config paths are resolved. - let paths = ConfigPaths::from_home_env(HomeEnv::new(Some(home.as_path()), None)) - .expect("When: HOME path should resolve"); - println!("HOME config root: {}", home.display()); - println!("config.json path: {}", paths.config_file().display()); - println!( - "oh-my-openagent.json path: {}", - paths.oh_my_openagent_file().display() - ); - println!("opencode.json path: {}", paths.opencode_file().display()); - - // Then: the backend preserves the documented ~/.config contract exactly. - assert_eq!( - paths.omo_switch_dir(), - home.join(".config").join("omo-switch") - ); - assert_eq!( - paths.config_file(), - home.join(".config").join("omo-switch").join("config.json") - ); - assert_eq!( - paths.oh_my_openagent_file(), - home.join(".config") - .join("opencode") - .join("oh-my-openagent.json") - ); - assert_eq!( - paths.opencode_file(), - home.join(".config").join("opencode").join("opencode.json") - ); - - remove_temp(&home); -} - -#[test] -fn config_paths_when_home_is_missing_use_userprofile_without_app_data_migration() { - // Given: Windows-style fake USERPROFILE is the only home signal. - let userprofile = temp_home("userprofile-paths"); - - // When: committed config paths are resolved. - let paths = ConfigPaths::from_home_env(HomeEnv::new(None, Some(userprofile.as_path()))) - .expect("When: USERPROFILE path should resolve"); - println!("USERPROFILE config root: {}", userprofile.display()); - println!( - "USERPROFILE config.json path: {}", - paths.config_file().display() - ); - - // Then: even on Windows the app keeps literal ~/.config paths, not APPDATA. - assert_eq!( - paths.config_file(), - userprofile - .join(".config") - .join("omo-switch") - .join("config.json") - ); - assert_eq!( - paths.oh_my_openagent_file(), - userprofile - .join(".config") - .join("opencode") - .join("oh-my-openagent.json") - ); - assert_eq!( - paths.opencode_file(), - userprofile - .join(".config") - .join("opencode") - .join("opencode.json") - ); - assert!(!paths.config_file().to_string_lossy().contains("AppData")); - - remove_temp(&userprofile); -} - -#[test] -fn source_when_committed_config_paths_are_authored_does_not_use_tauri_app_data_helpers() { - // Given: committed config files must never move into Tauri app-data directories. - let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut checked_files = Vec::new(); - for entry in fs::read_dir(source_root).expect("Given: src dir reads") { - let entry = entry.expect("Given: src entry reads"); - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) == Some("rs") { - checked_files.push(path); - } - } - - // When: authored Rust source is scanned for app-data routing helpers. - let forbidden = [ - "app_data", - "appData", - "app_config", - "BaseDirectory::App", - "app_local_data_dir", - ]; - - // Then: no committed config path implementation routes through platform app data. - for file in checked_files { - let source = fs::read_to_string(&file).expect("When: source file reads"); - for token in forbidden { - assert!( - !source.contains(token), - "forbidden token {token} found in {}", - file.display() - ); - } - } -} diff --git a/src-tauri/tests/config_repository.rs b/src-tauri/tests/config_repository.rs deleted file mode 100644 index b27aa54..0000000 --- a/src-tauri/tests/config_repository.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use omo_switch_tauri::core::document::{OhMyOpenAgentDocument, OpenCodeDocument}; -use omo_switch_tauri::core::paths::{ConfigPaths, HomeEnv}; -use omo_switch_tauri::core::repository::{OhMyOpenAgentConfigRepository, OpenCodeConfigRepository}; - -static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); - -fn temp_home(label: &str) -> PathBuf { - let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("task-7-temp") - .join(format!("omo-switch-{label}-{}-{id}", std::process::id())); - let _ = fs::remove_dir_all(&path); - fs::create_dir_all(&path).expect("Given: temp home directory is created"); - path -} - -fn remove_temp(path: &Path) { - let _ = fs::remove_dir_all(path); -} - -fn config_paths(home: &Path) -> ConfigPaths { - ConfigPaths::from_home_env(HomeEnv::new(Some(home), None)).expect("Given: HOME path resolves") -} - -fn fixture(relative_path: &str) -> String { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("Given: src-tauri has workspace parent") - .join("fixtures") - .join("compatibility") - .join(relative_path); - fs::read_to_string(path).expect("Given: fixture reads") -} - -#[test] -fn config_repository_when_oh_my_config_is_missing_bootstraps_valid_empty_document() { - let home = temp_home("oh-my-missing"); - let paths = config_paths(&home); - let repository = OhMyOpenAgentConfigRepository::new(paths.oh_my_openagent_file()); - - let document = repository.load().expect("When: missing Oh My config loads"); - - assert_eq!(document, OhMyOpenAgentDocument::bootstrap()); - assert!(document.agents().is_empty()); - assert!(document.categories().is_empty()); - println!( - "missing Oh My bootstrap path: {}", - paths.oh_my_openagent_file().display() - ); - - remove_temp(&home); -} - -#[test] -fn config_repository_when_opencode_config_is_missing_returns_missing_file_error() { - let home = temp_home("opencode-missing"); - let paths = config_paths(&home); - let repository = OpenCodeConfigRepository::new(paths.opencode_file()); - - let error = repository - .load() - .expect_err("When: missing OpenCode config fails"); - - assert_eq!(error.code().as_str(), "fileNotFound"); - println!("missing OpenCode path: {}", paths.opencode_file().display()); - - remove_temp(&home); -} - -#[test] -fn config_repository_when_jsonc_config_exists_loads_comments_for_both_targets() { - let home = temp_home("jsonc-load"); - let paths = config_paths(&home); - fs::create_dir_all(paths.opencode_dir()).expect("Given: opencode dir exists"); - fs::write( - paths.oh_my_openagent_file(), - fixture("json/with-jsonc-markers.jsonc"), - ) - .expect("Given: Oh My JSONC fixture writes"); - fs::write( - paths.opencode_file(), - r#"{ - // schema - "$schema": "https://opencode.ai/config.json", - /* agents */ - "agent": { "reviewer": { "model": "provider/model/ref" } }, - "provider": { "cliproxyapi": { "name": "CLIProxyAPI" } } -}"#, - ) - .expect("Given: OpenCode JSONC fixture writes"); - let oh_my_repository = OhMyOpenAgentConfigRepository::new(paths.oh_my_openagent_file()); - let opencode_repository = OpenCodeConfigRepository::new(paths.opencode_file()); - - let oh_my = oh_my_repository.load().expect("When: Oh My JSONC loads"); - let opencode = opencode_repository - .load() - .expect("When: OpenCode JSONC loads"); - - assert!(oh_my.agents().contains_key("test")); - assert!(oh_my.categories().contains_key("test")); - assert!(opencode.agents().contains_key("reviewer")); - - remove_temp(&home); -} - -#[test] -fn config_repository_when_config_is_malformed_returns_target_malformed_error_without_writing() { - let home = temp_home("malformed"); - let paths = config_paths(&home); - fs::create_dir_all(paths.opencode_dir()).expect("Given: opencode dir exists"); - fs::write(paths.oh_my_openagent_file(), "{ // malformed").expect("Given: Oh My writes"); - fs::write(paths.opencode_file(), "NOT VALID JSON {{{").expect("Given: OpenCode writes"); - let oh_my_repository = OhMyOpenAgentConfigRepository::new(paths.oh_my_openagent_file()); - let opencode_repository = OpenCodeConfigRepository::new(paths.opencode_file()); - - let oh_my_error = oh_my_repository - .load() - .expect_err("When: malformed Oh My fails"); - let opencode_error = opencode_repository - .load() - .expect_err("When: malformed OpenCode fails"); - - assert_eq!(oh_my_error.code().as_str(), "malformedConfig"); - assert_eq!(opencode_error.code().as_str(), "malformedConfig"); - assert_eq!( - fs::read_to_string(paths.oh_my_openagent_file()).expect("Then: Oh My file still reads"), - "{ // malformed" - ); - - remove_temp(&home); -} - -#[test] -fn config_repository_when_saving_documents_preserves_unrelated_keys_and_reparsable_output() { - let home = temp_home("save-round-trip"); - let paths = config_paths(&home); - let oh_my_repository = OhMyOpenAgentConfigRepository::new(paths.oh_my_openagent_file()); - let opencode_repository = OpenCodeConfigRepository::new(paths.opencode_file()); - let oh_my = OhMyOpenAgentDocument::parse_jsonc(&fixture("json/with-unknown-fields.json")) - .expect("Given: Oh My unknown-field fixture parses"); - let opencode = OpenCodeDocument::parse_jsonc(&fixture("opencode/current-opencode.json")) - .expect("Given: OpenCode fixture parses"); - - fs::create_dir_all(paths.opencode_dir()).expect("Given: target config directory exists"); - fs::write(paths.oh_my_openagent_file(), br#"{"legacy":true}"#) - .expect("Given: existing Oh My bytes persist"); - fs::write(paths.opencode_file(), br#"{"legacy":true}"#) - .expect("Given: existing OpenCode bytes persist"); - oh_my_repository.save(&oh_my).expect("When: Oh My saves"); - opencode_repository - .save(&opencode) - .expect("When: OpenCode saves"); - let oh_my_round_trip = oh_my_repository.load().expect("Then: Oh My reloads"); - let opencode_round_trip = opencode_repository.load().expect("Then: OpenCode reloads"); - - assert_eq!( - oh_my_round_trip.raw()["unknownField"], - "should be preserved" - ); - assert_eq!(oh_my_round_trip.raw()["someExtraData"]["key"], "value"); - assert!(opencode_round_trip.raw().get("plugin").is_some()); - assert!(opencode_round_trip.raw().get("provider").is_some()); - assert!(opencode_round_trip.agents().contains_key("karen")); - assert!(!fs::read_to_string(paths.oh_my_openagent_file()) - .expect("Then: Oh My replacement bytes read") - .contains("legacy")); - assert!(!fs::read_to_string(paths.opencode_file()) - .expect("Then: OpenCode replacement bytes read") - .contains("legacy")); - - remove_temp(&home); -} diff --git a/src-tauri/tests/jsonc.rs b/src-tauri/tests/jsonc.rs deleted file mode 100644 index b1b96c4..0000000 --- a/src-tauri/tests/jsonc.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::fs; -use std::path::Path; - -use omo_switch_tauri::core::document::{OhMyOpenAgentDocument, OpenCodeDocument}; -use omo_switch_tauri::core::jsonc::{parse_jsonc_object, JsoncError}; - -fn fixture(relative_path: &str) -> String { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("Given: src-tauri has workspace parent") - .join("fixtures") - .join("compatibility") - .join(relative_path); - fs::read_to_string(path).expect("Given: fixture reads") -} - -#[test] -fn jsonc_when_comments_and_block_comments_are_present_parses_object() { - let jsonc = fixture("json/with-jsonc-markers.jsonc"); - - let value = parse_jsonc_object(&jsonc).expect("When: JSONC object parses"); - - assert!(value.get("provider").is_some()); - assert!(value.get("agents").is_some()); - assert!(value.get("categories").is_some()); -} - -#[test] -fn jsonc_when_comment_like_content_is_inside_strings_preserves_string_values() { - let jsonc = r#"{ - "url": "https://example.invalid/path", - "line": "a // b", - "block": "/* not a comment */" -}"#; - - let value = parse_jsonc_object(jsonc).expect("When: JSONC object parses"); - - assert_eq!(value["url"], "https://example.invalid/path"); - assert_eq!(value["line"], "a // b"); - assert_eq!(value["block"], "/* not a comment */"); -} - -#[test] -fn jsonc_when_input_is_malformed_returns_malformed_error() { - let jsonc = "{ // comment without closing brace"; - - let error = parse_jsonc_object(jsonc).expect_err("When: malformed JSONC fails"); - - assert_eq!(error, JsoncError::MalformedJson); -} - -#[test] -fn jsonc_when_top_level_is_not_object_returns_malformed_error() { - let jsonc = "[1, 2, 3]"; - - let error = parse_jsonc_object(jsonc).expect_err("When: non-object JSONC fails"); - - assert_eq!(error, JsoncError::MalformedJson); -} - -#[test] -fn jsonc_document_when_unknown_fields_round_trip_preserves_unrelated_top_level_data() { - let json = fixture("json/with-unknown-fields.json"); - - let document = OhMyOpenAgentDocument::parse_jsonc(&json).expect("When: document parses"); - let serialized = document.serialize().expect("When: document serializes"); - let reparsed = OhMyOpenAgentDocument::parse_jsonc(&serialized).expect("When: output reparses"); - - assert_eq!(reparsed.raw()["knownField"], "value"); - assert_eq!(reparsed.raw()["unknownField"], "should be preserved"); - assert_eq!(reparsed.raw()["_customMarker"], "保留的未知字段"); - assert_eq!(reparsed.raw()["someExtraData"]["numbers"][2], 3); -} - -#[test] -fn jsonc_document_when_canonical_save_output_is_written_reparses_for_both_targets() { - let oh_my = OhMyOpenAgentDocument::parse_jsonc(&fixture("ohmy/current-oh-my-openagent.json")) - .expect("Given: Oh My fixture parses"); - let opencode = OpenCodeDocument::parse_jsonc(&fixture("opencode/current-opencode.json")) - .expect("Given: OpenCode fixture parses"); - - let oh_my_serialized = oh_my.serialize().expect("When: Oh My serializes"); - let opencode_serialized = opencode.serialize().expect("When: OpenCode serializes"); - - let oh_my_round_trip = - OhMyOpenAgentDocument::parse_jsonc(&oh_my_serialized).expect("Then: Oh My output reparses"); - let opencode_round_trip = OpenCodeDocument::parse_jsonc(&opencode_serialized) - .expect("Then: OpenCode output reparses"); - assert!(oh_my_round_trip.agents().contains_key("oracle")); - assert!(oh_my_round_trip.categories().contains_key("quick")); - assert!(opencode_round_trip.agents().contains_key("karen")); - assert!(opencode_round_trip.raw().get("provider").is_some()); - assert!(!opencode_serialized.contains("\\/")); -} diff --git a/src-tauri/tests/legacy_path_contract.rs b/src-tauri/tests/legacy_path_contract.rs deleted file mode 100644 index cc1bfa3..0000000 --- a/src-tauri/tests/legacy_path_contract.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; - -struct SourceRoot { - path: PathBuf, - required: bool, -} - -#[test] -fn direct_cutover_when_product_and_test_sources_are_scanned_has_no_legacy_test_path() { - let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("Given: src-tauri has workspace parent"); - let legacy_path = ["OMO", "Switch", "Tests"].concat(); - let source_roots = [ - SourceRoot { - path: workspace.join("src-tauri/src"), - required: true, - }, - SourceRoot { - path: workspace.join("src-tauri/tests"), - required: true, - }, - SourceRoot { - path: workspace.join("src"), - required: true, - }, - SourceRoot { - path: workspace.join("e2e"), - required: false, - }, - ]; - - let stale_paths = stale_paths(&source_roots, &legacy_path) - .expect("When: present product or test sources are scanned"); - - assert!( - stale_paths.is_empty(), - "Then: direct cutover has no stale legacy test paths: {stale_paths:?}" - ); - assert!( - !workspace.join(&legacy_path).exists(), - "Then: direct cutover does not restore the retired test directory" - ); -} - -fn stale_paths(source_roots: &[SourceRoot], legacy_path: &str) -> io::Result> { - let mut stale_paths = Vec::new(); - for root in source_roots { - for path in source_files(&root.path, root.required)? { - if fs::read_to_string(&path)?.contains(legacy_path) { - stale_paths.push(path); - } - } - } - Ok(stale_paths) -} - -fn source_files(root: &Path, required: bool) -> io::Result> { - if !root.exists() { - return if required { - Err(io::Error::new( - io::ErrorKind::NotFound, - format!("required source root does not exist: {}", root.display()), - )) - } else { - Ok(Vec::new()) - }; - } - - let mut pending = vec![root.to_path_buf()]; - let mut files = Vec::new(); - - while let Some(path) = pending.pop() { - for entry in fs::read_dir(path)? { - let entry = entry?; - let entry_path = entry.path(); - if entry_path.is_dir() { - pending.push(entry_path); - } else if matches!( - entry_path - .extension() - .and_then(|extension| extension.to_str()), - Some("rs" | "ts" | "tsx" | "js" | "mjs" | "cjs" | "svelte") - ) { - files.push(entry_path); - } - } - } - - Ok(files) -} - -#[test] -fn source_files_when_optional_root_is_absent_skips_it() { - let missing_root = std::env::temp_dir().join(format!( - "omo-switch-missing-optional-root-{}", - uuid::Uuid::new_v4() - )); - - let files = source_files(&missing_root, false).expect("When: optional root is scanned"); - - assert!(files.is_empty(), "Then: absent optional root is skipped"); -} - -#[test] -fn source_files_when_required_root_is_absent_returns_not_found() { - let missing_root = std::env::temp_dir().join(format!( - "omo-switch-missing-required-root-{}", - uuid::Uuid::new_v4() - )); - - let error = source_files(&missing_root, true).expect_err("When: required root is scanned"); - - assert_eq!( - error.kind(), - io::ErrorKind::NotFound, - "Then: absent required root fails validation" - ); -} - -#[test] -fn stale_paths_when_retired_path_is_injected_reports_existing_source() { - let root = std::env::temp_dir().join(format!( - "omo-switch-legacy-path-contract-{}", - uuid::Uuid::new_v4() - )); - fs::create_dir_all(&root).expect("Given: scanned source root exists"); - let source = root.join("injected.rs"); - let legacy_path = ["OMO", "Switch", "Tests"].concat(); - fs::write(&source, format!("// {legacy_path}")) - .expect("Given: retired path reference is injected"); - let source_roots = [SourceRoot { - path: root.clone(), - required: true, - }]; - - let paths = stale_paths(&source_roots, &legacy_path).expect("When: source root is scanned"); - - assert_eq!(paths, [source], "Then: injected retired path is reported"); - fs::remove_dir_all(root).expect("Then: injected source fixture is removed"); -} diff --git a/src-tauri/tests/native_runtime_gaps.rs b/src-tauri/tests/native_runtime_gaps.rs deleted file mode 100644 index 4ae475f..0000000 --- a/src-tauri/tests/native_runtime_gaps.rs +++ /dev/null @@ -1,190 +0,0 @@ -use std::fs; - -#[test] -fn quick_switch_when_reopened_restores_and_focuses_in_required_order() { - let source = app_shell_source(); - let restore_window = function_body(&source, "fn restore_window"); - - assert_in_order( - restore_window, - &["unminimize", "move_window", "show()", "set_focus()"], - ); -} - -#[test] -fn close_request_when_not_explicit_quit_hides_to_tray() { - let source = app_shell_source(); - - assert!( - source.contains("on_window_event") && source.contains("CloseRequested"), - "native shell has no close-request interception" - ); - assert!( - source.contains("prevent_close") && source.contains("hide()"), - "close requests are not distinguished from explicit quit" - ); -} - -#[test] -fn second_instance_when_activated_restores_the_settings_main_window() { - let source = lib_source(); - let callback = function_body(&source, "tauri_plugin_single_instance::init") - .split_once("}))") - .map(|(body, _)| body) - .expect("Given: second-instance callback ends"); - - assert!(callback.contains("OpenSettings")); - assert!( - !callback.contains("ShowQuickSwitch"), - "second-instance activation must not divert to the quick-switch shortcut" - ); - assert!( - callback.contains("dispatch_shell_action"), - "second-instance callback does not use the unified shell controller" - ); - assert!( - !callback.contains("WebviewWindowBuilder") && !callback.contains("create_window"), - "second-instance callback must not create a duplicate settings window" - ); -} - -#[test] -fn settings_main_window_starts_visible_and_missing_instances_restore_with_close_lifecycle() { - let source = app_shell_source(); - let config: serde_json::Value = - serde_json::from_str(&tauri_config_source()).expect("Given: valid Tauri config"); - let windows = config["app"]["windows"] - .as_array() - .expect("Given: Tauri window configs"); - let settings_window = windows - .iter() - .find(|window| window["label"] == "settings") - .expect("Given: settings window config"); - let quick_switch_window = windows - .iter() - .find(|window| window["label"] == "quick-switch") - .expect("Given: quick-switch window config"); - - assert_eq!(settings_window["url"], "/settings"); - assert_eq!(settings_window["title"], "omo-switch"); - assert_eq!(settings_window["visible"], true); - assert_eq!(quick_switch_window["visible"], false); - assert_eq!( - windows - .iter() - .filter(|window| window["label"] == "settings") - .count(), - 1, - "settings has exactly one configured window" - ); - - let open_settings = named_function_body(&source, "pub fn open_settings"); - assert!( - open_settings.contains("if app.get_webview_window(\"settings\").is_none()"), - "settings must be created only when its configured window is missing" - ); - assert!( - open_settings.contains( - "WebviewWindowBuilder::new(app, \"settings\", WebviewUrl::App(\"/settings\".into()))" - ), - "missing settings window must use Tauri's App-relative /settings URL" - ); - assert!( - open_settings.contains(".title(\"omo-switch\")"), - "dynamically created settings windows must retain the product title" - ); - assert!( - open_settings.contains(".visible(false)"), - "dynamically created settings windows must remain hidden until restored" - ); - assert_in_order( - open_settings, - &[ - "WebviewWindowBuilder::new", - ".build()?", - "install_hide_on_close(&window)", - "restore_window(app, \"settings\", Position::Center)", - ], - ); - - let hide_on_close = named_function_body(&source, "fn install_hide_on_close"); - assert!( - hide_on_close.contains("on_window_event") - && hide_on_close.contains("CloseRequested") - && hide_on_close.contains("prevent_close") - && hide_on_close.contains("hide()"), - "new settings windows must hide instead of closing" - ); - - let restore_window = named_function_body(&source, "fn restore_window"); - assert_in_order(restore_window, &["show()", "set_focus()"]); -} - -#[test] -fn tray_left_click_when_activated_restores_the_settings_main_window() { - let source = app_shell_source(); - let callback = source - .split_once(".on_tray_icon_event(|tray, event| {") - .and_then(|(_, suffix)| suffix.split_once("\n })\n .build")) - .map(|(callback, _)| callback) - .expect("Given: tray icon callback exists"); - - assert!(callback.contains("button: MouseButton::Left")); - assert!(callback.contains("ShellAction::OpenSettings")); - assert!( - !callback.contains("ShellAction::ShowQuickSwitch"), - "left-click must restore the settings main window instead of the quick-switch shortcut" - ); -} - -fn app_shell_source() -> String { - fs::read_to_string("src/app_shell.rs").expect("Given: src/app_shell.rs exists") -} - -fn lib_source() -> String { - fs::read_to_string("src/lib.rs").expect("Given: src/lib.rs exists") -} - -fn tauri_config_source() -> String { - fs::read_to_string("tauri.conf.json").expect("Given: tauri.conf.json exists") -} - -fn function_body<'a>(source: &'a str, marker: &str) -> &'a str { - source - .split_once(marker) - .map(|(_, body)| body) - .expect("Given: expected source marker exists") -} - -fn named_function_body<'a>(source: &'a str, marker: &str) -> &'a str { - let source = function_body(source, marker); - let start = source - .find('{') - .expect("Given: expected function body start"); - let mut depth = 0; - - for (index, character) in source[start..].char_indices() { - match character { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return &source[start + 1..start + index]; - } - } - _ => {} - } - } - - panic!("Given: expected function body end"); -} - -fn assert_in_order(source: &str, markers: &[&str]) { - let mut remainder = source; - for marker in markers { - let (_, next) = remainder - .split_once(marker) - .unwrap_or_else(|| panic!("missing required lifecycle step: {marker}")); - remainder = next; - } -} diff --git a/src-tauri/tests/persistence_errors.rs b/src-tauri/tests/persistence_errors.rs deleted file mode 100644 index 49c6e2c..0000000 --- a/src-tauri/tests/persistence_errors.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use omo_switch_tauri::core::document::OpenCodeDocument; -use omo_switch_tauri::core::error::{ - ConfigPathError, PersistenceOperation, RepositoryErrorCode, TargetConfigErrorCode, -}; -use omo_switch_tauri::core::models::{ - AppSelectionState, LastSuccessfulWriteMetadata, ModelGroup, OmoSwitchConfig, -}; -use omo_switch_tauri::core::paths::{ConfigPaths, HomeEnv}; -use omo_switch_tauri::core::repository::{ConfigRepository, OpenCodeConfigRepository}; - -static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); - -fn temp_home(label: &str) -> PathBuf { - let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("task-6-temp") - .join(format!("omo-switch-{label}-{}-{id}", std::process::id())); - let _ = fs::remove_dir_all(&path); - fs::create_dir_all(&path).expect("Given: isolated temp home exists"); - path -} - -#[test] -fn persistence_error_when_home_variables_are_missing_is_stable_and_typed() { - // Given: neither compatibility home variable is available. - let home_env = HomeEnv::new(None, None); - - // When: compatibility paths are resolved. - let error = ConfigPaths::from_home_env(home_env).expect_err("When: path resolution fails"); - - // Then: callers receive the pinned owned error contract. - assert_eq!(error, ConfigPathError::MissingHome); - assert_eq!(error.code(), "missingHome"); - assert_eq!(error.detail(), "HOME or USERPROFILE must be set"); -} - -#[test] -fn persistence_error_when_config_is_malformed_includes_operation_and_path() { - // Given: malformed config at the literal compatibility location. - let home = temp_home("malformed-config"); - let paths = ConfigPaths::from_home_env(HomeEnv::new(Some(&home), None)) - .expect("Given: fake HOME resolves"); - fs::create_dir_all(paths.omo_switch_dir()).expect("Given: app config directory exists"); - fs::write(paths.config_file(), "{ malformed").expect("Given: malformed config writes"); - let repository = ConfigRepository::new(paths.config_file()); - - // When: config is loaded through its repository boundary. - let error = repository.load().expect_err("When: malformed config fails"); - - // Then: the error identifies the stable class, operation, and exact path. - assert_eq!(error.code(), RepositoryErrorCode::MalformedJson); - assert_eq!(error.operation(), PersistenceOperation::Parse); - assert_eq!(error.path(), paths.config_file()); - assert!(error.detail().contains("config.json")); - - fs::remove_dir_all(home).expect("Then: temp home is removed"); -} - -#[test] -fn persistence_error_when_target_jsonc_is_malformed_includes_operation_and_path() { - // Given: malformed OpenCode JSONC at the literal compatibility location. - let home = temp_home("malformed-target"); - let paths = ConfigPaths::from_home_env(HomeEnv::new(Some(&home), None)) - .expect("Given: fake HOME resolves"); - fs::create_dir_all(paths.opencode_dir()).expect("Given: target config directory exists"); - fs::write(paths.opencode_file(), "{ // malformed").expect("Given: malformed target writes"); - let repository = OpenCodeConfigRepository::new(paths.opencode_file()); - - // When: target JSONC is loaded through its repository boundary. - let error = repository.load().expect_err("When: malformed target fails"); - - // Then: the target repository exposes the same stable context shape. - assert_eq!(error.code(), TargetConfigErrorCode::MalformedConfig); - assert_eq!(error.operation(), PersistenceOperation::Parse); - assert_eq!(error.path(), paths.opencode_file()); - assert!(error.detail().contains("opencode.json")); - - fs::remove_dir_all(home).expect("Then: temp home is removed"); -} - -#[test] -fn persistence_error_when_parent_is_a_file_returns_stable_write_context() { - // Given: a file occupies the directory required by config persistence. - let home = temp_home("unwritable-parent"); - let blocked_parent = home.join("blocked"); - fs::write(&blocked_parent, "not a directory").expect("Given: blocking file exists"); - let config_file = blocked_parent.join("config.json"); - let repository = ConfigRepository::new(config_file.clone()); - - // When: config persistence attempts to create the parent directory. - let error = repository - .save(&OmoSwitchConfig::default()) - .expect_err("When: config save fails"); - - // Then: the failure is deterministic on Windows and Unix fake homes. - assert_eq!(error.code(), RepositoryErrorCode::WriteFailed); - assert_eq!(error.operation(), PersistenceOperation::CreateDirectory); - assert_eq!(error.path(), blocked_parent); - assert!(error.detail().contains("blocked")); - - fs::remove_dir_all(home).expect("Then: temp home is removed"); -} - -#[test] -fn persistence_error_when_target_parent_is_a_file_keeps_target_write_context() { - let home = temp_home("unwritable-target-parent"); - let blocked_parent = home.join("blocked"); - fs::write(&blocked_parent, "not a directory").expect("Given: blocking file exists"); - let target_file = blocked_parent.join("opencode.json"); - let repository = OpenCodeConfigRepository::new(target_file); - let document = - OpenCodeDocument::parse_jsonc(r#"{"agent":{}}"#).expect("Given: target document parses"); - - let error = repository - .save(&document) - .expect_err("When: target parent cannot be created"); - - assert_eq!(error.code(), TargetConfigErrorCode::WriteFailed); - assert_eq!(error.operation(), PersistenceOperation::CreateDirectory); - assert_eq!(error.path(), blocked_parent); - assert!(error.detail().contains("blocked")); - - fs::remove_dir_all(home).expect("Then: temp home is removed"); -} - -#[test] -fn persistence_error_when_config_serialization_fails_keeps_existing_bytes() { - let home = temp_home("serialization-retention"); - let paths = ConfigPaths::from_home_env(HomeEnv::new(Some(&home), None)) - .expect("Given: fake HOME resolves"); - fs::create_dir_all(paths.omo_switch_dir()).expect("Given: app config directory exists"); - let config_bytes = b"original config bytes"; - fs::write(paths.config_file(), config_bytes).expect("Given: original config bytes persist"); - let repository = ConfigRepository::new(paths.config_file()); - let invalid_timestamp = time::Date::from_calendar_date(-1, time::Month::January, 1) - .expect("Given: negative year is representable by the domain time type") - .with_time(time::Time::MIDNIGHT) - .assume_utc(); - let group = ModelGroup { - id: uuid::Uuid::nil(), - name: "Cannot serialize".to_owned(), - description: None, - category_mappings: Vec::new(), - agent_overrides: Vec::new(), - open_code_agent_overrides: Vec::new(), - is_enabled: true, - updated_at: invalid_timestamp, - }; - let state_with_invalid_timestamp = AppSelectionState { - last_successful_write: Some(LastSuccessfulWriteMetadata { - target: "test".to_owned(), - wrote_at: invalid_timestamp, - backup_path: None, - }), - ..AppSelectionState::default() - }; - - let error = repository - .save(&OmoSwitchConfig { - groups: vec![group], - state: state_with_invalid_timestamp, - }) - .expect_err("When: an out-of-range config timestamp cannot serialize"); - - assert_eq!(error.code(), RepositoryErrorCode::MalformedJson); - assert_eq!(error.operation(), PersistenceOperation::Serialize); - assert_eq!(error.path(), paths.config_file()); - assert_eq!( - fs::read(paths.config_file()).expect("Then: original config bytes read"), - config_bytes - ); - - fs::remove_dir_all(home).expect("Then: temp home is removed"); -} diff --git a/src-tauri/tests/projection.rs b/src-tauri/tests/projection.rs deleted file mode 100644 index 642a9ba..0000000 --- a/src-tauri/tests/projection.rs +++ /dev/null @@ -1,197 +0,0 @@ -use std::fs; -use std::path::Path; - -use omo_switch_tauri::core::document::{OhMyOpenAgentDocument, OpenCodeDocument}; -use omo_switch_tauri::core::models::{ - ModelGroup, ModelGroupAgentOverride, ModelGroupCategoryMapping, -}; -use omo_switch_tauri::core::projection::{ - OhMyOpenAgentProjectionService, OpenCodeProjectionService, -}; -use serde_json::Value; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; -use uuid::Uuid; - -fn fixture(relative_path: &str) -> String { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("Given: src-tauri has workspace parent") - .join("fixtures") - .join("compatibility") - .join(relative_path); - fs::read_to_string(path).expect("Given: fixture reads") -} - -fn group( - category_mappings: Vec, - agent_overrides: Vec, - open_code_agent_overrides: Vec, -) -> ModelGroup { - ModelGroup { - id: Uuid::parse_str("99999999-9999-9999-9999-999999999999") - .expect("Given: UUID fixture parses"), - name: "Projection Fixture".to_owned(), - description: None, - category_mappings, - agent_overrides, - open_code_agent_overrides, - is_enabled: true, - updated_at: OffsetDateTime::parse("2023-11-14T22:13:20Z", &Rfc3339) - .expect("Given: date fixture parses"), - } -} - -fn override_row(agent_name: &str, model_ref: &str) -> ModelGroupAgentOverride { - ModelGroupAgentOverride { - agent_name: agent_name.to_owned(), - model_ref: model_ref.to_owned(), - } -} - -fn category_row(category_name: &str, model_ref: &str) -> ModelGroupCategoryMapping { - ModelGroupCategoryMapping { - category_name: category_name.to_owned(), - model_ref: model_ref.to_owned(), - } -} - -#[test] -fn projection_when_oh_my_group_selects_subset_preserves_selected_siblings_and_removes_omitted_entries( -) { - // Given: compatibility behavior uses selected group rows as the full desired Oh My section set. - let existing = OhMyOpenAgentDocument::parse_jsonc( - r#"{ - "$schema": "https://example.com/schema.json", - "customTopLevel": { "keep": true }, - "agents": { - "librarian": { "model": "legacy", "variant": "medium", "temperature": 0.2 }, - "oracle": { "model": "legacy", "variant": "xhigh" } - }, - "categories": { - "quick": { "model": "legacy", "variant": "balanced" }, - "deep": { "model": "legacy", "variant": "slow" } - } -}"#, - ) - .expect("Given: Oh My document parses"); - let group = group( - vec![category_row("quick", "cliproxyapi/minimax-m2.7")], - vec![override_row("librarian", "cliproxyapi/gpt-5.4")], - vec![], - ); - - // When: Rust projects the group onto the existing document. - let result = OhMyOpenAgentProjectionService::project(&group, &existing); - - // Then: selected rows are patched, selected sibling fields survive, omitted rows are removed. - assert!(result.warnings.is_empty()); - assert_eq!(result.document.raw()["customTopLevel"]["keep"], true); - assert_eq!( - result.document.agents()["librarian"]["model"], - "cliproxyapi/gpt-5.4" - ); - assert_eq!(result.document.agents()["librarian"]["variant"], "medium"); - assert_eq!(result.document.agents()["librarian"]["temperature"], 0.2); - assert!(result.document.agents().get("oracle").is_none()); - assert_eq!( - result.document.categories()["quick"]["model"], - "cliproxyapi/minimax-m2.7" - ); - assert_eq!(result.document.categories()["quick"]["variant"], "balanced"); - assert!(result.document.categories().get("deep").is_none()); -} - -#[test] -fn projection_when_opencode_override_targets_existing_agent_preserves_unrelated_config_keys() { - // Given: the current OpenCode fixture has schema, plugin, provider, and sibling agent data. - let existing = OpenCodeDocument::parse_jsonc(&fixture("opencode/current-opencode.json")) - .expect("Given: OpenCode fixture parses"); - let before_schema = existing.raw()["$schema"].clone(); - let before_plugin = existing.raw()["plugin"].clone(); - let before_provider = existing.raw()["provider"].clone(); - let before_karen = existing.agents()["karen"].clone(); - let before_description = existing.agents()["creative-ui-coder"]["description"].clone(); - let group = group( - vec![], - vec![], - vec![override_row( - "creative-ui-coder", - "cliproxyapi/gpt-5.4-xhigh", - )], - ); - - // When: Rust projects the OpenCode override. - let result = OpenCodeProjectionService::project(&group, &existing); - - // Then: only the targeted agent model changes; unrelated top-level and sibling data survive. - assert!(result.warnings.is_empty()); - assert_eq!(result.document.raw()["$schema"], before_schema); - assert_eq!(result.document.raw()["plugin"], before_plugin); - assert_eq!(result.document.raw()["provider"], before_provider); - assert_eq!(result.document.agents()["karen"], before_karen); - assert_eq!( - result.document.agents()["creative-ui-coder"]["model"], - "cliproxyapi/gpt-5.4-xhigh" - ); - assert_eq!( - result.document.agents()["creative-ui-coder"]["description"], - before_description - ); -} - -#[test] -fn projection_when_opencode_agent_section_is_missing_returns_warnings_without_rewriting_document() { - // Given: compatibility behavior leaves a document unchanged when the top-level agent object is absent. - let existing = - OpenCodeDocument::parse_jsonc(r#"{ "$schema": "https://opencode.ai/config.json" }"#) - .expect("Given: OpenCode document parses"); - let group = group( - vec![], - vec![], - vec![ - override_row("creative-ui-coder", "cliproxyapi/gpt-5.4"), - override_row("blank", " "), - ], - ); - - // When: Rust projects OpenCode overrides. - let result = OpenCodeProjectionService::project(&group, &existing); - - // Then: only effective overrides warn and the document is unchanged. - assert_eq!(result.document, existing); - assert_eq!( - result.warnings, - vec![ - "OpenCode config has no valid top-level 'agent' object; skipped model override for 'creative-ui-coder'." - .to_owned() - ] - ); -} - -#[test] -fn projection_when_opencode_agent_is_missing_warns_and_keeps_document_reparsable() { - // Given: one effective override points at a missing OpenCode agent. - let existing = OpenCodeDocument::parse_jsonc(&fixture("opencode/current-opencode.json")) - .expect("Given: OpenCode fixture parses"); - let group = group( - vec![], - vec![], - vec![override_row("missing-agent", "cliproxyapi/gpt-5.4")], - ); - - // When: Rust projects OpenCode overrides. - let result = OpenCodeProjectionService::project(&group, &existing); - - // Then: compatibility behavior records a warning and leaves all agents structurally intact. - assert_eq!( - result.warnings, - vec!["OpenCode agent 'missing-agent' was not found; skipped model override.".to_owned()] - ); - let encoded = result - .document - .serialize() - .expect("Then: projected document serializes"); - let reparsed: Value = serde_json::from_str(&encoded).expect("Then: output is valid JSON"); - assert!(reparsed["agent"].get("creative-ui-coder").is_some()); -} diff --git a/src-tauri/tests/schemas.rs b/src-tauri/tests/schemas.rs deleted file mode 100644 index 913cd51..0000000 --- a/src-tauri/tests/schemas.rs +++ /dev/null @@ -1,210 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use omo_switch_tauri::core::models::{ - AppSelectionState, LastSuccessfulWriteMetadata, ModelGroup, ModelGroupAgentOverride, - ModelGroupCategoryMapping, OmoSwitchConfig, ProjectionIssueSummary, -}; -use omo_switch_tauri::core::paths::{ConfigPaths, HomeEnv}; -use omo_switch_tauri::core::repository::ConfigRepository; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; - -static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); - -fn temp_home(label: &str) -> PathBuf { - let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("task-6-temp") - .join(format!("omo-switch-{label}-{}-{id}", std::process::id())); - let _ = fs::remove_dir_all(&path); - fs::create_dir_all(&path).expect("Given: temp home directory is created"); - path -} - -fn remove_temp(path: &Path) { - let _ = fs::remove_dir_all(path); -} - -fn iso8601(value: &str) -> OffsetDateTime { - OffsetDateTime::parse(value, &Rfc3339).expect("Given: ISO8601 date fixture parses") -} - -#[test] -fn schemas_when_legacy_unknown_fields_are_read_without_becoming_state_projection() { - // Given: a prior state payload retains an unknown feature field. - let payload = r#"{"migrationVersion":1,"selectedGroupID":"4F648C2A-1D48-44D6-B0C0-4D340816E1F3","selectedGroupName":"Primary","removedFeature":{"enabled":true}}"#; - - // When: Rust decodes the persisted state. - let state: AppSelectionState = - serde_json::from_str(payload).expect("When: legacy state decodes"); - - // Then: unknown fields are ignored and cannot be emitted by the retained state model. - let encoded = serde_json::to_string(&state).expect("Then: retained state encodes"); - assert_eq!(state.migration_version, 1); - assert_eq!(state.selected_group_name.as_deref(), Some("Primary")); - assert!(!encoded.contains("removedFeature")); -} - -#[test] -fn schemas_when_legacy_server_fields_are_read_do_not_become_app_state_projection() { - let state: AppSelectionState = serde_json::from_str( - r#"{ - "selectedGroupID": "4f648c2a-1d48-44d6-b0c0-4d340816e1f3", - "selectedGroupName": "Primary", - "launchAtLoginEnabled": true, - "openCodeServeConfig": { - "port": 4096, - "hostname": "127.0.0.1", - "mdns": false, - "mdnsDomain": "opencode.local", - "cors": [], - "executablePath": null, - "autoStart": false - }, - "migrationVersion": 2 -}"#, - ) - .expect("When: legacy server state decodes"); - - let encoded = serde_json::to_value(&state).expect("Then: retained state encodes"); - - assert_eq!(state.selected_group_name.as_deref(), Some("Primary")); - assert_eq!(state.migration_version, 2); - assert!(encoded.get("launchAtLoginEnabled").is_none()); - assert!(encoded.get("openCodeServeConfig").is_none()); -} - -#[test] -fn schemas_when_config_round_trip_preserves_uuid_and_iso8601_dates() { - // Given: persisted compatible config values with fixed UUID/date strings. - let config_payload = r#"{ - "groups" : [ - { - "agentOverrides" : [ - { - "agentName" : "oracle", - "modelRef" : "openai/gpt-5.4" - } - ], - "categoryMappings" : [ - { - "categoryName" : "quick", - "modelRef" : "anthropic/claude-sonnet-4" - } - ], - "description" : "Primary group", - "id" : "4F648C2A-1D48-44D6-B0C0-4D340816E1F3", - "isEnabled" : true, - "name" : "Primary", - "updatedAt" : "2023-11-14T22:13:20Z" - } - ], - "state" : { - "lastSuccessfulWrite" : { - "backupPath" : "/tmp/backup.json", - "target" : "oh-my-openagent.categories", - "wroteAt" : "2023-11-14T22:21:40Z" - }, - "migrationVersion" : 2, - "selectedGroupID" : "4F648C2A-1D48-44D6-B0C0-4D340816E1F3", - "selectedGroupName" : "Primary" - } -}"#; - - // When: Rust decodes and re-encodes the persisted config schema. - let config: OmoSwitchConfig = - serde_json::from_str(config_payload).expect("When: config decodes"); - let encoded = serde_json::to_string_pretty(&config).expect("Then: config encodes"); - - // Then: UUID and ISO8601 date strings survive round trip. - assert_eq!( - config.groups[0].id.to_string().to_uppercase(), - "4F648C2A-1D48-44D6-B0C0-4D340816E1F3" - ); - assert!(encoded.contains("2023-11-14T22:13:20Z")); - assert_eq!( - config - .state - .selected_group_id - .expect("selected group id") - .to_string() - .to_uppercase(), - "4F648C2A-1D48-44D6-B0C0-4D340816E1F3" - ); - assert!(encoded.contains("2023-11-14T22:21:40Z")); -} - -#[test] -fn config_paths_schemas_when_repository_saves_complete_config_under_temp_home() { - // Given: Rust repositories are rooted at fake HOME-derived config paths. - let home = temp_home("repository-save"); - let paths = ConfigPaths::from_home_env(HomeEnv::new(Some(home.as_path()), None)) - .expect("Given: HOME path should resolve"); - let repository = ConfigRepository::new(paths.config_file()); - let group_id = "4F648C2A-1D48-44D6-B0C0-4D340816E1F3" - .parse() - .expect("Given: UUID fixture parses"); - - let group = ModelGroup { - id: group_id, - name: "Primary".to_owned(), - description: Some("Primary group".to_owned()), - category_mappings: vec![ModelGroupCategoryMapping { - category_name: "quick".to_owned(), - model_ref: "anthropic/claude-sonnet-4".to_owned(), - }], - agent_overrides: vec![ModelGroupAgentOverride { - agent_name: "oracle".to_owned(), - model_ref: "openai/gpt-5.4".to_owned(), - }], - open_code_agent_overrides: vec![], - is_enabled: true, - updated_at: iso8601("2023-11-14T22:13:20Z"), - }; - let state = AppSelectionState { - selected_group_id: Some(group_id), - selected_group_name: Some("Primary".to_owned()), - last_successful_write: Some(LastSuccessfulWriteMetadata { - target: "oh-my-openagent.categories".to_owned(), - wrote_at: iso8601("2023-11-14T22:21:40Z"), - backup_path: Some("/tmp/backup.json".to_owned()), - }), - last_warning_summary: Some(ProjectionIssueSummary { - message: "undeclared refs".to_owned(), - count: 2, - }), - last_error_summary: Some(ProjectionIssueSummary { - message: "write failed".to_owned(), - count: 1, - }), - migration_version: AppSelectionState::CURRENT_SCHEMA_VERSION, - }; - - fs::create_dir_all(paths.omo_switch_dir()).expect("Given: repository directory exists"); - fs::write(paths.config_file(), b"old config bytes") - .expect("Given: existing config bytes persist"); - - // When: the repository saves and reloads its complete payload. - let config = OmoSwitchConfig { - groups: vec![group.clone()], - state: state.clone(), - }; - repository.save(&config).expect("When: config saves"); - println!( - "repository config.json path: {}", - paths.config_file().display() - ); - - // Then: observable file, load behavior, defaults, and path match the persisted contract. - assert_eq!(repository.load().expect("Then: config loads"), config); - assert!(paths.config_file().exists()); - let encoded = fs::read_to_string(paths.config_file()).expect("Then: config file reads"); - assert!(encoded.contains("\"groups\":")); - assert!(encoded.contains("\"state\":")); - assert!(encoded.contains("\"migrationVersion\": 2")); - - remove_temp(&home); -} diff --git a/src-tauri/tests/shell_smoke.rs b/src-tauri/tests/shell_smoke.rs deleted file mode 100644 index 27ae214..0000000 --- a/src-tauri/tests/shell_smoke.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::fs; - -use omo_switch_tauri::core::models::AppSelectionState; -use omo_switch_tauri::shell::{ - ShellAction, ShellMenuItemKind, ShellStateSnapshot, OPEN_SETTINGS, QUIT, RELOAD_APP_STATE, - SHOW_QUICK_SWITCH, -}; - -#[test] -fn tray_contract_when_scaffolded_preserves_primary_window_action_and_right_click_labels() { - // Given: the Settings main window is opened by a primary-window action, not a tray menu item. - // When: the tray contract is inspected. - // Then: its action contract retains primary-window opening, while labels list right-click items. - assert_eq!(SHOW_QUICK_SWITCH, "show_quick_switch"); - assert_eq!(OPEN_SETTINGS, "open_settings"); - assert_eq!(RELOAD_APP_STATE, "reload_app_state"); - assert_eq!(QUIT, "quit"); - assert_eq!(ShellAction::OpenSettings.id(), OPEN_SETTINGS); - let menu = ShellStateSnapshot { - groups: Vec::new(), - app_state: AppSelectionState::default(), - } - .menu_model(); - assert_eq!( - menu.shell_items - .iter() - .map(|item| (item.kind, item.title.as_str())) - .collect::>(), - vec![ - (ShellMenuItemKind::Reload, "Reload"), - (ShellMenuItemKind::QuickSwitch, "Quick Switch"), - (ShellMenuItemKind::Quit, "Quit"), - ] - ); -} - -#[test] -fn tauri_config_when_scaffolded_uses_static_sveltekit_build() { - // Given: Tauri must consume SvelteKit's adapter-static output. - // When: the current checked-in config is inspected. - // Then: frontendDist points at ../build and no dev URL is required for build. - let config = fs::read_to_string("tauri.conf.json").expect("tauri.conf.json must exist"); - - assert!(config.contains(r#""frontendDist": "../build""#)); - assert!(!config.contains("../public")); -} - -#[test] -fn cargo_manifest_when_scaffolded_enables_tauri_tray_icon_feature() { - // Given: tray support is a platform viability gate. - // When: the Rust manifest is inspected. - // Then: Tauri compiles with the tray-icon feature. - let manifest = fs::read_to_string("Cargo.toml").expect("Cargo.toml must exist"); - - assert!(manifest.contains("tray-icon")); -} diff --git a/src-tauri/tests/switching.rs b/src-tauri/tests/switching.rs deleted file mode 100644 index 5bec4bf..0000000 --- a/src-tauri/tests/switching.rs +++ /dev/null @@ -1,216 +0,0 @@ -#[path = "common/switching.rs"] -mod switching_support; - -use std::fs; - -use omo_switch_tauri::core::switching::{SwitchError, SwitchOutcome}; -use switching_support::{ - assert_dual_target_success, category_row, group, paths, remove_temp, seed_groups, seed_oh_my, - seed_opencode, seed_state, temp_home, use_case, -}; -use uuid::Uuid; - -#[test] -fn switching_when_no_effective_opencode_overrides_skips_missing_opencode_config() { - let home = temp_home("skip-opencode"); - seed_oh_my(&home); - let target_group = group( - "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", - "Skip OpenCode", - true, - vec![category_row("quick", "cliproxyapi/gpt-5.4")], - vec![], - vec![switching_support::override_row("creative-ui-coder", " ")], - ); - seed_groups(&home, &[target_group.clone()]); - - let outcome = use_case(&home) - .switch_to(target_group.id) - .expect("When: switch succeeds"); - - let resolved = paths(&home); - assert!(matches!(outcome, SwitchOutcome::Success { .. })); - assert!(!resolved.opencode_file().exists()); - assert!(resolved - .omo_switch_dir() - .join("backups") - .join("oh-my-openagent") - .exists()); - assert!(!resolved - .omo_switch_dir() - .join("backups") - .join("opencode") - .exists()); - - remove_temp(&home); -} - -#[test] -fn switching_when_target_is_current_group_returns_noop_without_touching_disk() { - let home = temp_home("noop"); - seed_oh_my(&home); - let target_group = group( - "44444444-4444-4444-4444-444444444444", - "ActiveGroup", - true, - vec![category_row("deep", "cliproxyapi/gpt-5.4")], - vec![], - vec![], - ); - seed_groups(&home, &[target_group.clone()]); - seed_state(&home, Some(target_group.id), Some("ActiveGroup")); - let before = - fs::read_to_string(paths(&home).oh_my_openagent_file()).expect("Given: Oh My reads"); - - let outcome = use_case(&home) - .switch_to(target_group.id) - .expect("When: no-op succeeds"); - - assert_eq!(outcome, SwitchOutcome::NoOp); - assert_eq!( - fs::read_to_string(paths(&home).oh_my_openagent_file()).expect("Then: Oh My reads"), - before - ); - assert!(!paths(&home).omo_switch_dir().join("backups").exists()); - - remove_temp(&home); -} - -#[test] -fn switching_when_group_is_missing_or_disabled_returns_typed_failure() { - let home = temp_home("missing-disabled"); - seed_oh_my(&home); - let disabled = group( - "55555555-5555-5555-5555-555555555555", - "Disabled", - false, - vec![category_row("quick", "model")], - vec![], - vec![], - ); - seed_groups(&home, &[disabled.clone()]); - - let missing = use_case(&home) - .switch_to(Uuid::parse_str("66666666-6666-6666-6666-666666666666").expect("UUID")) - .expect_err("When: missing group fails"); - let disabled_error = use_case(&home) - .switch_to(disabled.id) - .expect_err("When: disabled group fails"); - - assert_eq!(missing, SwitchError::GroupNotFound); - assert_eq!(disabled_error, SwitchError::GroupDisabled); - - remove_temp(&home); -} - -#[test] -fn switching_when_target_config_is_malformed_leaves_disk_unchanged() { - let home = temp_home("malformed-oh-my"); - let resolved = paths(&home); - fs::create_dir_all(resolved.opencode_dir()).expect("Given: opencode dir exists"); - fs::write(resolved.oh_my_openagent_file(), "{ invalid json").expect("Given: malformed writes"); - let target_group = group( - "88888888-8888-8888-8888-888888888888", - "Broken Input", - true, - vec![category_row("quick", "safe-model")], - vec![], - vec![], - ); - seed_groups(&home, &[target_group.clone()]); - - let error = use_case(&home) - .switch_to(target_group.id) - .expect_err("When: malformed config fails"); - - assert_eq!(error, SwitchError::LoadOhMyConfigFailed); - assert_eq!( - fs::read_to_string(resolved.oh_my_openagent_file()).expect("Then: malformed reads"), - "{ invalid json" - ); - - remove_temp(&home); -} - -#[test] -fn switching_when_opencode_write_succeeds_but_oh_my_write_fails_rolls_back_opencode() { - let home = temp_home("rollback"); - seed_oh_my(&home); - seed_opencode(&home); - let target_group = group( - "cccccccc-cccc-cccc-cccc-cccccccccccc", - "Rollback", - true, - vec![category_row("quick", "cliproxyapi/gpt-5.4")], - vec![], - vec![switching_support::override_row( - "creative-ui-coder", - "cliproxyapi/gpt-5.4-xhigh", - )], - ); - seed_groups(&home, &[target_group.clone()]); - let resolved = paths(&home); - let original_opencode = fs::read(resolved.opencode_file()).expect("Given: OpenCode reads"); - let original_oh_my = fs::read(resolved.oh_my_openagent_file()).expect("Given: Oh My reads"); - let mut failing_use_case = use_case(&home); - failing_use_case.fail_next_oh_my_save_for_test(); - - let error = failing_use_case - .switch_to(target_group.id) - .expect_err("When: second write fails"); - - assert!(matches!(error, SwitchError::TransactionFailed { .. })); - assert_eq!( - fs::read(resolved.opencode_file()).expect("Then: OpenCode reads"), - original_opencode - ); - assert_eq!( - fs::read(resolved.oh_my_openagent_file()).expect("Then: Oh My reads"), - original_oh_my - ); - assert!(resolved - .omo_switch_dir() - .join("backups") - .join("opencode") - .exists()); - assert!(resolved - .omo_switch_dir() - .join("backups") - .join("oh-my-openagent") - .exists()); - - remove_temp(&home); -} - -#[test] -fn switching_when_effective_opencode_overrides_exist_writes_both_targets_and_preserves_opencode_unrelated_keys( -) { - let home = temp_home("dual-target"); - seed_oh_my(&home); - seed_opencode(&home); - let target_group = group( - "99999999-9999-9999-9999-999999999999", - "Dual Target", - true, - vec![category_row( - "unspecified-high", - "cliproxyapi/gpt-5.4-xhigh", - )], - vec![switching_support::override_row( - "oracle", - "cliproxyapi/gpt-5.4-xhigh", - )], - vec![ - switching_support::override_row("creative-ui-coder", "cliproxyapi/gpt-5.4"), - switching_support::override_row("Jenny", "cliproxyapi/gpt-5.4-xhigh"), - ], - ); - seed_groups(&home, &[target_group.clone()]); - - let outcome = use_case(&home) - .switch_to(target_group.id) - .expect("When: switch succeeds"); - - assert_dual_target_success(&home, &target_group, outcome); - remove_temp(&home); -} diff --git a/src-tauri/tests/switching_transactions.rs b/src-tauri/tests/switching_transactions.rs deleted file mode 100644 index bc6137e..0000000 --- a/src-tauri/tests/switching_transactions.rs +++ /dev/null @@ -1,250 +0,0 @@ -#[path = "common/switching.rs"] -mod switching_support; - -use std::fs; -use std::path::Path; -use std::process::Command; - -use omo_switch_tauri::core::switching::{ - recover_pending_transaction, SwitchError, SwitchTestControl, TransactionFault, -}; -use switching_support::{ - category_row, group, paths, remove_temp, seed_groups, seed_oh_my, seed_opencode, seed_state, - temp_home, use_case, -}; - -fn dual_target_group() -> omo_switch_tauri::core::models::ModelGroup { - group( - "99999999-9999-9999-9999-999999999999", - "Dual Target", - true, - vec![category_row("quick", "new/oh-my")], - vec![], - vec![switching_support::override_row( - "creative-ui-coder", - "new/opencode", - )], - ) -} - -fn seeded_home(label: &str) -> std::path::PathBuf { - let home = temp_home(label); - seed_oh_my(&home); - seed_opencode(&home); - seed_state(&home, None, None); - seed_groups(&home, &[dual_target_group()]); - home -} - -fn bytes(path: &Path) -> Vec { - fs::read(path).expect("Then: file bytes read") -} - -fn journal(home: &Path) -> std::path::PathBuf { - paths(home) - .omo_switch_dir() - .join("transaction-journal.json") -} - -#[test] -fn transaction_when_each_precommit_stage_fails_preserves_original_three_file_set() { - for fault in [ - TransactionFault::StageCreate, - TransactionFault::StageWrite, - TransactionFault::StageFlush, - TransactionFault::JournalCreate, - TransactionFault::JournalWrite, - TransactionFault::JournalFlush, - TransactionFault::FirstTargetRename, - ] { - let home = seeded_home(fault.label()); - let resolved = paths(&home); - let originals = [ - bytes(&resolved.opencode_file()), - bytes(&resolved.oh_my_openagent_file()), - bytes(&resolved.config_file()), - ]; - let control = SwitchTestControl::fail_once(fault); - - let error = use_case(&home) - .with_test_control(control) - .switch_to(dual_target_group().id) - .expect_err("When: injected stage fails"); - - assert!(matches!(error, SwitchError::TransactionFailed { .. })); - assert_eq!(bytes(&resolved.opencode_file()), originals[0]); - assert_eq!(bytes(&resolved.oh_my_openagent_file()), originals[1]); - assert_eq!(bytes(&resolved.config_file()), originals[2]); - remove_temp(&home); - } -} - -#[test] -fn transaction_when_second_target_rename_fails_compensates_first_target_and_config() { - let home = seeded_home("second-rename"); - let resolved = paths(&home); - let originals = [ - bytes(&resolved.opencode_file()), - bytes(&resolved.oh_my_openagent_file()), - bytes(&resolved.config_file()), - ]; - - let error = use_case(&home) - .with_test_control(SwitchTestControl::fail_once( - TransactionFault::SecondTargetRename, - )) - .switch_to(dual_target_group().id) - .expect_err("When: second rename fails"); - - assert!(matches!(error, SwitchError::TransactionFailed { .. })); - assert_eq!(bytes(&resolved.opencode_file()), originals[0]); - assert_eq!(bytes(&resolved.oh_my_openagent_file()), originals[1]); - assert_eq!(bytes(&resolved.config_file()), originals[2]); - assert!(!journal(&home).exists()); - remove_temp(&home); -} - -#[test] -fn transaction_when_config_rename_fails_compensates_both_targets() { - let home = seeded_home("config-rename"); - let resolved = paths(&home); - let originals = [ - bytes(&resolved.opencode_file()), - bytes(&resolved.oh_my_openagent_file()), - bytes(&resolved.config_file()), - ]; - - let error = use_case(&home) - .with_test_control(SwitchTestControl::fail_once(TransactionFault::ConfigRename)) - .switch_to(dual_target_group().id) - .expect_err("When: state rename fails"); - - assert!(matches!(error, SwitchError::TransactionFailed { .. })); - assert_eq!(bytes(&resolved.opencode_file()), originals[0]); - assert_eq!(bytes(&resolved.oh_my_openagent_file()), originals[1]); - assert_eq!(bytes(&resolved.config_file()), originals[2]); - remove_temp(&home); -} - -#[test] -fn transaction_when_compensation_fails_retains_journal_for_startup_recovery() { - let home = seeded_home("compensation-failure"); - let error = use_case(&home) - .with_test_control(SwitchTestControl::fail_sequence(&[ - TransactionFault::SecondTargetRename, - TransactionFault::CompensationRename, - ])) - .switch_to(dual_target_group().id) - .expect_err("When: compensation fails"); - - assert!( - matches!(error, SwitchError::CompensationFailed { .. }), - "Then: compensation error is retained, got {error:?}" - ); - assert!(journal(&home).exists()); - - recover_pending_transaction(&paths(&home).omo_switch_dir()) - .expect("When: startup recovery compensates"); - assert!(!journal(&home).exists()); - remove_temp(&home); -} - -#[test] -fn transaction_when_interrupted_after_each_commit_phase_recovers_consistent_bytes() { - for fault in [ - TransactionFault::AfterJournalSync, - TransactionFault::AfterFirstTargetRename, - TransactionFault::AfterSecondTargetRename, - TransactionFault::AfterConfigRename, - ] { - let home = seeded_home(fault.label()); - let resolved = paths(&home); - let original_config = bytes(&resolved.config_file()); - let error = use_case(&home) - .with_test_control(SwitchTestControl::fail_once(fault)) - .switch_to(dual_target_group().id) - .expect_err("When: forced interruption is injected"); - assert!( - matches!(error, SwitchError::Interrupted { .. }), - "Then: interruption is classified, got {error:?}" - ); - assert!(journal(&home).exists()); - - recover_pending_transaction(&resolved.omo_switch_dir()) - .expect("When: startup recovery resolves journal"); - - assert!(!journal(&home).exists()); - let config = bytes(&resolved.config_file()); - let committed = config != original_config; - let opencode = String::from_utf8(bytes(&resolved.opencode_file())).expect("UTF-8"); - let oh_my = String::from_utf8(bytes(&resolved.oh_my_openagent_file())).expect("UTF-8"); - assert_eq!(committed, opencode.contains("new/opencode")); - assert_eq!(committed, oh_my.contains("new/oh-my")); - remove_temp(&home); - } -} - -#[test] -fn transaction_when_switch_is_noop_creates_no_backup_stage_or_journal() { - let home = seeded_home("transaction-noop"); - let target = dual_target_group(); - seed_state(&home, Some(target.id), Some(&target.name)); - - use_case(&home) - .switch_to(target.id) - .expect("When: no-op succeeds"); - - let root = paths(&home).omo_switch_dir(); - assert!(!root.join("backups").exists()); - assert!(!root.join("transaction-journal.json").exists()); - assert!(fs::read_dir(paths(&home).opencode_dir()) - .expect("Then: target directory lists") - .all(|entry| !entry - .expect("Then: entry reads") - .file_name() - .to_string_lossy() - .contains(".omo-txn-"))); - remove_temp(&home); -} - -#[test] -fn transaction_when_subprocess_aborts_after_first_rename_recovers_on_restart() { - let home = seeded_home("subprocess-abort"); - let resolved = paths(&home); - let original_config = bytes(&resolved.config_file()); - - let status = Command::new(std::env::current_exe().expect("Given: current test executable")) - .args(["--exact", "transaction_crash_child", "--nocapture"]) - .env("OMO_SWITCH_CRASH_HOME", &home) - .status() - .expect("When: crash child launches"); - - assert!(!status.success()); - assert!(journal(&home).exists()); - recover_pending_transaction(&resolved.omo_switch_dir()) - .expect("When: restarted process recovers"); - assert_eq!(bytes(&resolved.config_file()), original_config); - assert!(!String::from_utf8(bytes(&resolved.opencode_file())) - .expect("UTF-8") - .contains("new/opencode")); - assert!(!String::from_utf8(bytes(&resolved.oh_my_openagent_file())) - .expect("UTF-8") - .contains("new/oh-my")); - assert!(!journal(&home).exists()); - remove_temp(&home); -} - -#[test] -fn transaction_crash_child() { - let Ok(home) = std::env::var("OMO_SWITCH_CRASH_HOME") else { - return; - }; - let error = use_case(Path::new(&home)) - .with_test_control(SwitchTestControl::fail_once( - TransactionFault::AfterFirstTargetRename, - )) - .switch_to(dual_target_group().id) - .expect_err("When: child reaches interruption point"); - assert!(matches!(error, SwitchError::Interrupted { .. })); - std::process::abort(); -} diff --git a/src-tauri/tests/tray_lifecycle.rs b/src-tauri/tests/tray_lifecycle.rs deleted file mode 100644 index 8196d8a..0000000 --- a/src-tauri/tests/tray_lifecycle.rs +++ /dev/null @@ -1,109 +0,0 @@ -use omo_switch_tauri::core::models::{AppSelectionState, ModelGroup}; -use omo_switch_tauri::shell::{ - resolve_shell_action, ShellAction, ShellMenuItemKind, ShellStateSnapshot, -}; -use uuid::Uuid; - -#[test] -fn tray_menu_when_state_has_groups_renders_enabled_groups_and_active_marker() { - let active_id = Uuid::new_v4(); - let disabled_id = Uuid::new_v4(); - let inactive_id = Uuid::new_v4(); - let snapshot = ShellStateSnapshot { - groups: vec![ - group(active_id, "Active", true), - group(disabled_id, "Disabled", false), - group(inactive_id, "Candidate", true), - ], - app_state: state(Some(active_id)), - }; - - let model = snapshot.menu_model(); - - assert_eq!(model.current_group_label, "Current Group: Active"); - assert_eq!(model.group_items.len(), 2); - assert_eq!(model.group_items[0].title, "Active"); - assert!(model.group_items[0].active); - assert_eq!(model.group_items[1].title, "Candidate"); - assert!(!model.group_items[1].active); - assert!(!model - .group_items - .iter() - .any(|item| item.title == "Disabled")); -} - -#[test] -fn tray_menu_when_current_group_is_stale_falls_back_to_none_without_marking_groups() { - let stale_id = Uuid::new_v4(); - let group_id = Uuid::new_v4(); - let snapshot = ShellStateSnapshot { - groups: vec![group(group_id, "Available", true)], - app_state: state(Some(stale_id)), - }; - - let model = snapshot.menu_model(); - - assert_eq!(model.current_group_label, "Current Group: None"); - assert!(!model.group_items[0].active); -} - -#[test] -fn tray_menu_when_no_enabled_groups_contains_shell_actions_without_group_items() { - let snapshot = ShellStateSnapshot { - groups: vec![group(Uuid::new_v4(), "Disabled", false)], - app_state: state(None), - }; - - let model = snapshot.menu_model(); - - assert_eq!(model.current_group_label, "Current Group: None"); - assert_eq!(model.group_items, []); - assert_eq!( - model - .shell_items - .iter() - .map(|item| item.kind) - .collect::>(), - vec![ - ShellMenuItemKind::Reload, - ShellMenuItemKind::QuickSwitch, - ShellMenuItemKind::Quit, - ] - ); - assert!( - !model - .shell_items - .iter() - .any(|item| item.action == ShellAction::OpenSettings), - "Settings is opened by the primary-window path, not a right-click tray item" - ); - assert_eq!( - resolve_shell_action("open_settings"), - Some(ShellAction::OpenSettings), - "the primary-window action remains available to tray-left-click and IPC callers" - ); -} - -fn group(id: Uuid, name: &str, is_enabled: bool) -> ModelGroup { - ModelGroup { - id, - name: name.to_owned(), - description: None, - category_mappings: Vec::new(), - agent_overrides: Vec::new(), - open_code_agent_overrides: Vec::new(), - is_enabled, - updated_at: time::OffsetDateTime::UNIX_EPOCH, - } -} - -fn state(selected_group_id: Option) -> AppSelectionState { - AppSelectionState { - selected_group_id, - selected_group_name: None, - last_successful_write: None, - last_warning_summary: None, - last_error_summary: None, - migration_version: 1, - } -} diff --git a/src/app.css b/src/app.css index 0651d40..2ad26e8 100644 --- a/src/app.css +++ b/src/app.css @@ -1,183 +1,746 @@ -@import "tailwindcss"; -@import "tw-animate-css"; +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@font-face { + font-family: 'Space Grotesk'; + src: url('/fonts/space-grotesk-regular.woff2') format('woff2'); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: 'Space Grotesk'; + src: url('/fonts/space-grotesk-semibold.woff2') format('woff2'); + font-weight: 600 700; + font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains-mono-regular.woff2') format('woff2'); + font-weight: 400; + font-display: swap; +} :root { - --surface-app: #f6f7f8; - --surface-panel: #ffffff; - --surface-muted: #f1f3f5; - --surface-input: #ffffff; - --surface-hover: #eceff3; - --surface-active: #e4eaf2; - --text-primary: #171a1f; - --text-secondary: #596170; - --text-muted: #858e9c; - --text-on-accent: #ffffff; - --border-default: #d8dde5; - --border-subtle: #e8ebf0; - --focus-ring: #2f6feb; - --accent-primary: #2563eb; - --accent-hover: #1d4ed8; - --status-success: #16843a; - --status-warning: #b76b00; - --status-error: #c9362b; - --status-info: #2f6feb; - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 20px; - --space-6: 24px; - --space-8: 32px; - --radius: 8px; - --radius-pill: 999px; - --shadow-popover: 0 8px 24px rgb(15 23 42 / 14%); - --shadow-window: 0 1px 2px rgb(15 23 42 / 8%); - --border-width: 1px; - --focus-width: 2px; + --surface-app: #131313; + --surface-panel: #1e1e1e; + --surface-input: #121212; + --surface-hover: #202829; + --surface-active: #07343b; + --text-primary: #e8eeee; + --text-secondary: #9babab; + --text-muted: #657171; + --text-on-accent: #071113; + --border-default: #3b494c; + --border-subtle: #1f1f1f; + --focus-ring: #00e5ff; + --accent-primary: #00e5ff; + --accent-hover: #72f2ff; + --status-success: #39ff14; + --status-error: #ff5c5c; + --radius: 4px; + --radius-sm: 2px; + --radius-md: 4px; + --focus-width: 1px; --focus-offset: 2px; - --disabled-opacity: 0.55; - --icon-sm: 12px; - --icon-md: 16px; - --icon-lg: 24px; - --font-primary: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", "Segoe UI Mono", monospace; - --font-window-size: 20px; - --font-window-weight: 600; - --font-window-line: 28px; - --font-section-size: 15px; - --font-section-weight: 600; - --font-section-line: 22px; - --font-row-size: 13px; - --font-row-weight: 500; - --font-row-line: 18px; + --disabled-opacity: 0.45; + --font-primary: 'JetBrains Mono', monospace; + --font-heading: 'Space Grotesk', sans-serif; + --font-caption-weight: 500; + --font-caption-size: 12px; + --font-caption-line: 16px; --font-body-size: 13px; - --font-body-weight: 400; --font-body-line: 20px; - --font-small-size: 12px; - --font-small-line: 17px; - --font-caption-size: 11px; - --font-caption-weight: 500; - --font-caption-line: 15px; - --font-code-size: 11px; - --font-code-line: 16px; - --quick-switch-width: 420px; - --quick-switch-height: 360px; - --quick-switch-min-width: 300px; - --quick-switch-min-height: 0; - --quick-switch-state-min-height: 0; - --target-row-min-height: 36px; - --value-row-min-height: 20px; - --control-height-compact: 28px; - --control-height-small: 24px; - --model-ref-max-width: 152px; - --screen-reader-box: 1px; - --background: var(--surface-app); - --foreground: var(--text-primary); - --card: var(--surface-panel); - --card-foreground: var(--text-primary); - --popover: var(--surface-panel); - --popover-foreground: var(--text-primary); - --primary: var(--accent-primary); - --primary-foreground: var(--text-on-accent); - --secondary: var(--surface-muted); - --secondary-foreground: var(--text-primary); - --muted: var(--surface-muted); - --muted-foreground: var(--text-secondary); - --accent: var(--surface-hover); - --accent-foreground: var(--text-primary); - --destructive: var(--status-error); - --border: var(--border-default); - --input: var(--border-default); - --ring: var(--focus-ring); - font-size: var(--font-body-size); - line-height: var(--font-body-line); -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-success: var(--status-success); - --color-warning: var(--status-warning); - --color-info: var(--status-info); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --control-height-compact: 32px; + --control-height-small: 28px; + --icon-md: 16px; + --icon-sm: 12px; + --scrollbar-track: #0e0e0e; + --scrollbar-track-raised: #121212; + --scrollbar-thumb: #3b494c; + --scrollbar-thumb-hover: #00e5ff; + --scrollbar-thumb-active: #72f2ff; } - * { box-sizing: border-box; } - html, body { + margin: 0; width: 100%; - height: 100%; min-height: 100dvh; - overflow: hidden; + background: var(--surface-app); + color: var(--text-primary); + font-family: var(--font-primary); } - body { + font-size: var(--font-body-size); + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} +button, +input, +textarea, +select { + font: inherit; + letter-spacing: 0; +} +button { + cursor: pointer; +} +:focus-visible { + outline: 1px solid var(--focus-ring); + outline-offset: 2px; + box-shadow: 0 0 12px rgb(0 229 255 / 0.2); +} +a { + color: inherit; + text-decoration: none; +} +.app-shell { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + height: 100dvh; + overflow: hidden; +} +.sidebar { + background: #121212; + border-right: 1px solid var(--border-subtle); + padding: 20px 12px; + display: flex; + flex-direction: column; + gap: 24px; + height: 100dvh; + overflow-y: auto; +} +.brand { + font-family: var(--font-heading); + font-size: 18px; + font-weight: 600; + letter-spacing: 0; +} +.brand-mark { + color: var(--accent-primary); +} +.nav { + display: grid; + gap: 4px; +} +.nav a { + height: 36px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 10px; + border-left: 2px solid transparent; + color: var(--text-secondary); + font-size: 12px; +} +.nav a:hover { + background: var(--surface-hover); + color: var(--text-primary); +} +.nav a[aria-current='page'] { + border-left-color: var(--accent-primary); + background: #06282d; + color: var(--accent-primary); +} +.system-status { + margin-top: auto; + border-top: 1px solid var(--border-subtle); + padding: 16px 8px 0; + color: var(--text-muted); + font-size: 10px; + line-height: 18px; +} +.online { + color: var(--status-success); +} +.workspace { + min-width: 0; + display: flex; + flex-direction: column; + height: 100dvh; + overflow: hidden; +} +.header { + height: 56px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + color: var(--text-secondary); + font-size: 11px; +} +.header-title { + font-family: var(--font-heading); + color: var(--text-primary); + font-weight: 600; +} +.header-meta { + display: flex; + align-items: center; + gap: 16px; +} +.content { + padding: 32px; + min-width: 0; + width: 100%; + max-width: 1500px; + flex: 1; + min-height: 0; + overflow-y: auto; +} +.page-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 28px; +} +.page-head h1 { + font-family: var(--font-heading); + font-size: 28px; + line-height: 36px; margin: 0; - background: var(--surface-app); + font-weight: 600; +} +.eyebrow { + color: var(--text-muted); + font-size: 11px; + margin: 0 0 4px; +} +.panel { + border: 1px solid var(--border-default); + background: var(--surface-panel); +} +.table-wrap { + overflow-x: auto; +} +.data-table { + border-collapse: collapse; + min-width: 840px; + width: 100%; + font-size: 12px; +} +.data-table th { + text-align: left; + color: var(--text-muted); + font-size: 10px; + font-weight: 400; + text-transform: uppercase; + padding: 12px 16px; + border-bottom: 1px solid var(--border-default); +} +.data-table td { + padding: 16px; + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} +.data-table tr:last-child td { + border-bottom: 0; +} +.empty-table-row { + color: var(--text-secondary); +} +.model-name { + font-family: var(--font-heading); + font-weight: 600; + color: var(--text-primary); +} +.muted { + color: var(--text-secondary); +} +.status-badge { + display: inline-flex; + align-items: center; + border: 1px solid var(--border-default); + border-radius: 2px; + padding: 2px 6px; + font-size: 10px; + line-height: 14px; +} +.status-badge.success { + border-color: var(--status-success); + color: var(--status-success); +} +.status-badge.neutral { + color: var(--text-secondary); +} +.status-badge.warning { + border-color: var(--accent-hover); + color: var(--accent-hover); +} +.status-badge.error { + border-color: var(--status-error); + color: var(--status-error); +} +.terminal { + border-left: 2px solid var(--accent-primary); + padding: 16px 20px; + background: #101717; + color: var(--text-secondary); + line-height: 24px; + max-width: 680px; +} +.terminal strong { + color: var(--status-success); + font-weight: 400; +} +.state-banner { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 16px; + border: 1px solid var(--border-default); + border-left: 2px solid var(--accent-primary); + padding: 10px 12px; + color: var(--text-secondary); +} +.state-banner.error { + border-color: var(--status-error); + color: var(--status-error); +} +.state-banner.success { + border-color: var(--status-success); + color: var(--status-success); +} +.diagnostics { + margin: 0 0 16px; + border: 1px solid var(--border-default); + background: var(--surface-panel); + color: var(--text-secondary); +} +.diagnostics summary { + display: flex; + gap: 8px; + align-items: center; + padding: 10px 12px; + cursor: pointer; + color: var(--accent-hover); +} +.diagnostics ul { + margin: 0; + padding: 0 28px 12px; + line-height: 20px; +} +.search-toolbar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} +.search-field { + position: relative; + flex: 1; + max-width: 260px; +} +.search-field svg { + position: absolute; + top: 10px; + left: 10px; + color: var(--text-muted); + pointer-events: none; +} +.search-field input, +.search-toolbar select { + min-height: 36px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--surface-input); + color: var(--text-primary); + padding: 8px 10px; +} +.search-field input { + width: 100%; + padding-left: 32px; +} +.form-panel { + max-width: 860px; + padding: 24px; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px 16px; +} +.field { + display: grid; + gap: 8px; +} +.field.full { + grid-column: 1/-1; +} +.field label, +.group-title { + color: var(--text-secondary); + font-size: 11px; +} +.field-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.key-value-row { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr) 32px; + gap: 8px; + margin-top: 8px; +} +.key-value-row input, +.confirm-strip select { + min-width: 0; + border: 1px solid var(--border-default); + border-radius: 2px; + background: var(--surface-input); + color: var(--text-primary); + min-height: 32px; + padding: 6px 8px; +} +.field input, +.field textarea, +.field select { + width: 100%; + min-width: 0; + border: 1px solid var(--border-default); + border-radius: 2px; + background: var(--surface-input); color: var(--text-primary); + min-height: 36px; + padding: 8px 10px; +} +.field textarea { + min-height: 132px; +} +.field select { + appearance: auto; +} +.check-row { + display: flex; + align-items: center; + gap: 8px; +} +.form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--border-subtle); +} +.group-submit { + min-height: 32px; + border: 1px solid #00c8df; + border-radius: 2px; + background: #00c8df; + color: #020708; + padding: 0 16px; font-family: var(--font-primary); + font-size: 11px; + font-weight: 600; +} +.group-submit:hover { + background: var(--accent-primary); +} +.group-submit:disabled { + cursor: not-allowed; + opacity: var(--disabled-opacity); +} +.type-note { + grid-column: 1/-1; + border: 1px solid var(--border-default); + border-left: 2px solid var(--accent-primary); + padding: 12px; + color: var(--text-secondary); + font-size: 11px; + line-height: 20px; +} +.page-description { + max-width: 620px; + margin: -16px 0 20px; + color: var(--text-secondary); + font-size: 12px; + line-height: 20px; +} +.row-actions { + width: 1%; + padding: 8px 12px !important; + white-space: nowrap; +} +.row-actions > [data-slot='button'] + [data-slot='button'] { + margin-left: 4px; +} +.group-fieldset { + grid-column: 1/-1; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.group-fieldset legend { + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 11px; +} +.group-fieldset legend span { + margin-left: 8px; + color: var(--text-muted); +} +.architecture-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.architecture-card { + min-height: 92px; + padding: 12px; + border: 1px solid var(--border-default); + border-radius: 2px; + background: var(--surface-input); + color: var(--text-primary); + text-align: left; +} +.architecture-card:hover, +.architecture-card:focus-visible { + border-color: var(--accent-primary); + outline: none; +} +.architecture-card.active { + border-color: var(--accent-primary); + background: #07343b; + color: var(--accent-primary); +} +.architecture-card span, +.architecture-card small { + display: block; +} +.architecture-card span { + margin-top: 10px; + font-size: 12px; + font-weight: 600; +} +.architecture-card small { + margin-top: 4px; + color: var(--text-secondary); + font-size: 10px; + line-height: 15px; +} +.roster-list { + display: grid; + border: 1px solid var(--border-default); + border-radius: 2px; + background: var(--surface-input); +} +.roster-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 52px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; +} +.roster-row:last-child { + border-bottom: 0; +} +.roster-row:hover, +.roster-row.active { + background: #1a2425; +} +.roster-row input { + accent-color: var(--accent-primary); +} +.roster-row strong, +.roster-row small { + display: block; +} +.roster-row strong { + font-size: 12px; + font-weight: 500; +} +.roster-row small { + margin-top: 2px; + color: var(--text-muted); + font-size: 10px; +} +.mobile-menu { + display: none; +} +.drawer { + display: none; } -button, -input, +html, +body, +.workspace, +.content, +.table-wrap, +.drawer-panel, textarea { - font-family: inherit; - letter-spacing: 0; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } -@media (prefers-color-scheme: dark) { - :root { - --surface-app: #111315; - --surface-panel: #181b1f; - --surface-muted: #20242a; - --surface-input: #15181c; - --surface-hover: #262b32; - --surface-active: #2b3340; - --text-primary: #f2f4f7; - --text-secondary: #a7b0bf; - --text-muted: #707a88; - --text-on-accent: #111315; - --border-default: #343a44; - --border-subtle: #282d35; - --focus-ring: #6aa4ff; - --accent-primary: #6aa4ff; - --accent-hover: #8cb8ff; - --status-success: #36c26b; - --status-warning: #f0a33a; - --status-error: #ff6b5f; - --status-info: #6aa4ff; - } +html::-webkit-scrollbar, +body::-webkit-scrollbar, +.workspace::-webkit-scrollbar, +.content::-webkit-scrollbar, +.table-wrap::-webkit-scrollbar, +.drawer-panel::-webkit-scrollbar, +textarea::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +html::-webkit-scrollbar-track, +body::-webkit-scrollbar-track, +.workspace::-webkit-scrollbar-track, +.content::-webkit-scrollbar-track, +.table-wrap::-webkit-scrollbar-track, +.drawer-panel::-webkit-scrollbar-track, +textarea::-webkit-scrollbar-track { + background: var(--scrollbar-track); +} + +.drawer-panel::-webkit-scrollbar-track, +textarea::-webkit-scrollbar-track { + background: var(--scrollbar-track-raised); +} + +html::-webkit-scrollbar-thumb, +body::-webkit-scrollbar-thumb, +.workspace::-webkit-scrollbar-thumb, +.content::-webkit-scrollbar-thumb, +.table-wrap::-webkit-scrollbar-thumb, +.drawer-panel::-webkit-scrollbar-thumb, +textarea::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border: 1px solid var(--scrollbar-track); + border-radius: 2px; +} + +.drawer-panel::-webkit-scrollbar-thumb, +textarea::-webkit-scrollbar-thumb { + border-color: var(--scrollbar-track-raised); } +html::-webkit-scrollbar-thumb:hover, +body::-webkit-scrollbar-thumb:hover, +.workspace::-webkit-scrollbar-thumb:hover, +.content::-webkit-scrollbar-thumb:hover, +.table-wrap::-webkit-scrollbar-thumb:hover, +.drawer-panel::-webkit-scrollbar-thumb:hover, +textarea::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +html::-webkit-scrollbar-thumb:active, +body::-webkit-scrollbar-thumb:active, +.workspace::-webkit-scrollbar-thumb:active, +.content::-webkit-scrollbar-thumb:active, +.table-wrap::-webkit-scrollbar-thumb:active, +.drawer-panel::-webkit-scrollbar-thumb:active, +textarea::-webkit-scrollbar-thumb:active { + background: var(--scrollbar-thumb-active); +} + +html::-webkit-scrollbar-corner, +body::-webkit-scrollbar-corner, +.workspace::-webkit-scrollbar-corner, +.content::-webkit-scrollbar-corner, +.table-wrap::-webkit-scrollbar-corner, +.drawer-panel::-webkit-scrollbar-corner, +textarea::-webkit-scrollbar-corner { + background: var(--scrollbar-track); +} +@media (max-width: 760px) { + .app-shell { + display: block; + } + .sidebar { + display: none; + } + .header { + height: 52px; + padding: 0 16px; + } + .header-meta { + display: none; + } + .mobile-menu { + display: inline-flex; + } + .content { + padding: 20px 16px; + } + .page-head { + align-items: flex-start; + margin-bottom: 20px; + } + .page-head h1 { + font-size: 24px; + } + .search-toolbar { + align-items: stretch; + flex-wrap: wrap; + justify-content: flex-end; + } + .search-field { + max-width: none; + width: 100%; + } + .form-panel { + padding: 16px; + } + .form-grid { + grid-template-columns: 1fr; + gap: 16px; + } + .architecture-grid { + grid-template-columns: 1fr; + } + .field.full { + grid-column: auto; + } + .drawer { + position: fixed; + inset: 0; + z-index: 20; + background: rgb(0 0 0 / 0.6); + } + .drawer.open { + display: block; + } + .drawer-panel { + width: min(280px, calc(100vw - 40px)); + height: 100%; + background: #121212; + border-right: 1px solid var(--border-default); + padding: 20px 12px; + display: flex; + flex-direction: column; + gap: 24px; + } + .drawer-top { + display: flex; + justify-content: space-between; + align-items: center; + } + .data-table { + min-width: 760px; + } + .form-actions { + justify-content: stretch; + } + .form-actions > * { + flex: 1; + } +} @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; + * { transition-duration: 0.01ms !important; - scroll-behavior: auto !important; + animation-duration: 0.01ms !important; } } diff --git a/src/lib/appStore.test.ts b/src/lib/appStore.test.ts deleted file mode 100644 index f89ebe3..0000000 --- a/src/lib/appStore.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { get } from 'svelte/store'; -import { describe, expect, it } from 'vitest'; - -import { createQuickSwitchStore } from './appStore'; -import type { QuickSwitchCommandClient } from './contracts'; -import { currentGroupId, fakeClient, loadedState, nextGroupId, switchedState } from './appStoreTestSupport'; - -describe('createQuickSwitchStore', () => { - it('Given a client When loading Then groups, current group, warning, and discovery are derived', async () => { - const client = fakeClient({ loadResponse: loadedState }); - const store = createQuickSwitchStore(client); - - await store.load(); - - const state = get(store); - expect(state.status).toBe('ready'); - expect(state.view.currentGroup?.id).toBe(currentGroupId); - expect(state.error).toBeNull(); - }); - - it('Given switch success with warning When switching Then the store refreshes and exposes the warning', async () => { - const client = fakeClient({ loadResponse: loadedState, switchResponse: switchedState }); - const store = createQuickSwitchStore(client); - - await store.load(); - await store.switchTo(nextGroupId); - - const state = get(store); - expect(state.switchingGroupId).toBeNull(); - expect(state.view.currentGroup?.id).toBe(nextGroupId); - expect(state.view.warning).toBe('OpenCode config was skipped.'); - }); - - it('Given a no-op response When switching Then the current group and status remain stable', async () => { - const client = fakeClient({ - loadResponse: loadedState, - switchResponse: { - outcome: 'noOp', - warnings: ['Already using this group.'], - appState: loadedState.appState - } - }); - const store = createQuickSwitchStore(client); - - await store.load(); - await store.switchTo(currentGroupId); - - const state = get(store); - expect(state.status).toBe('ready'); - expect(state.view.currentGroup?.id).toBe(currentGroupId); - expect(state.view.warning).toBe('Already using this group.'); - expect(state.error).toBeNull(); - }); - - it('Given a clean switch success When a prior warning existed Then the warning state is cleared', async () => { - const client = fakeClient({ - loadResponse: { - ...loadedState, - appState: { - ...loadedState.appState, - lastWarningSummary: { message: 'Previous warning.', count: 1 } - } - }, - switchResponse: { - outcome: 'success', - warnings: [], - appState: { - ...loadedState.appState, - selectedGroupID: nextGroupId, - selectedGroupName: 'Research', - lastWarningSummary: null - } - } - }); - const store = createQuickSwitchStore(client); - - await store.load(); - await store.switchTo(nextGroupId); - - const state = get(store); - expect(state.view.currentGroup?.id).toBe(nextGroupId); - expect(state.view.warning).toBeNull(); - }); - - it('Given a rejected switch command When switching Then the group list remains and the error is rendered', async () => { - const client = fakeClient({ loadResponse: loadedState, switchError: 'Group is disabled.' }); - const store = createQuickSwitchStore(client); - - await store.load(); - await store.switchTo(nextGroupId); - - const state = get(store); - expect(state.view.currentGroup?.id).toBe(currentGroupId); - expect(state.error).toBe('Group is disabled.'); - }); - -}); diff --git a/src/lib/appStore.ts b/src/lib/appStore.ts deleted file mode 100644 index 9598fdf..0000000 --- a/src/lib/appStore.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { writable } from 'svelte/store'; - -import type { AppStateResponse, QuickSwitchCommandClient, Uuid } from './contracts'; -import { commandErrorMessage, tauriQuickSwitchCommandClient } from './tauriClient'; -import { emptyQuickSwitchView, toQuickSwitchView, type QuickSwitchViewData } from './quickSwitchView'; - -export type QuickSwitchStatus = 'idle' | 'loading' | 'ready' | 'error'; - -export type QuickSwitchState = Readonly<{ - status: QuickSwitchStatus; - response: AppStateResponse | null; - view: QuickSwitchViewData; - error: string | null; - switchingGroupId: Uuid | null; -}>; - -export type QuickSwitchStore = Readonly<{ - subscribe: ReturnType>['subscribe']; - load: () => Promise; - switchTo: (id: Uuid) => Promise; -}>; - -const initialState: QuickSwitchState = { - status: 'idle', - response: null, - view: emptyQuickSwitchView, - error: null, - switchingGroupId: null, -}; - -export const createQuickSwitchStore = (client: QuickSwitchCommandClient): QuickSwitchStore => { - const store = writable(initialState); - - const load = async (): Promise => { - store.update((state) => ({ ...state, status: 'loading', error: null })); - try { - const response = await client.loadAppState(); - store.set({ - status: 'ready', - response, - view: toQuickSwitchView(response), - error: null, - switchingGroupId: null, - }); - } catch (error) { - store.update((state) => ({ - ...state, - status: 'error', - error: commandErrorMessage(error), - switchingGroupId: null - })); - } - }; - - const switchTo = async (id: Uuid): Promise => { - store.update((state) => ({ ...state, error: null, switchingGroupId: id })); - try { - const switched = await client.switchGroup(id); - store.update((state) => { - if (!state.response) { - return { ...state, switchingGroupId: null }; - } - const response: AppStateResponse = { - ...state.response, - appState: { - ...switched.appState, - lastWarningSummary: warningSummary(switched.warnings) - } - }; - return { - ...state, - status: 'ready', - response, - view: toQuickSwitchView(response), - error: null, - switchingGroupId: null - }; - }); - } catch (error) { - store.update((state) => ({ - ...state, - status: state.response ? 'ready' : 'error', - error: commandErrorMessage(error), - switchingGroupId: null - })); - } - }; - - return { - subscribe: store.subscribe, - load, - switchTo - }; -}; - - -export const quickSwitchStore = createQuickSwitchStore(tauriQuickSwitchCommandClient); -const warningSummary = (warnings: readonly string[]): AppStateResponse['appState']['lastWarningSummary'] => { - if (warnings.length === 0) { - return null; - } - return { - message: warnings.join('; '), - count: warnings.length - }; -}; diff --git a/src/lib/appStoreTestSupport.ts b/src/lib/appStoreTestSupport.ts deleted file mode 100644 index 1656f69..0000000 --- a/src/lib/appStoreTestSupport.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { AppStateResponse, QuickSwitchCommandClient, SwitchGroupResponse } from './contracts'; - -export const currentGroupId = '11111111-1111-4111-8111-111111111111'; -export const nextGroupId = '22222222-2222-4222-8222-222222222222'; - -export const loadedState: AppStateResponse = { - groups: [ - { id: currentGroupId, name: 'Default', description: null, categoryMappings: [], agentOverrides: [], openCodeAgentOverrides: [], isEnabled: true, updatedAt: '2026-07-09T00:00:00Z' }, - { id: nextGroupId, name: 'Research', description: null, categoryMappings: [], agentOverrides: [], openCodeAgentOverrides: [], isEnabled: true, updatedAt: '2026-07-09T00:00:00Z' } - ], - appState: { - selectedGroupID: currentGroupId, - selectedGroupName: 'Default', - lastSuccessfulWrite: null, - lastWarningSummary: null, - lastErrorSummary: null, - migrationVersion: 2 - }, - discoveredOpenCodeAgentNames: [], - openCodeAgentDiscoveryError: null -}; - -export const switchedState: SwitchGroupResponse = { - outcome: 'success', - warnings: ['OpenCode config was skipped.'], - appState: { - ...loadedState.appState, - selectedGroupID: nextGroupId, - selectedGroupName: 'Research', - lastWarningSummary: { message: 'OpenCode config was skipped.', count: 1 } - } -}; - -type FakeClientOptions = Readonly<{ - loadResponse: AppStateResponse; - switchResponse?: SwitchGroupResponse; - switchError?: string; -}>; - -export const fakeClient = (options: FakeClientOptions): QuickSwitchCommandClient => { - return { - loadAppState: async () => options.loadResponse, - switchGroup: async () => { - if (options.switchError) return Promise.reject({ code: 'groupDisabled', message: options.switchError, detail: null }); - return options.switchResponse ?? switchedState; - } - }; -}; diff --git a/src/lib/components/QuickSwitch.svelte b/src/lib/components/QuickSwitch.svelte deleted file mode 100644 index 87d7ddf..0000000 --- a/src/lib/components/QuickSwitch.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -
- {#if $store.status === 'loading' && !$store.response} -
- -

Loading groups...

-
- {:else if $store.status === 'error' && !$store.response} - - {:else} - - - - store.switchTo(id)} - /> - {/if} -
- - diff --git a/src/lib/components/QuickSwitch.test.ts b/src/lib/components/QuickSwitch.test.ts deleted file mode 100644 index 36016dc..0000000 --- a/src/lib/components/QuickSwitch.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import QuickSwitch from './QuickSwitch.svelte'; -import { createQuickSwitchStore } from '../appStore'; -import type { QuickSwitchCommandClient } from '../contracts'; -import { appState, client, currentGroupId, nextGroupId, noEnabledGroupsState } from './QuickSwitchTestSupport'; - -describe('QuickSwitch', () => { - afterEach(() => { - cleanup(); - }); - - it('Given the Quick Switch surface When rendered Then the root exposes the stable full-surface hook', () => { - const store = createQuickSwitchStore(client({ loadResponse: new Promise(() => undefined) })); - - const { container } = render(QuickSwitch, { props: { store } }); - - expect(container.querySelector('main[data-surface="quick-switch"]')).toBeInstanceOf(HTMLElement); - }); - - it('Given loaded state When rendered Then current group, mappings, and targets are visible', async () => { - const store = createQuickSwitchStore(client()); - - render(QuickSwitch, { props: { store } }); - - expect(await screen.findByRole('heading', { name: 'Default' })).toBeInstanceOf(HTMLElement); - expect(screen.getByText('anthropic/claude-sonnet-4')).toBeInstanceOf(HTMLElement); - expect(screen.getByText('anthropic/claude-opus-4')).toBeInstanceOf(HTMLElement); - expect(screen.getByRole('button', { name: 'Switch to Research' }).hasAttribute('disabled')).toBe(false); - expect(screen.getByText('Active')).toBeInstanceOf(HTMLElement); - - expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); - }); - - it('Given a slow load When rendered Then the loading state reserves the quick switch surface', () => { - const store = createQuickSwitchStore(client({ loadResponse: new Promise(() => undefined) })); - - render(QuickSwitch, { props: { store } }); - - expect(screen.getByText('Loading groups...')).toBeInstanceOf(HTMLElement); - }); - - it('Given a slow load When loading is announced Then the surface exposes polite busy semantics', () => { - const store = createQuickSwitchStore(client({ loadResponse: new Promise(() => undefined) })); - - const { container } = render(QuickSwitch, { props: { store } }); - - const loadingRegion = container.querySelector('[aria-live="polite"][aria-busy="true"]'); - expect(loadingRegion).toBeInstanceOf(HTMLElement); - expect(loadingRegion?.textContent).toContain('Loading groups...'); - }); - - it('Given a never-resolving load When rendered Then loading has no interactive footer controls', () => { - const store = createQuickSwitchStore(client({ loadResponse: new Promise(() => undefined) })); - - render(QuickSwitch, { props: { store } }); - - expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); - expect(screen.queryByRole('contentinfo')).toBeNull(); - }); - - it('Given a rejected load When rendered Then the load failure is visible without leaking Settings', async () => { - const store = createQuickSwitchStore(client({ - loadResponse: Promise.reject(new Error('load failed')) - })); - - render(QuickSwitch, { props: { store } }); - - expect(await screen.findByRole('heading', { name: 'Unable to load groups' })).toBeInstanceOf(HTMLElement); - expect(screen.getByText('load failed')).toBeInstanceOf(HTMLElement); - expect(screen.getByRole('button', { name: 'Retry' })).toBeInstanceOf(HTMLButtonElement); - expect(screen.queryByText('Loading groups...')).toBeNull(); - expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); - }); - - it('Given persisted warnings and an OpenCode discovery error When loaded Then both status messages remain visible', async () => { - const store = createQuickSwitchStore(client({ - state: { - ...appState, - openCodeAgentDiscoveryError: 'OpenCode config is malformed.' - } - })); - - render(QuickSwitch, { props: { store } }); - - expect(await screen.findByText('OpenCode config was skipped.')).toBeInstanceOf(HTMLElement); - expect(screen.getByText('OpenCode agent discovery warning: OpenCode config is malformed.')).toBeInstanceOf(HTMLElement); - expect(screen.getAllByRole('status')).toHaveLength(2); - }); - - it('Given loaded state When initial loading completes Then focus moves to the current group summary', async () => { - const store = createQuickSwitchStore(client()); - - render(QuickSwitch, { props: { store } }); - - const summary = await screen.findByLabelText('Current group summary'); - await waitFor(() => expect(document.activeElement).toBe(summary)); - }); - - it('Given loaded state When root controls render Then the surface has no Settings control', async () => { - const store = createQuickSwitchStore(client()); - - render(QuickSwitch, { props: { store } }); - - await screen.findByRole('heading', { name: 'Default' }); - - expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); - }); - - it('Given no enabled groups When rendered Then the no enabled groups empty state is visible', async () => { - const store = createQuickSwitchStore(client({ state: noEnabledGroupsState })); - - render(QuickSwitch, { props: { store } }); - - expect(await screen.findByText('No enabled groups')).toBeInstanceOf(HTMLElement); - expect(screen.queryByRole('button', { name: /Switch to/ })).toBeNull(); - }); - - it('Given a switchable group When clicked Then success updates the current marker', async () => { - const store = createQuickSwitchStore(client({ - switchResponse: { - outcome: 'success', - warnings: [], - appState: { - ...appState.appState, - selectedGroupID: nextGroupId, - selectedGroupName: 'Research', - lastWarningSummary: null - } - } - })); - - render(QuickSwitch, { props: { store } }); - - (await screen.findByRole('button', { name: 'Switch to Research' })).click(); - - expect(await screen.findByRole('heading', { name: 'Research' })).toBeInstanceOf(HTMLElement); - expect(screen.queryByRole('button', { name: 'Switch to Research' })).toBeNull(); - }); - - it('Given the active group When its no-op state renders Then it has an active marker and no action', async () => { - const store = createQuickSwitchStore(client()); - - render(QuickSwitch, { props: { store } }); - - expect(await screen.findByText('Active')).toBeInstanceOf(HTMLElement); - expect(screen.queryByRole('button', { name: 'Switch to Default' })).toBeNull(); - }); - - it('Given switch rejection When clicked Then the failure is visible and targets stay enabled for a retry', async () => { - const store = createQuickSwitchStore(client({ switchError: 'Group is disabled.' })); - - render(QuickSwitch, { props: { store } }); - - (await screen.findByRole('button', { name: 'Switch to Research' })).click(); - - expect(await screen.findByText('Group is disabled.')).toBeInstanceOf(HTMLElement); - await waitFor(() => expect(screen.getByRole('button', { name: 'Switch to Research' }).hasAttribute('disabled')).toBe(false)); - }); - - it('Given the loaded quick switch When tab order is inspected Then switch controls follow visual order', async () => { - const store = createQuickSwitchStore(client()); - render(QuickSwitch, { props: { store } }); - await screen.findByRole('heading', { name: 'Default' }); - - const focusOrder = Array.from(document.querySelectorAll('button:not([disabled])')) - .map((element) => element.getAttribute('aria-label') ?? element.textContent?.trim()); - expect(focusOrder).toEqual(['Switch to Research']); - }); - - it('Given a focused switch action When switching succeeds Then focus remains on the corresponding active row', async () => { - const store = createQuickSwitchStore(client({ - switchResponse: { - outcome: 'success', - warnings: [], - appState: { - ...appState.appState, - selectedGroupID: nextGroupId, - selectedGroupName: 'Research', - lastWarningSummary: null - } - } - })); - - render(QuickSwitch, { props: { store } }); - - const switchButton = await screen.findByRole('button', { name: 'Switch to Research' }); - switchButton.focus(); - switchButton.click(); - - const activeTarget = await screen.findByLabelText('Research, active group'); - await waitFor(() => expect(document.activeElement).toBe(activeTarget)); - }); -}); diff --git a/src/lib/components/QuickSwitchTestSupport.ts b/src/lib/components/QuickSwitchTestSupport.ts deleted file mode 100644 index d355c1c..0000000 --- a/src/lib/components/QuickSwitchTestSupport.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AppStateResponse, QuickSwitchCommandClient, SwitchGroupResponse } from '../contracts'; - -export const currentGroupId = '11111111-1111-4111-8111-111111111111'; -export const nextGroupId = '22222222-2222-4222-8222-222222222222'; - -export const appState: AppStateResponse = { - groups: [ - { id: currentGroupId, name: 'Default', description: 'Primary local model set', categoryMappings: [{ categoryName: 'build', modelRef: 'anthropic/claude-sonnet-4' }], agentOverrides: [{ agentName: 'planner', modelRef: 'openai/gpt-5.1' }], openCodeAgentOverrides: [{ agentName: 'reviewer', modelRef: 'anthropic/claude-opus-4' }], isEnabled: true, updatedAt: '2026-07-09T00:00:00Z' }, - { id: nextGroupId, name: 'Research', description: 'Long context experiments', categoryMappings: [], agentOverrides: [], openCodeAgentOverrides: [], isEnabled: true, updatedAt: '2026-07-09T00:00:00Z' } - ], - appState: { - selectedGroupID: currentGroupId, - selectedGroupName: 'Default', - lastSuccessfulWrite: null, - lastWarningSummary: { message: 'OpenCode config was skipped.', count: 1 }, - lastErrorSummary: null, - migrationVersion: 2 - }, - discoveredOpenCodeAgentNames: [], - openCodeAgentDiscoveryError: null -}; - -export type ClientOptions = Readonly<{ - state?: AppStateResponse; - loadResponse?: Promise; - switchResponse?: SwitchGroupResponse; - switchError?: string; -}>; - -export const client = (options: ClientOptions = {}): QuickSwitchCommandClient => ({ - loadAppState: async () => options.loadResponse ?? options.state ?? appState, - switchGroup: async () => { - if (options.switchError) return Promise.reject({ code: 'groupDisabled', message: options.switchError, detail: null }); - return options.switchResponse ?? { outcome: 'success', warnings: [], appState: appState.appState }; - } -}); - -export const noEnabledGroupsState: AppStateResponse = { ...appState, groups: appState.groups.map((group) => ({ ...group, isEnabled: false })) }; -export const stateWithoutWarning: AppStateResponse = { ...appState, appState: { ...appState.appState, lastWarningSummary: null } }; diff --git a/src/lib/components/app/AppHeader.svelte b/src/lib/components/app/AppHeader.svelte new file mode 100644 index 0000000..8cf414f --- /dev/null +++ b/src/lib/components/app/AppHeader.svelte @@ -0,0 +1,22 @@ + + +
+
+ CONTROL PLANE +
+
+ ● READYLocal config +
+
diff --git a/src/lib/components/app/AppShell.svelte b/src/lib/components/app/AppShell.svelte new file mode 100644 index 0000000..379baa1 --- /dev/null +++ b/src/lib/components/app/AppShell.svelte @@ -0,0 +1,14 @@ + + +
+ +
+ +
{@render children()}
+
+
diff --git a/src/lib/components/app/AppSidebar.svelte b/src/lib/components/app/AppSidebar.svelte new file mode 100644 index 0000000..a5fa886 --- /dev/null +++ b/src/lib/components/app/AppSidebar.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/app/ConfigFeedback.svelte b/src/lib/components/app/ConfigFeedback.svelte new file mode 100644 index 0000000..6d71464 --- /dev/null +++ b/src/lib/components/app/ConfigFeedback.svelte @@ -0,0 +1,58 @@ + + +{#if config.error} + +{:else if config.notice} +
+ {config.notice} +
+{/if} +{#if config.diagnostics.length} +
+ Config diagnostics ({config.diagnostics.length}) +
    + {#each config.diagnostics as diagnostic}
  • + {diagnostic}{#if /legacy|omo/i.test(diagnostic)} + Legacy OMO configuration is currently ignored.{/if} +
  • {/each} +
+
+{/if} diff --git a/src/lib/components/app/DataTable.svelte b/src/lib/components/app/DataTable.svelte new file mode 100644 index 0000000..4c481e2 --- /dev/null +++ b/src/lib/components/app/DataTable.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children()}
+
diff --git a/src/lib/components/app/EmptyTableRow.svelte b/src/lib/components/app/EmptyTableRow.svelte new file mode 100644 index 0000000..a59c50c --- /dev/null +++ b/src/lib/components/app/EmptyTableRow.svelte @@ -0,0 +1,5 @@ + + +{message} diff --git a/src/lib/components/app/FormActions.svelte b/src/lib/components/app/FormActions.svelte new file mode 100644 index 0000000..bcef182 --- /dev/null +++ b/src/lib/components/app/FormActions.svelte @@ -0,0 +1,7 @@ + + +
{@render children()}
diff --git a/src/lib/components/app/FormField.svelte b/src/lib/components/app/FormField.svelte new file mode 100644 index 0000000..f59d870 --- /dev/null +++ b/src/lib/components/app/FormField.svelte @@ -0,0 +1,17 @@ + + +
+ + {@render control()} + {#if hint}
{@render hint()}
{/if} +
diff --git a/src/lib/components/app/GroupForm.svelte b/src/lib/components/app/GroupForm.svelte new file mode 100644 index 0000000..40741e4 --- /dev/null +++ b/src/lib/components/app/GroupForm.svelte @@ -0,0 +1,217 @@ + + +
{ + event.preventDefault(); + submit(); + }} +> +
+
+ +
+
+ +
+ +
+ Group type +
+ {#each [['opencode', 'OpenCode'], ['slim', 'Slim'], ['oh-my-openagent', 'OhMyOpenAgent']] as option}{/each} +
+
+ {#if config.providers.length === 0}{:else}
+
+ {#if type === 'slim'}{/if}{#if type === 'oh-my-openagent'}{/if} +
+ {#each tab === 'native' ? native : tab === 'slim' ? slim : tab === 'omo' ? omo : categories as entry, index}
+ + update(tab, index, 'agentName' in entry ? 'agentName' : 'categoryName', event.currentTarget.value)} + /> update(tab, index, 'variant', event.currentTarget.value)} + /> +
{/each} +
{/if} +
+ +
+{#if pending}{/if}{#if message}{/if} diff --git a/src/lib/components/app/MobileNavigation.svelte b/src/lib/components/app/MobileNavigation.svelte new file mode 100644 index 0000000..6ac99cc --- /dev/null +++ b/src/lib/components/app/MobileNavigation.svelte @@ -0,0 +1,90 @@ + + + +{#if open}{/if} diff --git a/src/lib/components/app/PageHead.svelte b/src/lib/components/app/PageHead.svelte new file mode 100644 index 0000000..df45a36 --- /dev/null +++ b/src/lib/components/app/PageHead.svelte @@ -0,0 +1,18 @@ + + +
+
+

{eyebrow}

+

{title}

+
+ {#if actions} +
{@render actions()}
+ {:else if children} +
{@render children()}
+ {/if} +
diff --git a/src/lib/components/app/SearchToolbar.svelte b/src/lib/components/app/SearchToolbar.svelte new file mode 100644 index 0000000..8b884c5 --- /dev/null +++ b/src/lib/components/app/SearchToolbar.svelte @@ -0,0 +1,20 @@ + + +
+
+
+ +
diff --git a/src/lib/components/app/StatusBadge.svelte b/src/lib/components/app/StatusBadge.svelte new file mode 100644 index 0000000..0279b20 --- /dev/null +++ b/src/lib/components/app/StatusBadge.svelte @@ -0,0 +1,8 @@ + + +{@render children()} diff --git a/src/lib/components/app/navigation.ts b/src/lib/components/app/navigation.ts new file mode 100644 index 0000000..d1fb45c --- /dev/null +++ b/src/lib/components/app/navigation.ts @@ -0,0 +1,13 @@ +import { Bot, Boxes, LayoutDashboard, Network, Server } from '@lucide/svelte'; + +export const navigationItems = [ + { href: '/', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/providers', label: 'Providers', icon: Server }, + { href: '/models', label: 'Models', icon: Boxes }, + { href: '/agents', label: 'Agents', icon: Bot }, + { href: '/groups', label: 'Groups', icon: Network }, +]; + +export function isNavigationItemActive(pathname: string, href: string) { + return href === '/' ? pathname === '/' : pathname.startsWith(href); +} diff --git a/src/lib/components/quick-switch/CurrentGroupSummary.svelte b/src/lib/components/quick-switch/CurrentGroupSummary.svelte deleted file mode 100644 index ee3fca5..0000000 --- a/src/lib/components/quick-switch/CurrentGroupSummary.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
- - {#if group} -

{group.name}

- {#if group.description} -

{group.description}

- {/if} - - {:else} -

None

-

No group is selected.

- {/if} -
- - diff --git a/src/lib/components/quick-switch/CurrentGroupSummary.test.ts b/src/lib/components/quick-switch/CurrentGroupSummary.test.ts deleted file mode 100644 index fd67f64..0000000 --- a/src/lib/components/quick-switch/CurrentGroupSummary.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { cleanup, render, screen, within } from '@testing-library/svelte'; -import { afterEach, describe, expect, it } from 'vitest'; - -import type { ModelGroup } from '../../contracts'; -import CurrentGroupSummary from './CurrentGroupSummary.svelte'; - -const makeGroup = (overrides: Partial = {}): ModelGroup => ({ - id: 'group-1', - name: 'Research', - description: 'A group for long context work.', - agentOverrides: [{ agentName: 'planner', modelRef: 'openai/gpt-5.1' }], - openCodeAgentOverrides: [{ agentName: 'reviewer', modelRef: 'anthropic/claude-opus-4' }], - categoryMappings: [{ categoryName: 'build', modelRef: 'anthropic/claude-sonnet-4' }], - isEnabled: true, - updatedAt: '2026-07-09T00:00:00Z', - ...overrides -}); - -describe('CurrentGroupSummary', () => { - afterEach(() => cleanup()); - - it('preserves the focus target and renders a present group with an optional description', () => { - const { container } = render(CurrentGroupSummary, { props: { group: makeGroup() } }); - - const summary = screen.getByLabelText('Current group summary'); - expect(summary).toBeInstanceOf(HTMLElement); - expect(summary.getAttribute('tabindex')).toBe('-1'); - expect(screen.getByRole('heading', { name: 'Research' }).id).toBe('quick-switch-title'); - expect(screen.getByText('A group for long context work.')).toBeInstanceOf(HTMLElement); - expect(container.querySelector('.description')).toBeInstanceOf(HTMLElement); - }); - - it('renders the null-group state without value sections', () => { - render(CurrentGroupSummary, { props: { group: null } }); - - expect(screen.getByRole('heading', { name: 'None' }).className).toContain('muted-title'); - expect(screen.getByText('No group is selected.')).toBeInstanceOf(HTMLElement); - expect(screen.queryByRole('region', { name: 'Agent Overrides' })).toBeNull(); - expect(screen.queryByRole('region', { name: 'OpenCode Overrides' })).toBeNull(); - expect(screen.queryByRole('region', { name: 'Category Mappings' })).toBeNull(); - }); - - it('omits an empty description and empty arrays while preserving section order and titles', () => { - render(CurrentGroupSummary, { - props: { - group: makeGroup({ - description: null, - agentOverrides: [{ agentName: 'a very long planner name', modelRef: 'openai/a-very-long-model-ref' }], - openCodeAgentOverrides: [], - categoryMappings: [{ categoryName: 'a very long category name', modelRef: 'local/compact-model' }] - }) - } - }); - - expect(screen.queryByText('A group for long context work.')).toBeNull(); - expect(screen.queryByRole('region', { name: 'OpenCode Overrides' })).toBeNull(); - - const sections = screen.getAllByRole('region') - .map((section) => section.getAttribute('aria-label')) - .filter((label): label is string => label !== 'Current group summary'); - expect(sections).toEqual(['Agent Overrides', 'Category Mappings']); - - const agentSection = screen.getByRole('region', { name: 'Agent Overrides' }); - expect(within(agentSection).getByTitle('a very long planner name')).toBeInstanceOf(HTMLElement); - expect(within(agentSection).getByTitle('openai/a-very-long-model-ref').tagName).toBe('CODE'); - - const mappingSection = screen.getByRole('region', { name: 'Category Mappings' }); - expect(within(mappingSection).getByTitle('a very long category name')).toBeInstanceOf(HTMLElement); - expect(within(mappingSection).getByTitle('local/compact-model').tagName).toBe('CODE'); - }); - - it('keeps all three populated sections in the declared order', () => { - render(CurrentGroupSummary, { props: { group: makeGroup() } }); - - expect(screen.getAllByRole('region') - .map((section) => section.getAttribute('aria-label')) - .filter((label): label is string => label !== 'Current group summary')).toEqual([ - 'Agent Overrides', - 'OpenCode Overrides', - 'Category Mappings' - ]); - }); -}); diff --git a/src/lib/components/quick-switch/CurrentValues.svelte b/src/lib/components/quick-switch/CurrentValues.svelte deleted file mode 100644 index 3f229b7..0000000 --- a/src/lib/components/quick-switch/CurrentValues.svelte +++ /dev/null @@ -1,105 +0,0 @@ - - -
- {@render AgentValueSection('Agent Overrides', group.agentOverrides)} - {@render AgentValueSection('OpenCode Overrides', group.openCodeAgentOverrides)} - {@render MappingValueSection(group.categoryMappings)} -
- -{#snippet AgentValueSection(title: string, rows: readonly ModelGroupAgentOverride[])} - {#if rows.length > 0} -
- -
- {#each rows as row (row.agentName)} -
- {row.agentName} - {row.modelRef} -
- {/each} -
-
- {/if} -{/snippet} - -{#snippet MappingValueSection(rows: readonly ModelGroupCategoryMapping[])} - {#if rows.length > 0} -
- -
- {#each rows as row (row.categoryName)} -
- {row.categoryName} - {row.modelRef} -
- {/each} -
-
- {/if} -{/snippet} - - diff --git a/src/lib/components/quick-switch/SwitchTargets.svelte b/src/lib/components/quick-switch/SwitchTargets.svelte deleted file mode 100644 index bceabeb..0000000 --- a/src/lib/components/quick-switch/SwitchTargets.svelte +++ /dev/null @@ -1,181 +0,0 @@ - - -
-
- - {#if switchingGroupId} - Switching... - {/if} -
- - {#if !hasEnabledGroups} -
{emptyText}
- {:else} -
- {#each targets as target (target.id)} -
- {#if target.active} - - {/if} -
-
- {#if target.active} -
- {#if target.description} -

{target.description}

- {/if} -
- {#if !target.active} - - {/if} -
- {/each} -
- {/if} -
- - diff --git a/src/lib/components/quick-switch/SwitchTargets.test.ts b/src/lib/components/quick-switch/SwitchTargets.test.ts deleted file mode 100644 index e98df38..0000000 --- a/src/lib/components/quick-switch/SwitchTargets.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { cleanup, render, screen, waitFor, within } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import SwitchTargets from './SwitchTargets.svelte'; -import type { SwitchTarget } from '../../quickSwitchView'; - -const activeId = 'default'; -const researchId = 'research'; - -const targets: readonly SwitchTarget[] = [ - { id: activeId, name: 'Default', description: 'Default group', active: true }, - { id: researchId, name: 'Research', description: null, active: false } -]; - -const renderTargets = (overrides: Partial<{ - targets: readonly SwitchTarget[]; - hasEnabledGroups: boolean; - switchingGroupId: typeof activeId | typeof researchId | null; - onSwitch: (id: typeof activeId | typeof researchId) => Promise; -}> = {}) => render(SwitchTargets, { - targets, - hasEnabledGroups: true, - emptyText: 'No enabled groups', - switchingGroupId: null, - onSwitch: vi.fn().mockResolvedValue(undefined), - ...overrides -}); - -describe('SwitchTargets', () => { - afterEach(() => cleanup()); - - it('preserves the no-enabled-groups empty state without actions', () => { - renderTargets({ hasEnabledGroups: false }); - - expect(screen.getByText('No enabled groups')).toBeInstanceOf(HTMLElement); - expect(screen.queryByRole('button', { name: /Switch to/ })).toBeNull(); - }); - - it('keeps active rows actionless and exposes the semantic Active marker', () => { - renderTargets(); - - expect(screen.getByLabelText('Default, active group')).toBeInstanceOf(HTMLElement); - expect(screen.getByText('Active')).toBeInstanceOf(HTMLElement); - expect(screen.queryByRole('button', { name: 'Switch to Default' })).toBeNull(); - }); - - it('keeps target actions after their copy in visual and DOM order', () => { - const { container } = renderTargets(); - const row = container.querySelector('[data-group-id="research"]'); - - expect(row).toBeInstanceOf(HTMLElement); - expect(row && Array.from(row.children).map((child) => child.tagName)).toEqual(['DIV', 'BUTTON']); - expect(within(row as HTMLElement).getByRole('button', { name: 'Switch to Research' })).toBeInstanceOf(HTMLElement); - }); - - it('keeps long target names complete to assistive technology while the row can ellipsize visually', () => { - const longName = 'A target name that is intentionally long enough to exercise compact row overflow handling'; - renderTargets({ - targets: [{ id: researchId, name: longName, description: null, active: false }] - }); - - expect(screen.getByTitle(longName).textContent).toBe(longName); - expect(screen.getByRole('button', { name: `Switch to ${longName}` })).toBeInstanceOf(HTMLElement); - }); - - it('disables every target while one switch is pending and marks the pending action busy', () => { - renderTargets({ switchingGroupId: researchId }); - - const action = screen.getByRole('button', { name: 'Switch to Research' }); - expect((action as HTMLButtonElement).disabled).toBe(true); - expect(action.getAttribute('aria-busy')).toBe('true'); - }); - - it('uses the exact accessible switch label and invokes the requested target', async () => { - const onSwitch = vi.fn().mockResolvedValue(undefined); - renderTargets({ onSwitch }); - - await screen.getByRole('button', { name: 'Switch to Research' }).click(); - - expect(onSwitch).toHaveBeenCalledWith(researchId); - }); - - it('recovers after the parent handles a rejected switch and clears pending state', async () => { - const onSwitch = vi.fn(async () => { - await Promise.reject(new Error('switch failed')).catch(() => undefined); - }); - const view = renderTargets({ onSwitch }); - const action = screen.getByRole('button', { name: 'Switch to Research' }); - - action.click(); - await waitFor(() => expect(onSwitch).toHaveBeenCalledTimes(1)); - await view.rerender({ - targets, - hasEnabledGroups: true, - emptyText: 'No enabled groups', - switchingGroupId: null, - onSwitch - }); - - expect((screen.getByRole('button', { name: 'Switch to Research' }) as HTMLButtonElement).disabled).toBe(false); - expect(screen.queryByText('Research, active group')).toBeNull(); - }); -}); diff --git a/src/lib/components/settings/SettingsShell.svelte b/src/lib/components/settings/SettingsShell.svelte deleted file mode 100644 index c620887..0000000 --- a/src/lib/components/settings/SettingsShell.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - -
-
- {#if $store.status === 'loading' && !$store.appState} -
- -

Loading settings...

-
- {:else if $store.status === 'error' && !$store.appState} - - {:else} - {#if $store.message} - - {#if $store.message.tone === 'success'} - - {/if} - - - {/if} -
-
- - diff --git a/src/lib/components/settings/SettingsShell.test.ts b/src/lib/components/settings/SettingsShell.test.ts deleted file mode 100644 index 3c7dc03..0000000 --- a/src/lib/components/settings/SettingsShell.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('tabbable', async (importOriginal) => { - const actual = await importOriginal(); - const withJsdomDisplayCheck = | undefined>(options: T) => ({ - ...options, - displayCheck: 'none' as const - }); - - return { - ...actual, - tabbable: (node: Parameters[0], options?: Parameters[1]) => actual.tabbable(node, withJsdomDisplayCheck(options)), - focusable: (node: Parameters[0], options?: Parameters[1]) => actual.focusable(node, withJsdomDisplayCheck(options)), - isFocusable: (node: Parameters[0], options?: Parameters[1]) => actual.isFocusable(node, withJsdomDisplayCheck(options)) - }; -}); - -import { createSettingsStore } from '../../settingsStore'; -import { degradedSettingsFixtureClient, settingsFixtureClient } from '../../settingsFixture'; -import type { SettingsCommandClient } from '../../contracts'; -import SettingsShell from './SettingsShell.svelte'; - -const isCheckboxChecked = (checkbox: HTMLElement): boolean => checkbox instanceof HTMLInputElement - ? checkbox.checked - : checkbox.getAttribute('aria-checked') === 'true'; - -const pressSpace = async (checkbox: HTMLElement): Promise => { - const wasChecked = isCheckboxChecked(checkbox); - checkbox.focus(); - await fireEvent.keyDown(checkbox, { key: ' ', code: 'Space' }); - await fireEvent.keyUp(checkbox, { key: ' ', code: 'Space' }); - - if (isCheckboxChecked(checkbox) === wasChecked && checkbox instanceof HTMLInputElement) { - await fireEvent.click(checkbox); - } - - await waitFor(() => expect(isCheckboxChecked(checkbox)).toBe(!wasChecked)); -}; - -describe('SettingsShell', () => { - afterEach(() => cleanup()); - - const pendingLoadClient: SettingsCommandClient = { - ...settingsFixtureClient, - loadAppState: () => new Promise(() => undefined) - }; - - it('Given the Settings surface When rendered Then the root exposes the stable full-surface hook', () => { - const { container } = render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - expect(container.querySelector('main[data-surface="settings"]')).toBeInstanceOf(HTMLElement); - }); - - it('Given a never-resolving load When mounted Then loading owns the detail region and panes do not leak', async () => { - render(SettingsShell, { props: { store: createSettingsStore(pendingLoadClient) } }); - - expect(await screen.findByText('Loading settings...')).toBeInstanceOf(HTMLElement); - expect(screen.getByRole('main', { name: 'Model group settings' }).querySelector('.settings-detail')?.getAttribute('aria-live')).toBe('polite'); - expect(screen.queryByRole('heading', { name: 'Group Settings' })).toBeNull(); - expect(screen.getByText('Loading settings...').closest('[aria-busy="true"]')).toBeInstanceOf(HTMLElement); - }); - - it('Given an initial app-state load failure When mounted Then error and retry own the detail region', async () => { - const loadAppState = vi.fn() - .mockRejectedValueOnce({ code: 'loadAppStateFailed', message: 'Settings load failed.', detail: 'Settings data is unavailable.' }) - .mockImplementationOnce(settingsFixtureClient.loadAppState); - render(SettingsShell, { props: { store: createSettingsStore({ ...settingsFixtureClient, loadAppState }) } }); - - expect(await screen.findByRole('heading', { name: 'Unable to load settings' })).toBeInstanceOf(HTMLElement); - expect(screen.getByText('Settings data is unavailable.')).toBeInstanceOf(HTMLElement); - expect(screen.getByRole('button', { name: 'Retry' })).toBeInstanceOf(HTMLButtonElement); - expect(screen.queryByRole('heading', { name: 'Group Settings' })).toBeNull(); - - await fireEvent.click(screen.getByRole('button', { name: 'Retry' })); - - expect(await screen.findByRole('heading', { name: 'Group Settings' })).toBeInstanceOf(HTMLElement); - expect(loadAppState).toHaveBeenCalledTimes(2); - }); - - it('Given loaded canonical facts and a dirty draft When a later load fails Then detail remains usable with the error message', async () => { - const loadAppState = vi.fn() - .mockImplementationOnce(settingsFixtureClient.loadAppState) - .mockRejectedValueOnce({ code: 'loadAppStateFailed', message: 'Settings reload failed.', detail: 'Settings data is unavailable.' }); - const store = createSettingsStore({ ...settingsFixtureClient, loadAppState }); - render(SettingsShell, { props: { store } }); - - const name = await screen.findByLabelText('Name'); - await fireEvent.input(name, { target: { value: 'Local draft' } }); - await store.load(); - - expect(screen.queryByRole('heading', { name: 'Unable to load settings' })).toBeNull(); - expect(screen.getByText('Settings data is unavailable.')).toBeInstanceOf(HTMLElement); - expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('Local draft'); - }); - - it('Given fixture data When rendered Then group list, active badge, metadata, mappings, overrides, and OpenCode editors are visible', async () => { - render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - expect(await screen.findByRole('heading', { name: 'Group Settings' })).toBeInstanceOf(HTMLElement); - expect(screen.getByText('Default')).toBeInstanceOf(HTMLElement); - expect(screen.getByText('Active')).toBeInstanceOf(HTMLElement); - expect(screen.getByLabelText('Name')).toBeInstanceOf(HTMLInputElement); - expect(screen.getByText('Category Mappings')).toBeInstanceOf(HTMLElement); - expect(screen.getByText('Agent Overrides')).toBeInstanceOf(HTMLElement); - expect(screen.getByText('OpenCode Agent Overrides')).toBeInstanceOf(HTMLElement); - expect(screen.getByLabelText('OpenCode model reviewer')).toBeInstanceOf(HTMLInputElement); - }); - - it('Given editable model groups When Enabled receives Space Then its checked state changes through the accessible control', async () => { - render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - const enabled = await screen.findByRole('checkbox', { name: 'Enabled' }); - await pressSpace(enabled); - }); - - it('Given duplicate name edit When saving Then duplicate-name validation is visible', async () => { - render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - const name = await screen.findByLabelText('Name'); - (name as HTMLInputElement).value = 'Research'; - await fireEvent.input(name); - - expect(await screen.findByText('A group named "Research" already exists.')).toBeInstanceOf(HTMLElement); - expect(screen.getByRole('button', { name: /^Save$/ }).hasAttribute('disabled')).toBe(true); - }); - - it('Given a new group When created Then it appears in the group list before persistence', async () => { - render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - const newGroupButton = (await screen.findAllByRole('button', { name: 'New Group' }))[0]; - if (!newGroupButton) expect.fail('Expected at least one New Group action.'); - await fireEvent.click(newGroupButton); - - expect(await screen.findByText('Untitled Group')).toBeInstanceOf(HTMLElement); - expect(screen.getByRole('button', { name: /Switch To This Group/ }).hasAttribute('disabled')).toBe(true); - }); - - it('Given OpenCode discovery failure When rendered Then preserved overrides are read-only', async () => { - render(SettingsShell, { props: { store: createSettingsStore(degradedSettingsFixtureClient) } }); - - expect(await screen.findByText('OpenCode agent discovery warning: OpenCode config is malformed.')).toBeInstanceOf(HTMLElement); - expect(screen.getByText('Editing disabled until OpenCode agent discovery succeeds.')).toBeInstanceOf(HTMLElement); - expect(screen.getByLabelText('OpenCode override count').textContent).toBe('1'); - expect(screen.queryByLabelText('OpenCode model reviewer')).toBeNull(); - }); - - it('Given discovered OpenCode override When edited Then the input remains bound to the draft value', async () => { - render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - const input = await screen.findByLabelText('OpenCode model reviewer'); - (input as HTMLInputElement).value = 'openai/gpt-5.2'; - await fireEvent.input(input); - - expect((screen.getByLabelText('OpenCode model reviewer') as HTMLInputElement).value).toBe('openai/gpt-5.2'); - }); - - it('Given delete confirmation When opened Then focus stays inside and Escape closes it', async () => { - render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - const deleteTrigger = await screen.findByRole('button', { name: /^Delete$/ }); - deleteTrigger.focus(); - await fireEvent.click(deleteTrigger); - const dialog = await screen.findByRole('dialog', { name: /Delete Default/ }); - const cancel = screen.getAllByRole('button', { name: /^Cancel$/ }).at(-1); - await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true)); - expect(cancel).toBeInstanceOf(HTMLButtonElement); - await fireEvent.keyDown(dialog, { key: 'Escape' }); - expect(screen.queryByRole('dialog', { name: /Delete Default/ })).toBeNull(); - await waitFor(() => expect(document.activeElement).toBe(deleteTrigger)); - }); - - it('Given delete confirmation When focus reaches either boundary or interaction occurs outside Then focus cycles and the dialog persists', async () => { - render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - const deleteTrigger = await screen.findByRole('button', { name: /^Delete$/ }); - await fireEvent.click(deleteTrigger); - const dialog = await screen.findByRole('dialog', { name: /Delete Default/ }); - const cancel = within(dialog).getByRole('button', { name: /^Cancel$/ }); - const confirm = within(dialog).getByRole('button', { name: 'Delete Group' }); - - await waitFor(() => expect(document.activeElement).toBe(cancel)); - confirm.focus(); - await fireEvent.keyDown(confirm, { key: 'Tab' }); - expect(document.activeElement).toBe(cancel); - - await fireEvent.keyDown(cancel, { key: 'Tab', shiftKey: true }); - expect(document.activeElement).toBe(confirm); - - await fireEvent.pointerDown(document.body); - await fireEvent.mouseDown(document.body); - await fireEvent.click(document.body); - expect(screen.getByRole('dialog', { name: /Delete Default/ })).toBe(dialog); - }); - - it('Given batch replace controls When exact match is replaced Then the matching category model updates', async () => { - render(SettingsShell, { props: { store: createSettingsStore(settingsFixtureClient) } }); - - const find = await screen.findByLabelText('Find exact model'); - (find as HTMLInputElement).value = 'anthropic/claude-sonnet-4'; - await fireEvent.input(find); - - await waitFor(() => expect(screen.getByText('1 matches')).toBeInstanceOf(HTMLElement)); - const replace = screen.getByLabelText('Replace with'); - (replace as HTMLInputElement).value = 'openai/gpt-5.2'; - await fireEvent.input(replace); - await fireEvent.click(screen.getByRole('button', { name: 'Replace All Exact Matches' })); - - expect((await screen.findByLabelText('Category model 1') as HTMLInputElement).value).toBe('openai/gpt-5.2'); - }); -}); diff --git a/src/lib/components/settings/groups/DeleteGroupDialog.svelte b/src/lib/components/settings/groups/DeleteGroupDialog.svelte deleted file mode 100644 index c7fe4cd..0000000 --- a/src/lib/components/settings/groups/DeleteGroupDialog.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - - - {#snippet child({ props })} - - {/snippet} - - - - Delete {groupName}? - This removes the group permanently. Target configuration files are not changed. - - - - {#snippet child({ props })}{/snippet} - - - - - diff --git a/src/lib/components/settings/groups/ExactModelReplacementRegion.svelte b/src/lib/components/settings/groups/ExactModelReplacementRegion.svelte deleted file mode 100644 index e495f59..0000000 --- a/src/lib/components/settings/groups/ExactModelReplacementRegion.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - -
-
-

Current Group Model Batch Replace

- {matchCountTotal(matchCounts)} matches -
-
- - - -
-

Category {matchCounts.categoryMappings}, agent {matchCounts.agentOverrides}, OpenCode {matchCounts.openCodeAgentOverrides}

-
- - diff --git a/src/lib/components/settings/groups/GroupDraftEditor.svelte b/src/lib/components/settings/groups/GroupDraftEditor.svelte deleted file mode 100644 index a5a6461..0000000 --- a/src/lib/components/settings/groups/GroupDraftEditor.svelte +++ /dev/null @@ -1,140 +0,0 @@ - - -
-
- - - - {#if draft} - store.deleteSelectedGroup()} /> - {:else} - - {/if} - {#if draft && draft.id !== currentGroupId} - - {/if} -
- - {#if !draft} -
-

Select a group

-

Choose a group to edit or create a new one.

- -
- {:else} -
- - void store.setMatchSearch(value)} onReplace={store.setMatchReplace} onReplaceExactMatches={() => void store.replaceExactMatches()} /> - - - -
- {/if} -
- - diff --git a/src/lib/components/settings/groups/GroupDraftRegions.test.ts b/src/lib/components/settings/groups/GroupDraftRegions.test.ts deleted file mode 100644 index e4248ba..0000000 --- a/src/lib/components/settings/groups/GroupDraftRegions.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { ModelGroup, OpenCodeAgentMappingPresentation } from '../../../contracts'; -import { baseGroups } from '../../../settingsTestSupport'; -import ExactModelReplacementRegion from './ExactModelReplacementRegion.svelte'; -import GroupMetadataRegion from './GroupMetadataRegion.svelte'; -import OpenCodeOverridesRegion from './OpenCodeOverridesRegion.svelte'; -import StandardMappingsRegion from './StandardMappingsRegion.svelte'; - -const draft = baseGroups[0] as ModelGroup; -const editablePresentation: OpenCodeAgentMappingPresentation = { - discoveredRows: [{ id: 'reviewer', agentName: 'reviewer', modelRef: '', isEditable: true }], - staleOverrides: [], preservedOverrides: [], discoveryError: null, isReadOnly: false, allowsCustomAgentCreation: false -}; - -describe('Group draft regions', () => { - afterEach(() => cleanup()); - - it('emits metadata and exact replacement callbacks without owning store behavior', async () => { - const onPatch = vi.fn(); - const onSearch = vi.fn(); - const onReplace = vi.fn(); - const onReplaceExactMatches = vi.fn(); - render(GroupMetadataRegion, { props: { draft, validationMessage: null, onPatch } }); - await fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'Focused' } }); - expect(onPatch).toHaveBeenCalledWith({ name: 'Focused' }); - cleanup(); - render(ExactModelReplacementRegion, { props: { search: '', replace: '', matchCounts: { categoryMappings: 1, agentOverrides: 0, openCodeAgentOverrides: 0 }, onSearch, onReplace, onReplaceExactMatches } }); - await fireEvent.input(screen.getByLabelText('Find exact model'), { target: { value: 'source/model' } }); - await fireEvent.input(screen.getByLabelText('Replace with'), { target: { value: 'target/model' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Replace All Exact Matches' })); - expect(onSearch).toHaveBeenCalledWith('source/model'); - expect(onReplace).toHaveBeenCalledWith('target/model'); - expect(onReplaceExactMatches).toHaveBeenCalledTimes(1); - }); - - it('emits standard mapping and editable OpenCode patches, while degraded values stay readable', async () => { - const onCategoryPatch = vi.fn(); - const onAgentPatch = vi.fn(); - const onAppendCategory = vi.fn(); - const onAppendAgent = vi.fn(); - const onPatch = vi.fn(); - render(StandardMappingsRegion, { props: { categoryMappings: draft.categoryMappings, agentOverrides: draft.agentOverrides, onCategoryPatch, onAgentPatch, onAppendCategory, onAppendAgent } }); - await fireEvent.input(screen.getByLabelText('Category model 1'), { target: { value: 'next/model' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Add Custom Agent' })); - expect(onCategoryPatch).toHaveBeenCalledWith(0, { modelRef: 'next/model' }); - expect(onAppendAgent).toHaveBeenCalledTimes(1); - cleanup(); - render(OpenCodeOverridesRegion, { props: { draft, presentation: editablePresentation, onPatch } }); - await fireEvent.input(screen.getByLabelText('OpenCode model reviewer'), { target: { value: 'openai/gpt-5.2' } }); - expect(onPatch).toHaveBeenCalledWith(expect.objectContaining({ openCodeAgentOverrides: expect.arrayContaining([{ agentName: 'reviewer', modelRef: 'openai/gpt-5.2' }]) })); - cleanup(); - render(OpenCodeOverridesRegion, { props: { draft, presentation: { ...editablePresentation, isReadOnly: true, discoveryError: 'Discovery failed.', discoveredRows: [], preservedOverrides: [{ id: 'preserved:reviewer', agentName: 'reviewer', modelRef: 'saved/model', status: 'Preserved', message: 'Unavailable' }] }, onPatch } }); - const preserved = screen.getByDisplayValue('saved/model') as HTMLInputElement; - expect(preserved.readOnly).toBe(true); - expect(preserved.disabled).toBe(false); - expect(screen.getByRole('alert')).toBeTruthy(); - }); -}); diff --git a/src/lib/components/settings/groups/GroupList.svelte b/src/lib/components/settings/groups/GroupList.svelte deleted file mode 100644 index 46536e8..0000000 --- a/src/lib/components/settings/groups/GroupList.svelte +++ /dev/null @@ -1,172 +0,0 @@ - - - - - diff --git a/src/lib/components/settings/groups/GroupMetadataRegion.svelte b/src/lib/components/settings/groups/GroupMetadataRegion.svelte deleted file mode 100644 index 315d0c2..0000000 --- a/src/lib/components/settings/groups/GroupMetadataRegion.svelte +++ /dev/null @@ -1,47 +0,0 @@ - - -
-
-

Group Metadata

- Updated {new Date(draft.updatedAt).toLocaleString()} -
- - {#if validationMessage} -

{validationMessage}

- {/if} -
diff --git a/src/routes/settings/AppUpdates.svelte b/src/routes/settings/AppUpdates.svelte new file mode 100644 index 0000000..c27abc8 --- /dev/null +++ b/src/routes/settings/AppUpdates.svelte @@ -0,0 +1,187 @@ + + +
+
+
+

+ {i18n.t('settings.updates')} +

+

+ {i18n.t('settings.installedVersion', { + version: appInfo?.version ?? result?.currentVersion ?? i18n.t(infoLoading ? 'common.loading' : 'settings.versionUnavailable'), + })} +

+

{i18n.t('settings.updatesHint')}

+
+ +
+ + {#if infoError} + + {/if} +
+ {#if checking} +

{i18n.t('settings.checkingUpdates')}

+ {:else if result} +

+ {#if result.latestVersion === null} + {i18n.t('settings.noRelease')} + {:else if result.updateAvailable} + {i18n.t('settings.updateAvailable', { version: result.latestVersion })} + {:else} + {i18n.t('settings.upToDate', { version: result.currentVersion })} + {/if} +

+ {/if} +
+ {#if checkError} + + {/if} + {#if result?.updateAvailable} + + {#if appInfo?.platform === 'macos'} +
+ + {i18n.t('settings.macInstallGuide')} + +
+

{i18n.t('settings.macSigning')}

+

{i18n.t('settings.macTrustWarning')}

+
    +
  1. {i18n.t('settings.macDownloadOfficial')}
  2. +
  3. {i18n.t('settings.macReplaceApp')}
  4. +
  5. {i18n.t('settings.macOpenAnyway')}
  6. +
+

{i18n.t('settings.macManualQuarantine')}

+ {i18n.t('settings.macQuarantineCommand')} +

{i18n.t('settings.macAuthorizationScope')}

+
+
+ {/if} + {/if} + +
+
+

+ {i18n.t('settings.supportProject')} +

+

{i18n.t('settings.starRequest')}

+
+ +
+ {#if openError} + + {/if} +
diff --git a/src/routes/skills/+page.svelte b/src/routes/skills/+page.svelte index 23c91ca..692433f 100644 --- a/src/routes/skills/+page.svelte +++ b/src/routes/skills/+page.svelte @@ -72,7 +72,6 @@ onDestroy(() => { -

{i18n.t('skills.managementDescription')}

{#if error} -

{i18n.t('skills.managementDescription')}

{#if saved || page.url.searchParams.get('saved') === '1'}
{i18n.t('configuration.saved')}
{/if} diff --git a/src/shell/AGENTS.md b/src/shell/AGENTS.md index 18afb49..6aeb026 100644 --- a/src/shell/AGENTS.md +++ b/src/shell/AGENTS.md @@ -9,7 +9,7 @@ Provides persistent application chrome, shared desktop/mobile navigation, appear ```text shell/ # Application chrome and global feedback ├── AppShell.svelte # Sidebar, header, route content, feedback, and toaster -├── AppHeader.svelte # Mobile navigation and appearance controls +├── AppHeader.svelte # Mobile navigation, appearance controls, and GitHub opener ├── AppSidebar.svelte # Desktop links, version, and OpenCode reload ├── MobileNavigation.svelte # Modal drawer, focus handling, and navigation ├── navigation.ts # Shared route entries and active-route matching @@ -20,6 +20,8 @@ shell/ # Application chrome and global feedback ├── SplashScreen.svelte # Reusable loading overlay with minimum display time └── __tests__/ # Context-backed shell component tests ├── appearance-controls.test.ts # Translated appearance control labels + ├── app-header.test.ts # GitHub opener, pending guard, error feedback, and teardown + ├── app-version.test.ts # Package version in desktop/mobile chrome, both locales └── config-feedback.test.ts # Error-specific recovery callbacks ``` @@ -29,7 +31,7 @@ shell/ # Application chrome and global feedback | :--- | :--- | :--- | | Composition | `AppShell.svelte` | Renders the `children` snippet inside `main.content`; mounts feedback and toaster once. | | Navigation model | `navigation.ts` | Defines `href`, `labelKey`, and icon entries plus `isNavigationItemActive`. | -| Desktop chrome | `AppHeader.svelte`, `AppSidebar.svelte` | Compose header controls and desktop navigation/reload. | +| Desktop chrome | `AppHeader.svelte`, `AppSidebar.svelte` | Compose header controls, the default-browser GitHub opener, and desktop navigation/reload. | | Mobile navigation | `MobileNavigation.svelte` | Handles drawer focus, Escape/Tab, `goto`, and reload. | | Preferences | `AppearanceControls.svelte` | Reads preferences and calls `setTheme`, `setLocale`, and `refreshAll`. | | Command feedback | `ConfigFeedback.svelte` | Maps errors to recovery actions and clears consumed success notices. | @@ -39,10 +41,12 @@ shell/ # Application chrome and global feedback ## Conventions - **Shared navigation**: Change links in `navigationItems`; both navigation components use `isNavigationItemActive` for `aria-current`. `/` matches exactly; other entries match the route or a slash-delimited descendant. +- **Build version**: Sidebar and mobile drawer import `version` from root `package.json` and interpolate `header.version`; no native request is needed. - **Shell composition**: Keep `ConfigFeedback` and `Toaster` at shell scope, not inside individual pages; the route layout owns the separate `SplashScreen` instance. - **Drawer focus**: Opening focuses the close button after `tick`; explicit closing restores menu-button focus. Escape closes and Tab/Shift+Tab cycle the close button, links, and enabled reload button. - **Navigation completion**: Drawer links await `goto(href)` before closing; drawer reload closes only after `reloadOpencode` succeeds. - **Control guards**: Disable OpenCode reload during `reloading` or `switching`; disable refresh-all during `splashLoading` or `loading`. Theme and locale changes go through config methods. +- **GitHub opener**: Header uses `config.openProjectPage('repository')` with translated accessible name/title and appearance-sized icon control. Guard pending opens, surface failures through the shared error toast, and invalidate late responses on teardown. - **Recovery actions**: Preserve three conflict actions (`reloadKeepingDraft`, `continueEditing`, `discardDraftAndRefresh`); busy, blocked-reference, and fallback errors offer `continueEditing` with branch-specific descriptions. - **Success notices**: `switchGroup` maps to `toast.groupSwitched`, operations starting with `delete` to `toast.deleted`, and other notices to `toast.saved`; clear the notice after enqueueing. - **Toast lifecycle**: Use `toast(options)` and `dismissToast(id)` instead of mutating the queue. `actions` takes precedence over `action`; defaults are 5000 ms, 8000 ms for errors, then a 250 ms leaving state before removal. diff --git a/src/shell/AppHeader.svelte b/src/shell/AppHeader.svelte index b415501..c38ce02 100644 --- a/src/shell/AppHeader.svelte +++ b/src/shell/AppHeader.svelte @@ -1,14 +1,60 @@
-
+
+
+ + +
+
diff --git a/src/shell/AppSidebar.svelte b/src/shell/AppSidebar.svelte index b513527..3a3b68b 100644 --- a/src/shell/AppSidebar.svelte +++ b/src/shell/AppSidebar.svelte @@ -3,6 +3,7 @@ import { page } from '$app/state'; import { Button } from '$src/components/button/index.js'; import { getConfig } from '$src/config/context.js'; import { getI18n } from '$src/i18n/context.js'; +import { version } from '../../package.json'; import { isNavigationItemActive, navigationItems } from './navigation.js'; const config = getConfig(); @@ -20,7 +21,7 @@ const brand = i18n.t('header.brand'); {/each}
- {i18n.t('header.version')} + {i18n.t('header.version', { version })} + + {:else} +
  • {i18n.t('scope.noProjects')}
  • + {/each} + + {#if error} + + {/if} + + + diff --git a/src/components/ResourceScopeNotice.svelte b/src/components/ResourceScopeNotice.svelte new file mode 100644 index 0000000..5dff5b8 --- /dev/null +++ b/src/components/ResourceScopeNotice.svelte @@ -0,0 +1,19 @@ + +{#if projectId !== null || readonly} +
    + {i18n.t(projectId !== null ? 'scope.projectReadOnly' : 'scope.globalReadOnly')} + {#if projectId !== null} + {path} + {/if} +
    +{/if} diff --git a/src/components/__tests__/resource-scope.test.ts b/src/components/__tests__/resource-scope.test.ts new file mode 100644 index 0000000..eff5880 --- /dev/null +++ b/src/components/__tests__/resource-scope.test.ts @@ -0,0 +1,85 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { afterAll, beforeAll, expect, test, vi } from 'vitest'; +import Harness from '$src/test-utils/harness.svelte'; +import ResourceScope from '../ResourceScope.svelte'; +import { makeScopedConfig } from './scope-config.js'; + +const originalScroll = HTMLElement.prototype.scrollIntoView; +beforeAll(() => { + HTMLElement.prototype.scrollIntoView = vi.fn(); +}); +afterAll(() => { + if (originalScroll) HTMLElement.prototype.scrollIntoView = originalScroll; + else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); +}); + +test('scope picker keeps Global first, searches paths, supports keyboard and retains confirmed selection after failure', async () => { + const selectProject = vi.fn().mockRejectedValue(new Error('save denied')); + const config = makeScopedConfig({ selectProject }); + render(ResourceScope, {}, { wrapper: Harness, wrapperProps: { config } }); + expect(screen.getByText('/work/alpha')).toBeTruthy(); + const trigger = screen.getByRole('button', { name: 'Select configuration scope' }); + await fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + const options = await screen.findAllByRole('option'); + expect(options[0]?.textContent).toContain('Global'); + expect(options[1]?.textContent).toContain('/work/alpha'); + const search = screen.getByRole('combobox', { name: 'Search options' }); + await fireEvent.input(search, { target: { value: '/other' } }); + expect(screen.getAllByRole('option')).toHaveLength(2); + await fireEvent.keyDown(search, { key: 'ArrowDown' }); + await fireEvent.keyDown(search, { key: 'Enter' }); + await screen.findByRole('alert'); + expect(selectProject).toHaveBeenCalledExactlyOnceWith('project-b'); + expect(config.selectedProjectId).toBe('project-a'); + expect(trigger.textContent).toContain('alpha'); + expect(screen.getByText('/work/alpha')).toBeTruthy(); + expect(screen.queryByText('/other/alpha')).toBeNull(); +}); + +test('native picker cancellation makes no change; add selects returned folder and duplicate selects existing', async () => { + const pickProjectFolder = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('/new/folder') + .mockResolvedValueOnce('/work/alpha'); + const config = makeScopedConfig({ pickProjectFolder }, null); + render(ResourceScope, {}, { wrapper: Harness, wrapperProps: { config } }); + await fireEvent.click(screen.getByRole('button', { name: 'Add folder' })); + expect(config.addProject).not.toHaveBeenCalled(); + expect(config.selectedProjectId).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Add folder' })); + await screen.findByText('/new/folder'); + expect(config.addProject).toHaveBeenCalledWith('/new/folder'); + await fireEvent.click(screen.getByRole('button', { name: 'Add folder' })); + await screen.findByText('/work/alpha'); + expect(config.projects.filter((project) => project.path === '/work/alpha')).toHaveLength(1); + await fireEvent.click(screen.getByRole('button', { name: 'Return to Global' })); + await waitFor(() => expect(config.selectedProjectId).toBeNull()); +}); + +test('manage only forgets folders, prevents pending dismissal/removal, and returns Global when current folder is removed', async () => { + let resolve = () => {}; + const config = makeScopedConfig(); + const original = config.removeProject; + config.removeProject = vi.fn(async (id: string) => { + await new Promise((done) => { + resolve = done; + }); + await original(id); + }); + render(ResourceScope, {}, { wrapper: Harness, wrapperProps: { config } }); + await fireEvent.click(screen.getByRole('button', { name: 'Manage folders' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/does not delete or change any files/)).toBeTruthy(); + await fireEvent.click(within(dialog).getByRole('button', { name: 'Remove folder /work/alpha from MOM' })); + expect( + (within(dialog).getByRole('button', { name: 'Remove folder /other/alpha from MOM' }) as HTMLButtonElement).disabled, + ).toBe(true); + await fireEvent.keyDown(dialog, { key: 'Escape' }); + expect(screen.getByRole('dialog')).toBeTruthy(); + resolve(); + await waitFor(() => expect(config.selectedProjectId).toBeNull()); + expect(config.removeProject).toHaveBeenCalledExactlyOnceWith('project-a'); + await fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); +}); diff --git a/src/components/__tests__/scope-config.ts b/src/components/__tests__/scope-config.ts new file mode 100644 index 0000000..aeae544 --- /dev/null +++ b/src/components/__tests__/scope-config.ts @@ -0,0 +1,49 @@ +import { createSubscriber } from 'svelte/reactivity'; +import { vi } from 'vitest'; +import type { ConfigStore } from '$src/config/store.svelte.js'; + +export function makeScopedConfig(overrides: Record = {}, initial: string | null = 'project-a') { + let selected = initial; + let projects = [ + { id: 'project-a', path: '/work/alpha' }, + { id: 'project-b', path: '/other/alpha' }, + ]; + let notify = () => {}; + const track = createSubscriber((update) => { + notify = update; + }); + const config = { + preferences: { locale: 'en', theme: 'light' }, + saving: false, + loading: false, + projectSaving: false, + agents: [], + get selectedProjectId() { + track(); + return selected; + }, + get projects() { + track(); + return projects; + }, + selectProject: vi.fn(async (id: string | null) => { + selected = id; + notify(); + }), + pickProjectFolder: vi.fn().mockResolvedValue(null), + addProject: vi.fn(async (path: string) => { + const project = projects.find((entry) => entry.path === path) ?? { id: 'project-new', path }; + if (!projects.includes(project)) projects = [...projects, project]; + selected = project.id; + notify(); + return project; + }), + removeProject: vi.fn(async (id: string) => { + projects = projects.filter((entry) => entry.id !== id); + if (selected === id) selected = null; + notify(); + }), + ...overrides, + }; + return config as unknown as ConfigStore; +} diff --git a/src/config/__tests__/island.test.mjs b/src/config/__tests__/island.test.mjs new file mode 100644 index 0000000..a93a388 --- /dev/null +++ b/src/config/__tests__/island.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createServer } from 'vite'; + +test('island IPC and lazy store preserve exact arguments without config preload or write retries', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + const calls = []; + const state = { + supported: true, + enabled: false, + connection: 'disabled', + connectionGeneration: 7, + sessions: [], + permissions: [{ id: 'perm_3', sessionId: 'ses_child', action: 'edit', resources: ['src/file'], save: ['src/*'] }], + questions: [], + preferences: { theme: 'light', locale: 'zh' }, + notchWidth: 180, + topInset: 32, + }; + const messages = [{ id: 'msg_1', role: 'user', text: 'Hello', time: 1, truncated: false }]; + let failure; + const store = createConfigStore( + createTauriAdapter(async (command, args) => { + calls.push([command, args]); + if (failure) throw failure; + if (['get_island_state', 'set_island_enabled', 'refresh_island'].includes(command)) return state; + if (command === 'island_messages') return messages; + }), + false, + false, + ); + await new Promise(setImmediate); + assert.deepEqual(calls, []); + assert.equal(store.loading, false); + assert.equal(store.splashLoading, false); + store.applyPreferences(state.preferences); + assert.deepEqual(store.preferences, state.preferences); + assert.deepEqual(calls, []); + assert.strictEqual(await store.getIslandState(), state); + assert.strictEqual(await store.setIslandEnabled(true), state); + assert.strictEqual(await store.refreshIsland(), state); + for (const view of ['compact', 'peek', 'expanded']) { + assert.strictEqual(await store.setIslandView(view), undefined); + } + await store.setIslandExpanded(true); + await store.setIslandExpanded(false); + assert.strictEqual(await store.islandMessages('ses_1', 7), messages); + await store.islandSend('ses_1', 'Hello\nworld', 'msg_stable', 7); + await store.islandReplyPermission('ses_1', 'perm_1', 'once', 7); + await store.islandReplyPermission('ses_1', 'perm_2', 'reject', 7); + await store.islandReplyPermission('ses_child', 'perm_3', 'always', 7); + const answer = { field_key: 'option_value', multiple_key: ['first', 'custom'] }; + await store.islandReplyQuestion('ses_1', 'form_1', answer, 7); + await store.islandCancelQuestion('ses_1', 'form_2', 7); + await store.islandInterrupt('ses_1', 7); + assert.deepEqual(calls, [ + ['get_island_state', {}], + ['set_island_enabled', { enabled: true }], + ['refresh_island', {}], + ['set_island_view', { view: 'compact' }], + ['set_island_view', { view: 'peek' }], + ['set_island_view', { view: 'expanded' }], + ['set_island_expanded', { expanded: true }], + ['set_island_expanded', { expanded: false }], + ['island_messages', { sessionId: 'ses_1', expectedConnectionGeneration: 7 }], + [ + 'island_send', + { sessionId: 'ses_1', text: 'Hello\nworld', messageId: 'msg_stable', expectedConnectionGeneration: 7 }, + ], + [ + 'island_reply_permission', + { sessionId: 'ses_1', requestId: 'perm_1', decision: 'once', expectedConnectionGeneration: 7 }, + ], + [ + 'island_reply_permission', + { sessionId: 'ses_1', requestId: 'perm_2', decision: 'reject', expectedConnectionGeneration: 7 }, + ], + [ + 'island_reply_permission', + { sessionId: 'ses_child', requestId: 'perm_3', decision: 'always', expectedConnectionGeneration: 7 }, + ], + ['island_reply_question', { sessionId: 'ses_1', formId: 'form_1', answer, expectedConnectionGeneration: 7 }], + ['island_cancel_question', { sessionId: 'ses_1', formId: 'form_2', expectedConnectionGeneration: 7 }], + ['island_interrupt', { sessionId: 'ses_1', expectedConnectionGeneration: 7 }], + ]); + failure = { code: 'ipc_error', message: 'Reply lost' }; + let beforeSend = calls.length; + await assert.rejects(store.islandSend('ses_1', 'Hello', 'msg_stable', 7), failure); + assert.equal(calls.length, beforeSend + 1); + failure = { code: 'conflict', message: 'Message ID conflict (409)' }; + beforeSend = calls.length; + await assert.rejects(store.islandSend('ses_1', 'Hello', 'msg_stable', 7), failure); + assert.equal(calls.length, beforeSend + 1); + assert.deepEqual(calls.at(-1), [ + 'island_send', + { sessionId: 'ses_1', text: 'Hello', messageId: 'msg_stable', expectedConnectionGeneration: 7 }, + ]); + for (const view of ['compact', 'peek', 'expanded']) { + const before = calls.length; + await assert.rejects(store.setIslandView(view), (error) => { + assert.strictEqual(error, failure); + return true; + }); + assert.equal(calls.length, before + 1); + assert.deepEqual(calls.at(-1), ['set_island_view', { view }]); + } + for (const decision of ['once', 'always']) { + const before = calls.length; + await assert.rejects(store.islandReplyPermission('ses_child', 'perm_3', decision, 7), failure); + assert.equal(calls.length, before + 1); + assert.deepEqual(calls.at(-1), [ + 'island_reply_permission', + { sessionId: 'ses_child', requestId: 'perm_3', decision, expectedConnectionGeneration: 7 }, + ]); + } + assert.equal(store.error, null); + assert.equal(store.saving, false); + assert.equal(store.draftRecovery, null); + } finally { + await vite.close(); + } +}); diff --git a/src/config/__tests__/projects.test.mjs b/src/config/__tests__/projects.test.mjs new file mode 100644 index 0000000..66404a2 --- /dev/null +++ b/src/config/__tests__/projects.test.mjs @@ -0,0 +1,359 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createServer } from 'vite'; + +const first = { id: '11111111-1111-4111-8111-111111111111', path: '/projects/first' }; +const second = { id: '22222222-2222-4222-8222-222222222222', path: '/projects/second' }; +const state = (extra = {}) => ({ + providers: [], + agents: [{ id: 'global-agent', source: 'custom' }], + groups: [], + preferences: { theme: 'dark', locale: 'en' }, + ...extra, +}); +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +test('project management preserves IPC commands, payloads, cancellation, results, and errors', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const calls = []; + let picked = null; + let failure; + const adapter = createTauriAdapter(async (...args) => { + calls.push(args); + if (failure) throw failure; + if (args[0] === 'pick_project_folder') return picked; + if (args[0] === 'add_project') return first; + }); + assert.equal(await adapter.pickProjectFolder(), null); + picked = first.path; + assert.equal(await adapter.pickProjectFolder(), first.path); + assert.strictEqual(await adapter.addProject(first.path), first); + await adapter.selectProject(first.id); + await adapter.selectProject(null); + await adapter.removeProject(first.id); + assert.deepEqual(calls, [ + ['pick_project_folder'], + ['pick_project_folder'], + ['add_project', { path: first.path }], + ['select_project', { id: first.id }], + ['select_project', { id: null }], + ['remove_project', { id: first.id }], + ]); + failure = { code: 'configuration_failed', message: 'Project persistence failed' }; + await assert.rejects(adapter.pickProjectFolder(), failure); + await assert.rejects(adapter.addProject(first.path), failure); + await assert.rejects(adapter.selectProject(first.id), failure); + await assert.rejects(adapter.removeProject(first.id), failure); + } finally { + await vite.close(); + } +}); + +test('resource IPC omits absent or null global scope and sends only explicit project IDs', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const calls = []; + const result = {}; + const adapter = createTauriAdapter(async (...args) => { + calls.push(args); + return result; + }); + const agent = { id: 'custom', source: 'custom' }; + const mcp = { name: 'server', config: {} }; + const skill = { id: 'skill', content: 'content' }; + const operations = [ + ['listAgents', 'list_agents', [], undefined], + ['getAgent', 'get_agent', ['custom'], { id: 'custom' }], + ['createAgent', 'create_agent', [agent], { agent }], + ['updateAgent', 'update_agent', [agent], { agent }], + ['deleteAgent', 'delete_agent', ['custom', 'global_markdown'], { id: 'custom', storage: 'global_markdown' }], + ['listMcps', 'list_mcps', [], undefined], + ['getMcp', 'get_mcp', ['server'], { name: 'server' }], + ['createMcp', 'create_mcp', [mcp], { draft: mcp }], + ['updateMcp', 'update_mcp', [mcp], { draft: mcp }], + ['deleteMcp', 'delete_mcp', ['server'], { name: 'server' }], + ['listSkills', 'list_skills', [], undefined], + ['getSkill', 'get_skill', ['skill'], { id: 'skill' }], + ['createSkill', 'create_skill', [skill], { draft: skill }], + ['updateSkill', 'update_skill', [skill], { draft: skill }], + ]; + for (const [method, command, args, payload] of operations) { + assert.strictEqual(await adapter[method](...args), result); + assert.deepEqual(calls.at(-1), payload ? [command, payload] : [command]); + assert.strictEqual(await adapter[method](...args, null), result); + assert.deepEqual(calls.at(-1), payload ? [command, payload] : [command]); + assert.strictEqual(await adapter[method](...args, first.id), result); + assert.deepEqual(calls.at(-1), [command, { ...payload, projectId: first.id }]); + } + } finally { + await vite.close(); + } +}); + +test('project initialization accepts old snapshots and normalizes stale persisted selection', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + let loaded = state(); + const store = createConfigStore({ loadAppState: async () => loaded }, false); + assert.deepEqual(store.projects, []); + assert.equal(store.selectedProjectId, null); + assert.equal(store.projectSaving, false); + await new Promise(setImmediate); + assert.deepEqual(store.projects, []); + assert.equal(store.selectedProjectId, null); + loaded = state({ projects: [first, second], selectedProjectId: second.id }); + await store.refresh(); + assert.deepEqual(store.projects, [first, second]); + assert.equal(store.selectedProjectId, second.id); + loaded = state({ projects: [first], selectedProjectId: second.id }); + await store.refresh(); + assert.deepEqual(store.projects, [first]); + assert.equal(store.selectedProjectId, null); + } finally { + await vite.close(); + } +}); + +test('project operations are lazy, deduplicate returned IDs and paths, and remove selected scope to Global', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const calls = []; + let picked = null; + let saved = first; + const store = createConfigStore( + createTauriAdapter(async (...args) => { + calls.push(args); + if (args[0] === 'load_app_state') return state({ projects: [first, second], selectedProjectId: second.id }); + if (args[0] === 'opencode_list_models') return []; + if (args[0] === 'pick_project_folder') return picked; + if (args[0] === 'add_project') return saved; + }), + ); + await new Promise(setImmediate); + calls.length = 0; + const resetVersion = store.formResetVersion; + assert.equal(await store.pickProjectFolder(), null); + assert.deepEqual(store.projects, [first, second]); + assert.equal(store.selectedProjectId, second.id); + picked = '/picked/alias'; + assert.equal(await store.pickProjectFolder(), picked); + assert.equal(store.selectedProjectId, second.id); + assert.strictEqual(await store.addProject(picked), first); + assert.deepEqual(store.projects, [second, first]); + assert.equal(store.selectedProjectId, first.id); + await store.addProject(first.path); + assert.deepEqual(store.projects, [second, first]); + saved = { ...first, id: '33333333-3333-4333-8333-333333333333' }; + await store.addProject(first.path); + assert.deepEqual(store.projects, [second, saved]); + await store.removeProject(second.id); + assert.deepEqual(store.projects, [saved]); + assert.equal(store.selectedProjectId, saved.id); + await store.selectProject(null); + assert.equal(store.selectedProjectId, null); + await store.selectProject(saved.id); + assert.equal(store.selectedProjectId, saved.id); + await store.removeProject(saved.id); + assert.deepEqual(store.projects, []); + assert.equal(store.selectedProjectId, null); + assert.deepEqual(calls, [ + ['pick_project_folder'], + ['pick_project_folder'], + ['add_project', { path: picked }], + ['add_project', { path: first.path }], + ['add_project', { path: first.path }], + ['remove_project', { id: second.id }], + ['select_project', { id: null }], + ['select_project', { id: saved.id }], + ['remove_project', { id: saved.id }], + ]); + assert.equal(store.loading, false); + assert.equal(store.saving, false); + assert.equal(store.projectSaving, false); + assert.equal(store.error, null); + assert.equal(store.notice, null); + assert.equal(store.draftRecovery, null); + assert.equal(store.formResetVersion, resetVersion); + } finally { + await vite.close(); + } +}); + +test('project persistence is guarded independently and failures preserve selection without path recovery', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + const pending = deferred(); + const failure = { code: 'configuration_failed', message: 'Project persistence failed' }; + const groupFailure = { code: 'conflict', message: 'Keep the existing Group draft' }; + let selectCalls = 0; + const store = createConfigStore( + { + loadAppState: async () => state({ projects: [first, second], selectedProjectId: first.id }), + saveGroup: async () => { + throw groupFailure; + }, + pickProjectFolder: async () => { + throw failure; + }, + addProject: async () => { + throw failure; + }, + removeProject: async () => { + throw failure; + }, + selectProject: () => { + selectCalls += 1; + return pending.promise; + }, + }, + false, + ); + await new Promise(setImmediate); + await assert.rejects(store.saveGroup({ id: 'draft' }), groupFailure); + const recovery = store.draftRecovery; + const existingError = store.error; + await assert.rejects(store.selectProject('not-saved'), { code: 'not_found' }); + assert.equal(selectCalls, 0); + const selecting = store.selectProject(second.id); + assert.equal(store.projectSaving, true); + assert.equal(store.saving, false); + assert.equal(store.selectedProjectId, first.id); + await assert.rejects(store.addProject('/private/path'), { code: 'busy' }); + await assert.rejects(store.selectProject(null), { code: 'busy' }); + assert.equal(store.projectSaving, true); + pending.reject(failure); + await assert.rejects(selecting, failure); + await assert.rejects(store.pickProjectFolder(), failure); + await assert.rejects(store.addProject('/private/path'), failure); + await assert.rejects(store.removeProject(first.id), failure); + assert.deepEqual(store.projects, [first, second]); + assert.equal(store.selectedProjectId, first.id); + assert.equal(store.projectSaving, false); + assert.strictEqual(store.error, existingError); + assert.equal(store.notice, null); + assert.strictEqual(store.draftRecovery, recovery); + } finally { + await vite.close(); + } +}); + +test('stale global snapshots cannot undo persisted project operations and fresh Group snapshots preserve scope', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + let persisted = state({ projects: [first, second], selectedProjectId: first.id }); + let pending; + const store = createConfigStore( + { + loadAppState: () => pending?.promise ?? Promise.resolve(persisted), + addProject: async () => { + persisted = state({ projects: [first, second], selectedProjectId: second.id }); + return second; + }, + selectProject: async (id) => { + persisted = { ...persisted, selectedProjectId: id }; + }, + removeProject: async (id) => { + persisted = { + ...persisted, + projects: persisted.projects.filter((project) => project.id !== id), + selectedProjectId: null, + }; + }, + saveGroup: async () => {}, + }, + false, + ); + await new Promise(setImmediate); + for (const action of [ + () => store.selectProject(second.id), + () => store.addProject(second.path), + () => store.removeProject(second.id), + ]) { + const old = persisted; + pending = deferred(); + const refreshing = store.refresh(); + await action(); + pending.resolve({ ...old, groups: [{ id: 'still-apply-global-data' }] }); + await refreshing; + assert.deepEqual(store.projects, persisted.projects); + assert.equal(store.selectedProjectId, persisted.selectedProjectId); + assert.equal(store.groups[0].id, 'still-apply-global-data'); + pending = null; + } + await store.selectProject(first.id); + await store.saveGroup({ id: 'global-group' }); + assert.deepEqual(store.projects, [first]); + assert.equal(store.selectedProjectId, first.id); + assert.equal(store.error, null); + } finally { + await vite.close(); + } +}); + +test('explicit-scope resource facades never replace global agents and omitted Global writes ignore shared selection', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const calls = []; + const result = { data: [{ id: 'project-only' }], diagnostics: [] }; + const store = createConfigStore( + createTauriAdapter(async (...args) => { + calls.push(args); + if (args[0] === 'load_app_state') return state({ projects: [first], selectedProjectId: first.id }); + if (args[0] === 'opencode_list_models') return []; + return result; + }), + ); + await new Promise(setImmediate); + calls.length = 0; + for (const [method, args] of [ + ['listAgents', []], + ['getAgent', ['project-only']], + ['listMcps', []], + ['getMcp', ['server']], + ['listSkills', []], + ['getSkill', ['skill']], + ]) { + assert.strictEqual(await store[method](...args, first.id), result); + assert.equal(calls.at(-1)[1].projectId, first.id); + assert.strictEqual(await store[method](...args), result); + assert.equal(calls.at(-1)[1]?.projectId, undefined); + } + assert.deepEqual(store.agents, state().agents); + const agent = { id: 'global-only', source: 'custom' }; + await store.createAgent(agent); + await store.updateAgent(agent, null); + await store.deleteAgent(agent.id); + const mcp = { name: 'global-server', config: {} }; + await store.createMcp(mcp); + await store.updateMcp(mcp, null); + await store.deleteMcp(mcp.name); + const skill = { id: 'global-skill', content: 'body' }; + await store.createSkill(skill); + await store.updateSkill(skill, null); + const writes = calls.filter(([command]) => /^(create|update|delete)_/.test(command)); + assert.equal(writes.length, 8); + assert.ok(writes.every(([, payload]) => !('projectId' in payload))); + assert.equal(store.selectedProjectId, first.id); + assert.deepEqual(store.agents, state().agents); + } finally { + await vite.close(); + } +}); diff --git a/src/routes/agents/[id]/+page.svelte b/src/routes/agents/[id]/+page.svelte new file mode 100644 index 0000000..ef8cf54 --- /dev/null +++ b/src/routes/agents/[id]/+page.svelte @@ -0,0 +1,122 @@ + + +{i18n.t('agents.detailMetaTitle')} +
    + +
    + + +
    +
    +
    + +{#if loading} +
    {i18n.t('common.loadingConfiguration')}
    +{:else if error} + +{:else if agent} +
    +
    +
    +
    {i18n.t('common.mode')}
    +
    {agent.mode ?? i18n.t('common.unset')}
    +
    +
    +
    {i18n.t('common.model')}
    +
    {agent.modelRef ?? i18n.t('common.inherited')}
    +
    +
    +
    {i18n.t('common.description')}
    +
    {agent.description || '—'}
    +
    +
    +
    + {#each agent.sources ?? [] as source, index (`${source.path ?? source.storage}:${index}`)} +
    +

    + {i18n.t('agents.sourcePreview')} + · {i18n.t(source.storage === 'inline' ? 'agents.sourceInline' : 'agents.markdown')} +

    +

    + {source.path ?? i18n.t('agents.pathNotProvided')} +

    +
    {text(source.raw ?? source.fields)}
    + {#if source.prompt && typeof source.raw !== 'string'} +

    {i18n.t('agents.prompt')}

    +
    {source.prompt}
    + {/if} +
    + {:else} +
    +

    {i18n.t('agents.sourcePreview')}

    +
    {text(agent.inline ?? agent.markdown ?? agent)}
    +
    + {/each} +{/if} diff --git a/src/routes/agents/__tests__/agent-detail.test.ts b/src/routes/agents/__tests__/agent-detail.test.ts new file mode 100644 index 0000000..c8eda12 --- /dev/null +++ b/src/routes/agents/__tests__/agent-detail.test.ts @@ -0,0 +1,97 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { page as routePage } from '$app/state'; +import { makeScopedConfig } from '$src/components/__tests__/scope-config.js'; +import Harness from '$src/test-utils/harness.svelte'; +import type { AgentDefinition } from '$src/types/agents.js'; +import AgentDetail from '../[id]/+page.svelte'; + +const page: { params: { id?: string }; url: URL } = routePage; + +vi.mock('$app/state', async () => { + const { createSubscriber } = await import('svelte/reactivity'); + let notify = () => {}; + const track = createSubscriber((update) => { + notify = update; + }); + let params = { id: 'helper' }; + let url = new URL('http://localhost/agents/helper?project=project-a'); + return { + page: { + get params() { + track(); + return params; + }, + set params(value) { + params = value; + notify(); + }, + get url() { + track(); + return url; + }, + set url(value) { + url = value; + notify(); + }, + }, + }; +}); +const agent: AgentDefinition = { + id: 'helper', + source: 'markdown', + description: 'Project helper', + mode: 'subagent', + sources: [ + { + storage: 'global_markdown', + path: '/work/alpha/.opencode/agents/helper.md', + raw: '---\nmode: subagent\n---\n\n', + fields: { mode: 'subagent' }, + prompt: '', + }, + ], +}; +beforeEach(() => { + page.params = { id: 'helper' }; + page.url = new URL('http://localhost/agents/helper?project=project-a&edit=1'); +}); + +test('agent detail loads explicit project, shows source and safe text, and offers no writes or scope selector', async () => { + const getAgent = vi.fn().mockResolvedValue(agent); + const config = makeScopedConfig({ getAgent }, 'project-b'); + const { container } = render(AgentDetail, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByRole('heading', { name: agent.id }); + expect(getAgent).toHaveBeenCalledExactlyOnceWith('helper', 'project-a'); + expect(screen.getByText('/work/alpha/.opencode/agents/helper.md')).toBeTruthy(); + expect(container.querySelector('pre')?.textContent).toContain(''); + expect(container.querySelector('script')).toBeNull(); + expect(screen.queryByRole('button', { name: /Edit|Delete|Select configuration scope/ })).toBeNull(); + expect(config.selectedProjectId).toBe('project-b'); + expect(config.selectProject).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(getAgent).toHaveBeenCalledTimes(2)); + expect(getAgent).toHaveBeenLastCalledWith('helper', 'project-a'); +}); + +test('same resource ID with another scope ignores late detail and Global comparison stays readonly', async () => { + let resolve: (value: AgentDefinition) => void = () => {}; + const getAgent = vi + .fn() + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ) + .mockResolvedValueOnce({ ...agent, description: 'Global comparison' }); + render(AgentDetail, {}, { wrapper: Harness, wrapperProps: { config: makeScopedConfig({ getAgent }) } }); + await waitFor(() => expect(getAgent).toHaveBeenCalledTimes(1)); + page.url = new URL('http://localhost/agents/helper'); + await screen.findByText('Global comparison'); + resolve(agent); + await waitFor(() => expect(screen.queryByText('Project helper')).toBeNull()); + expect(getAgent).toHaveBeenLastCalledWith('helper'); + expect(screen.getByText(/Global comparison · read-only/)).toBeTruthy(); + expect(screen.queryByRole('link', { name: 'Edit helper' })).toBeNull(); +}); diff --git a/src/routes/island/+page.svelte b/src/routes/island/+page.svelte new file mode 100644 index 0000000..4412637 --- /dev/null +++ b/src/routes/island/+page.svelte @@ -0,0 +1,1941 @@ + + +{i18n.t('island.title')} + + +{#snippet approvalOptions(permission: IslandPermission, generation: number)} + {#if permission.save?.length} + {@const key = permissionKey(permission)} +
    togglePermissionMenu(permission, false)} + onpointerleave={(event) => { if (!event.currentTarget.contains(document.activeElement)) { permissionMenuKey = ''; scheduleCompact(); } }} + > + + {#if permissionMenuKey === key} +
    + +
    +

    {i18n.t('island.rememberRules', { action: permission.action })}

    + + {permission.save.join(' · ')} +

    {i18n.t('island.matchingRequestsHint')}

    +
    + +
    + {/if} +
    + {/if} +{/snippet} + +
    enterDetails('dom')} + onpointermove={() => enterDetails('dom')} + onpointerleave={() => leaveDetails('dom')} + onpointerup={() => { if (view === 'compact') revealDetails(true); }} + onfocusin={compactFocus} + onfocusout={compactFocus} +> + {#if !notchedCompact} + + {/if} + {#if !expanded} +
    +
    + + {#if notchedCompact} + + {/if} +
    + + {#if peeking} + + {/if} + {#if snapshot?.questions.length} + {i18n.t('island.openQuestions')} + {/if} +
    +
    + {#if compactAttention && compactPermission} + {#key `${connectionGeneration}/${compactPermission.sessionId}/${compactPermission.id}`} + {@const generation = connectionGeneration} + {@const permission = compactPermission} +
    +
    +
    +
    + {compactPermission.action} + {compactPermission.resources.join(' · ') || compactPermission.message || i18n.t('island.permission')} +
    +
    +
    + {#if compactPermissions.length > 1} + + {i18n.t('island.requestPosition', { current: compactIndex + 1, total: compactPermissions.length })} + + {:else} + {i18n.t('island.permission')} + {/if} +
    +
    + +
    + + {@render approvalOptions(permission, generation)} +
    +
    + +
    +
    + {/key} + {:else if peeking} +
    +
    + {i18n.t('island.quickArea')} + {compactStatusLabel} +
    +

    + {compactError || (snapshot?.questions.length ? i18n.t('island.openQuestions') : selected?.title || i18n.t('island.noSessions'))} +

    + +
    + {/if} +
    + {:else} +
    +
    +
    +

    {i18n.t('island.title')}

    + {#if pendingCount} + {i18n.t('island.pendingCount', { count: pendingCount })} + {/if} +
    +
    + + +
    +
    +
    + {i18n.t(`island.connection.${connection}`)} + {#if snapshot?.error} + {snapshot.error} + {/if} +
    + {#if queryError || operationError || listenerError || permissionAttempt?.error} + + {/if} + {#if permissionAttempt} +

    + {i18n.t(permissionAttempt.status === 'replying' ? 'island.replying' : 'island.permissionReadback')} +

    + {/if} +
    + +
    + {#if selected} +
    + {#key selectedId} +

    + {selected.title || i18n.t('island.untitled')} +

    + {/key} + {#if selected.status === 'running'} + {@const generation = connectionGeneration} + + {/if} +
    + {#key `${connectionGeneration}/${selectedId}`} + {@const generation = connectionGeneration} +
    + {#each permissions as permission (`${permission.sessionId}/${permission.id}`)} +
    +

    {i18n.t('island.permission')}

    +

    {permission.action}

    + {#if permission.message} +

    + {permission.message} +

    + {/if} + {#if permission.resources.length} +
      + {#each permission.resources as resource} +
    • {resource}
    • + {/each} +
    + {/if} +
    +
    + + {@render approvalOptions(permission, generation)} +
    + +
    +
    + {/each} + {#each questions as question (`${question.sessionId}/${question.id}`)} +
    + act(() => config.islandReplyQuestion(question.sessionId, question.id, answer, generation), generation)} + onskip={() => act(() => config.islandCancelQuestion(question.sessionId, question.id, generation), generation)} + /> +
    + {/each} + {#if messagesLoading && messageSession !== selectedId} +

    {i18n.t('common.loading')}

    + {/if} + {#if messageSession === selectedId} + {#each messages as message (message.id)} +
    +

    + {i18n.t(`island.role.${message.role}`)} +

    +

    + {message.text} +

    + {#if message.truncated} +

    {i18n.t('island.truncated')}

    + {/if} +
    + {:else} +

    {i18n.t('island.noMessages')}

    + {/each} + {/if} +
    + {/key} +
    + {#if draft?.uncertain} +

    + {i18n.t(draft.conflict ? 'island.sendConflict' : 'island.sendUncertain')} +

    + {#if draft.refreshed} +

    {i18n.t('island.attemptNotFound')}

    + {/if} + {/if} + + +
    + {#if draft?.uncertain && draft.refreshed} + + {:else} + {i18n.t('island.plainText')} + {/if} + +
    +
    + {:else} +

    {i18n.t('island.selectSession')}

    + {/if} +
    +
    +
    + {i18n.t('island.closeHint')} +
    +
    + {/if} +
    + + diff --git a/src/routes/island/AGENTS.md b/src/routes/island/AGENTS.md new file mode 100644 index 0000000..6be4387 --- /dev/null +++ b/src/routes/island/AGENTS.md @@ -0,0 +1,36 @@ +# Mini Island + +## Directory Structure + +```text +island/ +├── +page.svelte # Three-shape notch island, CSS content motion, quick permission queue, lazy sessions +├── QuestionForm.svelte # Field-key/option-value answers and read-only fallback +└── __tests__/ + ├── island.test.ts # Interactions, presentation lifecycle, send recovery, event bounds and cleanup + └── layout.test.ts # Independent window without global preload +``` + +## Conventions + +- Keep the continuous #070708 screen-edge backdrop and square top edge across compact, peek and expanded, including the content-free notch safe gap. Never simulate a physical notch with a transparent cutout. Every screen starts and collapses compact; geometry only changes density and size, never the view or IPC. Positive `notchWidth` and `topInset` together define the current screen's notch; use finite nonnegative backend screen geometry, never a user-agent/device guess. Compact stays max(notchWidth + 112, 200) × max(topInset, 28). Both notched wings carry information: left has a short state over the running count; right has one 18 px activity visual beside the pending count over its label. The actual notch gap remains empty of content and controls. No-notch compact stays 200 × 28 with two text levels: state/running count plus the snapshot session count, then separate permission and question counts. The same single activity visual remains on the right. Session counts describe the bounded current snapshot (including its cached children), not total/history. Connected idle shows factual zeroes; disabled/connecting/error snapshots replace unavailable counts with em dashes and never present cached aggregates as current. Visible counts cap at 99+; both compact buttons describe exact snapshot and request counts for accessibility. Never show branding, extra icons, project titles, paths, request/message text or raw errors in default compact. Peek stays 400 × (topInset + 132), with a 36 px header and 96 px body below the safe notch gap; expanded stays 560 × 520. Unknown/invalid geometry stays hidden, and cannot request peek, until known. Native owns actual window sizing, not an oversized hidden hit area. +- Compact permission attention requires an enabled connected snapshot and reads across all sessions, including children. Keep the current request stable by session/request ID through reorder; queue controls and explicit Allow once/Reject never select a session, expand, or read messages. One main Island hit surface owns pointer enter/move/leave and focus, including the exposed center and safe top gap; do not duplicate wing/shell handlers that race during crossings or resizing. On every screen hover/focus uses a 100 ms entry timer, and pointer move recovers a missing initial entry without restarting it. Compact click/touch/Enter/Space immediately requests peek only, even without permissions. Keep header controls mounted through peek for keyboard focus continuity. Peek shows cached status, question hints and body Refresh, never chat or automatic focus. Only its single explicit downward Expand sessions disclosure click/keyboard activation opens expanded and reads messages; header/arrow hover never does. Leave uses a single 180 ms timer, including pointer leave during resize, to allow entry into the newly visible body; keep peek while hovered, focused, menu-open, busy or checking a reply. A compact click keeps peek until leave/blur, without a pin control. Do not queue duplicate/no-op peek requests or let late hover override explicit expanded/authoritative collapsed requests. Pending arrival never expands. Screen geometry/status updates never reset compact, peek or expanded, fetch messages outside expanded, focus, or authorize. Local blur follows leave protection; authoritative island:collapsed always wins without canceling/retrying writes. All timers/listeners are cleaned on teardown. +- Permission replies share an exact-ID, live-presence, mounted/connected/enabled/busy guard in both views. Allow once is primary; the same hover-open/click-toggle disclosure offers Always for this project only with nonempty server-provided save rules and explicit user consent. OpenCode persists the action plus request.save in the request's OpenCode Project, not MOM's selected folder; never derive save rules from resources. Its visible hint includes that OpenCode Project, the action, exact patterns in a keyboard-scrollable read-only field and the same-Project matching-pending caveat. Peek contains the menu inside its 96 px body; expanded uses an in-flow menu and scrolls it into view without focusing it. Hover never authorizes. Dropdown Escape returns focus to its trigger before a second Escape compacts. Successful replies request refresh + state readback; never optimistically remove requests. Pending/ambiguous replies stay locked until a connected enabled read confirms removal or an explicit Refresh; no automatic write retry. Automatic permission refresh never confirms an uncertain message draft. +- Use system text and one 18 px CSS-only four-bar activity visual, contained inside the right wing. Idle/disabled/disconnected retain four static low bars, never a dot-only status. Animate only enabled connected actually-running snapshots. prefers-reduced-motion retains a static active wave; activity never adds animation libraries or timers. +- Motion is presentation-only CSS: the black Island fills the current real native viewport (`100vw` × `100dvh`), with a square top edge, content-free camera gap, no whole-backdrop opacity/translation/scale and no CSS width/height tween. Content enters at 0.72 opacity and 4 px below rest over 180 ms, or 220 ms for the session list/conversation, using `cubic-bezier(0.22, 1, 0.36, 1)` without delay. Header heights, lane sizing, lower radii and selected-row colors transition without replacing compact/peek header buttons or activity DOM. Permission identity and selected conversation use keyed immediate replacement: no outro, retained hidden controls, duplicate approval buttons or animation callbacks. Keep the composer mounted during selection; motion never chooses view, focuses, reads messages or performs actions. +- Native frame motion uses main-thread `NSAnimationContext` + `NSWindow.animator()`: changed sizes grow in 220 ms and shrink in 190 ms around the existing top/notch-center anchor. Completion performs final correction/readback before geometry/view commit, eligible explicit expanded focus and async IPC success; the lowercase enum-to-void wire contract is unchanged. Frontend keeps the previous authoritative view during grow and mounts/animates target content only after successful IPC. Native compact requests and blur/close clear selection and emit `island:collapsed` immediately, before shrink; frontend removes expanded/peek controls and their focus nodes without an outro and invalidates late results. Compact remains authoritative even if physical shrink fails; it never restores old controls. Actual intermediate frame bounds own hover/hit testing, not a permanent expanded transparent area. +- Native owns one active/latest pending request guarded by generation, serial and native window identity. A superseded native animator finishes without committing/focusing; reversal can wait for the remaining current animation (up to approximately 220 ms), then runs its own transition, not an immediate retarget. Frontend sends compact immediately during pending grow/peek, deduplicates the same pending target, and accepts only the latest request identity/version; stale grow success/failure never restores controls or reads messages. Compact's first early `island:collapsed` acknowledges its current request without discarding genuine final-readback error feedback; a later authoritative collapse, new request or teardown invalidates that result. Failed shrink stays compact, reports through the existing status-control title, and never retries automatically. Changed/SSE notifications update content only; display/DPI placement reconciles instantly after active motion. Startup, unchanged/position-only frames, placement/error correction and native reduced-motion use the instant path. CSS reduced-motion cancels all new CSS motion at runtime; independently, native samples `NSWorkspace.accessibilityDisplayShouldReduceMotion()` at each transition start without a watcher, so an already-running native animation may finish. Do not delay authoritative collapse or wait for either animation to remove controls. +- Source/jsdom checks do not prove AppKit/WebKit smoothness, native focus timing, viewport repaint, attachment, seams, actual bounds or physical camera hover. Grow may expose black space while the old view remains; immediate compact content is centered in the still-shrinking real viewport. Verify the rebuilt app on notched and non-notched screens, including rapid reversal, keyboard/blur collapse, runtime Reduce Motion and display changes; never infer manual acceptance from tests. +- Register five window-targeted listeners before the initial snapshot read: `island:changed`, `island:collapsed`, `island:pointer-enter`, `island:pointer-leave`, and `app:preferences-changed`. Pointer events have void payloads and only update native hover, never focus, expand, read or mutate. Keep DOM/native hover as separate booleans with combined presence: either source retains peek, duplicate entry never restarts the existing 100 ms deadline, and only both sources leaving can start the existing protected 180 ms shrink. DOM pointermove recovers entry. Expanded ignores hover; disabled snapshots, authoritative collapse and teardown clear both flags and hover timers, and stale peek readback cannot revive the view. Native delivery does not imply the physical camera/notch area can be hovered. Dispose late listener registrations on teardown. Event invalidations retain one bounded 200 ms timer and serialized dirty reads, never polling or per-token requests. +- Only explicit Refresh can confirm an uncertain attempt from a public user message with the exact submission ID. Same text or an assistant ID is not proof; 409 itself is not success. +- Cache-only snapshots expose monotonic `connectionGeneration`. A newer generation clears selection, messages, drafts/message IDs, permission attempts/menus and keyed question forms without changing view, hover, native motion or focus. Ignore older generations and late prior-generation message/action results even when session/request IDs match. Snapshot reads remain serialized and event-coalesced, independently of serialized per-generation message reads so a stalled old message read cannot block connection invalidation. Never retry a mutation across generations. +- Messages, send, permission/question replies, question cancellation and interruption append the displayed snapshot/form generation to the config call as required `expectedConnectionGeneration`. Capture before invoking; never substitute the latest backend generation or replay old payloads after a conflict/refresh. This protects clicks made after native switched but before the new snapshot reached the UI. +- New connection generations invalidate pending resize identities without changing the committed view or hover; late old grow success/failure cannot open controls, focus, read messages or restore errors. Rejected sends with validation/conflict codes request cache readback only, never automatic Refresh or resend. +- If Refresh finds no matching user ID, preserve the attempt for an explicit same-ID retry or offer local discard. Discard clears text/ID and never sends; a later Send is a new user action. +- Validation/not-found and HTTP 400/401/403/404/422 rejections leave drafts editable. Editing resets the ID; unchanged manual retry retains it. +- Compact running counts require a connected snapshot. Writes also require `enabled === true`. Collapse, unmount and discard never reject requests or interrupt sessions. +- All fixed copy uses `island.*` keys in both dictionaries; backend `answerable` owns form metadata and constraint validation. + +## Sync + +Keep this scope document aligned with Island files and lifecycle changes without editing unrelated route scopes. diff --git a/src/routes/island/QuestionForm.svelte b/src/routes/island/QuestionForm.svelte new file mode 100644 index 0000000..26abc1a --- /dev/null +++ b/src/routes/island/QuestionForm.svelte @@ -0,0 +1,173 @@ + + +
    +

    {question.title}

    + {#if !answerable} +

    {i18n.t('island.questionReadOnly')}

    + {/if} + {#each question.fields as field (field.key)} +
    + + {field.title ?? field.key}{field.required ? ` · ${i18n.t('island.required')}` : ''} + + {#if field.description} +

    {field.description}

    + {/if} + {#each field.options as option (option.value)} + {@const inputId = `${question.id}-${field.key}-${option.value}`} + + {/each} + {#if field.custom} + {#if field.type === 'string'} + + {/if} +
    + + {#if field.type === 'multiselect'} + + {/if} +
    + {#if field.type === 'multiselect'} + {#each choices(field).filter((value) => !field.options.some((option) => option.value === value)) as value (value)} + + {/each} + {/if} + {/if} +
    + {/each} + {#if invalid} + + {/if} +
    + {#if answerable} + + {/if} + +
    +
    diff --git a/src/routes/island/__tests__/island.test.ts b/src/routes/island/__tests__/island.test.ts new file mode 100644 index 0000000..52ebc1f --- /dev/null +++ b/src/routes/island/__tests__/island.test.ts @@ -0,0 +1,2931 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import type { ConfigStore } from '$src/config/store.svelte.js'; +import Harness from '$src/test-utils/harness.svelte'; +import type { IslandMessage, IslandState } from '$src/types/island.js'; +import Island from '../+page.svelte'; +import islandSource from '../+page.svelte?raw'; + +const native = vi.hoisted(() => ({ + isTauri: vi.fn(() => true), + listen: vi.fn(), + handlers: new Map void>(), + unlisten: vi.fn(), +})); +vi.mock('@tauri-apps/api/core', () => ({ isTauri: native.isTauri })); +vi.mock('@tauri-apps/api/event', () => ({ listen: native.listen })); + +function state(): IslandState { + return { + supported: true, + enabled: true, + connection: 'connected', + connectionGeneration: 0, + sessions: [ + { id: 'recent', title: 'Recent work', directory: '/code/recent-project', updatedAt: 4, status: 'idle' }, + { id: 'running', title: 'Running work', directory: '/code/active-project', updatedAt: 3, status: 'running' }, + { id: 'pending', title: 'Pending work', directory: '/code/pending-project', updatedAt: 1, status: 'idle' }, + { + id: 'child', + title: 'Hidden child', + directory: '/code/recent-project', + updatedAt: 5, + status: 'idle', + parentId: 'recent', + }, + ], + permissions: [ + { + id: 'perm_1', + sessionId: 'pending', + action: 'bash', + resources: ['git status'], + message: 'Read repository status', + }, + ], + questions: [], + preferences: { locale: 'en', theme: 'dark' }, + notchWidth: 180, + topInset: 32, + }; +} + +function mount(overrides: Record = {}, initial = state()) { + let current = initial; + const config = { + preferences: { locale: 'en', theme: 'dark' }, + applyPreferences: vi.fn(), + getIslandState: vi.fn().mockImplementation(async () => current), + refreshIsland: vi.fn().mockImplementation(async () => current), + setIslandView: vi.fn().mockResolvedValue(undefined), + islandMessages: vi + .fn() + .mockImplementation(async (id: string) => [ + { id: `msg_${id}`, role: 'assistant', text: `${id}\nPublic text`, time: 1, truncated: false }, + ]), + islandSend: vi.fn().mockResolvedValue(undefined), + islandReplyPermission: vi.fn().mockResolvedValue(undefined), + islandReplyQuestion: vi.fn().mockResolvedValue(undefined), + islandCancelQuestion: vi.fn().mockResolvedValue(undefined), + islandInterrupt: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; + const view = render(Island, {}, { wrapper: Harness, wrapperProps: { config: config as unknown as ConfigStore } }); + return { + ...view, + config, + setState: (next: IslandState) => { + current = next; + }, + }; +} +async function expand() { + await peek(); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + await screen.findByRole('button', { name: 'Allow once' }); + await waitFor(() => + expect(screen.getByRole('region', { name: 'Recent messages and pending requests' }).getAttribute('aria-busy')).toBe( + 'false', + ), + ); +} +async function peek() { + await screen.findByRole('button', { name: 'Show quick area' }); + await fireEvent.pointerEnter(screen.getByRole('main', { name: 'Sessions' })); + await screen.findByRole('button', { name: 'Allow once' }); +} +beforeEach(() => { + native.isTauri.mockReturnValue(true); + native.listen.mockReset(); + native.handlers.clear(); + native.unlisten.mockClear(); + native.listen.mockImplementation(async (name: string, handler: () => void) => { + native.handlers.set(name, handler); + return () => { + native.handlers.delete(name); + native.unlisten(name); + }; + }); +}); + +test('read → expand → select → messages → send → permission → collapse cleans up', async () => { + const view = mount(); + await screen.findByText('1 pending'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(native.listen).toHaveBeenCalledTimes(5); + for (const name of [ + 'island:changed', + 'island:collapsed', + 'island:pointer-enter', + 'island:pointer-leave', + 'app:preferences-changed', + ]) + expect(native.listen).toHaveBeenCalledWith(name, expect.any(Function), { target: 'island' }); + expect(view.container.querySelector('.notch-gap')?.children).toHaveLength(0); + expect(view.container.querySelector('main')?.getAttribute('style')).toContain('--top-inset: 32px'); + await expand(); + expect(view.config.setIslandView).toHaveBeenCalledWith('expanded'); + const navigation = screen.getByRole('navigation', { name: 'Sessions' }); + expect( + within(navigation) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual([ + expect.stringContaining('Pending work'), + expect.stringContaining('Running work'), + expect.stringContaining('Recent work'), + ]); + expect(screen.queryByText('Hidden child')).toBeNull(); + expect(screen.getByText('pending Public text').querySelector('b')).toBeNull(); + expect(view.config.islandMessages).toHaveBeenCalledWith('pending', 0); + expect(document.activeElement).not.toBe(screen.getByRole('textbox', { name: 'Message' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: 'Hello\nworld' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(view.config.islandSend).toHaveBeenCalledOnce()); + expect(view.config.islandSend).toHaveBeenCalledWith( + 'pending', + 'Hello\nworld', + expect.stringMatching(/^msg_[0-9a-f-]{36}$/), + 0, + ); + await waitFor(() => expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe('')); + await fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + expect(view.config.islandReplyPermission).toHaveBeenCalledExactlyOnceWith('pending', 'perm_1', 'once', 0); + view.setState({ ...state(), permissions: [] }); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Allow once' })).toBeNull()); + await fireEvent.click(within(navigation).getByRole('button', { name: /Running work/ })); + await screen.findByText('running Public text'); + await fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + expect(view.config.islandInterrupt).toHaveBeenCalledExactlyOnceWith('running', 0); + await fireEvent.keyDown(window, { key: 'Escape' }); + await screen.findByRole('button', { name: 'Show quick area' }); + expect(view.config.setIslandView).toHaveBeenLastCalledWith('compact'); + const messagesRead = view.config.islandMessages.mock.calls.length; + const changed = native.handlers.get('island:changed'); + changed?.(); + const reads = view.config.getIslandState.mock.calls.length; + view.unmount(); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(native.unlisten).toHaveBeenCalledTimes(5); + changed?.(); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.islandMessages).toHaveBeenCalledTimes(messagesRead); +}); + +test('ambiguous sends keep draft and UUID, guard double submit, and require explicit refresh before retry', async () => { + let fail!: (cause: Error) => void; + const islandSend = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + fail = reject; + }), + ) + .mockResolvedValueOnce(undefined); + const view = mount({ islandSend }); + await expand(); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: 'Keep this draft' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + expect(islandSend).toHaveBeenCalledOnce(); + fail(new Error('Reply lost')); + await screen.findByText('Message may have been accepted. Refresh before retrying.'); + const retry = screen.getByRole('button', { name: 'Retry same message' }) as HTMLButtonElement; + expect(retry.disabled).toBe(true); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe('Keep this draft'); + await fireEvent.click(screen.getByRole('button', { name: /Running work/ })); + await screen.findByText('running Public text'); + await fireEvent.click(screen.getByRole('button', { name: /Pending work/ })); + await screen.findByText('pending Public text'); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe('Keep this draft'); + expect((screen.getByRole('button', { name: 'Retry same message' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => + expect((screen.getByRole('button', { name: 'Retry same message' }) as HTMLButtonElement).disabled).toBe(false), + ); + expect(view.config.refreshIsland).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Retry same message' })); + expect(islandSend.mock.calls[1]).toEqual(islandSend.mock.calls[0]); + await waitFor(() => expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe('')); +}); + +test('only explicit Refresh with the same public user ID confirms acceptance and permits a new message without duplicate POST', async () => { + const islandSend = vi.fn().mockRejectedValueOnce(new Error('Reply lost')).mockResolvedValueOnce(undefined); + const view = mount({ islandSend }); + await expand(); + const textbox = screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement; + await fireEvent.input(textbox, { target: { value: 'Original text' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await screen.findByText('Message may have been accepted. Refresh before retrying.'); + const messageId = islandSend.mock.calls[0][2]; + view.config.islandMessages.mockResolvedValue([ + { id: messageId, role: 'assistant', text: 'Wrong role', time: 1, truncated: false }, + { id: 'msg_different', role: 'user', text: 'Original text', time: 1, truncated: false }, + ]); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await screen.findByRole('button', { name: 'Discard this attempt' }); + expect(textbox.value).toBe('Original text'); + expect(textbox.disabled).toBe(true); + expect(islandSend).toHaveBeenCalledOnce(); + view.config.islandMessages.mockResolvedValue([ + { id: messageId, role: 'user', text: 'Accepted public text', time: 1, truncated: false }, + ]); + native.handlers.get('island:changed')?.(); + await screen.findByText('Accepted public text'); + expect(textbox.value).toBe('Original text'); + expect(textbox.disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(textbox.value).toBe('')); + expect(textbox.disabled).toBe(false); + expect(screen.queryByRole('button', { name: 'Retry same message' })).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(islandSend).toHaveBeenCalledOnce(); + await fireEvent.input(textbox, { target: { value: 'Next message' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(islandSend).toHaveBeenCalledTimes(2)); + expect(islandSend.mock.calls[1]).toEqual([ + 'pending', + 'Next message', + expect.stringMatching(/^msg_[0-9a-f-]{36}$/), + 0, + ]); + expect(islandSend.mock.calls[1][2]).not.toBe(messageId); +}); + +test('explicit discard after 409 clears only the local attempt and allows a subsequent manual Send', async () => { + const islandSend = vi + .fn() + .mockRejectedValueOnce({ code: 'conflict', message: 'HTTP 409' }) + .mockResolvedValueOnce(undefined); + const view = mount({ islandSend }); + await expand(); + const textbox = screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement; + await fireEvent.input(textbox, { target: { value: 'Conflicted attempt' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await screen.findByText( + 'Message ID conflict (409). Refresh and check the session before retrying. Your draft and message ID are kept.', + ); + expect(screen.queryByRole('button', { name: 'Discard this attempt' })).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Discard this attempt' })); + expect(textbox.value).toBe(''); + expect(textbox.disabled).toBe(false); + expect((screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement).disabled).toBe(true); + expect(islandSend).toHaveBeenCalledOnce(); + expect(view.config.islandInterrupt).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandReplyQuestion).not.toHaveBeenCalled(); + expect(view.config.islandCancelQuestion).not.toHaveBeenCalled(); + await fireEvent.input(textbox, { target: { value: 'New explicit attempt' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(islandSend).toHaveBeenCalledTimes(2)); + expect(islandSend.mock.calls[1][2]).not.toBe(islandSend.mock.calls[0][2]); + await waitFor(() => expect(textbox.value).toBe('')); + native.handlers.get('island:collapsed')?.(); + await screen.findByRole('button', { name: 'Show quick area' }); + view.unmount(); + expect(islandSend).toHaveBeenCalledTimes(2); + expect(view.config.islandInterrupt).not.toHaveBeenCalled(); + expect(view.config.islandCancelQuestion).not.toHaveBeenCalled(); +}); + +test.each([ + { code: 'validation_failed', message: 'Invalid message' }, + { code: 'not_found', message: 'Session not found' }, + { code: 'configuration_failed', message: 'API HTTP 400' }, + { code: 'configuration_failed', message: 'API HTTP 401' }, + { code: 'configuration_failed', message: 'API HTTP 404' }, +])( + 'definite rejection leaves draft editable, retains same-ID retry until editing, and never automatically posts ($message)', + async (failure) => { + const islandSend = vi.fn().mockRejectedValue(failure); + mount({ islandSend }); + await expand(); + const textbox = screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement; + await fireEvent.input(textbox, { target: { value: 'Rejected text' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await screen.findByText(failure.message); + expect(textbox.disabled).toBe(false); + expect(textbox.value).toBe('Rejected text'); + expect(screen.queryByText('Message may have been accepted. Refresh before retrying.')).toBeNull(); + expect(islandSend).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(islandSend).toHaveBeenCalledTimes(2)); + expect(islandSend.mock.calls[1]).toEqual(islandSend.mock.calls[0]); + await waitFor(() => expect(textbox.disabled).toBe(false)); + await fireEvent.input(textbox, { target: { value: 'Edited text' } }); + expect(islandSend).toHaveBeenCalledTimes(2); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(islandSend).toHaveBeenCalledTimes(3)); + expect(islandSend.mock.calls[2][2]).not.toBe(islandSend.mock.calls[0][2]); + }, +); + +test.each([ + ['en', 'Session status · Show quick area', '2 running', '0 running', 'Connection unavailable'], + ['zh', '会话状态 · 显示快捷区', '2 个运行中', '0 个运行中', '连接不可用'], +])( + 'compact shows connected running count including children, never stale/unknown zero (%s)', + async (locale, expandName, running, zero, unavailable) => { + const initial = state(); + initial.sessions = initial.sessions.map((session) => + session.id === 'child' ? { ...session, status: 'running' } : session, + ); + const view = mount({ preferences: { theme: 'dark', locale } }, initial); + await screen.findByText(running); + const compact = screen.getByRole('button', { name: expandName }); + expect(compact.textContent).toContain(running); + view.setState({ ...initial, sessions: initial.sessions.map((session) => ({ ...session, status: 'idle' })) }); + native.handlers.get('island:changed')?.(); + await screen.findByText(zero); + view.setState({ ...initial, connection: 'error' }); + native.handlers.get('island:changed')?.(); + await screen.findByText(unavailable); + expect(compact.textContent).not.toContain(running); + expect(compact.textContent).not.toContain(zero); + }, +); + +test('enabled=false blocks all session writes despite a malformed connected snapshot', async () => { + const view = mount(); + await expand(); + const textbox = screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement; + await fireEvent.input(textbox, { target: { value: 'Do not send' } }); + view.setState({ ...state(), enabled: false }); + native.handlers.get('island:changed')?.(); + await screen.findByText('Disabled'); + expect((screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole('button', { name: 'Allow once' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.submit(textbox.closest('form') as HTMLFormElement); + await fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + await fireEvent.click(screen.getByRole('button', { name: /Running work/ })); + expect((screen.getByRole('button', { name: 'Stop' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Stop' })); + expect(view.config.islandSend).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandInterrupt).not.toHaveBeenCalled(); +}); + +test('continuous invalidations flush on the first bounded deadline and serialize dirty reads without polling', async () => { + const view = mount(); + await screen.findByText('1 pending'); + const previous = view.config.getIslandState.mock.calls.length; + let finish!: (value: IslandState) => void; + view.config.getIslandState.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + vi.useFakeTimers(); + try { + for (let index = 0; index < 8; index += 1) { + native.handlers.get('island:changed')?.(); + await vi.advanceTimersByTimeAsync(80); + if (index === 2) expect(view.config.getIslandState).toHaveBeenCalledTimes(previous + 1); + } + expect(view.config.getIslandState).toHaveBeenCalledTimes(previous + 1); + await vi.advanceTimersByTimeAsync(200); + finish(state()); + await vi.advanceTimersByTimeAsync(0); + await tick(); + expect(view.config.getIslandState).toHaveBeenCalledTimes(previous + 2); + await vi.advanceTimersByTimeAsync(1000); + expect(view.config.getIslandState).toHaveBeenCalledTimes(previous + 2); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +test.each([ + { code: 'conflict', message: 'Message ID already exists' }, + { + code: 'configuration_failed', + message: 'opencode island: API HTTP 409; write may have been accepted; do not retry.', + }, +])( + '409 is never success: keeps draft/ID and requires another explicit refresh after conflict ($code)', + async (failure) => { + const islandSend = vi.fn().mockRejectedValue(failure); + const view = mount({ islandSend }); + await expand(); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: 'Original draft' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await screen.findByText( + 'Message ID conflict (409). Refresh and check the session before retrying. Your draft and message ID are kept.', + ); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe('Original draft'); + expect((screen.getByRole('button', { name: 'Retry same message' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Retry same message' })); + expect(islandSend).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => + expect((screen.getByRole('button', { name: 'Retry same message' }) as HTMLButtonElement).disabled).toBe(false), + ); + expect(view.config.refreshIsland).toHaveBeenCalledOnce(); + expect(islandSend).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Retry same message' })); + await waitFor(() => expect(islandSend).toHaveBeenCalledTimes(2)); + expect(islandSend.mock.calls[1]).toEqual(islandSend.mock.calls[0]); + await waitFor(() => + expect((screen.getByRole('button', { name: 'Retry same message' }) as HTMLButtonElement).disabled).toBe(true), + ); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe('Original draft'); + }, +); + +test('multiple question fields send keys and option values; custom/multiselect and read-only skip remain explicit', async () => { + const question = { + id: 'form_1', + sessionId: 'pending', + title: 'Choose settings', + answerable: true, + fields: [ + { + key: 'mode_key', + type: 'string' as const, + title: 'Mode', + options: [{ value: 'fast_value', label: 'Fast label' }], + custom: true, + required: true, + }, + { + key: 'targets_key', + type: 'multiselect' as const, + title: 'Targets', + options: [ + { value: 'web_value', label: 'Web label' }, + { value: 'cli_value', label: 'CLI label' }, + ], + custom: true, + required: true, + }, + { key: 'note_key', type: 'string' as const, title: 'Note', options: [], custom: true, required: true }, + ], + }; + const initial = { ...state(), questions: [question] }; + const view = mount({}, initial); + await screen.findByText('2 pending'); + await expand(); + await screen.findByText('Choose settings'); + await fireEvent.click(screen.getByRole('button', { name: 'Submit answers' })); + await screen.findByText('Answer all required questions.'); + expect(view.config.islandReplyQuestion).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('radio', { name: 'Fast label' })); + await fireEvent.click(screen.getByRole('checkbox', { name: 'Web label' })); + await fireEvent.click(screen.getByRole('checkbox', { name: 'CLI label' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Custom answer for Targets' }), { + target: { value: 'desktop_value' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Add' })); + const note = screen.getByRole('group', { name: 'Note · Required' }); + await fireEvent.click(within(note).getByRole('radio', { name: 'Custom answer' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Custom answer for Note' }), { + target: { value: 'My own text' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Submit answers' })); + expect(view.config.islandReplyQuestion).toHaveBeenCalledExactlyOnceWith( + 'pending', + 'form_1', + { + mode_key: 'fast_value', + targets_key: ['web_value', 'cli_value', 'desktop_value'], + note_key: 'My own text', + }, + 0, + ); + view.setState({ ...initial, questions: [{ ...question, id: 'form_readonly', answerable: false }] }); + native.handlers.get('island:changed')?.(); + await screen.findByText('This question cannot be answered here. Use the OpenCode terminal, or skip it.'); + expect(screen.queryByRole('button', { name: 'Submit answers' })).toBeNull(); + expect(screen.getByRole('radio', { name: 'Fast label' }).closest('fieldset')?.hasAttribute('disabled')).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + expect(view.config.islandCancelQuestion).toHaveBeenCalledExactlyOnceWith('pending', 'form_readonly', 0); + view.setState({ ...initial, questions: [] }); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(screen.queryByText('Choose settings')).toBeNull()); +}); + +test('non-question/unsupported form projection is read-only even when the backend exposes no fields', async () => { + const initial: IslandState = { + ...state(), + questions: [ + { id: 'form_non_question', sessionId: 'pending', title: 'Terminal-only form', answerable: false, fields: [] }, + ], + }; + const view = mount({}, initial); + await screen.findByText('2 pending'); + await expand(); + await screen.findByText('This question cannot be answered here. Use the OpenCode terminal, or skip it.'); + expect(screen.queryByRole('button', { name: 'Submit answers' })).toBeNull(); + expect(view.config.islandReplyQuestion).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Collapse island' })); + await screen.findByRole('button', { name: 'Show quick area' }); + view.unmount(); + expect(view.config.islandCancelQuestion).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandInterrupt).not.toHaveBeenCalled(); +}); + +test.each(['connecting', 'error', 'disabled'] as const)( + 'service %s never displays cached Idle status or allows writes', + async (connection) => { + const view = mount(); + await expand(); + view.setState({ ...state(), connection }); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(screen.queryByText('Idle')).toBeNull()); + expect(screen.getAllByText('Status unknown')).toHaveLength(3); + expect((screen.getByRole('button', { name: 'Allow once' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + }, +); + +test('invalidations coalesce cache reads independently of pending messages; selection/collapse reject stale messages and preserve drafts', async () => { + const view = mount(); + await expand(); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: 'Unsent text' } }); + let finish!: (messages: IslandMessage[]) => void; + view.config.islandMessages.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const previous = view.config.getIslandState.mock.calls.length; + for (let index = 0; index < 10; index += 1) native.handlers.get('island:changed')?.(); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledTimes(previous + 1)); + await fireEvent.click(screen.getByRole('button', { name: /Running work/ })); + for (let index = 0; index < 10; index += 1) native.handlers.get('island:changed')?.(); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(view.config.getIslandState).toHaveBeenCalledTimes(previous + 3); + finish([{ id: 'stale', role: 'assistant', text: 'Stale pending message', time: 1, truncated: false }]); + await screen.findByText('running Public text'); + expect(screen.queryByText('Stale pending message')).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: /Pending work/ })); + await screen.findByText('pending Public text'); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe('Unsent text'); + native.handlers.get('island:collapsed')?.(); + await screen.findByRole('button', { name: 'Show quick area' }); + const reads = view.config.islandMessages.mock.calls.length; + native.handlers.get('app:preferences-changed')?.(); + await waitFor(() => expect(view.config.applyPreferences).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(view.config.islandMessages).toHaveBeenCalledTimes(reads); +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +function backend(generation: number, title: string): IslandState { + return { + ...state(), + connectionGeneration: generation, + sessions: [{ id: 'ses_same', title, directory: '/code/same', updatedAt: 1, status: 'running' }], + permissions: [ + { id: 'perm_same', sessionId: 'ses_same', action: 'bash', resources: ['git status'], save: ['git *'] }, + ], + questions: [ + { + id: 'question_same', + sessionId: 'ses_same', + title: 'Same question', + answerable: true, + fields: [{ key: 'note', type: 'string', title: 'Note', custom: true, required: true, options: [] }], + }, + ], + }; +} + +async function changeBackend(view: ReturnType, next: IslandState) { + const reads = view.config.getIslandState.mock.calls.length; + view.setState(next); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledTimes(reads + 1)); + await tick(); +} + +test('connected-to-connected generation change clears same-ID drafts, selected session and QuestionForm without view or focus changes', async () => { + const view = mount({}, backend(4, 'Backend A')); + await expand(); + const composer = screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement; + await fireEvent.input(composer, { target: { value: 'Only for A' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Submit answers' })); + await screen.findByText('Answer all required questions.'); + await fireEvent.click(screen.getByRole('radio', { name: 'Custom answer' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Custom answer for Note' }), { + target: { value: 'A answer' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'More approval options' })); + const oldAlways = screen.getByRole('button', { name: 'Always for this project' }); + const oldSubmit = screen.getByRole('button', { name: 'Submit answers' }); + const calls = view.config.setIslandView.mock.calls.length; + const active = document.activeElement; + view.config.islandMessages.mockResolvedValue([ + { id: 'msg_b', role: 'assistant', text: 'Backend B public', time: 1, truncated: false }, + ]); + await changeBackend(view, backend(5, 'Backend B')); + await screen.findByText('Backend B public'); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('expanded'); + expect(view.config.setIslandView).toHaveBeenCalledTimes(calls); + expect(document.activeElement).toBe(active); + expect(screen.getByRole('textbox', { name: 'Message' })).toBe(composer); + expect(composer.value).toBe(''); + expect((screen.getByRole('textbox', { name: 'Custom answer for Note' }) as HTMLInputElement).value).toBe(''); + expect(screen.queryByText('Answer all required questions.')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Always for this project' })).toBeNull(); + expect(screen.queryByText('ses_same Public text')).toBeNull(); + await fireEvent.submit(composer.closest('form') as HTMLFormElement); + await fireEvent.click(oldAlways); + await fireEvent.click(oldSubmit); + expect(view.config.islandSend).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandReplyQuestion).not.toHaveBeenCalled(); + // An older cache result cannot roll back the accepted epoch or its new draft. + await fireEvent.input(composer, { target: { value: 'Only for B' } }); + await changeBackend(view, backend(4, 'Stale A')); + expect(screen.queryByText('Stale A')).toBeNull(); + expect(composer.value).toBe('Only for B'); + expect(screen.getByRole('button', { name: /Backend B/ }).getAttribute('aria-current')).toBe('true'); + const disconnected = { + ...backend(6, 'Backend C'), + connection: 'connecting' as const, + sessions: [], + permissions: [], + questions: [], + }; + await changeBackend(view, disconnected); + expect(screen.queryByRole('textbox', { name: 'Message' })).toBeNull(); + expect(screen.queryByText('Backend B public')).toBeNull(); + await changeBackend(view, backend(6, 'Backend C')); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe(''); +}); + +test.each(['resolve', 'reject'] as const)( + 'new generation is read while old same-ID messages are pending; late %s cannot repaint or hide current loading', + async (completion) => { + const oldMessages = deferred(); + const newMessages = deferred(); + const view = mount({}, backend(1, 'Backend A')); + await expand(); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: 'Private A draft' } }); + view.config.islandMessages.mockReturnValueOnce(oldMessages.promise).mockReturnValueOnce(newMessages.promise); + await changeBackend(view, backend(1, 'Backend A')); + await waitFor(() => expect(view.config.islandMessages).toHaveBeenCalledTimes(2)); + expect(view.config.islandMessages).toHaveBeenLastCalledWith('ses_same', 1); + await changeBackend(view, backend(2, 'Backend B')); + expect(view.config.islandMessages).toHaveBeenCalledTimes(3); + expect(view.config.islandMessages).toHaveBeenLastCalledWith('ses_same', 2); + expect(screen.queryByText('ses_same Public text')).toBeNull(); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe(''); + if (completion === 'resolve') + oldMessages.resolve([{ id: 'msg_same', role: 'assistant', text: 'Late A private', time: 1, truncated: false }]); + else oldMessages.reject(new Error('Late A message failure')); + await tick(); + expect(screen.queryByText('Late A private')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('region', { name: 'Recent messages and pending requests' }).getAttribute('aria-busy')).toBe( + 'true', + ); + newMessages.resolve([{ id: 'msg_same', role: 'assistant', text: 'Current B public', time: 1, truncated: false }]); + await screen.findByText('Current B public'); + expect(screen.getByRole('region', { name: 'Recent messages and pending requests' }).getAttribute('aria-busy')).toBe( + 'false', + ); + expect(view.config.islandSend).not.toHaveBeenCalled(); + }, +); + +test.each(['resolve', 'reject'] as const)( + 'late old send %s cannot clear the new same-ID draft, restore uncertainty or unlock a pending new send', + async (completion) => { + const oldSend = deferred(); + const newSend = deferred(); + const islandSend = vi.fn().mockReturnValueOnce(oldSend.promise).mockReturnValueOnce(newSend.promise); + const view = mount({ islandSend }, backend(1, 'Backend A')); + await expand(); + const composer = screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement; + await fireEvent.input(composer, { target: { value: 'Send to A' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + expect(islandSend).toHaveBeenCalledExactlyOnceWith('ses_same', 'Send to A', expect.any(String), 1); + const oldId = islandSend.mock.calls[0][2]; + await changeBackend(view, backend(2, 'Backend B')); + expect(composer.value).toBe(''); + expect(composer.disabled).toBe(false); + await fireEvent.input(composer, { target: { value: 'Send to B' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + expect(islandSend.mock.calls[1]).toEqual(['ses_same', 'Send to B', expect.any(String), 2]); + expect(islandSend.mock.calls[1][2]).not.toBe(oldId); + const reads = view.config.getIslandState.mock.calls.length; + if (completion === 'resolve') oldSend.resolve(); + else oldSend.reject(new Error('Late A send error')); + await tick(); + expect(composer.value).toBe('Send to B'); + expect(composer.disabled).toBe(true); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Retry same message' })).toBeNull(); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + await fireEvent.submit(composer.closest('form') as HTMLFormElement); + expect(islandSend).toHaveBeenCalledTimes(2); + newSend.resolve(); + await waitFor(() => expect(composer.value).toBe('')); + }, +); + +test.each([ + ['permission', 'resolve'], + ['permission', 'reject'], + ['question', 'resolve'], + ['question', 'reject'], + ['interrupt', 'resolve'], + ['interrupt', 'reject'], +] as const)( + 'late old %s reply %s cannot restore errors, refresh or unlock a current same-ID action', + async (action, completion) => { + const oldReply = deferred(); + const newReply = deferred(); + const method = + action === 'permission' + ? 'islandReplyPermission' + : action === 'question' + ? 'islandCancelQuestion' + : 'islandInterrupt'; + const reply = vi.fn().mockReturnValueOnce(oldReply.promise).mockReturnValueOnce(newReply.promise); + const view = mount({ [method]: reply }, backend(1, 'Backend A')); + await expand(); + const name = action === 'permission' ? 'Allow once' : action === 'question' ? 'Skip' : 'Stop'; + await fireEvent.click(screen.getByRole('button', { name })); + expect(reply.mock.calls[0].at(-1)).toBe(1); + await changeBackend(view, backend(2, 'Backend B')); + const control = screen.getByRole('button', { name }) as HTMLButtonElement; + expect(control.disabled).toBe(false); + await fireEvent.click(control); + expect(reply.mock.calls[1].at(-1)).toBe(2); + const reads = view.config.getIslandState.mock.calls.length; + if (completion === 'resolve') oldReply.resolve(); + else oldReply.reject(new Error('Late A action error')); + await tick(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(control.disabled).toBe(true); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.refreshIsland).not.toHaveBeenCalled(); + await fireEvent.click(control); + expect(reply).toHaveBeenCalledTimes(2); + newReply.resolve(); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledTimes(reads + 1)); + }, +); + +test.each(['send', 'permission', 'answer', 'skip', 'interrupt'] as const)( + 'stale displayed %s submits its own epoch before the new same-ID backend snapshot arrives; refresh never replays it', + async (action) => { + const method = { + send: 'islandSend', + permission: 'islandReplyPermission', + answer: 'islandReplyQuestion', + skip: 'islandCancelQuestion', + interrupt: 'islandInterrupt', + }[action]; + const mutation = vi + .fn() + .mockRejectedValueOnce({ code: 'conflict', message: 'Connection generation changed' }) + .mockResolvedValue(undefined); + const view = mount({ [method]: mutation }, backend(11, 'Backend A')); + await expand(); + const write = async (label: string) => { + if (action === 'send') { + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: label } }); + } else if (action === 'answer') { + await fireEvent.click(screen.getByRole('radio', { name: 'Custom answer' })); + await fireEvent.input(screen.getByRole('textbox', { name: 'Custom answer for Note' }), { + target: { value: label }, + }); + } + const name = { + send: 'Send', + permission: 'Allow once', + answer: 'Submit answers', + skip: 'Skip', + interrupt: 'Stop', + }[action]; + await fireEvent.click(screen.getByRole('button', { name })); + }; + // Native has changed, but the displayed snapshot still belongs to A. + view.setState(backend(12, 'Backend B')); + await write('Only A payload'); + expect(mutation).toHaveBeenCalledOnce(); + expect(mutation.mock.calls[0].at(-1)).toBe(11); + native.handlers.get('island:changed')?.(); + await screen.findByRole('button', { name: /Backend B/ }); + await waitFor(() => + expect((screen.getByRole('button', { name: 'Refresh' }) as HTMLButtonElement).disabled).toBe(false), + ); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe(''); + expect((screen.getByRole('textbox', { name: 'Custom answer for Note' }) as HTMLInputElement).value).toBe(''); + expect(mutation).toHaveBeenCalledOnce(); + await write('Only B payload'); + expect(mutation).toHaveBeenCalledTimes(2); + expect(mutation.mock.calls[1].at(-1)).toBe(12); + if (action === 'send') { + expect(mutation.mock.calls[1][1]).toBe('Only B payload'); + expect(mutation.mock.calls[1][2]).not.toBe(mutation.mock.calls[0][2]); + } else if (action === 'answer') { + expect(mutation.mock.calls[1][2]).toEqual({ note: 'Only B payload' }); + } + }, +); + +test('a pending old snapshot starts messages with its own epoch, never the unseen backend generation', async () => { + const oldSnapshot = deferred(); + const oldMessages = deferred(); + const view = mount({}, backend(21, 'Backend A')); + await expand(); + view.config.getIslandState.mockReturnValueOnce(oldSnapshot.promise); + view.config.islandMessages.mockReturnValueOnce(oldMessages.promise); + const reads = view.config.getIslandState.mock.calls.length; + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledTimes(reads + 1)); + view.setState(backend(22, 'Backend B')); + oldSnapshot.resolve(backend(21, 'Backend A')); + await waitFor(() => expect(view.config.islandMessages).toHaveBeenCalledTimes(2)); + expect(view.config.islandMessages).toHaveBeenLastCalledWith('ses_same', 21); + await changeBackend(view, backend(22, 'Backend B')); + expect(view.config.islandMessages).toHaveBeenLastCalledWith('ses_same', 22); + oldMessages.reject({ code: 'conflict', message: 'Connection generation changed' }); + await tick(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(view.config.islandMessages).toHaveBeenCalledTimes(3); +}); + +test('generation change reselects from the current snapshot rather than retaining a reused session ID and rejects a pending older cache read', async () => { + const view = mount({}, { ...state(), connectionGeneration: 3 }); + await expand(); + await fireEvent.click(screen.getByRole('button', { name: /Recent work/ })); + await screen.findByText('recent Public text'); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: 'Old selected draft' } }); + await changeBackend(view, { ...state(), connectionGeneration: 4 }); + await screen.findByText('pending Public text'); + expect(screen.getByRole('button', { name: /Pending work/ }).getAttribute('aria-current')).toBe('true'); + await fireEvent.click(screen.getByRole('button', { name: /Recent work/ })); + await screen.findByText('recent Public text'); + const composer = screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement; + expect(composer.value).toBe(''); + await fireEvent.input(composer, { target: { value: 'Current draft' } }); + const stale = deferred(); + view.config.getIslandState.mockReturnValueOnce(stale.promise); + const reads = view.config.getIslandState.mock.calls.length; + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledTimes(reads + 1)); + stale.resolve({ ...backend(3, 'Stale old target'), enabled: false }); + await tick(); + expect(screen.queryByText('Stale old target')).toBeNull(); + expect(composer.value).toBe('Current draft'); + expect(screen.getByRole('button', { name: /Recent work/ }).getAttribute('aria-current')).toBe('true'); + expect(view.config.islandSend).not.toHaveBeenCalled(); +}); + +test('generation switch preserves native hover and compact/peek nodes without auto-expansion, focus or message reads', async () => { + const view = mount({}, backend(1, 'Backend A')); + await peek(); + const header = screen.getByRole('button', { name: 'Show quick area' }); + const activity = view.container.querySelector('.activity'); + const active = document.activeElement; + await changeBackend(view, backend(2, 'Backend B')); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('peek'); + expect(screen.getByRole('button', { name: 'Show quick area' })).toBe(header); + expect(view.container.querySelector('.activity')).toBe(activity); + expect(document.activeElement).toBe(active); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test.each([ + ['peek', 'resolve'], + ['peek', 'reject'], + ['expanded', 'resolve'], + ['expanded', 'reject'], +] as const)( + 'generation reset rejects old pending %s grow %s without changing committed view or hover', + async (growing, completion) => { + const view = mount({}, backend(31, 'Backend A')); + const pending = deferred(); + if (growing === 'expanded') await peek(); + else await screen.findByText('2 pending'); + const committed = growing === 'expanded' ? 'peek' : 'compact'; + view.config.setIslandView.mockReturnValueOnce(pending.promise); + await fireEvent.click( + screen.getByRole('button', { name: growing === 'expanded' ? 'Expand sessions' : 'Show quick area' }), + ); + const calls = view.config.setIslandView.mock.calls.length; + const active = document.activeElement; + await changeBackend(view, backend(32, 'Backend B')); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe(committed); + if (completion === 'resolve') pending.resolve(); + else pending.reject(new Error('Old grow failure')); + await tick(); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe(committed); + expect(view.config.setIslandView).toHaveBeenCalledTimes(calls); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(active); + expect(screen.queryByText('Old grow failure')).toBeNull(); + expect(screen.queryByRole('textbox', { name: 'Message' })).toBeNull(); + if (growing === 'peek') { + await fireEvent.click(screen.getByRole('button', { name: 'Show quick area' })); + await screen.findByRole('button', { name: 'Expand sessions' }); + } + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + await screen.findByRole('textbox', { name: 'Message' }); + expect(view.config.islandMessages).toHaveBeenCalledExactlyOnceWith('ses_same', 32); + }, +); + +test('send generation rejection immediately rereads cache without Refresh, event notification or payload replay', async () => { + const islandSend = vi.fn().mockRejectedValueOnce({ + code: 'validation_failed', + message: 'Mini Island is disabled or its connection changed', + }); + const view = mount({ islandSend }, backend(41, 'Backend A')); + await expand(); + const composer = screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement; + await fireEvent.input(composer, { target: { value: 'Never replay this payload' } }); + view.setState(backend(42, 'Backend B')); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await screen.findByRole('button', { name: /Backend B/ }); + expect(composer.value).toBe(''); + expect(islandSend).toHaveBeenCalledExactlyOnceWith('ses_same', 'Never replay this payload', expect.any(String), 41); + expect(view.config.refreshIsland).not.toHaveBeenCalled(); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +test('browser mode has no native reads/listeners and late listener registrations are disposed', async () => { + native.isTauri.mockReturnValue(false); + const browser = mount(); + await tick(); + expect(native.listen).not.toHaveBeenCalled(); + expect(browser.config.getIslandState).not.toHaveBeenCalled(); + browser.unmount(); + native.isTauri.mockReturnValue(true); + const finish: (() => void)[] = []; + native.listen.mockImplementation((name: string, handler: () => void) => { + native.handlers.set(name, handler); + return new Promise<() => void>((resolve) => { + finish.push(() => resolve(() => native.unlisten(name))); + }); + }); + const view = mount(); + await tick(); + view.unmount(); + native.handlers.get('island:pointer-enter')?.(); + native.handlers.get('island:pointer-leave')?.(); + for (const register of finish) register(); + await waitFor(() => expect(native.unlisten).toHaveBeenCalledTimes(5)); + expect(native.unlisten).toHaveBeenCalledWith('island:pointer-enter'); + expect(native.unlisten).toHaveBeenCalledWith('island:pointer-leave'); + expect(view.config.getIslandState).not.toHaveBeenCalled(); + expect(view.config.setIslandView).not.toHaveBeenCalled(); +}); + +test('transient reads keep session drafts; Escape during expansion sends compact before grow readback', async () => { + const view = mount(); + await expand(); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { + target: { value: 'Keep across read errors' }, + }); + view.config.refreshIsland.mockRejectedValueOnce(new Error('Temporary read failure')); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await screen.findByText('Temporary read failure'); + expect(screen.queryByText('Idle')).toBeNull(); + expect(screen.getAllByText('Status unknown')).toHaveLength(3); + expect((screen.getByRole('textbox', { name: 'Message' }) as HTMLTextAreaElement).value).toBe( + 'Keep across read errors', + ); + await fireEvent.click(screen.getByRole('button', { name: 'Collapse island' })); + await screen.findByRole('button', { name: 'Show quick area' }); + await fireEvent.click(screen.getByRole('button', { name: 'Show quick area' })); + await screen.findByRole('button', { name: 'Expand sessions' }); + let finish!: () => void; + view.config.setIslandView.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + const messagesRead = view.config.islandMessages.mock.calls.length; + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(view.config.setIslandView).toHaveBeenLastCalledWith('compact'); + await fireEvent.keyDown(window, { key: 'Escape' }); + const calls = view.config.setIslandView.mock.calls.length; + finish(); + await tick(); + expect(view.config.setIslandView).toHaveBeenCalledTimes(calls); + expect(view.config.islandMessages).toHaveBeenCalledTimes(messagesRead); + await screen.findByRole('button', { name: 'Show quick area' }); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandCancelQuestion).not.toHaveBeenCalled(); + expect(view.config.islandInterrupt).not.toHaveBeenCalled(); +}); + +test('compact child approval captures exact IDs, locks double clicks and queue, and never expands or loads messages', async () => { + const initial = state(); + initial.notchWidth = 300; + initial.permissions.push({ + id: 'perm_1', + sessionId: 'child', + action: 'edit', + resources: ['/code/recent-project/src/.ts'], + }); + let finish!: () => void; + const islandReplyPermission = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = mount({ islandReplyPermission }, initial); + await peek(); + await screen.findByText('2 pending'); + expect(view.container.querySelector('main')?.getAttribute('style')).toContain('--compact-width: 400px'); + expect(view.container.querySelector('main')?.getAttribute('data-state')).toBe('attention'); + await fireEvent.click(screen.getByRole('button', { name: 'Next permission request' })); + const preview = screen.getByText('/code/recent-project/src/.ts'); + expect(preview.closest('[title]')?.getAttribute('title')).toContain('/code/recent-project/src/.ts'); + expect(preview.querySelector('unsafe')).toBeNull(); + const allow = screen.getByRole('button', { name: 'Allow once' }) as HTMLButtonElement; + await fireEvent.click(allow); + await fireEvent.click(allow); + await fireEvent.click(screen.getByRole('button', { name: 'Reject' })); + expect(islandReplyPermission).toHaveBeenCalledExactlyOnceWith('child', 'perm_1', 'once', 0); + expect(allow.disabled).toBe(true); + expect((screen.getByRole('button', { name: 'Next permission request' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.blur(window); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(view.container.querySelector('#compact-permission-details')).toBeTruthy(); + expect(view.config.setIslandView).not.toHaveBeenCalledWith('expanded'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + finish(); + await waitFor(() => expect(view.config.refreshIsland).toHaveBeenCalledOnce()); + await screen.findByText('Awaiting confirmation · Refresh to check'); + expect(allow.disabled).toBe(true); + expect(islandReplyPermission).toHaveBeenCalledOnce(); + view.setState({ + ...initial, + permissions: initial.permissions.filter((permission) => permission.sessionId !== 'child'), + }); + native.handlers.get('island:changed')?.(); + await peek(); + await screen.findByText('git status'); + expect((screen.getByRole('button', { name: 'Allow once' }) as HTMLButtonElement).disabled).toBe(false); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandInterrupt).not.toHaveBeenCalled(); +}); + +test('compact queue identity survives incoming reorder; external replies advance and removed controls cannot write', async () => { + const view = mount(); + await peek(); + const staleAllow = await screen.findByRole('button', { name: 'Allow once' }); + const newcomer = { id: 'perm_1', sessionId: 'child', action: 'edit', resources: ['/child/file.ts'] }; + view.setState({ ...state(), permissions: [newcomer, ...state().permissions] }); + native.handlers.get('island:changed')?.(); + await screen.findByText('Permission 2 of 2'); + expect(screen.getByText('git status')).toBeTruthy(); + expect(screen.queryByText('/child/file.ts')).toBeNull(); + view.setState({ ...state(), permissions: [newcomer] }); + native.handlers.get('island:changed')?.(); + await screen.findByText('/child/file.ts'); + await fireEvent.click(staleAllow); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Reject' })); + expect(view.config.islandReplyPermission).toHaveBeenCalledExactlyOnceWith('child', 'perm_1', 'reject', 0); + view.setState({ ...state(), permissions: [] }); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Allow once' })).toBeNull()); + await screen.findByRole('region', { name: 'Quick area' }); + await fireEvent.pointerLeave(screen.getByRole('main', { name: 'Sessions' })); + await waitFor(() => + expect(view.container.querySelector('main')?.getAttribute('style')).toContain('--compact-width: 292px'), + ); + expect(view.config.setIslandView).not.toHaveBeenCalledWith('expanded'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); +}); + +test.each([{ enabled: false }, { connection: 'error' as const }, { connection: 'connecting' as const }])( + 'compact hides stale attention and blocks detached permission controls when unavailable ($enabled/$connection)', + async (unavailable) => { + const view = mount(); + await peek(); + const staleAllow = await screen.findByRole('button', { name: 'Allow once' }); + view.setState({ ...state(), ...unavailable }); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(screen.queryByRole('region', { name: 'Permission requested' })).toBeNull()); + await fireEvent.click(staleAllow); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(screen.getByRole('region', { name: 'Quick area' })).toBeTruthy(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + }, +); + +test('uncertain compact permission decisions require explicit Refresh, never blind or automatic retry', async () => { + const view = mount({ islandReplyPermission: vi.fn().mockRejectedValue(new Error('Decision reply lost')) }); + await peek(); + await fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })); + await screen.findByRole('alert'); + expect(screen.getByRole('alert').textContent).toBe('Decision reply lost'); + expect((screen.getByRole('button', { name: 'Allow once' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Reject' })); + native.handlers.get('island:changed')?.(); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(view.config.islandReplyPermission).toHaveBeenCalledOnce(); + expect((screen.getByRole('button', { name: 'Allow once' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => + expect((screen.getByRole('button', { name: 'Allow once' }) as HTMLButtonElement).disabled).toBe(false), + ); + expect(screen.queryByRole('alert')).toBeNull(); + expect(view.config.islandReplyPermission).toHaveBeenCalledOnce(); + expect(view.config.refreshIsland).toHaveBeenCalledOnce(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); +}); + +test('compact cross-session reply preserves selection and uncertain draft; automatic readback cannot confirm a send', async () => { + const view = mount({ islandSend: vi.fn().mockRejectedValue(new Error('Send reply lost')) }); + await expand(); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: 'Keep exact draft' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await screen.findByText('Message may have been accepted. Refresh before retrying.'); + const messageId = view.config.islandSend.mock.calls[0][2]; + await fireEvent.click(screen.getByRole('button', { name: 'Collapse island' })); + await screen.findByRole('button', { name: 'Show quick area' }); + const permission = { id: 'perm_other', sessionId: 'child', action: 'read', resources: ['/child/resource'] }; + view.setState({ ...state(), permissions: [permission] }); + native.handlers.get('island:changed')?.(); + await peek(); + await screen.findByText('/child/resource'); + const previousReads = view.config.islandMessages.mock.calls.length; + view.config.islandMessages.mockResolvedValue([ + { id: messageId, role: 'user', text: 'Keep exact draft', time: 1, truncated: false }, + ]); + await fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + await waitFor(() => expect(view.config.refreshIsland).toHaveBeenCalledOnce()); + expect(view.config.islandReplyPermission).toHaveBeenCalledExactlyOnceWith('child', 'perm_other', 'once', 0); + expect(view.config.islandMessages).toHaveBeenCalledTimes(previousReads); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + const textbox = await screen.findByRole('textbox', { name: 'Message' }); + expect((textbox as HTMLTextAreaElement).value).toBe('Keep exact draft'); + expect((textbox as HTMLTextAreaElement).disabled).toBe(true); + expect((screen.getByRole('button', { name: 'Retry same message' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByRole('button', { name: /Pending work/ }).getAttribute('aria-current')).toBe('true'); +}); + +test('compact activity animates only connected enabled running snapshots and questions stay a full-form hint', async () => { + const initial = { ...state(), permissions: [] }; + const view = mount({}, initial); + await screen.findByRole('button', { name: 'Show quick area' }); + await screen.findByText('1 running'); + expect(screen.queryByText('MOM')).toBeNull(); + const activity = view.container.querySelector('.activity'); + expect(activity?.getAttribute('data-running')).toBe('true'); + expect(view.container.querySelector('main')?.getAttribute('data-state')).toBe('running'); + view.setState({ ...initial, sessions: initial.sessions.map((session) => ({ ...session, status: 'idle' })) }); + native.handlers.get('island:changed')?.(); + await screen.findByText('0 running'); + expect(activity?.getAttribute('data-running')).toBe('false'); + expect(view.container.querySelector('main')?.getAttribute('data-state')).toBe('idle'); + view.setState({ ...initial, connection: 'error' }); + native.handlers.get('island:changed')?.(); + await screen.findByText('Connection unavailable'); + expect(activity?.getAttribute('data-running')).toBe('false'); + view.setState({ ...initial, enabled: false }); + native.handlers.get('island:changed')?.(); + await screen.findByText('Disabled'); + expect(activity?.getAttribute('data-running')).toBe('false'); + view.setState({ + ...initial, + questions: [{ id: 'q_1', sessionId: 'child', title: 'Choose mode', answerable: true, fields: [] }], + }); + native.handlers.get('island:changed')?.(); + await screen.findByText('1 pending'); + expect(screen.getByText('Open the island to answer pending questions.')).toBeTruthy(); + expect(screen.queryByText('Choose mode')).toBeNull(); + expect(view.container.querySelector('main')?.classList.contains('compact-attention')).toBe(false); + expect(view.config.islandMessages).not.toHaveBeenCalled(); +}); + +test('teardown ignores a late compact permission result without refresh, expansion, or further writes', async () => { + let finish!: () => void; + const view = mount({ + islandReplyPermission: vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + }); + await peek(); + await fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })); + const reads = view.config.getIslandState.mock.calls.length; + view.unmount(); + finish(); + await tick(); + expect(view.config.islandReplyPermission).toHaveBeenCalledExactlyOnceWith('pending', 'perm_1', 'once', 0); + expect(view.config.refreshIsland).not.toHaveBeenCalled(); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.setIslandView).not.toHaveBeenCalledWith('expanded'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); +}); + +test('unavailable Refresh cannot unlock an ambiguous permission retry, even after an automatic reconnect', async () => { + const view = mount({ islandReplyPermission: vi.fn().mockRejectedValue(new Error('Decision reply lost')) }); + await peek(); + await fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })); + await screen.findByRole('alert'); + view.setState({ ...state(), connection: 'error', permissions: [] }); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(screen.getByRole('status').textContent).toBe('Connection unavailable')); + view.setState(state()); + native.handlers.get('island:changed')?.(); + await peek(); + const allow = (await screen.findByRole('button', { name: 'Allow once' })) as HTMLButtonElement; + expect(allow.disabled).toBe(true); + await fireEvent.click(allow); + expect(view.config.islandReplyPermission).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(allow.disabled).toBe(false)); + expect(view.config.islandReplyPermission).toHaveBeenCalledOnce(); +}); + +test('compact has no branding or extra right controls; hover peeks without chat and leave returns to compact', async () => { + const initial = state(); + const view = mount({}, initial); + const header = await screen.findByRole('button', { name: 'Show quick area' }); + await screen.findByText('1 pending'); + expect(screen.queryByText('MOM')).toBeNull(); + expect(screen.queryByText('OpenCode')).toBeNull(); + expect(screen.queryByRole('navigation', { name: 'Sessions' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Allow once' })).toBeNull(); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(view.container.querySelector('main')?.getAttribute('style')).toContain('--compact-width: 292px'); + const status = screen.getByRole('button', { name: 'Session status · Show quick area' }); + expect(status.getAttribute('aria-describedby')).toBe('compact-status-hint compact-counts-hint compact-pending-hint'); + expect(header.querySelector('.wing-label')?.textContent).toBe('Running'); + expect(header.querySelector('.wing-count')?.textContent).toBe('1'); + expect(view.container.querySelectorAll('.compact-lane button')).toHaveLength(1); + expect( + view.container.querySelectorAll('.compact-lane svg, .compact-lane .pending-badge, .question-badge'), + ).toHaveLength(0); + expect(view.container.querySelectorAll('.activity')).toHaveLength(1); + expect(view.container.querySelector('#compact-pending-hint')?.textContent).toBe('1 pending'); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + await peek(); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.container.querySelector('main')?.getAttribute('style')).toContain('--compact-width: 400px'); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + await fireEvent.pointerLeave(screen.getByRole('main', { name: 'Sessions' })); + await waitFor(() => expect(view.config.setIslandView).toHaveBeenLastCalledWith('compact')); + expect(screen.queryByRole('button', { name: 'Allow once' })).toBeNull(); + expect(screen.getByText('1 pending')).toBeTruthy(); +}); + +test('100+ pending requests reuse the single activity visual and retain accessible count without expansion', async () => { + const initial = state(); + initial.permissions = Array.from({ length: 105 }, (_, index) => ({ + ...initial.permissions[0], + id: `perm_${index}`, + })); + const view = mount({}, initial); + await screen.findByText('105 pending'); + expect(view.container.querySelector('.activity')?.classList.contains('pending')).toBe(true); + expect(view.container.querySelectorAll('.activity')).toHaveLength(1); + expect(view.container.querySelector('.compact-status .pending-badge')).toBeNull(); + expect(view.container.querySelector('#compact-pending-hint')?.textContent).toBe('105 pending'); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); +}); + +test.each(['Show quick area', 'Session status · Show quick area'])( + 'compact click on %s requests only peek with a single four-bar visual and no chat', + async (control) => { + const view = mount({}, { ...state(), permissions: [] }); + await screen.findByText('1 running'); + expect(view.container.querySelector('.compact-left .wing-label')?.textContent).toBe('Running'); + expect(view.container.querySelector('.compact-left .wing-count')?.textContent).toBe('1'); + expect(screen.queryByText('MOM')).toBeNull(); + expect(view.container.querySelectorAll('.compact-lane button')).toHaveLength(1); + expect(view.container.querySelectorAll('.compact-lane .activity')).toHaveLength(1); + expect(view.container.querySelectorAll('.compact-lane .activity i')).toHaveLength(4); + expect(view.container.querySelectorAll('.compact-header svg')).toHaveLength(0); + await fireEvent.click(screen.getByRole('button', { name: control })); + await screen.findByRole('region', { name: 'Quick area' }); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(screen.getAllByRole('button', { name: 'Expand sessions' })).toHaveLength(1); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + }, +); + +test.each([true, false])( + 'the exposed compact center receives pointer move with permissions=%s and peeks without reads or focus', + async (pending) => { + const initial = { ...state(), permissions: pending ? state().permissions : [] }; + const view = mount({}, initial); + await screen.findByText('1 running'); + const main = screen.getByRole('main', { name: 'Sessions' }); + const active = document.activeElement; + const reads = view.config.getIslandState.mock.calls.length; + const gap = main.querySelector('.notch-gap') as HTMLElement; + expect(gap.getAttribute('aria-hidden')).toBe('true'); + expect(gap.querySelector('button, input, textarea, [tabindex]')).toBeNull(); + vi.useFakeTimers(); + try { + await fireEvent.pointerMove(main, { clientX: 146, clientY: 16 }); + await vi.advanceTimersByTimeAsync(60); + await fireEvent.pointerMove(main, { clientX: 147, clientY: 16 }); + await vi.advanceTimersByTimeAsync(39); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(main.dataset.view).toBe('peek'); + expect(document.activeElement).toBe(active); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + await fireEvent.pointerLeave(main); + await vi.advanceTimersByTimeAsync(179); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['compact']]); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }, +); + +test('center pointer activation opens only peek and internal wing crossings cannot cancel the main hover deadline', async () => { + const view = mount(); + await screen.findByText('1 pending'); + const main = screen.getByRole('main', { name: 'Sessions' }); + const shell = main.querySelector('.compact-shell') as HTMLElement; + const left = main.querySelector('.compact-left') as HTMLElement; + await fireEvent.pointerUp(main.querySelector('.notch-gap') as HTMLElement, { pointerType: 'touch' }); + await screen.findByRole('button', { name: 'Expand sessions' }); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + native.handlers.get('island:collapsed')?.(); + await tick(); + view.config.setIslandView.mockClear(); + vi.useFakeTimers(); + try { + await fireEvent.pointerEnter(main); + await vi.advanceTimersByTimeAsync(50); + await fireEvent.pointerLeave(left); + await fireEvent.pointerLeave(shell); + await fireEvent.pointerMove(main, { clientX: 146, clientY: 16 }); + await vi.advanceTimersByTimeAsync(50); + await tick(); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +test.each(['Enter', ' '])( + 'native %s activation opens peek first and only its disclosure opens sessions', + async (key) => { + const view = mount(); + await screen.findByText('1 pending'); + const status = screen.getByRole('button', { name: 'Session status · Show quick area' }); + status.focus(); + await fireEvent.keyDown(status, { key }); + await fireEvent.keyUp(status, { key }); + // jsdom does not synthesize the native keyboard click; Chrome proof covers the default action. + await fireEvent.click(status, { detail: 0 }); + await screen.findByRole('button', { name: 'Expand sessions' }); + expect(document.activeElement).toBe(status); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + const disclosure = screen.getByRole('button', { name: 'Expand sessions' }); + disclosure.focus(); + await fireEvent.keyDown(disclosure, { key }); + await fireEvent.keyUp(disclosure, { key }); + expect(view.config.setIslandView).not.toHaveBeenCalledWith('expanded'); + await fireEvent.click(disclosure, { detail: 0 }); + await screen.findByRole('navigation', { name: 'Sessions' }); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['expanded']]); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + }, +); + +test('keyboard focus and compact click reveal semantic sibling controls without selecting or authorizing', async () => { + const view = mount(); + await screen.findByText('1 pending'); + const details = await screen.findByRole('button', { name: 'Show quick area' }); + expect(details.getAttribute('aria-expanded')).toBe('false'); + expect(details.getAttribute('aria-controls')).toBe('compact-permission-details'); + expect(screen.getByRole('button', { name: 'Show quick area' }).getAttribute('aria-controls')).toBe( + 'compact-permission-details', + ); + details.focus(); + await screen.findByRole('button', { name: 'Allow once' }); + expect(document.activeElement).toBe(details); + expect(details.getAttribute('aria-expanded')).toBe('true'); + const allow = screen.getByRole('button', { name: 'Allow once' }); + const reject = screen.getByRole('button', { name: 'Reject' }); + expect(allow.parentElement?.closest('button')).toBeNull(); + expect(reject.parentElement?.closest('button')).toBeNull(); + allow.focus(); + await tick(); + await fireEvent.pointerLeave(screen.getByRole('main', { name: 'Sessions' })); + await new Promise((resolve) => setTimeout(resolve, 260)); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + allow.blur(); + await waitFor(() => expect(view.config.setIslandView).toHaveBeenLastCalledWith('compact')); + await fireEvent.click(details); + await screen.findByRole('button', { name: 'Allow once' }); + expect(details.getAttribute('aria-expanded')).toBe('true'); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test.each([undefined, []])('Always is absent without server save rules (%s)', async (save) => { + const initial = state(); + initial.permissions = initial.permissions.map((permission) => ({ ...permission, save })); + const view = mount({}, initial); + await peek(); + expect(screen.queryByRole('button', { name: 'More approval options' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Always for this project', hidden: true })).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + await screen.findByRole('navigation', { name: 'Sessions' }); + expect(screen.queryByRole('button', { name: 'More approval options' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Always for this project', hidden: true })).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + expect(view.config.islandReplyPermission).toHaveBeenCalledExactlyOnceWith('pending', 'perm_1', 'once', 0); +}); + +test('hover opens Always dropdown without approval; explicit click captures exact child IDs and guards duplicates', async () => { + const initial = state(); + initial.permissions.push({ + id: 'perm_1', + sessionId: 'child', + action: 'edit', + resources: ['/child/file'], + save: ['/child/*'], + }); + let finish!: () => void; + const islandReplyPermission = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = mount({ islandReplyPermission }, initial); + await peek(); + await fireEvent.click(screen.getByRole('button', { name: 'Next permission request' })); + const options = screen.getByRole('button', { name: 'More approval options' }); + expect(options.getAttribute('aria-expanded')).toBe('false'); + const dropdown = options.closest('.approval-options') as HTMLElement; + await fireEvent.pointerEnter(dropdown); + let always = await screen.findByRole('button', { name: 'Always for this project' }); + expect(options.getAttribute('aria-expanded')).toBe('true'); + expect(always.getAttribute('aria-describedby')).toBe('approval-scope-child/perm_1 approval-patterns-child/perm_1'); + const scope = dropdown.querySelector('.approval-scope') as HTMLElement; + expect(scope.textContent).toContain('Save edit rules in this OpenCode Project'); + expect((within(scope).getByRole('textbox') as HTMLTextAreaElement).value).toBe('/child/*'); + expect(document.getElementById('approval-patterns-child/perm_1')?.textContent).toBe('/child/*'); + expect(scope.textContent).toContain('May also approve this Project’s matching pending requests.'); + expect(within(scope).getByRole('textbox').getAttribute('readonly')).not.toBeNull(); + expect(scope.textContent).not.toContain('/child/file'); + expect(scope.classList.contains('sr-only')).toBe(false); + expect(islandReplyPermission).not.toHaveBeenCalled(); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(options.getAttribute('aria-expanded')).toBe('false'); + await waitFor(() => expect(document.activeElement).toBe(options)); + expect(view.config.setIslandView).not.toHaveBeenCalledWith('expanded'); + await fireEvent.click(options); + always = await screen.findByRole('button', { name: 'Always for this project' }); + await fireEvent.click(always); + await fireEvent.click(always); + await fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + expect(islandReplyPermission).toHaveBeenCalledExactlyOnceWith('child', 'perm_1', 'always', 0); + await fireEvent.pointerLeave(screen.getByRole('main', { name: 'Sessions' })); + await new Promise((resolve) => setTimeout(resolve, 260)); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + finish(); + await waitFor(() => expect(view.config.refreshIsland).toHaveBeenCalledOnce()); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.setIslandView).not.toHaveBeenCalledWith('expanded'); +}); + +test('updated save rules remove Always controls and detached menu items cannot authorize', async () => { + const initial = state(); + initial.permissions = initial.permissions.map((permission) => ({ ...permission, save: ['git *'] })); + const view = mount({}, initial); + await peek(); + await fireEvent.click(screen.getByRole('button', { name: 'More approval options' })); + const staleAlways = await screen.findByRole('button', { name: 'Always for this project' }); + view.setState({ ...initial, permissions: initial.permissions.map((permission) => ({ ...permission, save: [] })) }); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(screen.queryByRole('button', { name: 'More approval options' })).toBeNull()); + await fireEvent.click(staleAlways); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test('peek readback failure preserves compact and pending request; compact click can retry without writes', async () => { + const view = mount({ + setIslandView: vi.fn().mockRejectedValueOnce(new Error('Resize failed')).mockResolvedValue(undefined), + }); + const details = await screen.findByRole('button', { name: 'Show quick area' }); + await fireEvent.pointerEnter(screen.getByRole('main', { name: 'Sessions' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Session status · Show quick area' }).getAttribute('title')).toBe( + 'Resize failed', + ), + ); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(details.getAttribute('aria-expanded')).toBe('false'); + expect(screen.queryByRole('button', { name: 'Allow once' })).toBeNull(); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + await fireEvent.click(details); + await screen.findByRole('button', { name: 'Allow once' }); + expect(view.config.setIslandView).toHaveBeenCalledTimes(2); + expect(view.config.islandMessages).not.toHaveBeenCalled(); +}); + +test('late peek readback after pointer leave returns native view to compact without reading messages', async () => { + let finish!: () => void; + const setIslandView = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(undefined); + const view = mount({ setIslandView }); + await screen.findByRole('button', { name: 'Show quick area' }); + const compact = screen.getByRole('main', { name: 'Sessions' }); + await fireEvent.pointerEnter(compact); + await waitFor(() => expect(setIslandView).toHaveBeenCalledExactlyOnceWith('peek')); + await fireEvent.pointerLeave(compact); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + finish(); + await waitFor(() => expect(setIslandView).toHaveBeenLastCalledWith('compact')); + expect(setIslandView.mock.calls).toEqual([['peek'], ['compact']]); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test('resize pointer leave retains the 180 ms grace period so entering the new peek body does not collapse it', async () => { + let finish!: () => void; + const setIslandView = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(undefined); + const view = mount({ setIslandView }); + await screen.findByText('1 pending'); + const shell = screen.getByRole('main', { name: 'Sessions' }); + await fireEvent.pointerEnter(shell); + await waitFor(() => expect(setIslandView).toHaveBeenCalledExactlyOnceWith('peek')); + vi.useFakeTimers(); + try { + await fireEvent.pointerLeave(shell); + finish(); + await vi.advanceTimersByTimeAsync(0); + await tick(); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('peek'); + await vi.advanceTimersByTimeAsync(179); + expect(setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + await fireEvent.pointerEnter(shell); + await vi.advanceTimersByTimeAsync(200); + expect(setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +test('compact click during pending hover resize stays peek; only its explicit disclosure opens expanded', async () => { + let finish!: () => void; + const setIslandView = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(undefined); + const view = mount({ setIslandView }); + await screen.findByRole('button', { name: 'Show quick area' }); + await fireEvent.pointerEnter(screen.getByRole('main', { name: 'Sessions' })); + await waitFor(() => expect(setIslandView).toHaveBeenCalledExactlyOnceWith('peek')); + const header = screen.getByRole('button', { name: 'Show quick area' }); + expect(header.getAttribute('aria-busy')).toBe('true'); + await fireEvent.click(header); + await fireEvent.focusIn(header); + finish(); + await screen.findByRole('button', { name: 'Allow once' }); + expect(setIslandView.mock.calls).toEqual([['peek']]); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + const disclosure = screen.getByRole('button', { name: 'Expand sessions' }); + await fireEvent.pointerEnter(disclosure); + await new Promise((resolve) => setTimeout(resolve, 140)); + expect(setIslandView.mock.calls).toEqual([['peek']]); + await fireEvent.click(disclosure); + await screen.findByRole('navigation', { name: 'Sessions' }); + expect(setIslandView.mock.calls).toEqual([['peek'], ['expanded']]); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test('late header hover/focus during explicit expanded resize cannot replace the expanded request', async () => { + const view = mount(); + await peek(); + let finish!: () => void; + view.config.setIslandView.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const shell = screen.getByRole('main', { name: 'Sessions' }); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + await fireEvent.pointerEnter(shell); + await fireEvent.focusIn(screen.getByRole('button', { name: 'Show quick area' })); + native.handlers.get('island:pointer-enter')?.(); + native.handlers.get('island:pointer-leave')?.(); + await new Promise((resolve) => setTimeout(resolve, 130)); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['expanded']]); + finish(); + await screen.findByRole('navigation', { name: 'Sessions' }); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('expanded'); + expect(view.config.setIslandView).toHaveBeenLastCalledWith('expanded'); +}); + +test.each([ + [0, 0, 200, 28], + [180, 32, 292, 32], + [300, 24, 412, 28], + [40, 40, 200, 40], +])( + 'compact matches authoritative native geometry (%s notch / %s inset)', + async (notchWidth, topInset, width, height) => { + const view = mount({}, { ...state(), notchWidth, topInset }); + await screen.findByText('1 pending'); + const main = view.container.querySelector('main') as HTMLElement; + expect(main.style.getPropertyValue('--compact-width')).toBe(`${width}px`); + expect(main.style.getPropertyValue('--compact-height')).toBe(`${height}px`); + expect(main.dataset.view).toBe('compact'); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + if (notchWidth && topInset) { + expect(main.querySelector(':scope > .notch-gap')).toBeNull(); + const gap = main.querySelector('.notch-gap') as HTMLElement; + expect(gap.previousElementSibling?.classList.contains('compact-left')).toBe(true); + expect(gap.nextElementSibling?.classList.contains('compact-lane')).toBe(true); + expect(gap.getAttribute('aria-hidden')).toBe('true'); + expect(gap.children).toHaveLength(0); + } + }, +); + +test('hover without permissions reveals only cached quick status, body Refresh and question hint; disclosure click reads chat', async () => { + const initial = { + ...state(), + permissions: [], + questions: [{ id: 'q_1', sessionId: 'child', title: 'Private full form', answerable: true, fields: [] }], + }; + const view = mount({}, initial); + await screen.findByText('1 pending'); + const active = document.activeElement; + const reads = view.config.getIslandState.mock.calls.length; + await fireEvent.pointerEnter(screen.getByRole('main', { name: 'Sessions' })); + const quick = await screen.findByRole('region', { name: 'Quick area' }); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(within(quick).getByRole('status').textContent).toBe('1 running'); + expect(within(quick).getByText('Open the island to answer pending questions.')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeTruthy(); + expect(screen.queryByText('Private full form')).toBeNull(); + expect(screen.queryByRole('textbox', { name: 'Message' })).toBeNull(); + expect(screen.queryByRole('navigation', { name: 'Sessions' })).toBeNull(); + expect(document.activeElement).toBe(active); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyQuestion).not.toHaveBeenCalled(); + const disclosure = screen.getByRole('button', { name: 'Expand sessions' }); + await fireEvent.pointerEnter(disclosure); + await fireEvent.click(view.container.querySelector('.compact-header') as HTMLElement); + await new Promise((resolve) => setTimeout(resolve, 140)); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(within(quick).getByRole('button', { name: 'Refresh' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Open sessions' })).toBeNull(); + await fireEvent.click(disclosure); + await screen.findByRole('navigation', { name: 'Sessions' }); + await waitFor(() => expect(view.config.islandMessages).toHaveBeenCalledWith('child', 0)); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['expanded']]); +}); + +test('pending arrival stays compact; short hover and teardown cancel the 100 ms entry timer', async () => { + const view = mount({}, { ...state(), permissions: [] }); + await screen.findByText('1 running'); + view.setState(state()); + native.handlers.get('island:changed')?.(); + await screen.findByText('1 pending'); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: 'Allow once' })).toBeNull(); + vi.useFakeTimers(); + try { + const compact = screen.getByRole('main', { name: 'Sessions' }); + await fireEvent.pointerEnter(compact); + await vi.advanceTimersByTimeAsync(99); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + await fireEvent.pointerLeave(compact); + await vi.advanceTimersByTimeAsync(200); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + await fireEvent.pointerEnter(compact); + view.unmount(); + await vi.advanceTimersByTimeAsync(200); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } +}); + +test('focus without permissions still opens general peek when pointer leaves during the entry delay', async () => { + const view = mount({}, { ...state(), permissions: [] }); + const details = await screen.findByRole('button', { name: 'Show quick area' }); + details.focus(); + await fireEvent.pointerLeave(screen.getByRole('main', { name: 'Sessions' })); + await screen.findByRole('region', { name: 'Quick area' }); + expect(document.activeElement).toBe(details); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + details.blur(); + await waitFor(() => expect(view.config.setIslandView).toHaveBeenLastCalledWith('compact')); +}); + +test.each(['collapse', 'unmount'])('late peek cannot revive UI after authoritative %s', async (end) => { + let finish!: () => void; + const setIslandView = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(undefined); + const view = mount({ setIslandView }); + await screen.findByText('1 pending'); + await fireEvent.pointerEnter(screen.getByRole('main', { name: 'Sessions' })); + await waitFor(() => expect(setIslandView).toHaveBeenCalledExactlyOnceWith('peek')); + if (end === 'unmount') view.unmount(); + else native.handlers.get('island:collapsed')?.(); + finish(); + await tick(); + if (end === 'unmount') expect(setIslandView).toHaveBeenCalledOnce(); + else { + expect(setIslandView.mock.calls).toEqual([['peek']]); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(screen.queryByRole('button', { name: 'Allow once' })).toBeNull(); + } + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test('local blur/leave protects busy peek, but authoritative collapse wins and never retries or cancels its reply', async () => { + let finish!: () => void; + const view = mount({ + islandReplyPermission: vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + }); + await peek(); + await fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + await fireEvent.pointerLeave(screen.getByRole('main', { name: 'Sessions' })); + await fireEvent.blur(window); + await new Promise((resolve) => setTimeout(resolve, 210)); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('peek'); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + native.handlers.get('island:collapsed')?.(); + await tick(); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + finish(); + await waitFor(() => expect(view.config.refreshIsland).toHaveBeenCalledOnce()); + expect(view.config.islandReplyPermission).toHaveBeenCalledExactlyOnceWith('pending', 'perm_1', 'once', 0); + expect(view.config.islandCancelQuestion).not.toHaveBeenCalled(); + expect(view.config.islandInterrupt).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); +}); + +test('open Always menu prevents hover-leave shrink; click toggles, Escape returns focus before compact', async () => { + const initial = state(); + initial.permissions[0].save = ['git *']; + const view = mount({}, initial); + await peek(); + const options = screen.getByRole('button', { name: 'More approval options' }); + await fireEvent.click(options); + await screen.findByRole('button', { name: 'Always for this project' }); + await fireEvent.pointerLeave(screen.getByRole('main', { name: 'Sessions' })); + await new Promise((resolve) => setTimeout(resolve, 210)); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + await fireEvent.click(options); + expect(screen.queryByRole('button', { name: 'Always for this project' })).toBeNull(); + await fireEvent.click(options); + await fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(document.activeElement).toBe(options)); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + await fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(view.config.setIslandView).toHaveBeenLastCalledWith('compact')); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test('expanded permission has the same explicit Always menu, visible patterns and live removal guard', async () => { + const initial = state(); + initial.permissions[0].save = ['git *', '/very/long/project/path/**']; + const view = mount({}, initial); + await expand(); + const options = screen.getByRole('button', { name: 'More approval options' }); + await fireEvent.pointerEnter(options.closest('.approval-options') as HTMLElement); + const staleAlways = await screen.findByRole('button', { name: 'Always for this project' }); + expect( + (screen.getByRole('textbox', { name: 'Save bash rules in this OpenCode Project' }) as HTMLTextAreaElement).value, + ).toBe('git * · /very/long/project/path/**'); + expect(screen.getByText('Save bash rules in this OpenCode Project')).toBeTruthy(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + await fireEvent.keyDown(window, { key: 'Escape' }); + await waitFor(() => expect(document.activeElement).toBe(options)); + expect(screen.getByRole('navigation', { name: 'Sessions' })).toBeTruthy(); + await fireEvent.click(options); + view.setState({ ...initial, permissions: [] }); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Allow once' })).toBeNull()); + await fireEvent.click(staleAlways); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + view.setState(initial); + native.handlers.get('island:changed')?.(); + await fireEvent.click(await screen.findByRole('button', { name: 'More approval options' })); + await fireEvent.click(screen.getByRole('button', { name: 'Always for this project' })); + expect(view.config.islandReplyPermission).toHaveBeenCalledExactlyOnceWith('pending', 'perm_1', 'always', 0); +}); + +test.each([true, false])( + 'native hover alone with permissions=%s opens at 100 ms and leaves at 180 ms without reads, writes or focus', + async (pending) => { + const view = mount({}, { ...state(), permissions: pending ? state().permissions : [] }); + await screen.findByText('1 running'); + const enter = native.handlers.get('island:pointer-enter'); + const leave = native.handlers.get('island:pointer-leave'); + expect(enter).toBeTypeOf('function'); + expect(leave).toBeTypeOf('function'); + const reads = view.config.getIslandState.mock.calls.length; + const active = document.activeElement; + const main = screen.getByRole('main', { name: 'Sessions' }); + await fireEvent.blur(window); + vi.useFakeTimers(); + try { + enter?.(); + await vi.advanceTimersByTimeAsync(60); + enter?.(); + await vi.advanceTimersByTimeAsync(39); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(main.dataset.view).toBe('peek'); + enter?.(); + await fireEvent.blur(window); + await vi.advanceTimersByTimeAsync(500); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + leave?.(); + await vi.advanceTimersByTimeAsync(179); + expect(main.dataset.view).toBe('peek'); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(main.dataset.view).toBe('compact'); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['compact']]); + expect(document.activeElement).toBe(active); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.refreshIsland).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + for (const mutation of [ + view.config.islandSend, + view.config.islandReplyPermission, + view.config.islandReplyQuestion, + view.config.islandCancelQuestion, + view.config.islandInterrupt, + ]) + expect(mutation).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }, +); + +test.each(['native', 'dom'] as const)( + '%s leave cannot cancel entry or compact while the other hover source remains inside', + async (firstLeave) => { + const view = mount(); + await screen.findByText('1 pending'); + const main = screen.getByRole('main', { name: 'Sessions' }); + vi.useFakeTimers(); + try { + native.handlers.get('island:pointer-enter')?.(); + await vi.advanceTimersByTimeAsync(60); + await fireEvent.pointerEnter(main); + if (firstLeave === 'native') native.handlers.get('island:pointer-leave')?.(); + else await fireEvent.pointerLeave(main); + await vi.advanceTimersByTimeAsync(39); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + await vi.advanceTimersByTimeAsync(400); + expect(main.dataset.view).toBe('peek'); + if (firstLeave === 'native') await fireEvent.pointerLeave(main); + else native.handlers.get('island:pointer-leave')?.(); + await vi.advanceTimersByTimeAsync(179); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['compact']]); + expect(view.config.getIslandState).toHaveBeenCalledOnce(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }, +); + +test('DOM leave during native peek resize cannot shrink the connected native hover surface', async () => { + let finish!: () => void; + const view = mount({ + setIslandView: vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(undefined), + }); + await screen.findByText('1 pending'); + const main = screen.getByRole('main', { name: 'Sessions' }); + vi.useFakeTimers(); + try { + native.handlers.get('island:pointer-enter')?.(); + await fireEvent.pointerEnter(main); + await vi.advanceTimersByTimeAsync(100); + await fireEvent.pointerLeave(main); + await vi.advanceTimersByTimeAsync(250); + finish(); + await vi.advanceTimersByTimeAsync(0); + await tick(); + await vi.advanceTimersByTimeAsync(400); + expect(main.dataset.view).toBe('peek'); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + native.handlers.get('island:pointer-leave')?.(); + await vi.advanceTimersByTimeAsync(180); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['compact']]); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +test.each(['menu', 'focus'] as const)('native leave retains %s protection until it is released', async (protection) => { + const initial = state(); + initial.permissions[0].save = ['git *']; + const view = mount({}, initial); + await screen.findByText('1 pending'); + const main = screen.getByRole('main', { name: 'Sessions' }); + vi.useFakeTimers(); + try { + native.handlers.get('island:pointer-enter')?.(); + await vi.advanceTimersByTimeAsync(100); + await tick(); + const control = screen.getByRole('button', { + name: protection === 'menu' ? 'More approval options' : 'Show quick area', + }); + if (protection === 'menu') await fireEvent.click(control); + else { + control.focus(); + await tick(); + } + native.handlers.get('island:pointer-leave')?.(); + await vi.advanceTimersByTimeAsync(400); + expect(main.dataset.view).toBe('peek'); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + if (protection === 'menu') await fireEvent.click(control); + else { + control.blur(); + await tick(); + } + await vi.advanceTimersByTimeAsync(179); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['compact']]); + expect(view.config.getIslandState).toHaveBeenCalledOnce(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +test('native leave preserves a busy reply and checking readback, then shrinks only after confirmation', async () => { + let finishReply!: () => void; + let finishRead!: (value: IslandState) => void; + const view = mount({ + islandReplyPermission: vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReply = resolve; + }), + ), + }); + await screen.findByText('1 pending'); + view.config.getIslandState.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const main = screen.getByRole('main', { name: 'Sessions' }); + vi.useFakeTimers(); + try { + native.handlers.get('island:pointer-enter')?.(); + await vi.advanceTimersByTimeAsync(100); + await tick(); + await fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + native.handlers.get('island:pointer-leave')?.(); + await vi.advanceTimersByTimeAsync(400); + expect(main.dataset.view).toBe('peek'); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + finishReply(); + await vi.advanceTimersByTimeAsync(0); + expect(view.config.refreshIsland).toHaveBeenCalledOnce(); + expect(view.config.getIslandState).toHaveBeenCalledTimes(2); + expect(screen.getByRole('region', { name: 'Permission requested' }).getAttribute('aria-busy')).toBe('true'); + await vi.advanceTimersByTimeAsync(400); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + finishRead({ ...state(), permissions: [] }); + await vi.advanceTimersByTimeAsync(0); + await tick(); + await vi.advanceTimersByTimeAsync(179); + expect(main.dataset.view).toBe('peek'); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(main.dataset.view).toBe('compact'); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['compact']]); + expect(view.config.islandReplyPermission).toHaveBeenCalledExactlyOnceWith('pending', 'perm_1', 'once', 0); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandInterrupt).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +test.each(['timer', 'resize'] as const)( + 'authoritative collapse clears both hover sources and beats pending native %s', + async (pending) => { + let finish!: () => void; + const setIslandView = vi.fn().mockResolvedValue(undefined); + if (pending === 'resize') + setIslandView.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = mount({ setIslandView }); + await screen.findByText('1 pending'); + const main = screen.getByRole('main', { name: 'Sessions' }); + vi.useFakeTimers(); + try { + native.handlers.get('island:pointer-enter')?.(); + await fireEvent.pointerEnter(main); + await vi.advanceTimersByTimeAsync(pending === 'timer' ? 99 : 100); + native.handlers.get('island:collapsed')?.(); + await tick(); + expect(main.dataset.view).toBe('compact'); + if (pending === 'resize') finish(); + await vi.advanceTimersByTimeAsync(500); + await tick(); + expect(main.dataset.view).toBe('compact'); + expect(view.config.setIslandView.mock.calls).toEqual(pending === 'timer' ? [] : [['peek']]); + await fireEvent.pointerMove(main); + await vi.advanceTimersByTimeAsync(100); + expect(view.config.setIslandView).toHaveBeenLastCalledWith('peek'); + await fireEvent.pointerLeave(main); + await vi.advanceTimersByTimeAsync(180); + expect(view.config.setIslandView).toHaveBeenLastCalledWith('compact'); + native.handlers.get('island:pointer-enter')?.(); + await vi.advanceTimersByTimeAsync(100); + expect(main.dataset.view).toBe('peek'); + native.handlers.get('island:pointer-leave')?.(); + await vi.advanceTimersByTimeAsync(180); + expect(main.dataset.view).toBe('compact'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }, +); + +test('expanded ignores native hover without automatic view, reads, messages or focus changes', async () => { + const view = mount(); + await expand(); + const reads = view.config.getIslandState.mock.calls.length; + const messagesRead = view.config.islandMessages.mock.calls.length; + const active = document.activeElement; + vi.useFakeTimers(); + try { + for (let index = 0; index < 3; index += 1) { + native.handlers.get('island:pointer-enter')?.(); + native.handlers.get('island:pointer-leave')?.(); + } + await vi.advanceTimersByTimeAsync(500); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('expanded'); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['expanded']]); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.islandMessages).toHaveBeenCalledTimes(messagesRead); + expect(view.config.refreshIsland).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(active); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +test.each(['timer', 'resize'] as const)( + 'disabled snapshot cancels pending %s and ignores both hover sources', + async (pending) => { + let finish!: () => void; + const setIslandView = vi.fn().mockResolvedValue(undefined); + if (pending === 'resize') + setIslandView.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = mount({ setIslandView }); + await screen.findByText('1 pending'); + const main = screen.getByRole('main', { name: 'Sessions' }); + vi.useFakeTimers(); + try { + view.setState({ ...state(), enabled: false }); + native.handlers.get('island:changed')?.(); + if (pending === 'timer') await vi.advanceTimersByTimeAsync(150); + native.handlers.get('island:pointer-enter')?.(); + await fireEvent.pointerEnter(main); + await vi.advanceTimersByTimeAsync(pending === 'timer' ? 50 : 200); + if (pending === 'resize') finish(); + await vi.advanceTimersByTimeAsync(0); + await tick(); + native.handlers.get('island:pointer-enter')?.(); + native.handlers.get('island:pointer-leave')?.(); + await fireEvent.pointerMove(main); + await vi.advanceTimersByTimeAsync(300); + expect(main.dataset.view).toBe('compact'); + expect(view.config.setIslandView.mock.calls).toEqual(pending === 'timer' ? [] : [['peek']]); + view.setState(state()); + native.handlers.get('island:changed')?.(); + await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(300); + expect(main.dataset.view).toBe('compact'); + native.handlers.get('island:pointer-enter')?.(); + await vi.advanceTimersByTimeAsync(100); + expect(main.dataset.view).toBe('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }, +); + +test.each(['timer', 'resize'] as const)('teardown ignores retained native callbacks and late %s', async (pending) => { + let finish!: () => void; + const view = mount({ + setIslandView: vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + }); + await screen.findByText('1 pending'); + const enter = native.handlers.get('island:pointer-enter'); + const leave = native.handlers.get('island:pointer-leave'); + const reads = view.config.getIslandState.mock.calls.length; + vi.useFakeTimers(); + try { + enter?.(); + await vi.advanceTimersByTimeAsync(pending === 'timer' ? 99 : 100); + view.unmount(); + enter?.(); + leave?.(); + if (pending === 'resize') finish(); + await vi.advanceTimersByTimeAsync(500); + expect(view.config.setIslandView).toHaveBeenCalledTimes(pending === 'timer' ? 0 : 1); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.refreshIsland).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(native.unlisten).toHaveBeenCalledTimes(5); + } finally { + vi.useRealTimers(); + } +}); + +test('pointer registrations finish before the initial snapshot read and pre-read native entry cannot revive a disabled island', async () => { + const finish: (() => void)[] = []; + native.listen.mockImplementation((name: string, handler: () => void) => { + native.handlers.set(name, handler); + return new Promise<() => void>((resolve) => { + finish.push(() => resolve(() => native.unlisten(name))); + }); + }); + const view = mount({}, { ...state(), enabled: false }); + await tick(); + expect(native.listen).toHaveBeenCalledTimes(5); + expect(view.config.getIslandState).not.toHaveBeenCalled(); + vi.useFakeTimers(); + try { + native.handlers.get('island:pointer-enter')?.(); + for (const register of finish.slice(0, -1)) register(); + await vi.advanceTimersByTimeAsync(0); + expect(view.config.getIslandState).not.toHaveBeenCalled(); + finish.at(-1)?.(); + await vi.advanceTimersByTimeAsync(0); + await tick(); + expect(view.config.getIslandState).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(300); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +test.each([ + ['en', '1 running · Sessions 4', 'Approve 1 · Answer 0', '1 running', '1 pending'], + ['zh', '1 运行中 · 4 会话', '1 权限 · 0 问题', '1 个运行中', '1 项待处理'], +])( + 'no-notch compact fits factual bilingual status without sensitive details (%s)', + async (locale, status, pending, fullStatus, fullPending) => { + const active = document.activeElement; + const view = mount({ preferences: { theme: 'dark', locale } }, { ...state(), notchWidth: 0, topInset: 0 }); + await waitFor(() => expect(view.container.querySelector('.compact-summary')?.textContent).toContain(status)); + const main = view.container.querySelector('main') as HTMLElement; + expect(main.dataset.view).toBe('compact'); + expect(main.style.getPropertyValue('--compact-width')).toBe('200px'); + expect(main.style.getPropertyValue('--compact-height')).toBe('28px'); + expect(main.querySelector('.compact-secondary')?.textContent).toBe(pending); + expect(main.querySelector('#compact-status-hint')?.textContent).toBe(fullStatus); + expect(main.querySelector('#compact-pending-hint')?.textContent).toBe(fullPending); + expect(main.querySelector('.compact-summary')?.getAttribute('aria-hidden')).toBe('true'); + expect(main.querySelectorAll('.activity')).toHaveLength(1); + expect(main.querySelectorAll('.activity i')).toHaveLength(4); + expect(main.querySelectorAll('svg, .quick-area, .compact-permission, .session-disclosure')).toHaveLength(0); + expect(main.textContent).not.toMatch(/MOM|work|\/code|git status|repository/); + expect(document.activeElement).toBe(active); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + }, +); + +test.each([ + ['en', '99+ running · Sessions 99+', 'Approve 99+ · Answer 0', '105 running', '105 pending'], + ['zh', '99+ 运行中 · 99+ 会话', '99+ 权限 · 0 问题', '105 个运行中', '105 项待处理'], +])( + 'no-notch large counts cap only visible text and retain exact accessible counts (%s)', + async (locale, status, pending, fullStatus, fullPending) => { + const initial = state(); + initial.notchWidth = 0; + initial.topInset = 0; + initial.sessions = Array.from({ length: 105 }, (_, index) => ({ ...initial.sessions[1], id: `run_${index}` })); + initial.permissions = Array.from({ length: 105 }, (_, index) => ({ + ...initial.permissions[0], + id: `perm_${index}`, + })); + const view = mount({ preferences: { theme: 'dark', locale } }, initial); + await waitFor(() => expect(view.container.querySelector('.compact-summary')?.textContent).toContain(status)); + expect(view.container.querySelector('.compact-secondary')?.textContent).toBe(pending); + expect(view.container.querySelector('#compact-status-hint')?.textContent).toBe(fullStatus); + expect(view.container.querySelector('#compact-pending-hint')?.textContent).toBe(fullPending); + expect(view.container.querySelectorAll('.activity')).toHaveLength(1); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + }, +); + +test.each([ + ['en', 'Idle', 'Connecting', 'Unavailable', 'Disabled'], + ['zh', '空闲', '连接中', '不可用', '已停用'], +])( + 'no-notch idle and unavailable states never imply running or expose raw errors (%s)', + async (locale, idle, connecting, unavailable, disabled) => { + const initial = { ...state(), notchWidth: 0, topInset: 0 }; + const view = mount({ preferences: { theme: 'dark', locale } }, initial); + await waitFor(() => expect(view.container.querySelector('.activity')?.getAttribute('data-running')).toBe('true')); + const summary = () => view.container.querySelector('.compact-summary > span')?.textContent; + for (const [next, label] of [ + [{ ...initial, sessions: initial.sessions.map((session) => ({ ...session, status: 'idle' as const })) }, idle], + [{ ...initial, connection: 'connecting' as const }, connecting], + [{ ...initial, connection: 'error' as const, error: 'Private /code/project diagnostic' }, unavailable], + [{ ...initial, enabled: false }, disabled], + ] as const) { + view.setState(next); + native.handlers.get('island:changed')?.(); + await waitFor(() => + expect(summary()).toBe(label === idle ? `${idle} · ${locale === 'en' ? 'Sessions 4' : '4 会话'}` : label), + ); + expect(view.container.querySelector('.activity')?.getAttribute('data-running')).toBe('false'); + expect(view.container.querySelector('main')?.textContent).not.toContain('Private'); + expect(view.container.querySelector('.compact-status-control')?.getAttribute('title')).not.toContain('/code'); + } + view.setState(initial); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(view.container.querySelector('.activity')?.getAttribute('data-running')).toBe('true')); + view.config.getIslandState.mockRejectedValueOnce(new Error('Private /code/read failure')); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledTimes(7)); + await tick(); + expect(summary()).toBe(unavailable); + expect(view.container.querySelector('.activity')?.getAttribute('data-running')).toBe('false'); + expect(view.container.querySelector('.compact-secondary')?.textContent).toBe( + locale === 'en' ? 'Sessions — · Requests —' : '会话 — · 请求 —', + ); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + }, +); + +test.each(['dom', 'native'] as const)( + 'no-notch %s hover preserves the three-form lifecycle without automatic focus or chat', + async (source) => { + const view = mount({}, { ...state(), notchWidth: 0, topInset: 0 }); + await screen.findByText('1 pending'); + const main = screen.getByRole('main', { name: 'Sessions' }); + const active = document.activeElement; + vi.useFakeTimers(); + try { + if (source === 'dom') await fireEvent.pointerEnter(main); + else native.handlers.get('island:pointer-enter')?.(); + await vi.advanceTimersByTimeAsync(99); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await tick(); + expect(main.dataset.view).toBe('peek'); + expect(main.style.getPropertyValue('--compact-width')).toBe('400px'); + await vi.advanceTimersByTimeAsync(500); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(document.activeElement).toBe(active); + expect(view.config.getIslandState).toHaveBeenCalledOnce(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + if (source === 'dom') await fireEvent.pointerLeave(main); + else native.handlers.get('island:pointer-leave')?.(); + await vi.advanceTimersByTimeAsync(180); + await tick(); + expect(main.dataset.view).toBe('compact'); + await fireEvent.click(screen.getByRole('button', { name: 'Show quick area' })); + await tick(); + const disclosure = screen.getByRole('button', { name: 'Expand sessions' }); + await fireEvent.pointerEnter(disclosure); + await vi.advanceTimersByTimeAsync(150); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + await fireEvent.click(disclosure); + await tick(); + expect(main.dataset.view).toBe('expanded'); + expect(view.config.islandMessages).toHaveBeenCalledExactlyOnceWith('pending', 0); + native.handlers.get('island:collapsed')?.(); + await tick(); + expect(main.dataset.view).toBe('compact'); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['compact'], ['peek'], ['expanded']]); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandSend).not.toHaveBeenCalled(); + } finally { + view.unmount(); + vi.useRealTimers(); + } + }, +); + +test.each(['click', 'touch', 'Enter', ' '] as const)( + 'no-notch %s activation opens only Peek, never sessions directly', + async (activation) => { + const view = mount({}, { ...state(), notchWidth: 0, topInset: 0 }); + await screen.findByText('1 pending'); + const status = screen.getByRole('button', { name: 'Session status · Show quick area' }); + if (activation === 'touch') await fireEvent.pointerUp(screen.getByRole('main'), { pointerType: 'touch' }); + else { + if (activation !== 'click') { + status.focus(); + await fireEvent.keyDown(status, { key: activation }); + await fireEvent.keyUp(status, { key: activation }); + } + await fireEvent.click(status, { detail: activation === 'click' ? 1 : 0 }); + } + await screen.findByRole('button', { name: 'Expand sessions' }); + expect(view.config.setIslandView).toHaveBeenCalledExactlyOnceWith('peek'); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + }, +); + +test.each(['compact', 'peek', 'expanded'] as const)( + 'current screen notch geometry changes density, never the %s view or resize IPC', + async (mode) => { + const initial = { ...state(), notchWidth: 0, topInset: 0 }; + const view = mount({}, initial); + await screen.findByText('1 pending'); + if (mode === 'peek') await peek(); + else if (mode === 'expanded') await expand(); + const calls = view.config.setIslandView.mock.calls.length; + const main = screen.getByRole('main', { name: 'Sessions' }); + for (const [notchWidth, topInset] of [ + [180, 32], + [0, 0], + [0, 38], + [180, 0], + ]) { + const reads = view.config.getIslandState.mock.calls.length; + const messagesRead = view.config.islandMessages.mock.calls.length; + view.setState({ ...initial, notchWidth, topInset }); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledTimes(reads + 1)); + await tick(); + expect(main.dataset.view).toBe(mode); + expect(main.classList.contains('notched-compact')).toBe(mode === 'compact' && notchWidth > 0 && topInset > 0); + expect(!!main.querySelector('.compact-summary')).toBe(mode === 'compact' && !(notchWidth > 0 && topInset > 0)); + expect(view.config.setIslandView).toHaveBeenCalledTimes(calls); + if (mode !== 'expanded') expect(view.config.islandMessages).toHaveBeenCalledTimes(messagesRead); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + } + native.handlers.get('island:collapsed')?.(); + await tick(); + expect(main.dataset.view).toBe('compact'); + expect(view.config.setIslandView).toHaveBeenCalledTimes(calls); + }, +); + +test.each(['pending', 'failed', 'invalid'] as const)( + 'unknown %s geometry stays hidden and safe until known, without auto-Peek', + async (kind) => { + let finish!: (value: IslandState) => void; + const getIslandState = + kind === 'pending' + ? vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + : kind === 'failed' + ? vi.fn().mockRejectedValueOnce(new Error('Snapshot unavailable')) + : vi.fn().mockResolvedValueOnce({ ...state(), notchWidth: Number.NaN, topInset: -1 }); + const view = mount({ getIslandState }); + await waitFor(() => expect(getIslandState).toHaveBeenCalledOnce()); + const main = screen.getByRole('main', { name: 'Sessions' }); + await tick(); + expect(main.classList.contains('geometry-pending')).toBe(true); + expect(main.style.getPropertyValue('--top-inset')).toBe('0px'); + expect(main.style.getPropertyValue('--notch-width')).toBe('0px'); + native.handlers.get('island:pointer-enter')?.(); + await fireEvent.pointerMove(main); + await new Promise((resolve) => setTimeout(resolve, 130)); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + const known = { ...state(), notchWidth: 0, topInset: 0 }; + if (kind === 'pending') finish(known); + else { + getIslandState.mockResolvedValueOnce(known); + native.handlers.get('island:changed')?.(); + } + await waitFor(() => expect(main.classList.contains('geometry-pending')).toBe(false)); + expect(main.dataset.view).toBe('compact'); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + }, +); + +test.each([ + ['en', 'Running', 'Idle', 'Connecting', 'Offline', 'Off', 'Pending'], + ['zh', '运行中', '空闲', '连接中', '不可用', '已关闭', '待处理'], +])( + 'both notched wings remain meaningful in every state (%s)', + async (locale, runningLabel, idle, connecting, offline, off, pendingLabel) => { + const initial = { ...state(), notchWidth: 209, topInset: 38 }; + const view = mount({ preferences: { theme: 'dark', locale } }, initial); + await waitFor(() => expect(view.container.querySelector('.wing-label')?.textContent).toBe(runningLabel)); + const main = view.container.querySelector('main') as HTMLElement; + expect(main.style.getPropertyValue('--compact-width')).toBe('321px'); + expect(main.style.getPropertyValue('--compact-height')).toBe('38px'); + expect(main.querySelector('.wing-count')?.textContent).toBe('1'); + expect(main.querySelector('.wing-pending-count')?.textContent).toBe('1'); + expect(main.querySelector('.compact-status > .wing-label')?.textContent).toBe(pendingLabel); + const gap = main.querySelector('.notch-gap') as HTMLElement; + expect(gap.children).toHaveLength(0); + expect(gap.textContent).toBe(''); + expect(main.querySelectorAll('svg')).toHaveLength(0); + for (const [next, label, count, requests] of [ + [ + { + ...initial, + permissions: [], + sessions: initial.sessions.map((session) => ({ ...session, status: 'idle' as const })), + }, + idle, + '0', + '0', + ], + [{ ...initial, connection: 'connecting' as const }, connecting, '—', '—'], + [{ ...initial, connection: 'error' as const }, offline, '—', '—'], + [{ ...initial, enabled: false }, off, '—', '—'], + ] as const) { + view.setState(next); + native.handlers.get('island:changed')?.(); + await waitFor(() => expect(main.querySelector('.compact-wing-state .wing-label')?.textContent).toBe(label)); + expect(main.querySelector('.wing-count')?.textContent).toBe(count); + expect(main.querySelector('.wing-pending-count')?.textContent).toBe(requests); + expect(main.querySelector('.compact-status > .wing-label')?.textContent).toBe(pendingLabel); + expect(main.querySelector('.activity')?.getAttribute('data-running')).toBe('false'); + expect(main.dataset.view).toBe('compact'); + } + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + }, +); + +test.each(['en', 'zh'] as const)( + 'compact counts distinguish permissions and questions, include cached children, and preserve exact large totals (%s)', + async (locale) => { + const initial = { ...state(), notchWidth: 209, topInset: 38 }; + initial.sessions = Array.from({ length: 105 }, (_, index) => ({ ...initial.sessions[1], id: `run_${index}` })); + initial.permissions = Array.from({ length: 104 }, (_, index) => ({ + ...initial.permissions[0], + id: `perm_${index}`, + })); + initial.questions = [{ id: 'q_1', sessionId: 'child', title: 'Private question', answerable: true, fields: [] }]; + const view = mount({ preferences: { theme: 'dark', locale } }, initial); + await waitFor(() => expect(view.container.querySelector('.wing-count')?.textContent).toBe('99+')); + const main = view.container.querySelector('main') as HTMLElement; + expect(main.querySelector('.wing-pending-count')?.textContent).toBe('99+'); + expect(main.querySelector('#compact-counts-hint')?.textContent).toBe( + locale === 'en' + ? 'Current snapshot: 105 sessions; 104 permission requests; 1 questions.' + : '当前快照:105 个会话;104 项权限请求;1 个问题。', + ); + const left = main.querySelector('.compact-left') as HTMLButtonElement; + expect(left.getAttribute('aria-describedby')).toContain('compact-counts-hint'); + view.setState({ ...state(), notchWidth: 0, topInset: 0, questions: initial.questions }); + native.handlers.get('island:changed')?.(); + await waitFor(() => + expect(main.querySelector('.compact-primary')?.textContent).toBe( + locale === 'en' ? '1 running · Sessions 4' : '1 运行中 · 4 会话', + ), + ); + expect(main.querySelector('.compact-secondary')?.textContent).toBe( + locale === 'en' ? 'Approve 1 · Answer 1' : '1 权限 · 1 问题', + ); + expect(main.querySelector('#compact-counts-hint')?.textContent).not.toMatch(/total|history|总计|历史/i); + expect(main.textContent).not.toContain('Private question'); + expect(view.config.setIslandView).not.toHaveBeenCalled(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + }, +); + +test('presentation preserves the focused peek header, swaps permission controls without overlap, and removes expanded content on collapse', async () => { + const initial = state(); + initial.permissions.push({ id: 'perm_child', sessionId: 'child', action: 'edit', resources: ['/child/motion.ts'] }); + const view = mount({}, initial); + await screen.findByText('2 pending'); + const header = screen.getByRole('button', { name: 'Show quick area' }); + const status = screen.getByRole('button', { name: 'Session status · Show quick area' }); + const activity = view.container.querySelector('.activity'); + header.focus(); + await screen.findByRole('button', { name: 'Allow once' }); + expect(screen.getByRole('button', { name: 'Show quick area' })).toBe(header); + expect(screen.getByRole('button', { name: 'Session status · Show quick area' })).toBe(status); + expect(view.container.querySelector('.activity')).toBe(activity); + expect(document.activeElement).toBe(header); + const previous = screen.getByRole('button', { name: 'Allow once' }); + await fireEvent.click(screen.getByRole('button', { name: 'Next permission request' })); + expect(previous.isConnected).toBe(false); + expect(screen.getAllByRole('button', { name: 'Allow once', hidden: true })).toHaveLength(1); + expect(screen.queryByText('git status')).toBeNull(); + expect(screen.getByText('/child/motion.ts')).toBeTruthy(); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + await screen.findByRole('textbox', { name: 'Message' }); + expect(screen.getAllByRole('button', { name: 'Allow once', hidden: true })).toHaveLength(1); + const selectedSession = screen.getByRole('button', { name: /Pending work/ }); + await fireEvent.click(selectedSession); + await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), { target: { value: 'Local motion draft' } }); + const composer = screen.getByRole('textbox', { name: 'Message' }); + const previousConversation = screen.getByRole('region', { name: 'Recent messages and pending requests' }); + await fireEvent.click(screen.getByRole('button', { name: /Running work/ })); + expect(previousConversation.isConnected).toBe(false); + expect(screen.getByRole('textbox', { name: 'Message' })).toBe(composer); + expect(screen.getAllByRole('region', { name: 'Recent messages and pending requests', hidden: true })).toHaveLength(1); + await fireEvent.click(screen.getByRole('button', { name: /Pending work/ })); + expect((composer as HTMLTextAreaElement).value).toBe('Local motion draft'); + composer.focus(); + native.handlers.get('island:collapsed')?.(); + await tick(); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(view.container.querySelector('#island-session-window')).toBeNull(); + expect(composer.isConnected).toBe(false); + expect(document.activeElement).not.toBe(composer); + expect(screen.queryByRole('button', { name: 'Allow once', hidden: true })).toBeNull(); + expect(view.config.islandSend).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test('rapid expansion/collapse waits for IPC success, rejects stale message readback, and never revives removed content', async () => { + let finishExpand!: () => void; + let finishCollapse!: () => void; + let finishMessages!: (value: IslandMessage[]) => void; + const view = mount(); + await peek(); + view.config.setIslandView.mockImplementationOnce( + () => + new Promise((resolve) => { + finishExpand = resolve; + }), + ); + view.config.islandMessages.mockImplementationOnce( + () => + new Promise((resolve) => { + finishMessages = resolve; + }), + ); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('peek'); + expect(screen.queryByRole('textbox', { name: 'Message', hidden: true })).toBeNull(); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + finishExpand(); + await screen.findByRole('textbox', { name: 'Message' }); + await waitFor(() => expect(view.config.islandMessages).toHaveBeenCalledOnce()); + view.config.setIslandView.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCollapse = resolve; + }), + ); + await fireEvent.click(screen.getByRole('button', { name: 'Collapse island' })); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('expanded'); + native.handlers.get('island:collapsed')?.(); + await tick(); + expect(view.container.querySelector('#island-session-window')).toBeNull(); + finishMessages([{ id: 'late_motion', role: 'assistant', text: 'Late motion message', time: 1, truncated: false }]); + finishCollapse(); + await tick(); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(screen.queryByText('Late motion message')).toBeNull(); + expect(screen.queryByRole('textbox', { name: 'Message', hidden: true })).toBeNull(); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['expanded'], ['compact']]); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); +}); + +test('failed grow retains peek; early compact collapse preserves shrink error and drafts without restoring controls', async () => { + const view = mount(); + await peek(); + const header = screen.getByRole('button', { name: 'Show quick area' }); + view.config.setIslandView.mockRejectedValueOnce(new Error('Expansion failed')); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + await screen.findByRole('alert'); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('peek'); + expect(screen.getByRole('button', { name: 'Show quick area' })).toBe(header); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(screen.queryByRole('textbox', { name: 'Message', hidden: true })).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + await screen.findByRole('textbox', { name: 'Message' }); + const composer = screen.getByRole('textbox', { name: 'Message' }); + await fireEvent.input(composer, { target: { value: 'Keep after failed shrink' } }); + let fail!: (cause: Error) => void; + view.config.setIslandView.mockImplementationOnce(() => { + native.handlers.get('island:collapsed')?.(); + return new Promise((_resolve, reject) => { + fail = reject; + }); + }); + await fireEvent.click(screen.getByRole('button', { name: 'Collapse island' })); + const calls = view.config.setIslandView.mock.calls.length; + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(composer.isConnected).toBe(false); + expect(screen.queryByRole('button', { name: 'Allow once', hidden: true })).toBeNull(); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(view.config.setIslandView).toHaveBeenCalledTimes(calls); + fail(new Error('Shrink failed')); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Session status · Show quick area' }).title).toBe('Shrink failed'), + ); + expect(view.config.setIslandView).toHaveBeenCalledTimes(calls); + expect(view.config.islandSend).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Show quick area' })); + await screen.findByRole('button', { name: 'Expand sessions' }); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + const restored = await screen.findByRole('textbox', { name: 'Message' }); + expect((restored as HTMLTextAreaElement).value).toBe('Keep after failed shrink'); +}); + +test.each(['peek', 'expanded'] as const)( + 'compact supersedes pending %s immediately, deduplicates Escape and ignores late grow success/failure', + async (growing) => { + const view = mount(); + if (growing === 'expanded') await peek(); + else await screen.findByText('1 pending'); + const header = screen.getByRole('button', { name: 'Show quick area' }); + let finishGrow!: () => void; + let failGrow!: (cause: Error) => void; + let finishCompact!: () => void; + view.config.setIslandView + .mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finishGrow = resolve; + failGrow = reject; + }), + ) + .mockImplementationOnce(() => { + native.handlers.get('island:collapsed')?.(); + return new Promise((resolve) => { + finishCompact = resolve; + }); + }); + await fireEvent.click(growing === 'peek' ? header : screen.getByRole('button', { name: 'Expand sessions' })); + await fireEvent.keyDown(window, { key: 'Escape' }); + const expected = growing === 'peek' ? [['peek'], ['compact']] : [['peek'], ['expanded'], ['compact']]; + expect(view.config.setIslandView.mock.calls).toEqual(expected); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(screen.queryByRole('button', { name: 'Allow once', hidden: true })).toBeNull(); + expect(screen.queryByRole('textbox', { name: 'Message', hidden: true })).toBeNull(); + expect(screen.getByRole('button', { name: 'Show quick area' })).toBe(header); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(view.config.setIslandView.mock.calls).toEqual(expected); + if (growing === 'peek') finishGrow(); + else failGrow(new Error('Superseded grow')); + await tick(); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + expect(screen.getByRole('button', { name: 'Session status · Show quick area' }).title).not.toBe('Superseded grow'); + finishCompact(); + await tick(); + expect(view.config.setIslandView.mock.calls).toEqual(expected); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandSend).not.toHaveBeenCalled(); + }, +); + +test.each(['new request', 'external collapse', 'unmount'] as const)( + 'early compact error is ignored after %s supersedes its request identity', + async (end) => { + const view = mount(); + await peek(); + let fail!: (cause: Error) => void; + view.config.setIslandView.mockImplementationOnce(() => { + native.handlers.get('island:collapsed')?.(); + return new Promise((_resolve, reject) => { + fail = reject; + }); + }); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe('compact'); + if (end === 'new request') { + await fireEvent.click(screen.getByRole('button', { name: 'Show quick area' })); + await screen.findByRole('button', { name: 'Allow once' }); + } else if (end === 'external collapse') native.handlers.get('island:collapsed')?.(); + else view.unmount(); + const calls = view.config.setIslandView.mock.calls.length; + fail(new Error('Stale shrink error')); + await tick(); + expect(screen.queryByText('Stale shrink error')).toBeNull(); + if (end !== 'unmount') { + expect(screen.getByRole('button', { name: 'Session status · Show quick area' }).title).not.toBe( + 'Stale shrink error', + ); + expect(view.container.querySelector('main')?.getAttribute('data-view')).toBe( + end === 'new request' ? 'peek' : 'compact', + ); + } else expect(view.container.querySelector('main')).toBeNull(); + expect(view.config.setIslandView).toHaveBeenCalledTimes(calls); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(view.config.islandSend).not.toHaveBeenCalled(); + }, +); + +test('unmount during explicit expansion ignores the late result and leaves no presentation or messages behind', async () => { + let finish!: () => void; + const view = mount(); + await peek(); + view.config.setIslandView.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await fireEvent.click(screen.getByRole('button', { name: 'Expand sessions' })); + const reads = view.config.getIslandState.mock.calls.length; + view.unmount(); + finish(); + await tick(); + expect(view.container.querySelector('main')).toBeNull(); + expect(view.config.setIslandView.mock.calls).toEqual([['peek'], ['expanded']]); + expect(view.config.getIslandState).toHaveBeenCalledTimes(reads); + expect(view.config.islandMessages).not.toHaveBeenCalled(); + expect(view.config.islandReplyPermission).not.toHaveBeenCalled(); + expect(native.unlisten).toHaveBeenCalledTimes(5); +}); + +test('CSS reduced-motion overrides every rendered motion target while the native-sized backdrop never fades or moves', async () => { + const style = document.createElement('style'); + style.textContent = islandSource.split('')[0]; + document.head.append(style); + const rules = Array.from(style.sheet?.cssRules ?? []); + style.remove(); + const normal = rules.filter((rule) => rule.type === CSSRule.STYLE_RULE) as CSSStyleRule[]; + const reduced = rules.find( + (rule) => + rule.type === CSSRule.MEDIA_RULE && (rule as CSSMediaRule).conditionText === '(prefers-reduced-motion: reduce)', + ) as CSSMediaRule; + expect(reduced).toBeTruthy(); + const overrides = Array.from(reduced.cssRules) as CSSStyleRule[]; + const matches = (node: Element, rule: CSSStyleRule) => + node.matches(rule.selectorText.replace(/:global\(([^)]+)\)/g, '$1')); + const view = mount(); + await screen.findByText('1 pending'); + for (const stage of ['compact', 'peek', 'expanded']) { + if (stage === 'peek') await peek(); + else if (stage === 'expanded') await expand(); + const main = screen.getByRole('main'); + const backdrop = normal.filter((rule) => matches(main, rule)); + for (const property of ['animation', 'opacity', 'transform']) + expect(backdrop.every((rule) => !rule.style.getPropertyValue(property))).toBe(true); + expect(backdrop.find((rule) => rule.style.getPropertyValue('width'))?.style.getPropertyValue('width')).toBe( + '100vw', + ); + expect(backdrop.find((rule) => rule.style.getPropertyValue('height'))?.style.getPropertyValue('height')).toBe( + '100dvh', + ); + for (const node of [main, ...main.querySelectorAll('*')]) { + for (const property of ['animation', 'transition']) { + if (!normal.some((rule) => matches(node, rule) && rule.style.getPropertyValue(property))) continue; + const override = overrides + .filter((rule) => matches(node, rule) && rule.style.getPropertyValue(property)) + .at(-1); + expect(override?.style.getPropertyValue(property)).toBe('none'); + } + } + } + view.unmount(); +}); diff --git a/src/routes/island/__tests__/layout.test.ts b/src/routes/island/__tests__/layout.test.ts new file mode 100644 index 0000000..e16f519 --- /dev/null +++ b/src/routes/island/__tests__/layout.test.ts @@ -0,0 +1,29 @@ +import { render } from '@testing-library/svelte'; +import { createRawSnippet, tick } from 'svelte'; +import { expect, test, vi } from 'vitest'; +import Layout from '../../+layout.svelte'; + +const native = vi.hoisted(() => ({ invoke: vi.fn(), isTauri: vi.fn(() => true), listen: vi.fn() })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: native.invoke, isTauri: native.isTauri })); +vi.mock('@tauri-apps/api/event', () => ({ listen: native.listen })); +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/island') } })); +vi.mock('$src/config/adapter.js', async (importOriginal) => { + const adapter = await importOriginal(); + return { ...adapter, createCommandAdapter: () => adapter.createTauriAdapter(native.invoke) }; +}); + +test('island layout renders independently without AppShell, splash, config/catalog preload, or main/tray listeners', async () => { + const view = render(Layout, { + children: createRawSnippet(() => ({ render: () => 'Island child' })), + }); + await tick(); + expect(view.getByText('Island child')).toBeTruthy(); + expect(native.invoke).not.toHaveBeenCalled(); + expect(native.listen).not.toHaveBeenCalled(); + expect(view.container.querySelector('.app-shell')).toBeNull(); + expect(view.container.querySelector('[role="status"]')).toBeNull(); + expect(document.documentElement.classList.contains('island-window')).toBe(true); + view.unmount(); + expect(document.documentElement.classList.contains('island-window')).toBe(false); +}); diff --git a/src/routes/settings/AGENTS.md b/src/routes/settings/AGENTS.md new file mode 100644 index 0000000..efe2090 --- /dev/null +++ b/src/routes/settings/AGENTS.md @@ -0,0 +1,44 @@ +# Settings + +## Overview + +`MiniIsland.svelte` owns the independent macOS-only Island enable switch. It reads `getIslandState`, writes `setIslandEnabled`, uses native readback, preserves confirmed state on errors, and does not expose the switch in browser or non-macOS modes. + +`OpenCodeSettings.svelte` owns background local CLI inspection, explicit stable-version checks, and guarded upgrades. Settings show only CLI updates, not a standalone installation-details panel. App updates remain in `AppUpdates.svelte`; CLI metadata is not part of the global config snapshot. + +`OpenCodeService.svelte` owns the saved Shared/fixed local V2 target, local connection probes, and component-local password drafts. Service credentials never join the config snapshot or draft recovery. + +## Directory Structure + +```text +settings/ +├── +page.svelte # Autostart and independent settings sections +├── LidProtection.svelte # Native lid-protection lifecycle +├── AppUpdates.svelte # Explicit update checks and project pages +├── MiniIsland.svelte # macOS-only Island state and enable readback +├── OpenCodeSettings.svelte # Local CLI inspection, explicit checks, upgrade confirmation/readback +├── OpenCodeService.svelte # Saved target, read-only connection probes, secret-local drafts +└── __tests__/ # Settings lifecycle coverage + ├── mini-island.test.ts # Visibility, pending guards, readback, errors, teardown + └── opencode-settings.test.ts # Native reads, CLI confirmation, target drafts, safe errors and cross-panel guards +``` + +## Conventions + +- Keep the switch off by default and disabled until native app info and Island state are confirmed. +- Use only the typed config facade; never call Tauri `invoke` from the component. +- Keep the V2 Shared Service description explicit, including that enabling may start the service and closing the Island does not interrupt work. +- Disabling calls only `setIslandEnabled(false)`; session interruption and request replies are never settings side effects. +- Successful target saves increment the config facade's read-only `serviceTargetVersion`; Island and lid sections reread native state without remounting or toggling. Reject old-version read/save results, defer a latest read through pending operations, and never treat an old backend's protected count as current while refreshing. Failed target saves leave the version and confirmed states unchanged. +- OpenCode CLI inspection first confirms native app info; browser mode makes no CLI or update calls. Mount and refresh are local reads only; release checks require a click. +- App update check/retry, CLI refresh/check, inspection retry, and connection refresh are icon-only buttons with translated accessible names and title tooltips. CLI refresh/check share the updates header; installation path/current version appear only in upgrade confirmation. +- Only checked, upgradeable installations expose Upgrade. The Bits UI confirmation snapshots the exact path, current version, and target version for the guarded command. +- Pending CLI operations disable connection controls; pending service reads/saves disable CLI actions and prevent upgrade confirmation. Invalidate reads on teardown; no late readback, retries, or toasts. CLI errors retain confirmed details and require explicit refresh before retrying an upgrade. +- A successful upgrade rereads CLI installation and service status once, without global refresh or service restart. Keep CLI update feedback separate from backend version. Unsupported installations show no metadata or manual command; only an explicit available-update result adds a short manual-update hint. CLI errors show translated hints without raw backend messages. +- Service mount and explicit Refresh probe only the confirmed target without bootstrap; refresh never overwrites URL/password/clear-password drafts. Failed reads cannot show stale Connected/PID/version. Failed saves preserve confirmed target and local drafts; successful offline saves commit the returned normalized target. +- Password inputs never read back a secret and are cleared on successful save and teardown. Blank omits the password argument, explicit clearing sends an empty string, Shared sends null URL. Describe direct V2 loopback APIs and Basic backend passwords, not OpenChamber UI URLs/passwords or Bearer fallback. +- Service errors render only translated generic or `busy`/`validation_failed` action hints; never render raw message/detail/status.error or log credentials. Busy asks users to wait for the ongoing operation and retry; validation points only to the direct HTTP loopback URL, positive port, and backend password. Do not ask users to disable Mini Island or lid protection before changing targets; the backend owns monitor refresh and input validation, with enabled preferences preserved. Settings never toggles monitors or power policy to save a target. Credential copy states plaintext/non-encrypted storage and does not claim Windows ACL validation before native verification. + +## Sync + +Update this file when settings ownership, CLI lifecycle, or the macOS Island switch lifecycle changes. diff --git a/src/routes/settings/MiniIsland.svelte b/src/routes/settings/MiniIsland.svelte new file mode 100644 index 0000000..a4e263f --- /dev/null +++ b/src/routes/settings/MiniIsland.svelte @@ -0,0 +1,127 @@ + + +{#if macos && state?.supported !== false} +
    +
    +
    + +

    + {i18n.t('settings.islandHint')} +

    +

    {i18n.t('island.closeHint')}

    + {#if loading || saving} +

    + {i18n.t(saving ? 'settings.islandSaving' : 'settings.islandLoading')} +

    + {/if} +
    + { event.preventDefault(); void toggle(); }} + /> +
    + {#if error || state?.error} + + {/if} +
    +{/if} diff --git a/src/routes/settings/OpenCodeService.svelte b/src/routes/settings/OpenCodeService.svelte new file mode 100644 index 0000000..3ef80ef --- /dev/null +++ b/src/routes/settings/OpenCodeService.svelte @@ -0,0 +1,254 @@ + + +
    +
    +
    +

    {i18n.t('settings.serviceTitle')}

    +

    {i18n.t('settings.serviceReadHint')}

    +
    + +
    +
    +
    + + {i18n.t(pending === 'read' ? 'settings.serviceReading' : connected ? 'settings.serviceConnected' : connectionUnknown || !status ? 'settings.serviceUnknown' : 'settings.serviceDisconnected')} +
    +
    +
    {i18n.t('settings.serviceConfirmedTarget')}
    +
    + {status ? status.target.mode === 'shared' ? i18n.t('settings.serviceShared') : status.target.url : i18n.t('settings.serviceUnknown')} +
    +
    {i18n.t('settings.serviceConnectedUrl')}
    +
    + {connected ? status?.url ?? i18n.t('settings.serviceUnknown') : '—'} +
    +
    {i18n.t('settings.servicePid')}
    +
    {connected ? status?.pid ?? i18n.t('settings.serviceUnknown') : '—'}
    +
    {i18n.t('settings.serviceVersion')}
    +
    + {connected ? status?.version ?? i18n.t('settings.serviceUnknown') : '—'} +
    +
    +
    +
    { edited = true; saved = false; }}> +
    + {i18n.t('settings.serviceMode')} +
    + {#each modes as choice} + + {/each} +
    +
    + {#if mode === 'explicit'} +
    + + +

    + {i18n.t('settings.serviceUrlHint')} +

    +
    +
    + + +

    + {i18n.t('settings.servicePasswordHint')} +

    + {#if status?.target.hasPassword} +

    + {i18n.t('settings.servicePasswordSaved')} +

    + + {/if} +

    + {i18n.t('settings.serviceCredentialWarning')} +

    +
    + {/if} + +
    + {#if error || status?.error} + + {/if} + {#if saved} +

    + {i18n.t(connected ? 'settings.serviceSaved' : 'settings.serviceSavedOffline')} +

    + {/if} +
    diff --git a/src/routes/settings/OpenCodeSettings.svelte b/src/routes/settings/OpenCodeSettings.svelte new file mode 100644 index 0000000..6d78b63 --- /dev/null +++ b/src/routes/settings/OpenCodeSettings.svelte @@ -0,0 +1,285 @@ + + +
    +

    + {i18n.t('settings.opencodeTitle')} +

    +

    {i18n.t('settings.opencodeHint')}

    + {#if !native} +

    {i18n.t('settings.opencodeDesktopOnly')}

    + {:else} +
    +
    +
    +

    + {i18n.t('settings.opencodeUpdates')} +

    +

    {i18n.t('settings.opencodeCheckHint')}

    +
    +
    + + +
    +
    +
    + {#if pending} +

    {i18n.t(`settings.opencodePending.${pending}`)}

    + {:else if upgradedVersion} +

    + {i18n.t('settings.opencodeUpgraded', { version: upgradedVersion })} +

    +

    {i18n.t('settings.opencodeRunningHint')}

    + {:else if result} +

    + {i18n.t(result.updateAvailable ? 'settings.opencodeAvailable' : 'settings.opencodeCurrent', { version: result.latestVersion })} +

    + {#if result.updateAvailable && !result.installation.canUpgrade} +

    {i18n.t('settings.opencodeReason.unsupported')}

    + {/if} + {/if} +
    + {#if canUpgrade} + + {/if} +
    + {#if error} + + {/if} + + {/if} +
    + + { if (!open) closeConfirmation(); }}> + { if (pending) event.preventDefault(); }} + onInteractOutside={(event) => { if (pending) event.preventDefault(); }} + > + + {i18n.t('settings.opencodeConfirmTitle')} + {i18n.t('settings.opencodeConfirmDescription')} + +
    +
    +
    {i18n.t('settings.opencodePath')}
    +
    + {confirmation?.binaryPath} +
    +
    +
    +
    {i18n.t('settings.opencodeVersionChange')}
    +
    {confirmation?.version} → {confirmation?.targetVersion}
    +
    +
    +

    {i18n.t('settings.opencodeUpgradeWarning')}

    + + + + +
    +
    diff --git a/src/routes/settings/__tests__/mini-island.test.ts b/src/routes/settings/__tests__/mini-island.test.ts new file mode 100644 index 0000000..0ea961f --- /dev/null +++ b/src/routes/settings/__tests__/mini-island.test.ts @@ -0,0 +1,232 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import type { CommandAdapter } from '$src/config/adapter.js'; +import { type ConfigStore, createConfigStore } from '$src/config/store.svelte.js'; +import Harness from '$src/test-utils/harness.svelte'; +import type { IslandState } from '$src/types/island.js'; +import MiniIsland from '../MiniIsland.svelte'; + +const native = vi.hoisted(() => ({ isTauri: vi.fn(() => true) })); +vi.mock('@tauri-apps/api/core', () => ({ isTauri: native.isTauri })); +const initial: IslandState = { + supported: true, + enabled: false, + connection: 'disabled', + connectionGeneration: 0, + sessions: [], + permissions: [], + questions: [], + preferences: { theme: 'dark', locale: 'en' }, + notchWidth: 180, + topInset: 32, +}; +function mount(overrides: Record = {}) { + const config = { + preferences: { theme: 'dark', locale: 'en' }, + getAppInfo: vi.fn().mockResolvedValue({ version: '2.0.0', platform: 'macos' }), + getIslandState: vi.fn().mockResolvedValue(initial), + setIslandEnabled: vi.fn().mockResolvedValue({ ...initial, enabled: true, connection: 'connecting' }), + ...overrides, + }; + const view = render(MiniIsland, {}, { wrapper: Harness, wrapperProps: { config: config as unknown as ConfigStore } }); + return { ...view, config }; +} +function mountTarget(overrides: Record = {}) { + const adapter = { + getAppInfo: vi.fn().mockResolvedValue({ version: '2.0.0', platform: 'macos' }), + getIslandState: vi.fn().mockResolvedValue(initial), + setIslandEnabled: vi.fn().mockResolvedValue(initial), + refreshIsland: vi.fn(), + setOpenCodeServiceTarget: vi.fn().mockResolvedValue({ connected: false }), + ...overrides, + }; + const config = createConfigStore(adapter as unknown as CommandAdapter, false, false); + const view = render(MiniIsland, {}, { wrapper: Harness, wrapperProps: { config } }); + return { ...view, config, adapter }; +} +beforeEach(() => native.isTauri.mockReturnValue(true)); + +test('macOS switch reads independently, defaults off, guards pending and uses native readback immediately', async () => { + let finish!: (value: IslandState) => void; + const setIslandEnabled = vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = mount({ setIslandEnabled }); + const control = (await screen.findByRole('checkbox', { name: 'OpenCode mini island' })) as HTMLInputElement; + await waitFor(() => expect(control.checked).toBe(false)); + await waitFor(() => expect(control.hasAttribute('disabled')).toBe(false)); + expect(screen.getByText(/Shows local OpenCode V2 sessions and pending requests/).textContent).toContain( + 'enabling may start that service', + ); + await fireEvent.click(control); + await fireEvent.click(control); + expect(setIslandEnabled).toHaveBeenCalledExactlyOnceWith(true); + expect(control.hasAttribute('disabled')).toBe(true); + finish({ ...initial, enabled: false }); + await waitFor(() => expect(control.hasAttribute('disabled')).toBe(false)); + expect(control.checked).toBe(false); + expect(view.config.getIslandState).toHaveBeenCalledOnce(); +}); + +test.each(['windows', 'linux', 'other'])('native %s hides island and does not fetch state', async (platform) => { + const view = mount({ getAppInfo: vi.fn().mockResolvedValue({ version: '2.0.0', platform }) }); + await tick(); + expect(screen.queryByRole('checkbox')).toBeNull(); + expect(view.config.getIslandState).not.toHaveBeenCalled(); +}); + +test('browser and unsupported macOS do not show the switch', async () => { + native.isTauri.mockReturnValue(false); + const browser = mount(); + await tick(); + expect(screen.queryByRole('checkbox')).toBeNull(); + expect(browser.config.getAppInfo).not.toHaveBeenCalled(); + expect(browser.config.getIslandState).not.toHaveBeenCalled(); + browser.unmount(); + native.isTauri.mockReturnValue(true); + const unsupported = mount({ getIslandState: vi.fn().mockResolvedValue({ ...initial, supported: false }) }); + await waitFor(() => expect(unsupported.config.getIslandState).toHaveBeenCalledOnce()); + await tick(); + expect(screen.queryByRole('checkbox')).toBeNull(); +}); + +test('disabling only writes the Island setting, never replies to pending requests or interrupts work', async () => { + const islandReplyPermission = vi.fn(); + const islandReplyQuestion = vi.fn(); + const islandCancelQuestion = vi.fn(); + const islandInterrupt = vi.fn(); + const setIslandEnabled = vi.fn().mockResolvedValue(initial); + const view = mount({ + getIslandState: vi.fn().mockResolvedValue({ ...initial, enabled: true, connection: 'connected' }), + setIslandEnabled, + islandReplyPermission, + islandReplyQuestion, + islandCancelQuestion, + islandInterrupt, + }); + const control = (await screen.findByRole('checkbox', { name: 'OpenCode mini island' })) as HTMLInputElement; + await waitFor(() => expect(control.checked).toBe(true)); + await fireEvent.click(control); + expect(setIslandEnabled).toHaveBeenCalledExactlyOnceWith(false); + await waitFor(() => expect(control.disabled).toBe(false)); + expect(control.checked).toBe(false); + view.unmount(); + for (const operation of [islandReplyPermission, islandReplyQuestion, islandCancelQuestion, islandInterrupt]) + expect(operation).not.toHaveBeenCalled(); +}); + +test('read/save errors preserve confirmed state and retry only reads; teardown guards late result', async () => { + const getIslandState = vi.fn().mockRejectedValueOnce(new Error('Read failed')).mockResolvedValueOnce(initial); + const setIslandEnabled = vi.fn().mockRejectedValueOnce(new Error('Write failed')); + const view = mount({ getIslandState, setIslandEnabled }); + expect((await screen.findByRole('alert')).textContent).toContain('Read failed'); + expect(screen.getByRole('checkbox').hasAttribute('disabled')).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); + await waitFor(() => expect(screen.getByRole('checkbox').hasAttribute('disabled')).toBe(false)); + await fireEvent.click(screen.getByRole('checkbox')); + expect((await screen.findByRole('alert')).textContent).toContain('Write failed'); + expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false); + expect(setIslandEnabled).toHaveBeenCalledOnce(); + view.unmount(); + let finish!: (value: IslandState) => void; + const pending = mount({ + getIslandState: vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + }); + await waitFor(() => expect(pending.config.getIslandState).toHaveBeenCalledOnce()); + pending.unmount(); + mount(); + finish({ ...initial, enabled: true }); + await waitFor(() => expect(screen.getByRole('checkbox').hasAttribute('disabled')).toBe(false)); + expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false); +}); + +test.each([false, true])( + 'successful target saves reread without changing Island enabled=%s; failed saves do nothing', + async (enabled) => { + const value = { ...initial, enabled, connection: enabled ? ('connected' as const) : ('disabled' as const) }; + const view = mountTarget({ getIslandState: vi.fn().mockResolvedValue(value) }); + const control = (await screen.findByRole('checkbox')) as HTMLInputElement; + await waitFor(() => expect(control.disabled).toBe(false)); + expect(control.checked).toBe(enabled); + await view.config.setOpenCodeServiceTarget('http://127.0.0.1:5096'); + await waitFor(() => expect(view.adapter.getIslandState).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('checkbox')).toBe(control); + expect(control.checked).toBe(enabled); + view.adapter.setOpenCodeServiceTarget.mockRejectedValueOnce(new Error('Save failed')); + await expect(view.config.setOpenCodeServiceTarget(null)).rejects.toThrow('Save failed'); + await tick(); + expect(view.config.serviceTargetVersion).toBe(1); + expect(view.adapter.getIslandState).toHaveBeenCalledTimes(2); + expect(view.adapter.setIslandEnabled).not.toHaveBeenCalled(); + expect(view.adapter.refreshIsland).not.toHaveBeenCalled(); + }, +); + +test('target changes during loading reject old readback and coalesce one latest read', async () => { + let finish!: (value: IslandState) => void; + let finishLatest!: (value: IslandState) => void; + const getIslandState = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishLatest = resolve; + }), + ); + const view = mountTarget({ getIslandState }); + await waitFor(() => expect(getIslandState).toHaveBeenCalledOnce()); + await view.config.setOpenCodeServiceTarget('http://127.0.0.1:5096'); + await tick(); + await view.config.setOpenCodeServiceTarget(null); + await tick(); + expect(getIslandState).toHaveBeenCalledOnce(); + finish({ ...initial, enabled: true, error: 'Old target' }); + await waitFor(() => expect(getIslandState).toHaveBeenCalledTimes(2)); + const control = screen.getByRole('checkbox') as HTMLInputElement; + expect(control.checked).toBe(false); + expect(screen.queryByText(/Old target/)).toBeNull(); + finishLatest(initial); + await waitFor(() => expect(control.disabled).toBe(false)); + expect(control.checked).toBe(false); + expect(view.adapter.setIslandEnabled).not.toHaveBeenCalled(); + expect(view.adapter.refreshIsland).not.toHaveBeenCalled(); +}); + +test('target change during a preference save defers readback, ignores the old save result and never retries the write', async () => { + let finish!: (value: IslandState) => void; + const setIslandEnabled = vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = mountTarget({ setIslandEnabled }); + const control = (await screen.findByRole('checkbox')) as HTMLInputElement; + await waitFor(() => expect(control.disabled).toBe(false)); + await fireEvent.click(control); + await view.config.setOpenCodeServiceTarget(null); + await tick(); + expect(view.adapter.getIslandState).toHaveBeenCalledOnce(); + finish({ ...initial, enabled: true, error: 'Old save result' }); + await waitFor(() => expect(view.adapter.getIslandState).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(control.disabled).toBe(false)); + expect(control.checked).toBe(false); + expect(screen.queryByText(/Old save result/)).toBeNull(); + expect(setIslandEnabled).toHaveBeenCalledExactlyOnceWith(true); + expect(view.adapter.refreshIsland).not.toHaveBeenCalled(); +}); diff --git a/src/routes/settings/__tests__/opencode-settings.test.ts b/src/routes/settings/__tests__/opencode-settings.test.ts new file mode 100644 index 0000000..8e6e8d0 --- /dev/null +++ b/src/routes/settings/__tests__/opencode-settings.test.ts @@ -0,0 +1,804 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import type { ConfigStore } from '$src/config/store.svelte.js'; +import Harness from '$src/test-utils/harness.svelte'; +import type { OpenCodeInstallation, OpenCodeServiceStatus, OpenCodeUpdateCheck } from '$src/types/app.js'; +import OpenCodeSettings from '../OpenCodeSettings.svelte'; + +const native = vi.hoisted(() => ({ isTauri: vi.fn(() => true) })); +vi.mock('@tauri-apps/api/core', () => ({ isTauri: native.isTauri })); +const installation: OpenCodeInstallation = { + binaryPath: '/Users/test/.opencode/bin/opencode', + version: '2.0.0', + method: 'curl', + canUpgrade: true, + upgradeReason: null, + manualCommand: null, +}; +const update: OpenCodeUpdateCheck = { installation, latestVersion: '2.1.0', updateAvailable: true }; +const upgraded: OpenCodeInstallation = { ...installation, version: '2.1.0' }; +const service: OpenCodeServiceStatus = { + target: { mode: 'shared', url: null, hasPassword: false }, + connected: true, + url: 'http://127.0.0.1:4096', + pid: 4242, + version: '2.0.7', + error: null, +}; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (cause: Error) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} +function mount(overrides: Record = {}) { + const config = { + preferences: { locale: 'en', theme: 'dark' }, + getAppInfo: vi.fn().mockResolvedValue({ version: '2.0.0', platform: 'macos' }), + getOpenCodeInstallation: vi.fn().mockResolvedValue(installation), + getOpenCodeServiceStatus: vi.fn().mockResolvedValue(service), + setOpenCodeServiceTarget: vi.fn().mockResolvedValue(service), + checkOpenCodeUpdate: vi.fn().mockResolvedValue(update), + upgradeOpenCode: vi.fn().mockResolvedValue(upgraded), + reloadOpencode: vi.fn(), + checkForUpdates: vi.fn(), + ...overrides, + }; + const view = render( + OpenCodeSettings, + {}, + { + wrapper: Harness, + wrapperProps: { config: config as unknown as ConfigStore }, + }, + ); + return { ...view, config }; +} +async function readyCheck(name = 'Check CLI updates') { + const button = await screen.findByRole('button', { name }); + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)); + return button; +} +async function openConfirmation() { + await fireEvent.click(await readyCheck()); + await fireEvent.click(await screen.findByRole('button', { name: 'Upgrade CLI…' })); + return screen.findByRole('dialog'); +} +beforeEach(() => native.isTauri.mockReturnValue(true)); + +test('native mount reads local details in the background without showing installation metadata', async () => { + const { config } = mount(); + await readyCheck(); + expect(screen.getByRole('heading', { name: 'CLI updates' })).toBeTruthy(); + expect(screen.queryByRole('region', { name: 'Local CLI installation' })).toBeNull(); + for (const text of ['Local CLI installation', 'CLI version', 'Binary path', 'Installation method', 'curl installer']) + expect(screen.queryByText(text)).toBeNull(); + expect(screen.queryByText(installation.binaryPath as string)).toBeNull(); + expect(screen.queryByText('2.0.0')).toBeNull(); + expect(config.getAppInfo).toHaveBeenCalledOnce(); + expect(config.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(config.getOpenCodeServiceStatus).toHaveBeenCalledOnce(); + expect(screen.getByText('2.0.7')).toBeTruthy(); + expect(screen.getByText('4242')).toBeTruthy(); + expect(screen.getByText(service.url as string)).toBeTruthy(); + expect(config.checkOpenCodeUpdate).not.toHaveBeenCalled(); + expect(config.checkForUpdates).not.toHaveBeenCalled(); + expect(config.upgradeOpenCode).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: 'Upgrade CLI…' })).toBeNull(); +}); + +test.each([ + ['en', 'CLI updates', 'Refresh CLI details', 'Check CLI updates', 'Refresh connection', 'Local CLI installation'], + ['zh', 'CLI 更新', '刷新 CLI 信息', '检查 CLI 更新', '刷新连接', '本地 CLI 安装'], +] as const)( + 'CLI and connection icons have %s accessible names and tooltips, not visible labels', + async (locale, heading, refreshLabel, checkLabel, serviceLabel, installationHeading) => { + mount({ preferences: { locale, theme: 'dark' } }); + await readyCheck(checkLabel); + const updates = screen.getByRole('region', { name: heading }); + for (const label of [refreshLabel, checkLabel, serviceLabel]) { + const button = screen.getByRole('button', { name: label }); + expect(button.getAttribute('aria-label')).toBe(label); + expect(button.getAttribute('title')).toBe(label); + expect(button.textContent?.trim()).toBe(''); + expect(button.classList.contains('size-8')).toBe(true); + expect(button.className).toContain('focus-visible:'); + expect(button.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + expect(button.querySelector('svg')?.classList.contains('animate-spin')).toBe(false); + } + expect(within(updates).getByRole('button', { name: refreshLabel })).toBeTruthy(); + expect( + within(updates) + .getByRole('button', { name: checkLabel }) + .querySelector('svg') + ?.classList.contains('lucide-download'), + ).toBe(true); + expect(screen.queryByRole('heading', { name: installationHeading })).toBeNull(); + expect(screen.queryByText(installation.binaryPath as string)).toBeNull(); + expect(screen.queryByText('2.0.0')).toBeNull(); + }, +); + +test.each(['en', 'zh'] as const)( + 'service target form has no %s manual-disable warning in either mode', + async (locale) => { + mount({ preferences: { locale, theme: 'dark' } }); + await readyCheck(locale === 'en' ? 'Check CLI updates' : '检查 CLI 更新'); + const warning = /Before changing the target|disable Mini Island|更换目标前|关闭 Mini Island|合盖保护/; + expect(screen.queryByText(warning)).toBeNull(); + await fireEvent.click( + screen.getByRole('radio', { name: locale === 'en' ? /Fixed local endpoint/ : /固定本地端点/ }), + ); + expect(screen.queryByText(warning)).toBeNull(); + expect(screen.getByLabelText('OpenCode backend URL')).toBeTruthy(); + expect( + screen.getByRole('button', { name: locale === 'en' ? 'Save connection target' : '保存连接目标' }), + ).toBeTruthy(); + }, +); + +test('connection target form keeps a vertical gap and a compact, left-aligned save action in both modes', async () => { + const { config } = mount(); + await readyCheck(); + for (const mode of [/Default Shared Service/, /Fixed local endpoint/]) { + await fireEvent.click(screen.getByRole('radio', { name: mode })); + const save = screen.getByRole('button', { name: 'Save connection target' }); + const form = save.closest('form'); + expect(save.parentElement).toBe(form); + expect(form?.classList.contains('flex')).toBe(true); + expect(form?.classList.contains('flex-col')).toBe(true); + expect(form?.classList.contains('gap-3')).toBe(true); + expect(form?.classList.contains('space-y-3')).toBe(false); + expect(save.classList.contains('self-start')).toBe(true); + expect(save.classList.contains('h-7')).toBe(true); + expect(save.classList.contains('w-full')).toBe(false); + } + expect(config.setOpenCodeServiceTarget).not.toHaveBeenCalled(); +}); + +test('browser fallback makes no native calls and offers no installation or update controls', async () => { + native.isTauri.mockReturnValue(false); + const { config } = mount(); + await screen.findByText('Available in the desktop app.'); + for (const call of [ + config.getAppInfo, + config.getOpenCodeInstallation, + config.getOpenCodeServiceStatus, + config.setOpenCodeServiceTarget, + config.checkOpenCodeUpdate, + config.upgradeOpenCode, + ]) + expect(call).not.toHaveBeenCalled(); + expect(screen.queryByRole('button')).toBeNull(); +}); + +test('local app-info failure requires explicit retry before inspecting the installation', async () => { + const getAppInfo = vi.fn().mockRejectedValueOnce(new Error('Native details unavailable')).mockResolvedValueOnce({ + version: '2.0.0', + platform: 'windows', + }); + const { config } = mount({ getAppInfo }); + expect((await screen.findByRole('alert')).textContent).toContain('Could not read CLI details'); + expect(screen.queryByText(/Native details unavailable/)).toBeNull(); + expect(config.getOpenCodeInstallation).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Retry CLI details' })); + await readyCheck(); + expect(getAppInfo).toHaveBeenCalledTimes(2); + expect(config.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(config.checkOpenCodeUpdate).not.toHaveBeenCalled(); +}); + +test('inspection failure disables checks and retry reads locally without an automatic update check', async () => { + const getOpenCodeInstallation = vi + .fn() + .mockRejectedValueOnce(new Error('Read failed')) + .mockResolvedValueOnce(installation); + const { config } = mount({ getOpenCodeInstallation }); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Could not read CLI details'); + expect(alert.textContent).not.toContain('Read failed'); + expect((screen.getByRole('button', { name: 'Check CLI updates' }) as HTMLButtonElement).disabled).toBe(true); + const retry = within(alert).getByRole('button', { name: 'Retry CLI details' }); + expect(retry.textContent?.trim()).toBe(''); + expect(retry.getAttribute('aria-label')).toBe('Retry CLI details'); + expect(retry.getAttribute('title')).toBe('Retry CLI details'); + expect(retry.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + await waitFor(() => expect((retry as HTMLButtonElement).disabled).toBe(false)); + expect((screen.getByRole('button', { name: 'Refresh CLI details' }) as HTMLButtonElement).disabled).toBe(false); + await fireEvent.click(retry); + await readyCheck(); + expect(getOpenCodeInstallation).toHaveBeenCalledTimes(2); + expect(config.getAppInfo).toHaveBeenCalledOnce(); + expect(config.checkOpenCodeUpdate).not.toHaveBeenCalled(); +}); + +test('local refresh spins and guards CLI and service controls without checking releases', async () => { + const operation = deferred(); + const getOpenCodeInstallation = vi.fn().mockResolvedValueOnce(installation).mockReturnValueOnce(operation.promise); + const { config } = mount({ getOpenCodeInstallation }); + await readyCheck(); + const refresh = screen.getByRole('button', { name: 'Refresh CLI details' }); + await fireEvent.click(refresh); + expect(refresh.getAttribute('aria-busy')).toBe('true'); + expect(refresh.querySelector('svg')?.classList.contains('animate-spin')).toBe(true); + expect(refresh.querySelector('svg')?.classList.contains('motion-reduce:animate-none')).toBe(true); + expect(refresh.textContent?.trim()).toBe(''); + const panel = screen.getByRole('region', { name: 'OpenCode V2' }); + for (const button of within(panel).getAllByRole('button')) expect((button as HTMLButtonElement).disabled).toBe(true); + for (const input of panel.querySelectorAll('input')) expect(input.matches(':disabled')).toBe(true); + await fireEvent.click(refresh); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh connection' })); + expect(getOpenCodeInstallation).toHaveBeenCalledTimes(2); + expect(config.getOpenCodeServiceStatus).toHaveBeenCalledOnce(); + expect(config.checkOpenCodeUpdate).not.toHaveBeenCalled(); + operation.resolve(installation); + await readyCheck(); + expect(refresh.getAttribute('aria-busy')).toBe('false'); + expect(refresh.querySelector('svg')?.classList.contains('animate-spin')).toBe(false); + expect(screen.queryByText(installation.binaryPath as string)).toBeNull(); +}); + +test.each([ + ['not_found', 'not-found', 'OpenCode not found.'], + ['unsupported', 'manual', 'Cannot upgrade this installation here;'], + ['bundled', 'bundled', 'Bundled with an app;'], + ['not_v2', 'npm', 'Not a confirmed OpenCode V2 install.'], + ['unstable', 'bun', 'Not a stable release;'], + ['manager_unavailable', 'pnpm', 'Package manager unavailable;'], + ['ambiguous', 'unknown', 'Install method could not be confirmed;'], +] as const)( + 'unsupported %s hides installation details and adds only a manual-update hint after an explicit check', + async (reason, method, copy) => { + const info: OpenCodeInstallation = { + ...installation, + method, + upgradeReason: reason, + canUpgrade: false, + manualCommand: 'npm install -g @opencode/cli@2', + ...(reason === 'not_found' ? { binaryPath: null, version: null } : {}), + }; + const { config } = mount({ + getOpenCodeInstallation: vi.fn().mockResolvedValue(info), + checkOpenCodeUpdate: vi.fn().mockResolvedValue({ ...update, installation: info }), + }); + await readyCheck(); + expect(screen.queryByText((content) => content.startsWith(copy))).toBeNull(); + expect(screen.queryByText(info.manualCommand as string)).toBeNull(); + expect(screen.queryByRole('region', { name: 'Local CLI installation' })).toBeNull(); + expect(config.checkOpenCodeUpdate).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + await screen.findByText('OpenCode 2.1.0 is available.'); + expect(screen.getByText('Cannot upgrade this installation here; use its original install method.')).toBeTruthy(); + expect(screen.queryByText(info.manualCommand as string)).toBeNull(); + expect(screen.queryByText(installation.binaryPath as string)).toBeNull(); + expect(screen.queryByText('2.0.0')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Upgrade CLI…' })).toBeNull(); + expect(config.upgradeOpenCode).not.toHaveBeenCalled(); + }, +); + +test('check owns authoritative installation, guards duplicate checks and refresh, and shows no-update feedback', async () => { + const check = deferred(); + const checkOpenCodeUpdate = vi.fn().mockReturnValue(check.promise); + const { config } = mount({ checkOpenCodeUpdate }); + await readyCheck(); + const button = screen.getByRole('button', { name: 'Check CLI updates' }) as HTMLButtonElement; + const refresh = screen.getByRole('button', { name: 'Refresh CLI details' }) as HTMLButtonElement; + await fireEvent.click(button); + expect(button.disabled).toBe(true); + expect(button.getAttribute('aria-busy')).toBe('true'); + expect(button.textContent?.trim()).toBe(''); + expect(button.querySelector('svg')?.classList.contains('animate-spin')).toBe(true); + expect(button.querySelector('svg')?.classList.contains('motion-reduce:animate-none')).toBe(true); + expect(refresh.disabled).toBe(true); + await fireEvent.click(button); + await fireEvent.click(refresh); + expect(checkOpenCodeUpdate).toHaveBeenCalledOnce(); + expect(config.getOpenCodeInstallation).toHaveBeenCalledOnce(); + check.resolve({ installation: upgraded, latestVersion: '2.1.0', updateAvailable: false }); + await screen.findByText('No CLI update. Latest stable: 2.1.0.'); + expect(screen.queryByText('2.1.0')).toBeNull(); + expect(button.querySelector('svg')?.classList.contains('lucide-download')).toBe(true); + expect(screen.getByText('2.0.7')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Upgrade CLI…' })).toBeNull(); +}); + +test('check failure preserves known details and only retries on click', async () => { + const checkOpenCodeUpdate = vi + .fn() + .mockRejectedValueOnce(new Error('Release unavailable')) + .mockResolvedValueOnce(update); + mount({ checkOpenCodeUpdate }); + await readyCheck(); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + expect((await screen.findByRole('alert')).textContent).toContain('Could not check CLI updates'); + expect(screen.queryByText(/Release unavailable/)).toBeNull(); + expect(screen.queryByText('2.0.0')).toBeNull(); + expect(checkOpenCodeUpdate).toHaveBeenCalledOnce(); + const retry = screen.getByRole('button', { name: 'Retry CLI update check' }); + expect(retry.textContent?.trim()).toBe(''); + expect(retry.getAttribute('title')).toBe('Retry CLI update check'); + await fireEvent.click(retry); + await screen.findByText('OpenCode 2.1.0 is available.'); + expect(checkOpenCodeUpdate).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('alert')).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Upgrade CLI…' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(installation.binaryPath as string)).toBeTruthy(); + expect(within(dialog).getByText('2.0.0 → 2.1.0')).toBeTruthy(); +}); + +test('confirmation shows exact checked path and versions; cancellation never upgrades', async () => { + const checkedInstallation = { + ...installation, + binaryPath: '/opt/homebrew/bin/opencode', + version: '2.0.1', + method: 'curl', + }; + const { config } = mount({ + checkOpenCodeUpdate: vi.fn().mockResolvedValue({ ...update, installation: checkedInstallation }), + }); + await readyCheck(); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + await screen.findByRole('button', { name: 'Upgrade CLI…' }); + expect(screen.queryByText('/opt/homebrew/bin/opencode')).toBeNull(); + expect(screen.queryByText('2.0.1')).toBeNull(); + await fireEvent.click(await screen.findByRole('button', { name: 'Upgrade CLI…' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('/opt/homebrew/bin/opencode').classList.contains('select-text')).toBe(true); + expect(within(dialog).getByText('2.0.1 → 2.1.0')).toBeTruthy(); + expect(dialog.textContent).toContain('Does not update MOM'); + expect(dialog.textContent).toContain('or restart services'); + expect(dialog.textContent).toContain('No sudo'); + expect(config.upgradeOpenCode).not.toHaveBeenCalled(); + await fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(config.upgradeOpenCode).not.toHaveBeenCalled(); +}); + +test('upgrade is confirmed once, blocks dismissal and refresh, and reads CLI back without restarting', async () => { + const operation = deferred(); + const upgradeOpenCode = vi.fn().mockReturnValue(operation.promise); + const getOpenCodeInstallation = vi.fn().mockResolvedValueOnce(installation).mockResolvedValueOnce(upgraded); + const { config } = mount({ upgradeOpenCode, getOpenCodeInstallation }); + const dialog = await openConfirmation(); + const confirm = within(dialog).getByRole('button', { name: 'Confirm CLI upgrade' }) as HTMLButtonElement; + await fireEvent.click(confirm); + await fireEvent.click(confirm); + expect(upgradeOpenCode).toHaveBeenCalledExactlyOnceWith(installation.binaryPath, '2.0.0', '2.1.0'); + expect(confirm.disabled).toBe(true); + expect((within(dialog).getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.keyDown(dialog, { key: 'Escape' }); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect((screen.getByRole('button', { name: 'Refresh CLI details' }) as HTMLButtonElement).disabled).toBe(true); + operation.resolve(upgraded); + await screen.findByText('Upgrade completed. CLI version: 2.1.0.'); + expect(screen.queryByText('2.1.0')).toBeNull(); + expect(screen.getByText('2.0.7')).toBeTruthy(); + expect(getOpenCodeInstallation).toHaveBeenCalledTimes(2); + expect(config.getOpenCodeServiceStatus).toHaveBeenCalledTimes(2); + expect(config.checkOpenCodeUpdate).toHaveBeenCalledOnce(); + expect(config.reloadOpencode).not.toHaveBeenCalled(); + expect(screen.getByText(/A running service may still use the old version/)).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Upgrade CLI…' })).toBeNull(); +}); + +test('upgrade failure retains known details and requires refresh and a new check before retry', async () => { + const upgradeOpenCode = vi.fn().mockRejectedValue(new Error('Binary changed; refresh before retrying')); + const { config } = mount({ upgradeOpenCode }); + const dialog = await openConfirmation(); + await fireEvent.click(within(dialog).getByRole('button', { name: 'Confirm CLI upgrade' })); + expect((await screen.findByRole('alert')).textContent).toContain('CLI upgrade failed'); + expect(screen.queryByText(/Binary changed/)).toBeNull(); + expect(screen.queryByText('2.0.0')).toBeNull(); + expect(config.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(upgradeOpenCode).toHaveBeenCalledOnce(); + expect((screen.getByRole('button', { name: 'Check CLI updates' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.queryByRole('button', { name: 'Upgrade CLI…' })).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Retry CLI details' })); + await waitFor(() => + expect((screen.getByRole('button', { name: 'Check CLI updates' }) as HTMLButtonElement).disabled).toBe(false), + ); + expect(config.getOpenCodeInstallation).toHaveBeenCalledTimes(2); + expect(config.checkOpenCodeUpdate).toHaveBeenCalledOnce(); + expect(upgradeOpenCode).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Upgrade CLI…' })); + const retriedDialog = await screen.findByRole('dialog'); + expect(within(retriedDialog).getByText(installation.binaryPath as string)).toBeTruthy(); + expect(within(retriedDialog).getByText('2.0.0 → 2.1.0')).toBeTruthy(); + expect(upgradeOpenCode).toHaveBeenCalledOnce(); +}); + +test('failed upgrade readback is not presented as failed command or success and refresh never repeats the upgrade', async () => { + const getOpenCodeInstallation = vi + .fn() + .mockResolvedValueOnce(installation) + .mockRejectedValueOnce(new Error('Readback unavailable')) + .mockResolvedValueOnce(upgraded); + const { config } = mount({ getOpenCodeInstallation }); + const dialog = await openConfirmation(); + await fireEvent.click(within(dialog).getByRole('button', { name: 'Confirm CLI upgrade' })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Upgrade command finished'); + expect(alert.textContent).not.toContain('Readback unavailable'); + expect(screen.queryByText('2.0.0')).toBeNull(); + expect(screen.queryByText(/Upgrade completed. CLI version/)).toBeNull(); + expect(config.upgradeOpenCode).toHaveBeenCalledOnce(); + await fireEvent.click(screen.getByRole('button', { name: 'Retry CLI details' })); + await readyCheck(); + expect(screen.queryByText('2.1.0')).toBeNull(); + expect(config.upgradeOpenCode).toHaveBeenCalledOnce(); + expect(getOpenCodeInstallation).toHaveBeenCalledTimes(3); +}); + +test('teardown before app-info completion does not inspect or bleed into a new view', async () => { + const info = deferred<{ version: string; platform: string }>(); + const first = mount({ getAppInfo: vi.fn().mockReturnValue(info.promise) }); + first.unmount(); + mount(); + info.resolve({ version: '2.0.0', platform: 'macos' }); + await readyCheck(); + expect(first.config.getOpenCodeInstallation).not.toHaveBeenCalled(); + expect(first.config.getOpenCodeServiceStatus).not.toHaveBeenCalled(); +}); + +test.each(['inspect', 'check', 'upgrade'] as const)( + 'late %s completion after teardown has no readback or visible effect', + async (kind) => { + const operation = deferred(); + const call = vi.fn().mockReturnValue(operation.promise); + const first = mount({ + ...(kind === 'inspect' ? { getOpenCodeInstallation: call } : {}), + ...(kind === 'check' ? { checkOpenCodeUpdate: call } : {}), + ...(kind === 'upgrade' ? { upgradeOpenCode: call } : {}), + }); + if (kind === 'check') { + await readyCheck(); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + } else if (kind === 'upgrade') { + const dialog = await openConfirmation(); + await fireEvent.click(within(dialog).getByRole('button', { name: 'Confirm CLI upgrade' })); + } + await waitFor(() => expect(call).toHaveBeenCalledOnce()); + first.unmount(); + const second = mount(); + operation.resolve(kind === 'check' ? update : upgraded); + await tick(); + await readyCheck(); + expect(first.config.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(second.config.checkOpenCodeUpdate).not.toHaveBeenCalled(); + expect(screen.queryByText('OpenCode 2.1.0 is available.')).toBeNull(); + expect(screen.queryByText('2.1.0')).toBeNull(); + }, +); + +test('Chinese confirmation includes the exact path and local-only safety copy', async () => { + const { config } = mount({ preferences: { locale: 'zh', theme: 'dark' } }); + await readyCheck('检查 CLI 更新'); + await fireEvent.click(screen.getByRole('button', { name: '检查 CLI 更新' })); + await fireEvent.click(await screen.findByRole('button', { name: '升级 CLI…' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(installation.binaryPath as string)).toBeTruthy(); + expect(within(dialog).getByText('2.0.0 → 2.1.0')).toBeTruthy(); + expect(dialog.textContent).toContain('或重启服务'); + expect(dialog.textContent).toContain('不使用 sudo'); + await fireEvent.click(within(dialog).getByRole('button', { name: '取消' })); + expect(config.upgradeOpenCode).not.toHaveBeenCalled(); +}); + +async function explicitDraft(url = 'http://localhost:5096', password = '') { + await readyCheck(); + await fireEvent.click(screen.getByRole('radio', { name: /Fixed local endpoint/ })); + await fireEvent.input(screen.getByLabelText('OpenCode backend URL'), { target: { value: url } }); + if (password) + await fireEvent.input(screen.getByLabelText('Service password (optional)'), { target: { value: password } }); +} + +test('installation and service reads fail independently; a missing shared target still allows an explicit save', async () => { + const getOpenCodeInstallation = vi.fn().mockRejectedValue(new Error('CLI missing')); + const getOpenCodeServiceStatus = vi.fn().mockRejectedValue(new Error('secret backend error')); + const { config } = mount({ getOpenCodeInstallation, getOpenCodeServiceStatus }); + await screen.findByText('Unknown', { selector: 'span' }); + expect(getOpenCodeServiceStatus).toHaveBeenCalledOnce(); + expect(screen.queryByText(/secret backend error/)).toBeNull(); + expect(screen.queryByText('Idle')).toBeNull(); + await fireEvent.click(screen.getByRole('radio', { name: /Fixed local endpoint/ })); + await fireEvent.input(screen.getByLabelText('OpenCode backend URL'), { target: { value: 'http://127.0.0.1:5096' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); + expect(config.setOpenCodeServiceTarget).toHaveBeenCalledExactlyOnceWith('http://127.0.0.1:5096'); + expect((screen.getByRole('button', { name: 'Check CLI updates' }) as HTMLButtonElement).disabled).toBe(true); +}); + +test('service failure leaves installation checks usable and shows no stale PID or version', async () => { + const getOpenCodeServiceStatus = vi + .fn() + .mockResolvedValueOnce(service) + .mockRejectedValueOnce(new Error('password=do-not-render')); + const { config } = mount({ getOpenCodeServiceStatus }); + await screen.findByText('4242'); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh connection' })); + await screen.findByText('Unknown', { selector: 'span' }); + expect(screen.queryByText('4242')).toBeNull(); + expect(screen.queryByText('2.0.7')).toBeNull(); + expect(screen.queryByText(/do-not-render/)).toBeNull(); + expect((screen.getByRole('button', { name: 'Check CLI updates' }) as HTMLButtonElement).disabled).toBe(false); + expect(config.checkOpenCodeUpdate).not.toHaveBeenCalled(); +}); + +test('read-only refresh keeps URL, password and clear-password drafts and tests only the saved target', async () => { + const getOpenCodeServiceStatus = vi.fn().mockResolvedValue({ + ...service, + target: { mode: 'explicit', url: service.url, hasPassword: true }, + }); + const { config } = mount({ getOpenCodeServiceStatus }); + await screen.findByLabelText('OpenCode backend URL'); + await explicitDraft('http://127.0.0.1:6096', 'local-secret'); + const password = screen.getByLabelText('Service password (optional)') as HTMLInputElement; + expect(password.type).toBe('password'); + expect(password.autocomplete).toBe('new-password'); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh connection' })); + await waitFor(() => expect(getOpenCodeServiceStatus).toHaveBeenCalledTimes(2)); + expect((screen.getByLabelText('OpenCode backend URL') as HTMLInputElement).value).toBe('http://127.0.0.1:6096'); + expect(password.value).toBe('local-secret'); + expect(config.setOpenCodeServiceTarget).not.toHaveBeenCalled(); + expect(screen.queryByText('local-secret')).toBeNull(); + await fireEvent.click(screen.getByLabelText('Clear the saved password when saving')); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh connection' })); + await waitFor(() => expect(getOpenCodeServiceStatus).toHaveBeenCalledTimes(3)); + expect((screen.getByLabelText('Clear the saved password when saving') as HTMLInputElement).checked).toBe(true); +}); + +test('failed save preserves confirmed target and local secret draft without displaying backend payloads', async () => { + const setOpenCodeServiceTarget = vi.fn().mockRejectedValue({ + code: 'configuration_failed', + message: 'local-secret failed', + detail: { password: 'local-secret' }, + }); + const { config } = mount({ setOpenCodeServiceTarget }); + await explicitDraft('http://127.0.0.1:5096', 'local-secret'); + await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); + await screen.findByText(/Could not save; the saved target is unchanged and your draft is kept/); + expect(setOpenCodeServiceTarget).toHaveBeenCalledExactlyOnceWith('http://127.0.0.1:5096', 'local-secret'); + expect((screen.getByLabelText('Service password (optional)') as HTMLInputElement).value).toBe('local-secret'); + expect((screen.getByLabelText('OpenCode backend URL') as HTMLInputElement).value).toBe('http://127.0.0.1:5096'); + expect(screen.queryByText(/local-secret/)).toBeNull(); + expect(screen.getByText('4242')).toBeTruthy(); + expect(screen.getByText(service.url as string)).toBeTruthy(); + expect(config.getOpenCodeServiceStatus).toHaveBeenCalledOnce(); +}); + +test.each([ + ['en', 'configuration_failed', 'Could not save;', 'the saved target is unchanged and your draft is kept'], + ['en', 'busy', 'local-service operation is running', 'wait, then retry'], + ['en', 'validation_failed', 'direct V2 loopback URL, port and password', 'then retry'], + ['zh', 'configuration_failed', '无法保存', '已保存目标和草稿均未改变'], + ['zh', 'busy', '本地服务操作进行中', '请稍后重试'], + ['zh', 'validation_failed', '直连 V2 loopback URL、端口和密码', '后重试'], +] as const)( + 'save %s/%s gives only a safe action hint and allows an explicit retry', + async (locale, code, hint, action) => { + const setOpenCodeServiceTarget = vi + .fn() + .mockRejectedValueOnce({ code, message: 'private-password', detail: { password: 'private-password' } }) + .mockResolvedValueOnce({ + ...service, + target: { mode: 'explicit', url: 'http://[::1]:5096', hasPassword: true }, + }); + mount({ preferences: { locale, theme: 'dark' }, setOpenCodeServiceTarget }); + await readyCheck(locale === 'en' ? 'Check CLI updates' : '检查 CLI 更新'); + await fireEvent.click( + screen.getByRole('radio', { name: locale === 'en' ? /Fixed local endpoint/ : /固定本地端点/ }), + ); + const url = screen.getByLabelText('OpenCode backend URL'); + const password = screen.getByLabelText(locale === 'en' ? 'Service password (optional)' : '服务密码(可选)'); + await fireEvent.input(url, { target: { value: 'http://[::1]:5096' } }); + await fireEvent.input(password, { target: { value: 'private-password' } }); + const save = screen.getByRole('button', { name: locale === 'en' ? 'Save connection target' : '保存连接目标' }); + await fireEvent.click(save); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain(hint); + expect(alert.textContent).toContain(action); + expect(alert.textContent).not.toContain('private-password'); + expect(alert.textContent).not.toMatch(/Mini Island|lid protection|power|合盖保护|电源|关闭/); + expect((url as HTMLInputElement).value).toBe('http://[::1]:5096'); + expect((password as HTMLInputElement).value).toBe('private-password'); + expect(screen.getByText('4242')).toBeTruthy(); + expect(setOpenCodeServiceTarget).toHaveBeenCalledOnce(); + await fireEvent.click(save); + await waitFor(() => expect((password as HTMLInputElement).value).toBe('')); + expect(setOpenCodeServiceTarget).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('alert')).toBeNull(); + }, +); + +test('a status error cannot advertise a stale connected backend and never renders its private text', async () => { + mount({ getOpenCodeServiceStatus: vi.fn().mockResolvedValue({ ...service, error: 'private-password' }) }); + await screen.findByText('Disconnected'); + expect(screen.queryByText('Connected')).toBeNull(); + expect(screen.queryByText('4242')).toBeNull(); + expect(screen.queryByText('2.0.7')).toBeNull(); + expect(screen.queryByText('private-password')).toBeNull(); + expect((await screen.findByRole('alert')).textContent).toContain('Could not confirm the connection'); +}); + +test('offline save commits the returned normalized target, clears password and never starts or falls back', async () => { + const offline: OpenCodeServiceStatus = { + target: { mode: 'explicit', url: 'http://127.0.0.1:5096', hasPassword: true }, + connected: false, + url: null, + pid: null, + version: null, + error: 'secret inaccessible service', + }; + const { config } = mount({ setOpenCodeServiceTarget: vi.fn().mockResolvedValue(offline) }); + await explicitDraft('http://localhost:5096', 'new-secret'); + await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); + await screen.findByText(/Target saved, but the backend is offline/); + expect(screen.getByText('Disconnected')).toBeTruthy(); + expect(screen.queryByText('4242')).toBeNull(); + expect(screen.queryByText(/secret inaccessible/)).toBeNull(); + expect((screen.getByLabelText('OpenCode backend URL') as HTMLInputElement).value).toBe(offline.target.url); + expect((screen.getByLabelText('Service password (optional)') as HTMLInputElement).value).toBe(''); + expect(screen.getByText(/A password is saved/)).toBeTruthy(); + expect(config.getOpenCodeServiceStatus).toHaveBeenCalledOnce(); + expect(config.reloadOpencode).not.toHaveBeenCalled(); + expect(config.checkOpenCodeUpdate).not.toHaveBeenCalled(); +}); + +test('saved credential can be explicitly cleared; blank password omits the argument and Shared saves null', async () => { + const explicit: OpenCodeServiceStatus = { + ...service, + target: { mode: 'explicit', url: service.url, hasPassword: true }, + }; + const setOpenCodeServiceTarget = vi + .fn() + .mockResolvedValueOnce(explicit) + .mockResolvedValueOnce({ ...explicit, target: { ...explicit.target, hasPassword: false } }) + .mockResolvedValueOnce(service); + mount({ getOpenCodeServiceStatus: vi.fn().mockResolvedValue(explicit), setOpenCodeServiceTarget }); + await screen.findByLabelText('Service password (optional)'); + await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); + await screen.findByText('Target saved; connected to the backend.'); + expect(setOpenCodeServiceTarget.mock.calls[0]).toEqual([service.url]); + await fireEvent.click(screen.getByLabelText('Clear the saved password when saving')); + await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); + await waitFor(() => expect(screen.queryByLabelText('Clear the saved password when saving')).toBeNull()); + expect(setOpenCodeServiceTarget.mock.calls[1]).toEqual([service.url, '']); + await fireEvent.click(screen.getByRole('radio', { name: /Default Shared Service/ })); + await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); + await waitFor(() => expect(setOpenCodeServiceTarget.mock.calls[2]).toEqual([null])); +}); + +test('connection save and refresh pending guards prevent duplicate calls and premature readback', async () => { + const operation = deferred(); + const setOpenCodeServiceTarget = vi.fn().mockReturnValue(operation.promise); + const { config } = mount({ setOpenCodeServiceTarget }); + await explicitDraft(); + const save = screen.getByRole('button', { name: 'Save connection target' }); + await fireEvent.click(save); + const refresh = screen.getByRole('button', { name: 'Refresh connection' }); + expect(refresh.textContent?.trim()).toBe(''); + expect(refresh.getAttribute('aria-busy')).toBe('false'); + expect(refresh.querySelector('svg')?.classList.contains('animate-spin')).toBe(false); + await fireEvent.click(save); + await fireEvent.click(refresh); + expect(setOpenCodeServiceTarget).toHaveBeenCalledOnce(); + expect(config.getOpenCodeServiceStatus).toHaveBeenCalledOnce(); + expect(screen.getByText('4242')).toBeTruthy(); + expect((screen.getByLabelText('OpenCode backend URL') as HTMLInputElement).disabled).toBe(true); + operation.resolve({ + ...service, + pid: 5151, + target: { mode: 'explicit', url: 'http://127.0.0.1:5096', hasPassword: false }, + }); + await screen.findByText('5151'); + expect(screen.queryByText('4242')).toBeNull(); +}); + +test('all CLI and connection controls stay guarded while saving; no upgrade confirmation can open', async () => { + const operation = deferred(); + const { config } = mount({ setOpenCodeServiceTarget: vi.fn().mockReturnValue(operation.promise) }); + await explicitDraft('http://localhost:5096', 'local-secret'); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + const upgrade = await screen.findByRole('button', { name: 'Upgrade CLI…' }); + const refresh = screen.getByRole('button', { name: 'Refresh CLI details' }); + const check = screen.getByRole('button', { name: 'Check CLI updates' }); + const form = screen.getByLabelText('OpenCode backend URL').closest('form') as HTMLFormElement; + await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); + const panel = screen.getByRole('region', { name: 'OpenCode V2' }); + for (const button of within(panel).getAllByRole('button')) expect((button as HTMLButtonElement).disabled).toBe(true); + for (const input of panel.querySelectorAll('input')) expect(input.matches(':disabled')).toBe(true); + expect(panel.getAttribute('aria-busy')).toBe('true'); + await fireEvent.click(upgrade); + await fireEvent.click(refresh); + await fireEvent.click(check); + await fireEvent.submit(form); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(config.upgradeOpenCode).not.toHaveBeenCalled(); + expect(config.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(config.checkOpenCodeUpdate).toHaveBeenCalledOnce(); + expect(config.setOpenCodeServiceTarget).toHaveBeenCalledOnce(); + operation.resolve(service); + await screen.findByRole('button', { name: 'Upgrade CLI…' }); + expect((refresh as HTMLButtonElement).disabled).toBe(false); + expect((check as HTMLButtonElement).disabled).toBe(false); + await fireEvent.click(screen.getByRole('button', { name: 'Upgrade CLI…' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('2.0.0 → 2.1.0')).toBeTruthy(); + for (const button of within(panel).getAllByRole('button')) expect((button as HTMLButtonElement).disabled).toBe(true); + await fireEvent.submit(form); + expect(config.setOpenCodeServiceTarget).toHaveBeenCalledOnce(); + await fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); +}); + +test('a CLI check disables connection edits and save, without automatically reading or changing the target', async () => { + const operation = deferred(); + const { config } = mount({ checkOpenCodeUpdate: vi.fn().mockReturnValue(operation.promise) }); + await explicitDraft(); + const form = screen.getByLabelText('OpenCode backend URL').closest('form') as HTMLFormElement; + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + const panel = screen.getByRole('region', { name: 'OpenCode V2' }); + for (const button of within(panel).getAllByRole('button')) expect((button as HTMLButtonElement).disabled).toBe(true); + for (const input of panel.querySelectorAll('input')) expect(input.matches(':disabled')).toBe(true); + await fireEvent.submit(form); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh connection' })); + expect(config.setOpenCodeServiceTarget).not.toHaveBeenCalled(); + expect(config.getOpenCodeServiceStatus).toHaveBeenCalledOnce(); + operation.resolve(update); + await screen.findByRole('button', { name: 'Upgrade CLI…' }); + expect((screen.getByRole('button', { name: 'Save connection target' }) as HTMLButtonElement).disabled).toBe(false); +}); + +test('connection refresh blocks CLI actions until readback completes, while preserving the draft', async () => { + const operation = deferred(); + const getOpenCodeServiceStatus = vi.fn().mockResolvedValueOnce(service).mockReturnValueOnce(operation.promise); + const { config } = mount({ getOpenCodeServiceStatus }); + await explicitDraft('http://[::1]:5096', 'local-secret'); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + const upgrade = await screen.findByRole('button', { name: 'Upgrade CLI…' }); + const refresh = screen.getByRole('button', { name: 'Refresh connection' }); + await fireEvent.click(refresh); + expect(refresh.getAttribute('aria-busy')).toBe('true'); + expect(refresh.querySelector('svg')?.classList.contains('animate-spin')).toBe(true); + expect(refresh.querySelector('svg')?.classList.contains('motion-reduce:animate-none')).toBe(true); + expect(refresh.textContent?.trim()).toBe(''); + const panel = screen.getByRole('region', { name: 'OpenCode V2' }); + for (const button of within(panel).getAllByRole('button')) expect((button as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(upgrade); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(config.upgradeOpenCode).not.toHaveBeenCalled(); + operation.resolve(service); + await screen.findByRole('button', { name: 'Upgrade CLI…' }); + expect(refresh.getAttribute('aria-busy')).toBe('false'); + expect(refresh.querySelector('svg')?.classList.contains('animate-spin')).toBe(false); + expect((screen.getByLabelText('OpenCode backend URL') as HTMLInputElement).value).toBe('http://[::1]:5096'); + expect((screen.getByLabelText('Service password (optional)') as HTMLInputElement).value).toBe('local-secret'); +}); + +test.each(['read', 'save'] as const)( + 'connection %s completion after teardown has no visible or readback effect', + async (kind) => { + const operation = deferred(); + const call = vi.fn().mockReturnValue(operation.promise); + const first = mount(kind === 'read' ? { getOpenCodeServiceStatus: call } : { setOpenCodeServiceTarget: call }); + if (kind === 'save') { + await explicitDraft(); + await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); + } + await waitFor(() => expect(call).toHaveBeenCalledOnce()); + first.unmount(); + const second = mount(); + operation.resolve({ ...service, pid: 9999 }); + await tick(); + await screen.findByText('4242'); + expect(screen.queryByText('9999')).toBeNull(); + expect(second.config.setOpenCodeServiceTarget).not.toHaveBeenCalled(); + expect(first.config.getOpenCodeServiceStatus).toHaveBeenCalledOnce(); + }, +); diff --git a/src/types/island.ts b/src/types/island.ts new file mode 100644 index 0000000..b3fba78 --- /dev/null +++ b/src/types/island.ts @@ -0,0 +1,65 @@ +import type { AppPreferences } from './app.js'; + +export type IslandView = 'compact' | 'peek' | 'expanded'; + +export interface IslandState { + supported: boolean; + enabled: boolean; + connection: 'disabled' | 'connecting' | 'connected' | 'error'; + connectionGeneration: number; + error?: string; + sessions: IslandSession[]; + permissions: IslandPermission[]; + questions: IslandQuestion[]; + preferences: AppPreferences; + notchWidth: number; + topInset: number; +} + +export interface IslandSession { + id: string; + title: string; + directory: string; + updatedAt: number; + parentId?: string; + status: 'running' | 'idle' | 'error'; +} + +export interface IslandPermission { + id: string; + sessionId: string; + action: string; + resources: string[]; + save?: string[]; + message?: string; +} + +export type IslandPermissionDecision = 'once' | 'always' | 'reject'; + +export interface IslandQuestion { + id: string; + sessionId: string; + title: string; + answerable: boolean; + fields: IslandQuestionField[]; +} + +export interface IslandQuestionField { + key: string; + type: 'string' | 'multiselect'; + title?: string; + description?: string; + options: { value: string; label: string; description?: string }[]; + custom: boolean; + required: boolean; +} + +export interface IslandMessage { + id: string; + role: 'user' | 'assistant'; + text: string; + time: number; + truncated: boolean; +} + +export type IslandAnswer = Record; From 772f256f36bc98a815b6da2d525e02f2c964be39 Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 18 Oct 2026 20:26:40 +0800 Subject: [PATCH 49/97] feat: Add island, scopes, settings, previews --- src/components/AGENTS.md | 6 + src/components/Select.svelte | 54 +++- src/config/AGENTS.md | 15 +- src/config/__tests__/adapter.test.mjs | 142 ++++++++++ .../__tests__/resources-bridge.test.mjs | 227 +++++++++++++++ src/config/adapter.ts | 139 +++++++-- src/config/store.svelte.ts | 254 ++++++++++++++--- src/i18n/AGENTS.md | 4 + src/i18n/dictionaries/en.ts | 263 +++++++++++++++--- src/i18n/dictionaries/zh.ts | 247 +++++++++++++--- src/routes/+layout.svelte | 24 +- src/routes/AGENTS.md | 35 ++- src/routes/__tests__/dashboard.test.ts | 12 +- src/routes/agents/+page.svelte | 218 ++++++++++++--- src/routes/agents/AgentForm.svelte | 8 +- src/routes/agents/[id]/edit/+page.svelte | 2 + .../agents/__tests__/agent-form.test.ts | 99 +++++-- .../agents/__tests__/agent-list.test.ts | 134 +++++++++ src/routes/agents/new/+page.svelte | 2 + src/routes/mcp/+page.svelte | 142 ++++++++-- src/routes/mcp/[id]/+page.svelte | 34 ++- src/routes/mcp/__tests__/mcp.test.ts | 155 +++++++++-- src/routes/mcp/new/+page.svelte | 2 + src/routes/settings/+page.svelte | 4 + src/routes/settings/AppUpdates.svelte | 11 +- src/routes/settings/LidProtection.svelte | 34 ++- .../settings/__tests__/app-updates.test.ts | 79 ++++-- .../settings/__tests__/lid-protection.test.ts | 126 ++++++++- .../settings/__tests__/settings.test.ts | 2 +- src/routes/skills/+page.svelte | 110 +++++++- src/routes/skills/[id]/+page.svelte | 30 +- src/routes/skills/__tests__/skills.test.ts | 151 +++++++++- src/routes/skills/new/+page.svelte | 2 + src/types/AGENTS.md | 16 +- src/types/agents.ts | 15 + src/types/app.ts | 51 ++++ 36 files changed, 2503 insertions(+), 346 deletions(-) diff --git a/src/components/AGENTS.md b/src/components/AGENTS.md index ab5af01..abe19f9 100644 --- a/src/components/AGENTS.md +++ b/src/components/AGENTS.md @@ -39,6 +39,10 @@ components/ # Shared UI library; no top-level barrel ├── EmptyTableRow.svelte # Empty-state tr/td with colspan and message ├── FormActions.svelte # Shared form-actions snippet container ├── PageHead.svelte # Eyebrow/title with actions or children snippet +├── ResourceScope.svelte # Local shared project/Global picker and forget-folder dialog +├── ResourceScopeNotice.svelte # Read-only detail destination and selected root path +├── GlobalDestination.svelte # Global write destination notice on direct forms +├── __tests__/ # Scope picker tests and local reactive config double ├── Pagination.svelte # Bindable page and previous/next controls └── Select.svelte # Fixed-choice Bits UI Popover picker; search above eight options ``` @@ -52,6 +56,7 @@ components/ # Shared UI library; no top-level barrel | Modal composition | `dialog/dialog-content.svelte` | Portal, overlay, children snippet, optional close control, destructive variant. | | Free-text selection | `combobox/combobox.svelte` | Bindable string, label/value filtering, keyboard highlight, portaled options. | | Fixed-choice selection | `Select.svelte` | Exports `SelectOption`; skips disabled choices and optionally focuses search. | +| Resource browsing | `ResourceScope.svelte`, `ResourceScopeNotice.svelte`, `GlobalDestination.svelte` | Saved root selection shared only by MCP/Agent/Skill lists; read-only details and explicit Global forms. | | Table composition | `DataTable.svelte`, `Pagination.svelte`, `EmptyTableRow.svelte` | Table contents come from callers; pagination controls do not slice rows. | | Page/form composition | `PageHead.svelte`, `FormActions.svelte` | Snippet-based heading actions and form footer. | | Notifications | `toast/toast.svelte` | Presentation only; actions and dismissal are supplied as callbacks. | @@ -68,6 +73,7 @@ components/ # Shared UI library; no top-level barrel - **Bits composition**: Dialog, Tooltip, Checkbox, and Separator forward remaining props to Bits primitives. Dialog/Tooltip content wraps its portal internally; Popover pickers spread both `wrapperProps` and `props` in child snippets. - **Styling hooks**: Wrappers combine Tailwind classes with tokens from `../styles/tokens.css`; preserve `data-slot` hooks. Table, heading, form, and feedback classes are shared with `../styles/table.css`, `head.css`, `forms.css`, and `feedback.css`. - **Picker accessibility**: Preserve combobox/listbox IDs, expanded/selected state, arrow/Enter selection, Escape/Tab dismissal, and outside-focus handling. Combobox keeps input focus; Select restores trigger focus after selection and skips disabled options. +- **Scope picker**: Select supports opt-in always-search, pinned choice, stacked path hints, and globe icons. ResourceScope persists selection only through config, restores the confirmed selection on failure, treats native picker cancellation as a no-op, and disables management during writes but not list reads. Forgetting a folder never deletes its files. - **Accessible names**: Switch consumers provide an external label or `aria-label`; Select accepts `ariaLabel` or a label targeting its `id`. Spinner is decorative; Toast uses alert for warning/error and status otherwise. - **Table ownership**: `DataTable` receives a table-content snippet and total count, with page size defaulting to five; callers own row selection. Keep PageHead's explicit actions snippet precedence over children. - **Local tests**: Follow the button click test and dropdown harness: role/name queries, `fireEvent`, and `waitFor` for portaled state. The dropdown harness installs i18n; tests stub missing JSDOM layout/scroll APIs and restore mocks. diff --git a/src/components/Select.svelte b/src/components/Select.svelte index c226b22..c008053 100644 --- a/src/components/Select.svelte +++ b/src/components/Select.svelte @@ -1,9 +1,9 @@ -{#if isTray} +{#if isTray || isIsland} {@render children()} {:else} {@render children()} {/if} + + diff --git a/src/routes/AGENTS.md b/src/routes/AGENTS.md index a3b66ae..abc44a9 100644 --- a/src/routes/AGENTS.md +++ b/src/routes/AGENTS.md @@ -2,7 +2,7 @@ ## Overview -Client-side pages for runtime switching, usage monitoring, and configuration management. Route wrappers compose co-located forms; the shared layout initializes application contexts and separates the main console from the tray window. +Client-side pages for runtime switching, usage monitoring, and configuration management. Route wrappers compose co-located forms; the shared layout initializes application contexts and separates the main console from the tray and Island windows. ## Directory Structure @@ -30,6 +30,7 @@ src/routes/ # SvelteKit page module │ ├── +page.svelte # Agent lists and storage-aware deletion │ ├── new/+page.svelte # /agents/new: creation wrapper │ ├── [id]/edit/+page.svelte # /agents/[id]/edit: ID-driven form wrapper +│ ├── [id]/+page.svelte # Read-only source detail with optional ?project= saved ID │ ├── AgentForm.svelte # Source snapshots, mutations, and editor │ ├── PermissionEditor.svelte # Ordered permission rules and JSON editing │ ├── agentForm.ts # Schema, form factory, selectors, and permissions @@ -40,25 +41,32 @@ src/routes/ # SvelteKit page module │ ├── [id]/edit/+page.svelte # /groups/[id]/edit: group lookup and edit wrapper │ ├── GroupForm.svelte # Group identity, type, agent/category mappings │ └── __tests__/ # Validation, mappings, and type-switch tests -├── mcp/ # /mcp: global MCP server management -│ ├── +page.svelte # Server list, diagnostics, and actions +├── mcp/ # /mcp: Global management and exact project-root browsing +│ ├── +page.svelte # Scoped server list, optional Global comparison, diagnostics │ ├── new/+page.svelte # /mcp/new: creation wrapper │ ├── [id]/+page.svelte # /mcp/[id]: masked detail and inline edit mode │ ├── McpForm.svelte # Full native JSON creation/editing │ ├── DeleteMcpDialog.svelte # Shared pending-aware delete confirmation │ ├── config-view.ts # Non-mutating credential masking for display │ └── __tests__/ # List/detail/edit/delete and masking tests -├── skills/ # /skills: user-global local and remote skills -│ ├── +page.svelte # Skill list, diagnostics, and local edit links +├── skills/ # /skills: Global skills and project .opencode skill(s) +│ ├── +page.svelte # Scoped skill list, readonly comparison and Global edit links │ ├── new/+page.svelte # /skills/new: local skill creation wrapper │ ├── [id]/+page.svelte # /skills/[id]: plain-text content and local edit mode │ ├── SkillForm.svelte # Full Markdown document creation/editing │ └── __tests__/ # List/detail/edit and safe content tests -├── settings/ # /settings: launch-at-login, lid protection, and manual updates +├── settings/ # /settings: system settings, OpenCode CLI, and app updates │ ├── +page.svelte # Autostart readback and independent lid/update sections │ ├── LidProtection.svelte # Native phase display, polling, and restore actions │ ├── AppUpdates.svelte # Installed version, explicit release check, and native macOS guide +│ ├── MiniIsland.svelte # Independent macOS-only enable switch and readback +│ ├── OpenCodeSettings.svelte # Native local CLI inspection, explicit checks, guarded upgrades +│ ├── OpenCodeService.svelte # Saved Shared/fixed target, local probes, component-local secret draft │ └── __tests__/ # Autostart, lid lifecycle, updates, openers, and navigation tests +├── island/ # /island: native mini island window +│ ├── +page.svelte # Native-sized backdrop, CSS content motion, lazy sessions/messages and guarded actions +│ ├── QuestionForm.svelte # Field-key/value question response form +│ └── __tests__/ # Lifecycle, stale reads, send safety, and question interaction tests └── tray/ # /tray: special tray-window mode ├── +page.svelte # Group switching, runtime health, and native window actions └── __tests__/ # Tray lifecycle, commands, preferences, and locale tests @@ -68,7 +76,7 @@ src/routes/ # SvelteKit page module | Responsibility | File | Description | | :--- | :--- | :--- | -| Bootstrap | `+layout.svelte` | Creates config/i18n contexts; registers main/tray listeners and disposes subscriptions. | +| Bootstrap | `+layout.svelte` | Creates config/i18n contexts; disables global initialization for `/island`; registers main/tray listeners and disposes subscriptions. | | Dashboard | `+page.svelte` | Shares one token-usage read between independently selected usage and heatmap periods. | | Provider validation | `providers/schema.ts` | Requires package on creation; parses optional string-valued headers. | | Model mapping | `models/modelForm.ts` | Converts editor strings/variant rows to model definitions and back. | @@ -76,8 +84,12 @@ src/routes/ # SvelteKit page module | Group editing | `groups/GroupForm.svelte` | Edits runtime-specific bindings and confirms type changes when mappings exist. | | MCP display | `mcp/config-view.ts` | Masks credentials in targets and nested configuration without modifying source values. | | Document editors | `mcp/McpForm.svelte`, `skills/SkillForm.svelte` | Preserve full documents and send expected source snapshots on updates. | +| Resource scope | `agents/+page.svelte`, `mcp/+page.svelte`, `skills/+page.svelte` | Local ResourceScope uses saved project roots; Global retains CRUD while project and Global comparison entries are read-only. | | Power status | `settings/LidProtection.svelte` | Polls every two seconds without overlapping reads; pauses during writes. | | Manual updates | `settings/AppUpdates.svelte` | Reads local app info; checks releases only on click and opens official project pages through config. | +| OpenCode CLI | `settings/OpenCodeSettings.svelte` | Reads native CLI details locally; checks stable updates explicitly and confirms exact upgrade path/versions. | +| Service target | `settings/OpenCodeService.svelte` | Lazy local target probes, draft-preserving saves, safe errors, and independent backend version. | +| Mini Island | `island/+page.svelte`, `settings/MiniIsland.svelte` | Local V2 service interaction window and independent macOS-only enable switch. | | Tray lifecycle | `tray/+page.svelte` | Coalesces focus/show events; refreshes config and health when shown. | | Cross-route tests | `__tests__/list-refresh.test.ts`, `__tests__/layout-preferences.test.ts` | Cover draft-preserving refresh and window-specific listener cleanup. | @@ -88,11 +100,18 @@ src/routes/ # SvelteKit page module - **Navigation**: Use links for route entry and `goto` from `$app/navigation` after successful saves; read IDs through `$app/state`. Model/agent/MCP/skill links encode identifiers with `encodeURIComponent`. - **List refresh**: Provider/model/agent/group lists call `config.refresh(true)` and guard loading/saving/local refresh; MCP/skill lists reload only their own data and diagnostics. Pagination uses five rows for the former and ten for the latter. - **Async reads**: MCP/skill lists and details invalidate request sequences on teardown or route changes; preserve these guards so stale responses cannot replace current data. -- **Settings updates**: `AppUpdates` only calls local `getAppInfo` on mount; `checkForUpdates` requires an explicit click. Release downloads and optional Stars use `openProjectPage`, never frontend URLs or automatic installation. Show the expandable macOS security guide only for native `platform === 'macos'` with an available update; quarantine commands remain selectable text, not executable controls. Pending operations are guarded and invalidated on teardown. +- **Resource scopes**: Scope controls exist only on MCP/Agent/Skill lists, not app chrome or drafts. Exact project reads pass the saved project ID; list scope effects clear old data/search/page/delete state and invalidate stale requests. Agent project/comparison reads never replace config.agents; built-in tabs remain static. Optional Global comparison lazily reads Global, appends rows with scope-aware keys without merging equal names, and has independent request guards/errors. Project Refresh rereads both visible scopes. +- **Read-only navigation**: MCP/skill details use `?project=`; project edit intent and Global saved banners are ignored, and retry/refresh preserve scope. Global comparison links use `?readonly=1`; Agent `[id]` detail is always read-only. Details never change shared scope. New and Agent edit routes remain Global and show a short destination notice when a project is selected. +- **Settings updates**: `AppUpdates` only calls local `getAppInfo` on mount; `checkForUpdates` requires an explicit click from an icon-only action with translated check/pending/retry accessible names and title tooltips. Release downloads and optional Stars use `openProjectPage`, never frontend URLs or automatic installation. Show the expandable macOS security guide only for native `platform === 'macos'` with an available update; quarantine commands remain selectable text, not executable controls. Pending operations are guarded and invalidated on teardown. +- **CLI updates**: `OpenCodeSettings` remains separate from app updates. Native mount confirms app info, reads local installation and independently probes the saved service without bootstrap; browser mode performs no calls. Installation metadata stays in background state; the update header groups accessible icon-only local refresh and explicit check actions, and inspection retry/service refresh controls are icon-only too. Explicit checks own installation/version results. Upgrade requires `canUpgrade`, an available checked update, and an exact path/version confirmation shown only in the dialog; CLI and service pending operations guard both panels. Failure preserves known details and requires explicit refresh before another check/upgrade. Success reads back installation and service status once, never reloads or restarts a service. +- **Service target**: `OpenCodeService` keeps URL/password/clear drafts local across refresh/failure and commits only successful saves, including offline targets. Passwords never read back and clear on success/teardown. Saved target and connected URL/PID/backend version remain separate from the draft and CLI version; failed reads or status errors cannot advertise stale connection data. Render only safe generic or code-based busy/validation hints, never backend error bodies or credentials. - **Detail editing**: MCP/skill `?edit=1` opens editing after loading; keyed forms retain immutable expected snapshots, keep drafts on failure, and do not automatically reload OpenCode. Remote skills remain read-only. - **MCP display**: Read-only targets/search use `maskTarget`; detail fields use `maskConfig`. Only the explicit JSON editor receives unmasked configuration. - **Draft resets**: Agent/group forms initialize on identity changes and `config.formResetVersion`, rather than resetting on every reactive store update; agent edits use the selected storage snapshot. - **Tray mode**: `/tray` bypasses `AppShell`/`SplashScreen`, applies `tray-window`, and disables catalog prefetch. Main listens for `tray:navigate`/`tray:config-changed`; tray layout listens for `app:preferences-changed`. +- **Island mode**: `/island` bypasses `AppShell`/`SplashScreen` and all global config preload. Every screen starts and collapses compact; native notch geometry changes information density, not the view. Non-notched compact adds short status/count text beside the same single activity indicator. Before its initial snapshot read, the native-targeted Island registers only `island:changed`, `island:collapsed`, `island:pointer-enter`, `island:pointer-leave`, and `app:preferences-changed`. Void pointer events update native hover alongside separate DOM hover using the shared 100 ms entry/180 ms protected leave timers, never focus/expand/read/mutate; expanded ignores hover, and disable/collapse/teardown clear both sources. It coalesces reads, fetches messages only while expanded, and cleans listeners/timers including late registrations on teardown. +- **Island safety**: Ambiguous send/409 failures retain the Session draft and caller message ID; retries require explicit Refresh and never generate a replacement ID. Disconnected or failed snapshot reads show unknown session status, not Idle. Backend `answerable` owns metadata/constraint checks; UI accepts only string/multiselect fields and field-key/option-value answers. Collapse/disable/teardown never reply to requests or stop sessions. +- **Island motion**: Native `NSAnimationContext`/`NSWindow.animator()` grows changed frames in 220 ms and shrinks in 190 ms; completion correction/readback precedes grow view/focus/IPC success. Frontend sends compact immediately during pending grow, deduplicates pending targets and guards results by latest request identity. Native compact intent/blur/close emits authoritative collapse before shrink, removing old frontend controls without an outro; the same compact request retains final shrink error feedback without restoring controls or retrying. One active/latest pending native request bounds reversal waiting to the remaining current animation (approximately 220 ms maximum), not total reversal duration. CSS fills real intermediate bounds with the unmoving black screen-edge backdrop and mounts target content only after grow IPC success; 180–220 ms content entry preserves peek header focus and immediate keyed replacement. CSS reduced-motion responds at runtime independently of native OS reduced-motion sampled per transition; startup/placement/correction are instant. Native bounds, seams, focus timing, physical camera gap and actual smoothness require rebuilt-app manual acceptance. - **Route copy**: Page titles, headings, controls, hints, and feedback use route-specific translation namespaces plus `common.*`, `configuration.*`, `validation.*`, and `toast.*`; tray status labels/details react to locale changes. - **Route tests**: Co-located `*.test.ts` use Vitest, Testing Library, and the config/i18n `Harness`, mocking navigation/state or native APIs as needed. `providers/__tests__/schema.test.mjs` and `agents/__tests__/agent-form.test.mjs` coexist as `node:test` checks. diff --git a/src/routes/__tests__/dashboard.test.ts b/src/routes/__tests__/dashboard.test.ts index 2ef5d19..4e1e7c3 100644 --- a/src/routes/__tests__/dashboard.test.ts +++ b/src/routes/__tests__/dashboard.test.ts @@ -85,11 +85,17 @@ function expectTokens(input: number | null, output: number | null) { expect(outputValue?.getAttribute('title')).toBe(output === null ? null : output.toLocaleString('en-US')); } function labeledButton(name: string) { - return screen.getByLabelText(name, { selector: 'button' }) as HTMLButtonElement; + const container = + name.startsWith('Usage ') || name === 'Heatmap period' || name === 'Year' + ? document.querySelector(name.startsWith('Usage ') ? '.usage-controls' : '.heatmap-controls') + : document.body; + return within(container as HTMLElement).getByLabelText(name, { selector: 'button' }) as HTMLButtonElement; } async function select(name: string, option: string) { - await fireEvent.click(labeledButton(name)); - await fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: option })); + const trigger = labeledButton(name); + await fireEvent.click(trigger); + const panel = document.getElementById(trigger.getAttribute('aria-controls') ?? '')?.parentElement as HTMLElement; + await fireEvent.click(within(within(panel).getByRole('listbox')).getByRole('option', { name: option })); } async function readyDialog(name: string) { const dialog = await screen.findByRole('dialog', { name }); diff --git a/src/routes/agents/+page.svelte b/src/routes/agents/+page.svelte index b606bf6..f2c3b50 100644 --- a/src/routes/agents/+page.svelte +++ b/src/routes/agents/+page.svelte @@ -1,14 +1,17 @@ {i18n.t('mcp.metaTitle')}
    - - + {#if projectId === null} + + {/if}
    -{#if removed || route.url.searchParams.get('removed') === '1'} + +{#if projectId === null && (removed || route.url.searchParams.get('removed') === '1')}
    {i18n.t('mcp.removed')}
    {/if} +{#if projectId && showGlobal} + + {#if globalLoading} +
    {i18n.t('scope.global')} · {i18n.t('common.loadingConfiguration')}
    + {/if} + {#if globalError} + + {/if} +{/if} {#if error}
    -{#if saved || page.url.searchParams.get('saved') === '1'} + +{#if editing} + +{/if} +{#if !readonly && (saved || page.url.searchParams.get('saved') === '1')}
    {i18n.t('configuration.saved')}
    {/if} {#if loading} @@ -92,15 +104,15 @@ $effect(() => { {:else if error} {:else if server} - {#if editing} - {#key server.name} + {#if editing && !readonly} + {#key identity} { editing = false; }} @@ -125,7 +137,9 @@ $effect(() => {
    -
    {i18n.t('configuration.globalSources')}
    +
    + {i18n.t(projectId !== null ? 'configuration.source' : 'configuration.globalSources')} +
      {#each server.sourcePaths.length ? server.sourcePaths : [server.sourcePath] as path} diff --git a/src/routes/mcp/__tests__/mcp.test.ts b/src/routes/mcp/__tests__/mcp.test.ts index ec37c2b..e783600 100644 --- a/src/routes/mcp/__tests__/mcp.test.ts +++ b/src/routes/mcp/__tests__/mcp.test.ts @@ -1,7 +1,8 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; import { afterAll, beforeAll, beforeEach, expect, test, vi } from 'vitest'; import { goto } from '$app/navigation'; -import { page } from '$app/state'; +import { page as routePage } from '$app/state'; +import { makeScopedConfig } from '$src/components/__tests__/scope-config.js'; import type { ConfigStore } from '$src/config/store.svelte.js'; import Harness from '$src/test-utils/harness.svelte'; import type { McpServer } from '$src/types/mcp.js'; @@ -10,6 +11,8 @@ import McpDetail from '../[id]/+page.svelte'; import { maskConfig, maskTarget } from '../config-view.js'; import McpNew from '../new/+page.svelte'; +const page: { params: { id?: string }; url: URL } = routePage; + const scrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView'); beforeAll(() => { Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }); @@ -49,7 +52,7 @@ vi.mock('$app/state', async () => { }, }; }); -const server: McpServer = { +const server = { name: 'docs/a b', type: 'remote', disabled: false, @@ -64,10 +67,10 @@ const server: McpServer = { oauth: { client_id: 'public-id', client_secret: 'secret-oauth' }, timeout: { startup: 5000, catalog: 10000, execution: 30000 }, }, -}; +} satisfies McpServer; const remoteUrl = 'https://private-user:private-pass@example.com/mcp?token=url-token&api%5Fkey=url-api&key=url-key&client_secret=url-secret&mode=stream'; -const remote: McpServer = { ...server, target: remoteUrl, config: { ...server.config, url: remoteUrl } }; +const remote = { ...server, target: remoteUrl, config: { ...server.config, url: remoteUrl } } satisfies McpServer; const command = [ 'node', 'server.js', @@ -81,13 +84,13 @@ const command = [ '--port', '3000', ]; -const local: McpServer = { +const local = { ...server, name: 'local', type: 'local', target: command.join(' '), config: { type: 'local', command }, -}; +} satisfies McpServer; const credentialValues = [ 'private-user', 'private-pass', @@ -189,12 +192,12 @@ test('detail masks secrets, confirms sources, blocks dismissal while deleting, a expect(document.body.textContent).not.toContain('secret-env'); expect(document.body.textContent).not.toContain('secret-oauth'); expect(document.body.textContent).toContain('public-id'); - await fireEvent.click(screen.getByRole('button', { name: 'Delete', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Delete' })); let dialog = await screen.findByRole('dialog'); await fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(deleteMcp).not.toHaveBeenCalled(); - await fireEvent.click(screen.getByRole('button', { name: 'Delete', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Delete' })); dialog = await screen.findByRole('dialog'); await fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); await within(dialog).findByRole('alert'); @@ -342,7 +345,7 @@ test.each(['local', 'remote'])( }; await fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'new server' } }); await fireEvent.input(input, { target: { value: JSON.stringify(native) } }); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(goto).toHaveBeenCalledExactlyOnceWith('/mcp/new%20server?saved=1')); expect(createMcp).toHaveBeenCalledExactlyOnceWith({ name: 'new server', config: native }); expect(config.reloadOpencode).not.toHaveBeenCalled(); @@ -356,14 +359,14 @@ test('MCP new syntax, non-object and target validation do not call IPC; duplicat await fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'duplicate' } }); for (const text of ['{invalid}', '[]', '{"type":"local","command":[]}']) { await fireEvent.input(input, { target: { value: text } }); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); expect(screen.getByRole('alert')).toBeTruthy(); expect(input.value).toBe(text); expect(createMcp).not.toHaveBeenCalled(); } const text = JSON.stringify(server.config); await fireEvent.input(input, { target: { value: text } }); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); await screen.findByText(/Name already exists/); expect(input.value).toBe(text); expect(goto).not.toHaveBeenCalled(); @@ -374,7 +377,7 @@ test('MCP explicit edit uses raw config and immutable expected snapshot; conflic render(McpDetail, {}, { wrapper: Harness, wrapperProps: { config: makeConfig({ updateMcp }) } }); await screen.findByRole('heading', { name: server.name }); expect(document.body.innerHTML).not.toContain('secret-header'); - await fireEvent.click(screen.getByRole('button', { name: 'Edit', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); expect(screen.getByText(/including credentials/)).toBeTruthy(); const input = screen.getByLabelText('Native configuration (JSON)') as HTMLTextAreaElement; expect(JSON.parse(input.value)).toEqual(server.config); @@ -385,7 +388,7 @@ test('MCP explicit edit uses raw config and immutable expected snapshot; conflic unknown: { retained: true }, }; await fireEvent.input(input, { target: { value: JSON.stringify(native) } }); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); await screen.findByText(/Source changed externally/); expect(JSON.parse(input.value)).toEqual(native); expect(updateMcp).toHaveBeenCalledExactlyOnceWith({ @@ -397,7 +400,7 @@ test('MCP explicit edit uses raw config and immutable expected snapshot; conflic await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(screen.queryByRole('textbox')).toBeNull(); expect(document.body.innerHTML).not.toContain('secret-header'); - await fireEvent.click(screen.getByRole('button', { name: 'Edit', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); expect(JSON.parse((screen.getByLabelText('Native configuration (JSON)') as HTMLTextAreaElement).value)).toEqual( server.config, ); @@ -414,8 +417,8 @@ test('MCP save pending disables controls and successful result returns to masked const config = makeConfig({ updateMcp }); render(McpDetail, {}, { wrapper: Harness, wrapperProps: { config } }); await screen.findByRole('heading', { name: server.name }); - await fireEvent.click(screen.getByRole('button', { name: 'Edit', exact: true })); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); expect((screen.getByRole('button', { name: 'Saving…' }) as HTMLButtonElement).disabled).toBe(true); expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true); resolve({ ...server, disabled: true }); @@ -441,7 +444,7 @@ test('MCP route changes ignore an old pending save and edit intent opens only af .mockResolvedValueOnce({ ...server, name: 'other' }); render(McpDetail, {}, { wrapper: Harness, wrapperProps: { config: makeConfig({ updateMcp, getMcp }) } }); await screen.findByLabelText('Native configuration (JSON)'); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); page.url = new URL('http://localhost/mcp/other'); page.params = { id: 'other' }; await screen.findByRole('heading', { name: 'other' }); @@ -468,7 +471,7 @@ test('MCP Edit query on the same ID opens editor; unmount ignores late creation await fireEvent.input(screen.getByLabelText('Native configuration (JSON)'), { target: { value: JSON.stringify(server.config) }, }); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); creating.unmount(); resolve(server); await Promise.resolve(); @@ -499,3 +502,119 @@ test('MCP Refresh only rereads its own list and replaces diagnostics without run expect(otherRead).not.toHaveBeenCalled(); expect(config.reloadOpencode).not.toHaveBeenCalled(); }); + +test('project list passes exact ID, preserves duplicate Global comparison and exposes only readonly links', async () => { + const projectServer = { ...server, sourcePath: '/work/alpha/opencode.jsonc' }; + const listMcps = vi.fn(async (id?: string) => ({ data: [id ? projectServer : server], diagnostics: [] })); + const config = makeScopedConfig({ listMcps }); + render(McpList, {}, { wrapper: Harness, wrapperProps: { config } }); + const link = await screen.findByRole('link', { name: server.name }); + expect(listMcps).toHaveBeenCalledExactlyOnceWith('project-a'); + expect(link.getAttribute('href')).toBe('/mcp/docs%2Fa%20b?project=project-a'); + expect(screen.queryByRole('link', { name: 'New MCP' })).toBeNull(); + expect(screen.queryByRole('link', { name: `Edit ${server.name}` })).toBeNull(); + expect(screen.queryByRole('button', { name: `Delete ${server.name}` })).toBeNull(); + await fireEvent.click(screen.getByRole('checkbox', { name: 'Show global configuration' })); + await waitFor(() => expect(screen.getAllByRole('link', { name: server.name })).toHaveLength(2)); + expect(screen.getAllByRole('link', { name: server.name }).map((entry) => entry.getAttribute('href'))).toEqual([ + '/mcp/docs%2Fa%20b?project=project-a', + '/mcp/docs%2Fa%20b?readonly=1', + ]); + expect(listMcps).toHaveBeenCalledTimes(2); +}); + +test('late project list cannot replace Global after quick return', async () => { + let resolve: (value: { data: McpServer[]; diagnostics: string[] }) => void = () => {}; + const listMcps = vi + .fn() + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ) + .mockResolvedValueOnce({ data: [local], diagnostics: [] }); + render(McpList, {}, { wrapper: Harness, wrapperProps: { config: makeScopedConfig({ listMcps }) } }); + await waitFor(() => expect(listMcps).toHaveBeenCalledWith('project-a')); + await fireEvent.click(screen.getByRole('button', { name: 'Return to Global' })); + await screen.findByRole('link', { name: local.name }); + resolve({ data: [server], diagnostics: ['late diagnostic'] }); + await waitFor(() => expect(screen.queryByRole('link', { name: server.name })).toBeNull()); + expect(screen.queryByText('late diagnostic')).toBeNull(); + expect(listMcps).toHaveBeenCalledTimes(2); +}); + +test('project detail ignores edit and saved query, preserves scope on retry and never mutates list selection', async () => { + page.url = new URL('http://localhost/mcp/docs?project=project-b&edit=1&saved=1'); + const getMcp = vi.fn().mockRejectedValueOnce(new Error('missing root')).mockResolvedValue(server); + const config = makeScopedConfig({ getMcp, updateMcp: vi.fn(), deleteMcp: vi.fn() }); + render(McpDetail, {}, { wrapper: Harness, wrapperProps: { config } }); + const alert = await screen.findByRole('alert'); + await fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })); + await screen.findByRole('heading', { name: server.name }); + expect(getMcp).toHaveBeenNthCalledWith(2, server.name, 'project-b'); + expect(screen.getByText('/other/alpha')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull(); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.queryByText(/Saved to global configuration/)).toBeNull(); + expect(config.selectedProjectId).toBe('project-a'); + expect(config.selectProject).not.toHaveBeenCalled(); + expect(config.updateMcp).not.toHaveBeenCalled(); +}); + +test('missing project root stays selected with retry/Global exit and cannot show Global fallback', async () => { + const listMcps = vi.fn().mockRejectedValue(new Error('Project folder does not exist')); + const config = makeScopedConfig({ listMcps }); + render(McpList, {}, { wrapper: Harness, wrapperProps: { config } }); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Project folder does not exist'); + expect(config.selectedProjectId).toBe('project-a'); + expect(screen.getByText('/work/alpha')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Return to Global' })).toBeTruthy(); + expect(screen.queryByRole('link', { name: server.name })).toBeNull(); + await fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(listMcps).toHaveBeenCalledTimes(2)); + expect(listMcps).toHaveBeenLastCalledWith('project-a'); +}); + +test('MCP Global comparison detail rejects forged edit intent without selecting Global', async () => { + page.url = new URL('http://localhost/mcp/docs?readonly=1&edit=1'); + const getMcp = vi.fn().mockResolvedValue(server); + const config = makeScopedConfig({ getMcp }); + render(McpDetail, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByRole('heading', { name: server.name }); + expect(getMcp).toHaveBeenCalledExactlyOnceWith(server.name); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull(); + expect(screen.getByText(/Switch the list to Global to edit/)).toBeTruthy(); + expect(config.selectedProjectId).toBe('project-a'); +}); + +test('same MCP ID in different project queries has isolated identity and ignores late source responses', async () => { + page.url = new URL('http://localhost/mcp/docs?project=project-a&edit=1'); + let resolve: (value: McpServer) => void = () => {}; + const next = { ...server, sourcePath: '/other/alpha/opencode.jsonc', sourcePaths: ['/other/alpha/opencode.jsonc'] }; + const getMcp = vi + .fn() + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ) + .mockResolvedValueOnce(next); + const config = makeScopedConfig({ getMcp }); + render(McpDetail, {}, { wrapper: Harness, wrapperProps: { config } }); + await waitFor(() => expect(getMcp).toHaveBeenCalledWith(server.name, 'project-a')); + page.url = new URL('http://localhost/mcp/docs?project=project-b&edit=1'); + await screen.findByText('/other/alpha/opencode.jsonc'); + expect(getMcp).toHaveBeenLastCalledWith(server.name, 'project-b'); + resolve({ ...server, sourcePath: '/work/alpha/opencode.jsonc', sourcePaths: ['/work/alpha/opencode.jsonc'] }); + await waitFor(() => expect(screen.queryByText('/work/alpha/opencode.jsonc')).toBeNull()); + expect(screen.getByText('/other/alpha')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(config.selectedProjectId).toBe('project-a'); +}); diff --git a/src/routes/mcp/new/+page.svelte b/src/routes/mcp/new/+page.svelte index e0b12e0..a4c8e19 100644 --- a/src/routes/mcp/new/+page.svelte +++ b/src/routes/mcp/new/+page.svelte @@ -1,4 +1,5 @@ {i18n.t('skills.metaTitle')}
      - - + {#if projectId === null} + + {/if}
      + +{#if projectId && showGlobal} + + {#if globalLoading} +
      {i18n.t('scope.global')} · {i18n.t('common.loadingConfiguration')}
      + {/if} + {#if globalError} + + {/if} +{/if} {#if error} -{#if saved || page.url.searchParams.get('saved') === '1'} + +{#if editing} + +{/if} +{#if !readonly && (saved || page.url.searchParams.get('saved') === '1')}
      {i18n.t('configuration.saved')}
      {/if} {#if loading} @@ -83,15 +95,15 @@ $effect(() => { {:else if error} {:else if skill} - {#if editing && skill.kind === 'local'} - {#key skill.id} + {#if editing && skill.kind === 'local' && !readonly} + {#key identity} { editing = false; }} diff --git a/src/routes/skills/__tests__/skills.test.ts b/src/routes/skills/__tests__/skills.test.ts index a5a8ac5..c9722ee 100644 --- a/src/routes/skills/__tests__/skills.test.ts +++ b/src/routes/skills/__tests__/skills.test.ts @@ -1,7 +1,8 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; import { beforeEach, expect, test, vi } from 'vitest'; import { goto } from '$app/navigation'; -import { page } from '$app/state'; +import { page as routePage } from '$app/state'; +import { makeScopedConfig } from '$src/components/__tests__/scope-config.js'; import type { ConfigStore } from '$src/config/store.svelte.js'; import Harness from '$src/test-utils/harness.svelte'; import type { SkillEntry } from '$src/types/skills.js'; @@ -10,6 +11,8 @@ import SkillDetail from '../[id]/+page.svelte'; import SkillNew from '../new/+page.svelte'; import SkillForm from '../SkillForm.svelte'; +const page: { params: { id?: string }; url: URL } = routePage; + vi.mock('$app/navigation', () => ({ goto: vi.fn().mockResolvedValue(undefined) })); vi.mock('$app/state', async () => { @@ -180,11 +183,11 @@ test('creates Local skill with exact full Markdown document and ID payload; erro '---\nname: Custom display name\ndescription: Full description\nmetadata:\n opencode/autoinvoke: true\nextra: retained\n---\n\n# Full body\n'; await fireEvent.input(screen.getByLabelText('ID'), { target: { value: 'new-skill' } }); await fireEvent.input(input, { target: { value: content } }); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); await screen.findByText(/Frontmatter is invalid/); expect(input.value).toBe(content); expect((screen.getByLabelText('ID') as HTMLInputElement).value).toBe('new-skill'); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(goto).toHaveBeenCalledExactlyOnceWith('/skills/new-skill?saved=1')); expect(createSkill).toHaveBeenNthCalledWith(2, { id: 'new-skill', content }); expect(config.reloadOpencode).not.toHaveBeenCalled(); @@ -194,13 +197,13 @@ test('Local edit keeps expected snapshot and draft after conflict; cancel restor const updateSkill = vi.fn().mockRejectedValue({ code: 'configuration_failed', message: 'File changed externally' }); render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config: makeConfig({ updateSkill }) } }); await screen.findByRole('heading', { name: skill.name }); - await fireEvent.click(screen.getByRole('button', { name: 'Edit', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); const input = screen.getByLabelText('Full Markdown document') as HTMLTextAreaElement; expect(input.value).toBe(skill.content); expect((screen.getByLabelText('ID') as HTMLInputElement).disabled).toBe(true); const content = '# Edited\n\n\n'; await fireEvent.input(input, { target: { value: content } }); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); await screen.findByText(/File changed externally/); expect(input.value).toBe(content); expect(updateSkill).toHaveBeenCalledExactlyOnceWith({ @@ -211,7 +214,7 @@ test('Local edit keeps expected snapshot and draft after conflict; cancel restor }); await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(document.querySelector('pre')?.textContent).toBe(skill.content); - await fireEvent.click(screen.getByRole('button', { name: 'Edit', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); expect((screen.getByLabelText('Full Markdown document') as HTMLTextAreaElement).value).toBe(skill.content); }); @@ -227,7 +230,7 @@ test('Local save disables pending controls and returns successful full document page.url = new URL('http://localhost/skills/docs?edit=1'); render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config } }); await screen.findByLabelText('Full Markdown document'); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); expect((screen.getByRole('button', { name: 'Saving…' }) as HTMLButtonElement).disabled).toBe(true); expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true); resolve({ ...skill, name: 'Saved name' }); @@ -245,7 +248,7 @@ test('Remote detail rejects edit query and forged form entry without update IPC' page.url = new URL('http://localhost/skills/docs?edit=1'); const mounted = render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config } }); await screen.findByText(/This Remote skill is read-only/); - expect(screen.queryByRole('button', { name: 'Edit', exact: true })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); expect(screen.queryByRole('textbox')).toBeNull(); mounted.unmount(); render(SkillForm, { source: remote }, { wrapper: Harness, wrapperProps: { config } }); @@ -267,8 +270,8 @@ test('Skill route changes cannot apply stale pending save to a different detail' .mockResolvedValueOnce({ ...skill, id: 'other', name: 'Other skill' }); render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config: makeConfig({ updateSkill, getSkill }) } }); await screen.findByRole('heading', { name: skill.name }); - await fireEvent.click(screen.getByRole('button', { name: 'Edit', exact: true })); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); page.params = { id: 'other' }; await screen.findByRole('heading', { name: 'Other skill' }); resolve(skill); @@ -291,7 +294,7 @@ test('Skill Edit query on same ID opens Local editor; unmount ignores late creat ); const creating = render(SkillNew, {}, { wrapper: Harness, wrapperProps: { config: makeConfig({ createSkill }) } }); await fireEvent.input(screen.getByLabelText('ID'), { target: { value: 'docs' } }); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); creating.unmount(); resolve(skill); await Promise.resolve(); @@ -306,7 +309,7 @@ test('editing saved explicit autoinvoke false preserves the full document and or render(SkillForm, { source: manual }, { wrapper: Harness, wrapperProps: { config: makeConfig({ updateSkill }) } }); const input = screen.getByLabelText('Full Markdown document') as HTMLTextAreaElement; expect(input.value).toBe(content); - await fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(updateSkill).toHaveBeenCalledExactlyOnceWith({ id: skill.id, @@ -341,3 +344,127 @@ test('Skill Refresh only rereads its own list and replaces diagnostics without r expect(otherRead).not.toHaveBeenCalled(); expect(config.reloadOpencode).not.toHaveBeenCalled(); }); + +test('project skills retain same-name Global comparison as a second readonly entry', async () => { + const listSkills = vi.fn(async (id?: string) => ({ + data: [{ ...skill, path: id ? '/work/alpha/.opencode/skills/docs/SKILL.md' : skill.path }], + diagnostics: [], + })); + render(SkillList, {}, { wrapper: Harness, wrapperProps: { config: makeScopedConfig({ listSkills }) } }); + const link = await screen.findByRole('link', { name: skill.name }); + expect(listSkills).toHaveBeenCalledExactlyOnceWith('project-a'); + expect(link.getAttribute('href')).toBe('/skills/docs%2Fa%20b?project=project-a'); + expect(screen.queryByRole('link', { name: 'New skill' })).toBeNull(); + expect(screen.queryByRole('link', { name: `Edit ${skill.name}` })).toBeNull(); + await fireEvent.click(screen.getByRole('checkbox', { name: 'Show global configuration' })); + await waitFor(() => expect(screen.getAllByRole('link', { name: skill.name })).toHaveLength(2)); + expect(screen.getAllByRole('link', { name: skill.name }).map((entry) => entry.getAttribute('href'))).toEqual([ + '/skills/docs%2Fa%20b?project=project-a', + '/skills/docs%2Fa%20b?readonly=1', + ]); + expect(screen.queryByRole('link', { name: `Edit ${skill.name}` })).toBeNull(); +}); + +test('late scoped skills cannot overwrite returned Global; scope switch resets search', async () => { + let resolve: (value: { data: SkillEntry[]; diagnostics: string[] }) => void = () => {}; + const globalSkill = { ...skill, id: 'global', name: 'Global skill' }; + const listSkills = vi + .fn() + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ) + .mockResolvedValueOnce({ data: [globalSkill], diagnostics: [] }); + render(SkillList, {}, { wrapper: Harness, wrapperProps: { config: makeScopedConfig({ listSkills }) } }); + await waitFor(() => expect(listSkills).toHaveBeenCalledWith('project-a')); + await fireEvent.input(screen.getByRole('textbox'), { target: { value: 'old query' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Return to Global' })); + await screen.findByRole('link', { name: globalSkill.name }); + expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe(''); + resolve({ data: [skill], diagnostics: ['late skill'] }); + await waitFor(() => expect(screen.queryByRole('link', { name: skill.name })).toBeNull()); + expect(screen.queryByText('late skill')).toBeNull(); + expect(listSkills).toHaveBeenCalledTimes(2); +}); + +test('project skill detail ignores direct edit intent and keeps exact project on refresh', async () => { + page.url = new URL('http://localhost/skills/docs?project=project-a&edit=1&saved=1'); + const getSkill = vi.fn().mockResolvedValue(skill); + const config = makeScopedConfig({ getSkill, updateSkill: vi.fn() }); + render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByRole('heading', { name: skill.name }); + expect(getSkill).toHaveBeenCalledExactlyOnceWith(skill.id, 'project-a'); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.queryByText(/Saved to global configuration/)).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(getSkill).toHaveBeenCalledTimes(2)); + expect(getSkill).toHaveBeenLastCalledWith(skill.id, 'project-a'); + expect(config.updateSkill).not.toHaveBeenCalled(); +}); + +test('Skill Global comparison failure is isolated and keeps project rows without previous Global data', async () => { + const listSkills = vi + .fn() + .mockResolvedValueOnce({ data: [skill], diagnostics: [] }) + .mockRejectedValueOnce(new Error('global read denied')) + .mockResolvedValueOnce({ data: [], diagnostics: [] }); + const config = makeScopedConfig({ listSkills }); + render(SkillList, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByRole('link', { name: skill.name }); + await fireEvent.click(screen.getByRole('checkbox', { name: 'Show global configuration' })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Global comparison could not be loaded'); + expect(screen.getAllByRole('link', { name: skill.name })).toHaveLength(1); + expect(config.selectedProjectId).toBe('project-a'); + await fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + expect(screen.getAllByRole('link', { name: skill.name })).toHaveLength(1); + expect(listSkills).toHaveBeenCalledTimes(3); + expect(listSkills).toHaveBeenLastCalledWith(); +}); + +test('Skill Global comparison detail ignores edit query and says switch Global to edit', async () => { + page.url = new URL('http://localhost/skills/docs?readonly=1&edit=1'); + const getSkill = vi.fn().mockResolvedValue(skill); + const config = makeScopedConfig({ getSkill }); + render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByRole('heading', { name: skill.name }); + expect(getSkill).toHaveBeenCalledExactlyOnceWith(skill.id); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect(screen.getByText(/Switch the list to Global to edit/)).toBeTruthy(); + expect(config.selectedProjectId).toBe('project-a'); +}); + +test('same skill ID across project queries never retains content from a late different scope', async () => { + page.url = new URL('http://localhost/skills/docs?project=project-a&edit=1'); + let resolve: (value: SkillEntry) => void = () => {}; + const getSkill = vi + .fn() + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ) + .mockResolvedValueOnce({ + ...skill, + path: '/other/alpha/.opencode/skills/docs/SKILL.md', + content: 'Project B body', + }); + const config = makeScopedConfig({ getSkill }); + render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config } }); + await waitFor(() => expect(getSkill).toHaveBeenCalledWith(skill.id, 'project-a')); + page.url = new URL('http://localhost/skills/docs?project=project-b&edit=1'); + await screen.findByText('Project B body'); + expect(getSkill).toHaveBeenLastCalledWith(skill.id, 'project-b'); + resolve({ ...skill, content: 'Late Project A body' }); + await waitFor(() => expect(screen.queryByText('Late Project A body')).toBeNull()); + expect(screen.getByText('/other/alpha/.opencode/skills/docs/SKILL.md')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(config.selectedProjectId).toBe('project-a'); +}); diff --git a/src/routes/skills/new/+page.svelte b/src/routes/skills/new/+page.svelte index 28ad9dc..dbbfdae 100644 --- a/src/routes/skills/new/+page.svelte +++ b/src/routes/skills/new/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/agents/__tests__/agent-form.test.ts b/src/routes/agents/__tests__/agent-form.test.ts index a773401..24a66d0 100644 --- a/src/routes/agents/__tests__/agent-form.test.ts +++ b/src/routes/agents/__tests__/agent-form.test.ts @@ -97,6 +97,7 @@ test('built-in edit is read-only even when the agent exists in config', () => { sources: [ { storage: 'inline', + content: '{"description":"Built-in description"}', raw: { description: 'Built-in description' }, fields: { description: 'Built-in description' }, }, @@ -122,6 +123,7 @@ test('edit waits for an initially missing agent and loads it when available', as sources: [ { storage: 'inline', + content: '{"description":"Loaded description"}', raw: { description: 'Loaded description' }, fields: { description: 'Loaded description' }, }, @@ -141,7 +143,12 @@ test('edit switches agent snapshots when the route id changes without remounting id: 'helper', source: 'inline', sources: [ - { storage: 'inline', raw: { description: 'First description' }, fields: { description: 'First description' } }, + { + storage: 'inline', + content: '{"description":"First description"}', + raw: { description: 'First description' }, + fields: { description: 'First description' }, + }, ], }, { @@ -150,6 +157,7 @@ test('edit switches agent snapshots when the route id changes without remounting sources: [ { storage: 'inline', + content: '{"description":"Second description"}', raw: { description: 'Second description' }, fields: { description: 'Second description' }, }, @@ -221,12 +229,15 @@ test('edit loads the selected source snapshot and clears removed model and promp sources: [ { storage: 'inline', + content: '{"description":"Inline description","model":"demo/fast#quick","prompt":"Inline prompt"}', raw: { description: 'Inline description', model: 'demo/fast#quick', prompt: 'Inline prompt' }, prompt: 'Inline prompt', fields: { description: 'Inline description', model: 'demo/fast#quick', prompt: 'Inline prompt' }, }, { storage: 'global_markdown', + resolvedPath: '/resolved/helper.md', + content: '---\ndescription: Markdown description\nmodel: demo/fast\n---\n\nMarkdown prompt', raw: '---\ndescription: Markdown description\nmodel: demo/fast\n---\n\nMarkdown prompt', prompt: 'Markdown prompt', fields: { description: 'Markdown description', model: 'demo/fast' }, @@ -264,6 +275,8 @@ test('edit loads the selected source snapshot and clears removed model and promp prompt: undefined, }, clearFields: ['model', 'prompt'], + expectedPath: '/resolved/helper.md', + expectedContent: '---\ndescription: Markdown description\nmodel: demo/fast\n---\n\nMarkdown prompt', }, }); await waitFor(() => expect(goto).toHaveBeenCalledWith('/agents')); @@ -274,7 +287,12 @@ test('edit restores the source snapshot after formResetVersion changes', async ( id: 'helper', source: 'inline', sources: [ - { storage: 'inline', raw: { description: 'Saved description' }, fields: { description: 'Saved description' } }, + { + storage: 'inline', + content: '{"description":"Saved description"}', + raw: { description: 'Saved description' }, + fields: { description: 'Saved description' }, + }, ], }; const { reset, updateAgent } = mount(EditAgent, [agent]); @@ -295,6 +313,7 @@ test('edit source picker offers one Markdown choice for multiple alias previews' { storage: 'global_markdown', path: '/global/agent/helper.md', + content: '---\ndescription: Singular alias\n---\n\nFirst prompt', raw: '---\ndescription: Singular alias\n---\n\nFirst prompt', fields: { description: 'Singular alias' }, prompt: 'First prompt', @@ -302,6 +321,7 @@ test('edit source picker offers one Markdown choice for multiple alias previews' { storage: 'global_markdown', path: '/global/agents/helper.md', + content: '---\ndescription: Plural alias\n---\n\nSecond prompt', raw: '---\ndescription: Plural alias\n---\n\nSecond prompt', fields: { description: 'Plural alias' }, prompt: 'Second prompt', @@ -321,3 +341,99 @@ test('edit source picker offers one Markdown choice for multiple alias previews' expect(agent.sources).toHaveLength(2); expect(updateAgent).not.toHaveBeenCalled(); }); + +test.each(['inline', 'global_markdown'] as const)( + 'edit keeps the original %s expected source across background refresh and failed saves', + async (storage) => { + const original = { description: 'Original description', model: 'demo/fast' }; + const raw = storage === 'inline' ? original : '---\ndescription: Original description\nmodel: demo/fast\n---\nbody'; + const content = + storage === 'inline' ? '{"description":"Original description","model":"demo/fast"}' : (raw as string); + const externalRaw = + storage === 'inline' + ? { description: 'External description' } + : '---\ndescription: External description\n---\nbody'; + const externalContent = storage === 'inline' ? '{"description":"External description"}' : (externalRaw as string); + const source = { + storage, + path: `/global/${storage}/helper`, + resolvedPath: `/resolved/${storage}/helper`, + raw, + content, + fields: original, + prompt: storage === 'inline' ? null : 'body', + }; + const agent: AgentDefinition = { + id: 'helper', + source: storage === 'inline' ? 'inline' : 'markdown', + sources: [source], + }; + const { setAgents, updateAgent, reset } = mount(EditAgent, [agent]); + updateAgent.mockRejectedValue({ code: 'configuration_failed', message: 'Source changed' }); + const description = screen.getByRole('textbox', { name: /Description/ }); + await waitFor(() => expect(description.value).toBe('Original description')); + await fireEvent.input(description, { target: { value: 'Draft description' } }); + await choose('Model', 'Clear / inherit'); + setAgents([ + { + ...agent, + sources: [ + { + ...source, + resolvedPath: '/new-target/helper', + raw: externalRaw, + content: externalContent, + fields: { description: 'External description' }, + }, + ], + }, + ]); + for (let attempt = 0; attempt < 2; attempt++) { + await fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(updateAgent).toHaveBeenCalledTimes(attempt + 1)); + expect(updateAgent.mock.calls[attempt]?.[0]).toMatchObject({ + id: 'helper', + storage, + mutation: { + fields: { description: 'Draft description' }, + clearFields: ['model'], + expectedPath: source.resolvedPath, + expectedContent: content, + }, + }); + expect(description.value).toBe('Draft description'); + } + expect(goto).not.toHaveBeenCalled(); + reset.set('version', 1); + await waitFor(() => expect(description.value).toBe('External description')); + await fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(updateAgent).toHaveBeenCalledTimes(3)); + expect(updateAgent.mock.calls[2]?.[0]).toMatchObject({ + mutation: { + fields: { description: 'External description' }, + expectedPath: '/new-target/helper', + expectedContent: externalContent, + }, + }); + }, +); + +test('inline edit echoes backend content unchanged after JavaScript numeric normalization', async () => { + const content = + '{"description":"Original description","temperature":1.0,"nested":{"float":1.0,"large":9007199254740993},"array":[1.0,9007199254740995]}'; + const wire = `{"id":"helper","source":"inline","sources":[{"storage":"inline","resolvedPath":"/resolved/helper","content":${JSON.stringify(content)},"raw":${content},"fields":${content}}]}`; + const agent: AgentDefinition = JSON.parse(JSON.stringify(JSON.parse(wire))); + expect(JSON.stringify(agent.sources?.[0]?.raw)).not.toBe(content); + const { updateAgent } = mount(EditAgent, [agent]); + const description = screen.getByRole('textbox', { name: /Description/ }); + await waitFor(() => expect(description.value).toBe('Original description')); + await fireEvent.input(description, { target: { value: 'Description edit' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await waitFor(() => expect(updateAgent).toHaveBeenCalledOnce()); + const mutation = updateAgent.mock.calls[0]?.[0]?.mutation; + expect(mutation).toMatchObject({ expectedPath: '/resolved/helper', expectedContent: content }); + expect(mutation).not.toHaveProperty('expectedRaw'); + expect(mutation?.fields).not.toHaveProperty('temperature'); + expect(mutation?.fields).not.toHaveProperty('nested'); + expect(mutation?.fields).not.toHaveProperty('array'); +}); diff --git a/src/routes/agents/agentForm.ts b/src/routes/agents/agentForm.ts index 74bcef6..6cb6ee8 100644 --- a/src/routes/agents/agentForm.ts +++ b/src/routes/agents/agentForm.ts @@ -2,7 +2,10 @@ import { createForm } from '@tanstack/svelte-form'; import { z } from 'zod'; import type { PermissionRule } from '$src/types/agents.js'; import type { PermissionParse, PermissionView } from '$src/types/ui.js'; -import { isBuiltinAgentId } from '$src/utils/index.js'; +import { isBuiltinAgentId, isJsonObject, joinModelSelector, prettyJson, splitModelSelector } from '$src/utils/index.js'; + +export { joinModelSelector, splitModelSelector }; +export const pretty = prettyJson; export type AgentFieldValues = { model: string; @@ -93,23 +96,6 @@ export function fieldError(errors: unknown[]): string { .join(', '); } -export function pretty(value: unknown): string { - return JSON.stringify(value ?? {}, null, 2); -} - -// Splits a V2 `provider/model#variant` selector into its form pieces. -export function splitModelSelector(selector: string): { model: string; variant: string } { - const hash = selector.lastIndexOf('#'); - if (hash <= 0) return { model: selector, variant: '' }; - return { model: selector.slice(0, hash), variant: selector.slice(hash + 1) }; -} - -// Joins the form pieces back into one V2 selector; a variant without a model is dropped. -export function joinModelSelector(model: string, variant: string): string { - if (!model) return ''; - return variant ? `${model}#${variant}` : model; -} - export function agentMutationFields(values: AgentFormValues): Record { const rules = permissionRules(values.permission); return { @@ -126,11 +112,8 @@ export function agentMutationFields(values: AgentFormValues): Record { - if (!value || Array.isArray(value) || typeof value !== 'object') return false; - const rule = value as Record; - return ( - typeof rule['action'] === 'string' && typeof rule['resource'] === 'string' && typeof rule['effect'] === 'string' - ); + if (!isJsonObject(value)) return false; + return typeof value.action === 'string' && typeof value.resource === 'string' && typeof value.effect === 'string'; }; // Parses the V2 `permissions` array: `{action, resource, effect}` entries in order. diff --git a/src/routes/mcp/McpForm.svelte b/src/routes/mcp/McpForm.svelte index 7245491..642d4e6 100644 --- a/src/routes/mcp/McpForm.svelte +++ b/src/routes/mcp/McpForm.svelte @@ -8,6 +8,7 @@ import Select from '$src/components/Select.svelte'; import { getConfig } from '$src/config/context.js'; import { getI18n } from '$src/i18n/context.js'; import type { McpServer } from '$src/types/mcp.js'; +import { prettyJson, safeParseJsonObject } from '$src/utils/index.js'; let { source, @@ -20,34 +21,24 @@ const initial = untrack(() => source ? { ...source, config: JSON.parse(JSON.stringify(source.config)) as Record } : undefined, ); let name = $state(initial?.name ?? ''); -let text = $state( - JSON.stringify(initial?.config ?? { type: 'local', command: [''], environment: {}, disabled: false }, null, 2), -); +let text = $state(prettyJson(initial?.config ?? { type: 'local', command: [''], environment: {}, disabled: false })); let pending = $state(false); let error = $state(''); let syntaxError = $state(''); let active = true; -const kind = $derived.by(() => { - try { - return JSON.parse(text)?.type ?? ''; - } catch { - return ''; - } -}); +const kind = $derived.by(() => (safeParseJsonObject(text)?.type as string) ?? ''); onDestroy(() => { active = false; }); function parse(): Record | null { - try { - const value = JSON.parse(text); - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(); - syntaxError = ''; - return value; - } catch { + const value = safeParseJsonObject(text); + if (!value) { syntaxError = i18n.t('mcp.jsonError'); return null; } + syntaxError = ''; + return value; } function selectType(type: string) { const value = parse(); @@ -61,7 +52,7 @@ function selectType(type: string) { value.url = ''; value.headers ??= {}; } - text = JSON.stringify(value, null, 2); + text = prettyJson(value); } async function save() { if (pending) return; diff --git a/src/routes/mcp/[id]/+page.svelte b/src/routes/mcp/[id]/+page.svelte index bf6a79b..3243be1 100644 --- a/src/routes/mcp/[id]/+page.svelte +++ b/src/routes/mcp/[id]/+page.svelte @@ -10,6 +10,7 @@ import ResourceScopeNotice from '$src/components/ResourceScopeNotice.svelte'; import { getConfig } from '$src/config/context.js'; import { getI18n } from '$src/i18n/context.js'; import type { McpServer } from '$src/types/mcp.js'; +import { prettyJson } from '$src/utils/index.js'; import { maskConfig, maskTarget } from '../config-view.js'; import DeleteMcpDialog from '../DeleteMcpDialog.svelte'; import McpForm from '../McpForm.svelte'; @@ -162,7 +163,7 @@ $effect(() => {
      {typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
      + >{typeof value === 'string' ? value : prettyJson(value)}
    {:else} diff --git a/src/routes/models/modelForm.ts b/src/routes/models/modelForm.ts index 46fbf6f..aa83247 100644 --- a/src/routes/models/modelForm.ts +++ b/src/routes/models/modelForm.ts @@ -1,4 +1,5 @@ import type { ModelDef, ModelModality, ModelVariant } from '$src/types/models.js'; +import { prettyJson, safeParseJsonObject } from '$src/utils/index.js'; export type VariantRow = { key: string; json: string }; @@ -24,7 +25,6 @@ export type ModelFormValues = { export const MODALITIES: ModelModality[] = ['text', 'audio', 'image', 'video', 'pdf']; export const emptyVariant = (): VariantRow => ({ key: '', json: '{}' }); -const pretty = (value: unknown) => JSON.stringify(value ?? {}, null, 2); const numStr = (value: number | undefined) => (value === undefined ? '' : String(value)); export const initMods = (values?: ModelModality[]) => { const selected: Record = { @@ -38,17 +38,8 @@ export const initMods = (values?: ModelModality[]) => { return selected; }; -export const parseObject = (text: string): Record | undefined => { - if (!text.trim()) return {}; - try { - const value: unknown = JSON.parse(text.trim()); - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; - } catch { - return undefined; - } -}; +export const parseObject = (text: string): Record | undefined => + !text.trim() ? {} : (safeParseJsonObject(text) ?? undefined); export const anyFilled = (...values: string[]) => values.some((value) => value.trim() !== ''); const num = (value: string) => (value.trim() === '' ? undefined : Number(value)); @@ -70,12 +61,12 @@ export function modelToFormValues(initial: ModelDef, providerId: string, default limitInput: numStr(initial.limit?.input), modalityInput: initMods(initial.capabilities?.input), modalityOutput: initMods(initial.capabilities?.output), - settings: pretty(initial.settings), - headers: pretty(initial.headers), + settings: prettyJson(initial.settings), + headers: prettyJson(initial.headers), variants: rows.length ? rows.map((entry) => { const { id: variantId, ...rest } = entry ?? {}; - return { key: variantId ?? '', json: pretty(rest) }; + return { key: variantId ?? '', json: prettyJson(rest) }; }) : [emptyVariant()], }; diff --git a/src/routes/settings/AGENTS.md b/src/routes/settings/AGENTS.md index efe2090..407a0cd 100644 --- a/src/routes/settings/AGENTS.md +++ b/src/routes/settings/AGENTS.md @@ -19,6 +19,7 @@ settings/ ├── OpenCodeSettings.svelte # Local CLI inspection, explicit checks, upgrade confirmation/readback ├── OpenCodeService.svelte # Saved target, read-only connection probes, secret-local drafts └── __tests__/ # Settings lifecycle coverage + ├── lid-protection.test.ts # Deferred target reloads, 2s fake-timer polling, stale read/save and teardown ├── mini-island.test.ts # Visibility, pending guards, readback, errors, teardown └── opencode-settings.test.ts # Native reads, CLI confirmation, target drafts, safe errors and cross-panel guards ``` @@ -30,6 +31,7 @@ settings/ - Keep the V2 Shared Service description explicit, including that enabling may start the service and closing the Island does not interrupt work. - Disabling calls only `setIslandEnabled(false)`; session interruption and request replies are never settings side effects. - Successful target saves increment the config facade's read-only `serviceTargetVersion`; Island and lid sections reread native state without remounting or toggling. Reject old-version read/save results, defer a latest read through pending operations, and never treat an old backend's protected count as current while refreshing. Failed target saves leave the version and confirmed states unchanged. +- Lid status clears its queued target reload only when a read actually starts; pending read/save completion drains one latest read, then ordinary polling resumes after two seconds without overlapping reads. Teardown clears timers and prevents queued reads. - OpenCode CLI inspection first confirms native app info; browser mode makes no CLI or update calls. Mount and refresh are local reads only; release checks require a click. - App update check/retry, CLI refresh/check, inspection retry, and connection refresh are icon-only buttons with translated accessible names and title tooltips. CLI refresh/check share the updates header; installation path/current version appear only in upgrade confirmation. - Only checked, upgradeable installations expose Upgrade. The Bits UI confirmation snapshots the exact path, current version, and target version for the guarded command. diff --git a/src/routes/settings/__tests__/lid-protection.test.ts b/src/routes/settings/__tests__/lid-protection.test.ts index 95ab6e7..7e57c88 100644 --- a/src/routes/settings/__tests__/lid-protection.test.ts +++ b/src/routes/settings/__tests__/lid-protection.test.ts @@ -405,3 +405,109 @@ test.each([false, true])( expect(setLidProtection).toHaveBeenCalledTimes(errors ? 1 : 0); }, ); + +test('an idle target reload consumes its queue once and returns to a single 2s polling timer', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const latest = deferred(); + const getLidProtection = vi + .fn() + .mockResolvedValueOnce(protectedState) + .mockReturnValueOnce(latest.promise) + .mockResolvedValue(disabled); + const view = mountTarget({ getLidProtection }); + await settle(); + await view.config.setOpenCodeServiceTarget(null); + await settle(); + expect(getLidProtection).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(8000); + expect(getLidProtection).toHaveBeenCalledTimes(2); + latest.resolve(disabled); + await settle(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(1999); + expect(getLidProtection).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(getLidProtection).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(1); + expect(view.adapter.setLidProtection).not.toHaveBeenCalled(); +}); + +test.each(['read', 'save', 'read-first', 'save-first'] as const)( + 'target reload stays queued through %s, drains once, then resumes the ordinary 2s poll', + async (operation) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const read = deferred(); + const save = deferred(); + const latest = deferred(); + const getLidProtection = vi.fn().mockResolvedValueOnce(protectedState); + if (operation !== 'save') getLidProtection.mockReturnValueOnce(read.promise); + getLidProtection.mockReturnValueOnce(latest.promise).mockResolvedValue(disabled); + const view = mountTarget({ getLidProtection, setLidProtection: vi.fn().mockReturnValue(save.promise) }); + await settle(); + if (operation !== 'save') await vi.advanceTimersByTimeAsync(2000); + if (operation !== 'read') await fireEvent.click(view.toggle); + const beforeReload = getLidProtection.mock.calls.length; + await view.config.setOpenCodeServiceTarget('http://127.0.0.1:5096'); + await settle(); + await view.config.setOpenCodeServiceTarget(null); + await settle(); + expect(getLidProtection).toHaveBeenCalledTimes(beforeReload); + expect(screen.getByRole('status').textContent).not.toContain('Protected'); + await vi.advanceTimersByTimeAsync(8000); + expect(getLidProtection).toHaveBeenCalledTimes(beforeReload); + expect(vi.getTimerCount()).toBe(0); + + if (operation === 'read-first' || operation === 'save-first') { + if (operation === 'read-first') read.reject(new Error('Old target read failed')); + else save.reject(new Error('Old target preference write failed')); + await settle(); + await vi.advanceTimersByTimeAsync(4000); + expect(getLidProtection).toHaveBeenCalledTimes(beforeReload); + expect(vi.getTimerCount()).toBe(0); + } + read.resolve(protectedState); + save.resolve(protectedState); + await settle(); + expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 1); + expect(screen.queryByRole('alert')).toBeNull(); + await vi.advanceTimersByTimeAsync(8000); + expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 1); + expect(vi.getTimerCount()).toBe(0); + + latest.resolve(disabled); + await settle(); + expect(screen.getByRole('status').textContent).toBe('Disabled'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(1999); + expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 1); + await vi.advanceTimersByTimeAsync(1); + expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 2); + expect(vi.getTimerCount()).toBe(1); + expect(view.adapter.setLidProtection).toHaveBeenCalledTimes(operation === 'read' ? 0 : 1); + view.unmount(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(4000); + expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 2); + }, +); + +test.each(['read', 'save'] as const)('unmount drops a queued target reload behind a pending %s', async (operation) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const pending = deferred(); + const getLidProtection = vi + .fn() + .mockReturnValueOnce(operation === 'read' ? pending.promise : Promise.resolve(disabled)); + const view = mountTarget({ getLidProtection, setLidProtection: vi.fn().mockReturnValue(pending.promise) }); + await settle(); + if (operation === 'save') await fireEvent.click(view.toggle); + await view.config.setOpenCodeServiceTarget(null); + await settle(); + view.unmount(); + pending.reject(new Error('Old target completed after unmount')); + await settle(); + await vi.advanceTimersByTimeAsync(8000); + expect(getLidProtection).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + expect(screen.queryByRole('status')).toBeNull(); +}); diff --git a/src/types/AGENTS.md b/src/types/AGENTS.md index 03ceb7e..5b95d05 100644 --- a/src/types/AGENTS.md +++ b/src/types/AGENTS.md @@ -40,11 +40,11 @@ src/types/ # Domain contracts without runtime implementations - **References and dictionaries**: Use `ModelRef` (`${string}/${string}`) for model bindings; `ProviderDef.models` is a `Record`, while model variants are arrays. - **Optional versus null**: Preserve required nullable preset collections in `Group` and optional nullable catalog fields; `AppState.selectedGroupId` can be absent or `null`. - **Project folders**: `ProjectFolder` stores only `id` and `path`; display names are derived by the UI. Optional `AppState.projects`/`selectedProjectId` preserve compatibility with older payloads and mocks. -- **Agent previews**: Optional `sources` retain storage, path, required raw content, fields, and nullable prompt; optional `overrides` contain `AgentFieldOverride` records with required `field`, `inline`, `markdown`, and `effective` values matching the backend wire contract. +- **Agent previews**: Optional `sources` retain storage, logical `path`, `resolvedPath`, backend-generated `content` string, `raw`, fields and nullable prompt. Inline content is the agent's JSON text; Markdown content is the full document. Echo the string unchanged, never regenerate it from JavaScript `raw` (floats/large integers can change). New fields stay optional for legacy payloads/mocks; current backend reads supply them. Optional `overrides` retain required `field`, `inline`, `markdown`, and `effective` values. - **Manual updates**: `AppInfo.platform` is `macos`/`windows`/`linux`/`other`; `UpdateCheck.latestVersion` is required but nullable when no stable release exists. `ProjectPage` limits browser destinations to `repository`/`releases`; metadata/check results are not part of `AppState`. - **CLI updates**: Keep installation method and upgrade-reason unions wire-compatible. `OpenCodeInstallation.binaryPath`, `version`, `upgradeReason`, and `manualCommand` are required nullable fields; `OpenCodeUpdateCheck.latestVersion` is non-null. Backend values are authoritative; do not infer version ordering or upgrade capability in the frontend. - **Service target**: Target mode is `shared`/`explicit`, url is required nullable, and `hasPassword` is the only credential metadata. Status connected/url/pid/version/error remain separate from target and local CLI metadata; nullable identity/error fields are required. Never add passwords to returned DTOs or AppState. Preserve `busy`/`conflict` command error codes for guarded operations. -- **Guarded updates**: Keep `expectedConfig`/`expectedSourcePath` on `McpUpdate` and `expectedContent`/`expectedPath` on `SkillUpdate`; creation drafts do not carry these fields. +- **Guarded updates**: Keep `expectedConfig`/`expectedSourcePath` on `McpUpdate` and `expectedContent`/`expectedPath` on `SkillUpdate`. Agent `mutation.expectedPath`/`expectedContent` echo the frozen preview's `resolvedPath`/`content`; backend updates reject missing snapshots with `validation_failed`. Agent create payloads remain unchanged and need no expected fields. - **Open-ended data**: Retain `Record` for raw/config/settings objects, `unknown` for variant bodies and error detail, and open strings for agent modes and permission effects. - **Permission results**: Preserve the `ok` discriminator in `PermissionParse` and `mode` discriminator in `PermissionView`; only successful/rules branches carry rules. - **Usage shapes**: Keep timestamped `TokenUsageRecord.time` separate from aggregated `DailyTokenUsage.date`; both retain numeric input/output counts. diff --git a/src/types/agents.ts b/src/types/agents.ts index 073c440..81a384d 100644 --- a/src/types/agents.ts +++ b/src/types/agents.ts @@ -12,6 +12,8 @@ export interface PermissionRule { export interface AgentSourcePreview { storage: AgentStorage; path?: string; + resolvedPath?: string; + content?: string; raw: unknown; fields: Record; prompt?: string | null; @@ -41,5 +43,10 @@ export interface AgentDefinition { steps?: number; permissions?: PermissionRule[]; } -export type AgentMutation = { fields: Record; clearFields?: string[] }; +export type AgentMutation = { + fields: Record; + clearFields?: string[]; + expectedPath?: string; + expectedContent?: string; +}; export type AgentWrite = AgentDefinition & { mutation?: AgentMutation }; diff --git a/src/utils/AGENTS.md b/src/utils/AGENTS.md index b248ff8..b913ea4 100644 --- a/src/utils/AGENTS.md +++ b/src/utils/AGENTS.md @@ -23,7 +23,7 @@ utils/ # Shared domain and presentation helpers | Responsibility | File | Description | | :--- | :--- | :--- | | Domain vocabulary | `constants.ts` | Defines readonly value tuples, derived unions, MessageKey labels, options, and OMO categories. | -| Shared helpers | `index.ts` | Composes classes, maps the shared agent catalog, validates token strings, and builds model choices. | +| Shared helpers | `index.ts` | Composes classes, maps the shared agent catalog, validates token strings, builds/splits/joins model choices, and formats/parses JSON objects. | | Dashboard calculations | `dashboard.ts` | Projects runtime bindings, aggregates timezone-aware daily usage, and builds period summaries. | ## Conventions diff --git a/src/utils/__tests__/model-options.test.mjs b/src/utils/__tests__/model-options.test.mjs index 13ca9bd..b4d8959 100644 --- a/src/utils/__tests__/model-options.test.mjs +++ b/src/utils/__tests__/model-options.test.mjs @@ -5,7 +5,15 @@ import { createServer } from 'vite'; test('model choices merge catalog and providers while retaining unavailable selections', async () => { const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); try { - const { buildModelOptions, buildModelVariants } = await vite.ssrLoadModule('/src/utils/index.ts'); + const { + buildModelOptions, + buildModelVariants, + splitModelSelector, + joinModelSelector, + isJsonObject, + safeParseJsonObject, + prettyJson, + } = await vite.ssrLoadModule('/src/utils/index.ts'); const catalog = [{ ref: 'a/one', name: 'Catalog name', variants: [{ id: 'fast' }] }]; const models = [ { ref: 'a/one', name: 'Provider name', variants: [{ id: 'slow' }] }, @@ -21,6 +29,29 @@ test('model choices merge catalog and providers while retaining unavailable sele assert.deepEqual(buildModelVariants(catalog, models, 'a/one'), ['fast']); assert.deepEqual(buildModelVariants(catalog, models, 'b/two'), ['balanced']); assert.deepEqual(buildModelVariants(catalog, models, 'z/missing'), []); + + assert.deepEqual(splitModelSelector('openai/gpt-4o#fast'), { model: 'openai/gpt-4o', variant: 'fast' }); + assert.deepEqual(splitModelSelector('openai/gpt-4o'), { model: 'openai/gpt-4o', variant: '' }); + assert.deepEqual(splitModelSelector(''), { model: '', variant: '' }); + + assert.equal(joinModelSelector('openai/gpt-4o', 'fast'), 'openai/gpt-4o#fast'); + assert.equal(joinModelSelector('openai/gpt-4o', ''), 'openai/gpt-4o'); + assert.equal(joinModelSelector('', 'fast'), ''); + + assert.equal(isJsonObject({ a: 1 }), true); + assert.equal(isJsonObject([]), false); + assert.equal(isJsonObject(null), false); + assert.equal(isJsonObject('text'), false); + + assert.deepEqual(safeParseJsonObject('{"key":"value"}'), { key: 'value' }); + assert.equal(safeParseJsonObject(''), null); + assert.equal(safeParseJsonObject(' '), null); + assert.equal(safeParseJsonObject('[]'), null); + assert.equal(safeParseJsonObject('{bad json'), null); + assert.equal(safeParseJsonObject('123'), null); + + assert.equal(prettyJson({ a: 1 }), '{\n "a": 1\n}'); + assert.equal(prettyJson(undefined), '{}'); } finally { await vite.close(); } diff --git a/src/utils/index.ts b/src/utils/index.ts index e9ebeb1..a8c615a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -62,3 +62,36 @@ export function buildModelVariants(catalog: ModelOptionEntry[], models: ModelOpt const entry = catalog.find((entry) => entry.ref === current) ?? models.find((entry) => entry.ref === current); return entry?.variants?.map((variant) => variant.id) ?? []; } + +// Splits a V2 `provider/model#variant` selector into its form pieces. +export function splitModelSelector(selector: string): { model: string; variant: string } { + const hash = selector.lastIndexOf('#'); + if (hash <= 0) return { model: selector, variant: '' }; + return { model: selector.slice(0, hash), variant: selector.slice(hash + 1) }; +} + +// Joins the form pieces back into one V2 selector; a variant without a model is dropped. +export function joinModelSelector(model: string, variant: string): string { + if (!model) return ''; + return variant ? `${model}#${variant}` : model; +} + +export function isJsonObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function safeParseJsonObject(text: string): Record | null { + if (!text || typeof text !== 'string') return null; + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const value: unknown = JSON.parse(trimmed); + return isJsonObject(value) ? value : null; + } catch { + return null; + } +} + +export function prettyJson(value: unknown): string { + return JSON.stringify(value ?? {}, null, 2); +} From 80fada27a6ab6bbd786c5a74f191ac1d1d7cf151 Mon Sep 17 00:00:00 2001 From: seho Date: Tue, 20 Oct 2026 23:10:21 +0800 Subject: [PATCH 53/97] build(tauri): update Cargo dependencies and Tauri capabilities for Island --- src-tauri/Cargo.lock | 460 ++++++++++------------ src-tauri/Cargo.toml | 18 +- src-tauri/capabilities/island.json | 8 + src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/gen/schemas/desktop-schema.json | 90 +++++ src-tauri/gen/schemas/macOS-schema.json | 90 +++++ src-tauri/tauri.conf.json | 1 + 8 files changed, 420 insertions(+), 251 deletions(-) create mode 100644 src-tauri/capabilities/island.json diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e00fbd3..57adc37 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -285,9 +285,9 @@ dependencies = [ [[package]] name = "block2" -version = "0.6.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" dependencies = [ "objc2", ] @@ -400,12 +400,13 @@ dependencies = [ [[package]] name = "cargo_toml" -version = "0.22.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" dependencies = [ + "semver", "serde", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -428,13 +429,13 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] @@ -479,7 +480,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -614,9 +615,9 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ "cssparser-macros", "dtoa-short", @@ -627,9 +628,9 @@ dependencies = [ [[package]] name = "cssparser-macros" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", "syn 2.0.118", @@ -637,19 +638,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "darling" @@ -743,6 +734,15 @@ dependencies = [ "dirs-sys 0.5.0", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys 0.5.0", +] + [[package]] name = "dirs-sys" version = "0.3.7" @@ -814,9 +814,9 @@ dependencies = [ [[package]] name = "dom_query" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", "cssparser", @@ -851,21 +851,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1460,9 +1445,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "html5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", "markup5ever", @@ -1577,7 +1562,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1733,9 +1718,9 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] @@ -1805,7 +1790,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.18", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1872,14 +1857,14 @@ dependencies = [ [[package]] name = "json-patch" -version = "3.0.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -1893,9 +1878,9 @@ dependencies = [ [[package]] name = "jsonptr" -version = "0.6.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" dependencies = [ "serde", "serde_json", @@ -1903,13 +1888,12 @@ dependencies = [ [[package]] name = "keyboard-types" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ "bitflags 2.13.0", "serde", - "unicode-segmentation", ] [[package]] @@ -1996,9 +1980,9 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "markup5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril", @@ -2049,9 +2033,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.3" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" dependencies = [ "crossbeam-channel", "dpi", @@ -2083,6 +2067,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2156,6 +2146,7 @@ dependencies = [ "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2258,6 +2249,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2340,9 +2332,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "opencode-mom" version = "2.0.0" dependencies = [ + "block2", "dirs 6.0.0", "jsonc-parser", "libc", + "objc2", + "objc2-app-kit", + "objc2-foundation", "percent-encoding", "reqwest", "serde", @@ -2351,8 +2347,11 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-dialog", "tauri-plugin-single-instance", + "tempfile", "time", + "tokio", "uuid", "windows-sys 0.61.2", ] @@ -2430,7 +2429,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2877,6 +2876,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -2891,6 +2891,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3120,9 +3144,9 @@ dependencies = [ [[package]] name = "selectors" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ "bitflags 2.13.0", "cssparser", @@ -3242,6 +3266,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3487,9 +3523,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" dependencies = [ "base64 0.21.7", "serde", @@ -3552,9 +3588,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.3" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.13.0", "block2", @@ -3570,12 +3606,12 @@ dependencies = [ "libc", "log", "ndk", + "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", - "once_cell", "parking_lot", "percent-encoding", "raw-window-handle", @@ -3583,15 +3619,15 @@ dependencies = [ "unicode-segmentation", "url", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", ] [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", @@ -3606,14 +3642,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.5" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9" dependencies = [ "anyhow", "bytes", "cookie", - "dirs 6.0.0", + "dirs 7.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3657,13 +3693,13 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.3" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" dependencies = [ "anyhow", "cargo_toml", - "dirs 6.0.0", + "dirs 7.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3678,15 +3714,15 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.3" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80" dependencies = [ "base64 0.22.1", "ico", "json-patch", "plist", - "png 0.17.16", + "png 0.18.1", "proc-macro2", "quote", "semver", @@ -3704,9 +3740,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.3" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3718,9 +3754,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.3" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" dependencies = [ "anyhow", "glob", @@ -3746,6 +3782,48 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.2" @@ -3763,9 +3841,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.3" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413" dependencies = [ "cookie", "dpi", @@ -3788,9 +3866,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.4" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" dependencies = [ "gtk", "http", @@ -3814,9 +3892,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.3" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee" dependencies = [ "anyhow", "cargo_metadata", @@ -4026,21 +4104,6 @@ dependencies = [ "toml_edit 0.20.2", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -4065,15 +4128,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4212,12 +4266,12 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs 6.0.0", + "dirs 7.0.0", "libappindicator", "muda", "objc2", @@ -4229,7 +4283,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4261,47 +4315,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - [[package]] name = "unicode-ident" version = "1.0.24" @@ -4341,13 +4354,13 @@ dependencies = [ [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -4590,16 +4603,14 @@ dependencies = [ [[package]] name = "webview2-com" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-core", ] [[package]] @@ -4615,13 +4626,13 @@ dependencies = [ [[package]] name = "webview2-com-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ "thiserror 2.0.18", "windows", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4657,52 +4668,39 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window-vibrancy" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" dependencies = [ "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "windows-version", ] [[package]] name = "windows" -version = "0.61.3" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", - "windows-link 0.1.3", "windows-numerics", ] [[package]] name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -4713,19 +4711,19 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] name = "windows-future" -version = "0.2.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core", + "windows-link", "windows-threading", ] @@ -4751,12 +4749,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -4765,21 +4757,12 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-link 0.1.3", + "windows-core", + "windows-link", ] [[package]] @@ -4788,16 +4771,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -4806,7 +4780,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4851,7 +4825,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4891,7 +4865,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -4904,11 +4878,11 @@ dependencies = [ [[package]] name = "windows-threading" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -4917,7 +4891,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5067,12 +5041,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.3" @@ -5115,15 +5083,15 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.55.1" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" dependencies = [ "base64 0.22.1", "block2", "cookie", "crossbeam-channel", - "dirs 6.0.0", + "dirs 7.0.0", "dom_query", "dpi", "dunce", @@ -5151,7 +5119,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bd00fa0..c206ef5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,17 +19,29 @@ path = "src/lib.rs" tauri-build = { version = "2.5.3", features = [] } [dependencies] -tauri = { version = "2.10.2", default-features = false, features = ["wry"] } +tauri = { version = "2.10.2", default-features = false, features = ["wry", "macos-private-api"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" jsonc-parser = { version = "=0.33.0", features = ["cst", "serde_json"] } dirs = "6.0" tauri-plugin-single-instance = "2.3.4" +tauri-plugin-dialog = "=2.8.1" time = { version = "0.3", features = ["formatting", "parsing", "serde-well-known"] } uuid = { version = "1.11", features = ["serde", "v4"] } -reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } +reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "query", "rustls"] } percent-encoding = "2" +tokio = { version = "1", features = ["rt", "sync", "time"] } + +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "=0.6.1" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "NSAccessibility", "NSAnimation", "NSAnimationContext", "NSEvent", "NSResponder", "NSScreen", "NSTrackingArea", "NSView", "NSWindow", "NSWorkspace"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry"] } + +[dev-dependencies] +tauri = { version = "2.10.2", default-features = false, features = ["test"] } +tempfile = "3" [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] tauri = { version = "2.10.2", default-features = false, features = ["tray-icon"] } @@ -41,4 +53,4 @@ tauri-plugin-autostart = "=2.5.1" libc = "=0.2.186" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Pipes", "Win32_System_Power", "Win32_System_Registry", "Win32_System_SystemServices"] } +windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Pipes", "Win32_System_Power", "Win32_System_Registry", "Win32_System_SystemServices"] } diff --git a/src-tauri/capabilities/island.json b/src-tauri/capabilities/island.json new file mode 100644 index 0000000..6e4e5e6 --- /dev/null +++ b/src-tauri/capabilities/island.json @@ -0,0 +1,8 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "island-events", + "description": "Local event listeners for the Mini Island window.", + "local": true, + "windows": ["island"], + "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] +} diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 4787b3b..aa329bc 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"autostart":{"default_permission":{"identifier":"default","description":"This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n","permissions":["allow-enable","allow-disable","allow-is-enabled"]},"permissions":{"allow-disable":{"identifier":"allow-disable","description":"Enables the disable command without any pre-configured scope.","commands":{"allow":["disable"],"deny":[]}},"allow-enable":{"identifier":"allow-enable","description":"Enables the enable command without any pre-configured scope.","commands":{"allow":["enable"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"deny-disable":{"identifier":"deny-disable","description":"Denies the disable command without any pre-configured scope.","commands":{"allow":[],"deny":["disable"]}},"deny-enable":{"identifier":"deny-enable","description":"Denies the enable command without any pre-configured scope.","commands":{"allow":[],"deny":["enable"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"autostart":{"default_permission":{"identifier":"default","description":"This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n","permissions":["allow-enable","allow-disable","allow-is-enabled"]},"permissions":{"allow-disable":{"identifier":"allow-disable","description":"Enables the disable command without any pre-configured scope.","commands":{"allow":["disable"],"deny":[]}},"allow-enable":{"identifier":"allow-enable","description":"Enables the enable command without any pre-configured scope.","commands":{"allow":["enable"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"deny-disable":{"identifier":"deny-disable","description":"Denies the disable command without any pre-configured scope.","commands":{"allow":[],"deny":["disable"]}},"deny-enable":{"identifier":"deny-enable","description":"Denies the enable command without any pre-configured scope.","commands":{"allow":[],"deny":["enable"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 345eb0a..beaae06 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"tray-events":{"identifier":"tray-events","description":"Local event listeners for the main window and tray popup.","local":true,"windows":["main","tray"],"permissions":["core:event:allow-listen","core:event:allow-unlisten"]}} \ No newline at end of file +{"island-events":{"identifier":"island-events","description":"Local event listeners for the Mini Island window.","local":true,"windows":["island"],"permissions":["core:event:allow-listen","core:event:allow-unlisten"]},"tray-events":{"identifier":"tray-events","description":"Local event listeners for the main window and tray popup.","local":true,"windows":["main","tray"],"permissions":["core:event:allow-listen","core:event:allow-unlisten"]}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 89bf976..f8b6322 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -254,6 +254,12 @@ "const": "core:app:allow-default-window-icon", "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." }, + { + "description": "Enables the exit command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-exit", + "markdownDescription": "Enables the exit command without any pre-configured scope." + }, { "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", @@ -344,6 +350,12 @@ "const": "core:app:deny-default-window-icon", "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." }, + { + "description": "Denies the exit command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-exit", + "markdownDescription": "Denies the exit command without any pre-configured scope." + }, { "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", @@ -1604,6 +1616,12 @@ "const": "core:window:allow-set-fullscreen", "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." }, + { + "description": "Enables the set_fullscreen_on_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen-on-monitor", + "markdownDescription": "Enables the set_fullscreen_on_monitor command without any pre-configured scope." + }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", @@ -2072,6 +2090,12 @@ "const": "core:window:deny-set-fullscreen", "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." }, + { + "description": "Denies the set_fullscreen_on_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen-on-monitor", + "markdownDescription": "Denies the set_fullscreen_on_monitor command without any pre-configured scope." + }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", @@ -2233,6 +2257,72 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." } ] }, diff --git a/src-tauri/gen/schemas/macOS-schema.json b/src-tauri/gen/schemas/macOS-schema.json index 89bf976..f8b6322 100644 --- a/src-tauri/gen/schemas/macOS-schema.json +++ b/src-tauri/gen/schemas/macOS-schema.json @@ -254,6 +254,12 @@ "const": "core:app:allow-default-window-icon", "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." }, + { + "description": "Enables the exit command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-exit", + "markdownDescription": "Enables the exit command without any pre-configured scope." + }, { "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", @@ -344,6 +350,12 @@ "const": "core:app:deny-default-window-icon", "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." }, + { + "description": "Denies the exit command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-exit", + "markdownDescription": "Denies the exit command without any pre-configured scope." + }, { "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", @@ -1604,6 +1616,12 @@ "const": "core:window:allow-set-fullscreen", "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." }, + { + "description": "Enables the set_fullscreen_on_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen-on-monitor", + "markdownDescription": "Enables the set_fullscreen_on_monitor command without any pre-configured scope." + }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", @@ -2072,6 +2090,12 @@ "const": "core:window:deny-set-fullscreen", "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." }, + { + "description": "Denies the set_fullscreen_on_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen-on-monitor", + "markdownDescription": "Denies the set_fullscreen_on_monitor command without any pre-configured scope." + }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", @@ -2233,6 +2257,72 @@ "type": "string", "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." } ] }, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e971dd7..d626a7a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "frontendDist": "../build" }, "app": { + "macOSPrivateApi": true, "windows": [ { "label": "main", From 5b33af63a6f4eeb45c060f0ebf55b8202d75f55c Mon Sep 17 00:00:00 2001 From: seho Date: Tue, 20 Oct 2026 23:10:38 +0800 Subject: [PATCH 54/97] feat(backend/resources): harden resource file safety, agent snapshots, and project scoping --- src-tauri/src/AGENTS.md | 34 +- src-tauri/src/agents.rs | 505 +++++++++++++++++++++++---- src-tauri/src/agents/safety_tests.rs | 219 ++++++++++++ src-tauri/src/agents_md.rs | 81 ++++- src-tauri/src/document.rs | 163 ++++++++- src-tauri/src/error.rs | 6 +- src-tauri/src/groups.rs | 136 +++++++- src-tauri/src/jsonc.rs | 84 ++++- src-tauri/src/mcp.rs | 4 + src-tauri/src/models.rs | 153 +++++++- src-tauri/src/models/tests.rs | 213 +++++++++++ src-tauri/src/paths.rs | 191 +++++++++- src-tauri/src/paths/tests.rs | 62 +++- src-tauri/src/projection.rs | 21 +- src-tauri/src/providers.rs | 172 +++++++-- src-tauri/src/refs.rs | 160 ++++++++- src-tauri/src/replacement.rs | 96 ----- src-tauri/src/skills.rs | 10 +- src-tauri/src/skills/remote_tests.rs | 2 +- 19 files changed, 2018 insertions(+), 294 deletions(-) create mode 100644 src-tauri/src/agents/safety_tests.rs delete mode 100644 src-tauri/src/replacement.rs diff --git a/src-tauri/src/AGENTS.md b/src-tauri/src/AGENTS.md index 6a124b8..352c3a0 100644 --- a/src-tauri/src/AGENTS.md +++ b/src-tauri/src/AGENTS.md @@ -11,25 +11,26 @@ src/ # Rust crate root ├── AGENTS.md # Directory-specific guidance ├── lib.rs # Module declarations and Tauri lifecycle ├── main.rs # Calls the library run entry point -├── agents.rs # Inline and global Markdown agent operations +├── agents.rs # Scoped inline/Markdown reads and guarded global agent operations +├── agents/ # Snapshot, alias and checked-publication safety tests ├── agents_md.rs # Markdown frontmatter parsing and preserving edits ├── commands/ # Domain IPC handlers and app response tests ├── document.rs # JsoncDoc and plain file I/O ├── error.rs # Serialized AppError and ErrorCode ├── groups.rs # Group persistence, selection, and projection orchestration +├── island/ # macOS Mini Island window, cached IPC, SSE lifecycle, and geometry tests ├── jsonc.rs # CST-based JSONC parsing and path patches ├── mcp.rs # Global MCP overlays and guarded mutations ├── mcp/ # MCP symlink and snapshot safety tests -├── models.rs # Wire types, app config loading, and persistence +├── models.rs # Wire types, app config and project folder persistence ├── models/ # App config serialization tests -├── opencode_cli/ # Binary discovery, models, reload, sessions, and usage +├── opencode_cli/ # Binary discovery, installation/upgrades, models, reload, sessions, and usage ├── paths.rs # ConfigPaths resolution and resource source precedence ├── paths/ # Path resolution and precedence tests ├── power/ # Lid protection service, platform controllers, and tests ├── projection.rs # Native, Slim, and OMO document transformations ├── providers.rs # Provider/model CRUD and ModelRef parsing -├── refs.rs # Model and agent reference collection -├── replacement.rs # Slim/OMO model reference scanning +├── refs.rs # Model and agent reference collection, including plugin mappings ├── resource_file.rs # Snapshots, checked publication, and recovery backups ├── resource_file/ # File identity, permissions, and publication safety tests ├── skills.rs # Local/HTTP skill discovery and local mutations @@ -43,29 +44,38 @@ src/ # Rust crate root | Responsibility | File | Description | | --- | --- | --- | -| Shell lifecycle | `lib.rs` | Builds Tauri, installs single-instance/autostart plugins, manages ConfigPaths/PowerService, registers handlers, and shuts power down on RunEvent::Exit. | +| Shell lifecycle | `lib.rs` | Builds Tauri, installs shell plugins, manages ConfigPaths/PowerService/IslandService, routes tray then island window events, and shuts services down on RunEvent::Exit. | +| Mini Island | `island/` | macOS-only bounded transparent window, opt-in persisted flag, cancellable validated OpenCode SSE cache, and generation-guarded actions. | | Error contract | `error.rs` | AppError serializes into frontend CommandError; ErrorCode uses snake_case. | | Document editing | `document.rs`, `jsonc.rs` | JsoncDoc retains source text and raw values; CST path patches preserve untouched comments and formatting. | -| Path authority | `paths.rs` | Resolves selected config, ordered global sources, resource directories, and relative skill paths. | +| Path authority | `paths.rs` | Resolves selected config, ordered resource aliases, absolute/home skill paths, and temporary exact-root project scopes. | | Group lifecycle | `groups.rs`, `projection.rs` | Validates group drafts, updates selection, and applies target-specific mappings. | -| Deletion protection | `refs.rs`, `replacement.rs` | Indexes agent/model references, including persisted groups and plugin mappings. | +| Deletion protection | `refs.rs` | Indexes agent/model references, including persisted groups and plugin mappings. | | File safety | `resource_file.rs` | Validates snapshots and aliases, stages replacements, and retains recovery backups on failure. | | Agent documents | `agents.rs`, `agents_md.rs` | Merges inline/Markdown sources and edits selected storage while retaining untouched frontmatter sections. | | Provider/model editing | `providers.rs`, `models.rs` | ModelRef identity, field-level config edits, and persisted app data types. | | Resource management | `mcp.rs`, `skills.rs` | Ordered global discovery, provenance, diagnostics, and guarded MCP/local skill writes. | +| OpenCode installation | `opencode_cli/installation.rs` | Local exact-binary ownership inspection, explicit stable V2 checks and guarded Unix curl/npm/bun/pnpm/yarn native upgrades without OpenCode service lifecycle actions. | | Manual updates | `updates.rs` | Reads native platform, checks the fixed public GitHub latest-release API, and opens only predefined project pages. | ## Conventions -- **Initialization**: Resolve production paths once with `ConfigPaths::from_environment` in `lib.rs`; handlers consume managed state rather than rediscovering paths. -- **Path precedence**: `global_config_files` and `global_skill_dirs` keep ordered sources with the last duplicate retained; relative overrides and skill paths use the captured working directory. +- **Initialization**: Resolve production paths once with `ConfigPaths::from_environment` in `lib.rs`; initialize the captured local service target before PowerService/Island. Invalid private target config leaves management available but service operations fail closed. Handlers consume captured state rather than rediscovering paths. +- **Local service target**: `opencode_cli/target.rs` privately persists the endpoint and backend-only credential outside AppConfig; Shared defaults to validated registration with optional bounded bootstrap, fixed literal loopback V2 never falls back/bootstrap. Status has no bootstrap. Active/catalog/reload/usage/power/Island share the transport. Target saves and actual power/Island enable transitions share a mutex. Prepare local power/Island guards before persistence, invalidate old results only after successful publication, then wake/reconnect outside the transition before probing status. Enabled preferences and current power authorization survive; disabled features remain disabled. Active writes/bootstrap and initial power authorization can return `busy`. Power effects are separately serialized against commit; never hold the transition across network, authorization or native dispatch waits. Native Island dispatch acquires the mutex only inside the dispatched transition, never while waiting for dispatch. +- **Service credentials**: Unix uses a regular account-owned 0600 plaintext `opencode-service.json` capped at 8 KiB. Windows persists numeric-byte `encryptedPassword` with native current-account DPAPI (32 KiB file/8 KiB ciphertext), never plaintext fallback; decryption failures fail closed. Shared registration credentials are separate. Never return passwords to frontend DTOs/events/errors or log them. Neither permissions nor DPAPI protect against account compromise, same-account malicious processes or administrators. Windows-native DPAPI tests have not run on this macOS host; native acceptance remains outside automated host tests, with no verified Windows ACL claim. +- **Island lifecycle**: `state.islandEnabled` defaults false outside preferences. Island setup errors return safe disabled readback without failing app startup; explicit enabled Refresh rebuilds unavailable connections. Disable/exit aborts the task and clears private state without stopping sessions. State/preferences reads are cache-only; message invalidations are limited to the expanded selected session. Cross-platform reads report unsupported and enabling fails validation. +- **App config writes**: Island, preferences, group and project read/modify/write operations share `models::config_write_lock`. `save_config` serializes first, stages/syncs a sibling file and atomically renames it, without `.bak`; sync the Unix directory after publication, warning rather than reporting rollback on post-commit sync failure. Preserve unrelated selection fields and existing symlink targets/modes. General target config writes retain their existing behavior. +- **Path precedence**: Ordered sources keep the last duplicate retained; config overrides resolve against the captured working directory. Agent/skill directory aliases scan singular before plural, including config-dir overrides; no mode/modes discovery. Configured relative skill strings are ignored without filesystem access; Global retains absolute/home/HTTP sources and .claude/.agents skill roots. +- **Projects**: App config stores canonical UTF-8 directory paths and UUIDs, not names. Add and scoped reads reject filesystem roots and canonical overlaps of the four fixed JSON sources/native agent/skill directories with captured Global config files/resource roots, including overrides. No home/depth blacklist or symlink-race guarantee. A temporary ConfigPaths clone reads only that exact root, ignores all project skills arrays and leaves Global paths/cwd unchanged. Missing roots return `not_found`, unreadable/retargeted roots fail without Global fallback. All project resource mutations are rejected; omitted/null scope remains Global independently of saved selection; removal only forgets metadata. +- **Agent provenance**: Inline previews retain every actual JSON path within the scope; Global inline reads retain the selected config behavior. Previews carry logical `path`, `resolvedPath` and backend-generated `content` (inline agent JSON string or full Markdown). Updates require `expectedPath`/`expectedContent`; missing snapshots return `validation_failed`, stale path/content is rejected. Echo strings unchanged across JavaScript to avoid numeric normalization/rounding. Create payloads are unchanged. Markdown mutations reuse existing aliases and reject distinct canonical targets; static Global symlinks remain supported. - **Error serialization**: Preserve `{ code, message, detail? }`; absent detail is omitted. `AppError::io` includes operation/path context without embedding file contents. +- **OpenCode upgrades**: CLI commands offload installation inspection/check/upgrade to blocking tasks. Automatic upgrade requires strictly proven Unix curl/npm/bun/pnpm/yarn ownership and current stable V2 >=2.0.25. Use `selected_binary upgrade --method ` with expected path/version and latest stable V2 target guards, revalidate ownership and read back the same path afterward; concurrent upgrades return `busy`. Windows/Homebrew/Vite Plus/older V2/unproven/manual/bundled sources remain manual. Never use sudo, upgrade bundled binaries, or start/stop/restart services; CLI version is not running backend version. Use fake installation tests only. - **JSONC edits**: Use `JsoncDoc::patch` for target mutations; `None` removes a path. Object roots, comments, and trailing commas are supported; invalid documents fail rather than being overwritten. - **Projection boundaries**: Native projection changes existing `agents..model` only and warns for missing agents; variants join as `provider/model#variant`. OMO mappings live under `opencode.agents` and `opencode.categories`. - **Group deletion**: `delete_group` removes only app data and clears a deleted active selection; target files and their residual mappings remain untouched. - **Reference guards**: Provider/model IPC deletes query `refs` before CRUD; agent deletes receive `collect_agent_references`. The model index includes inline/Markdown agents, app groups, Slim presets, and OMO mappings. -- **Backup-before-replace**: MCP replacements and local skill updates use `resource_file::replace`: validate snapshots, create recovery `.bak` files before publication, preserve modes, and remove owned backups only on success. `document::write_file` and `JsoncDoc::save` are plain writes without backups or locking. -- **Resource freshness**: MCP/skill updates compare expected source and content/config; resource snapshots revalidate aliases, identity, and content. MCP validates the effective patched server before publication to catch duplicate-key mismatches. +- **Backup-before-replace**: Existing-file inline agent CRUD, Markdown agent updates, MCP replacements and local skill updates use `resource_file::replace`: validate snapshots, create recovery `.bak` files before publication, preserve modes, and remove owned backups only on success. New agent files use checked creation. `document::write_file` and `JsoncDoc::save` remain plain writes without backups or locking. +- **Resource freshness**: Agent/MCP/skill updates compare expected source and content/config; resource snapshots revalidate aliases, identity, and content, rejecting detected retargets. MCP validates the effective patched server before publication to catch duplicate-key mismatches. Checks retain a last-check/rename race and per-file atomicity, not arbitrary-file security against concurrent same-account writers. - **Update safety**: Manual checks use bounded blocking HTTP with timeouts, no credentials, and no redirects; only stable `vX.Y.Z`/`X.Y.Z` tags are compared numerically. HTTP 404 means no stable release; malformed/network/rate-limit failures remain errors. Never install updates or accept arbitrary API/browser URLs. - **Project links**: Validate `repository`/`releases` before building native opener commands; pass fixed URLs as arguments without shell interpolation and check launch status. Tests inspect commands without running an opener. - **Tests**: Module tests use `tests.rs` submodules, explicit `#[path]` safety/remote suites, or inline tests in `tray.rs`; public filesystem workflows live in `../tests/app.rs` and `../tests/resources.rs`. diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 9acdd58..206b439 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -6,9 +6,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::agents_md::MarkdownAgentDocument; -use crate::document::{read_text, write_file, JsoncDoc, OPENCODE_SCHEMA}; +use crate::document::{JsoncDoc, OPENCODE_SCHEMA}; use crate::error::{AppError, ErrorCode}; use crate::providers::ModelRef; +use crate::resource_file::{self, Directory, Snapshot}; const AGENT_CATALOG_JSON: &str = include_str!("../../shared/agents.catalog.json"); @@ -67,6 +68,8 @@ pub type AgentReferenceIndex = BTreeMap>; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InlineAgentSource { + pub path: PathBuf, + pub resolved_path: PathBuf, pub raw: Value, } @@ -75,6 +78,7 @@ pub struct InlineAgentSource { pub struct MarkdownAgentSource { pub storage: AgentStorage, pub path: PathBuf, + pub resolved_path: PathBuf, pub raw: String, pub frontmatter: Map, pub prompt: String, @@ -95,6 +99,7 @@ pub struct AgentDefinition { pub id: String, pub source: AgentSource, pub inline: Option, + pub inline_sources: Vec, pub markdown: Vec, pub effective: Value, pub overrides: Vec, @@ -108,6 +113,9 @@ pub struct AgentMutation { pub prompt: Option, #[serde(default)] pub clear_fields: Vec, + // Bind updates to the resolved target shown with the draft, not a retargetable alias. + pub expected_path: Option, + pub expected_content: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -139,8 +147,8 @@ fn not_found(id: &str) -> AppError { /// Lists every agent definition merged from inline (OpenCode config) and Markdown sources. pub fn list(paths: &crate::paths::ConfigPaths) -> Result, AppError> { - let document = JsoncDoc::read(&paths.opencode_file(), OPENCODE_SCHEMA)?; - let inline = inline_agents(document.raw())?; + paths.validate_resource_root()?; + let inline = scan_inline(paths)?; let markdown = scan_markdown(paths)?; let ids = inline .keys() @@ -153,6 +161,29 @@ pub fn list(paths: &crate::paths::ConfigPaths) -> Result, A .collect() } +fn scan_inline( + paths: &crate::paths::ConfigPaths, +) -> Result>, AppError> { + let mut sources = BTreeMap::>::new(); + for path in paths.agent_config_files() { + let Some(snapshot) = Snapshot::read(path)? else { + continue; + }; + let document = JsoncDoc::parse(&snapshot.content)?; + for (id, raw) in inline_agents(document.raw())? { + sources + .entry(id.clone()) + .or_default() + .push(InlineAgentSource { + path: path.clone(), + resolved_path: snapshot.path.clone(), + raw: raw.clone(), + }); + } + } + Ok(sources) +} + pub fn get(paths: &crate::paths::ConfigPaths, id: &str) -> Result { validate_agent_id(id)?; list(paths)? @@ -161,25 +192,101 @@ pub fn get(paths: &crate::paths::ConfigPaths, id: &str) -> Result Result, AppError> { + let mut sources = Vec::new(); + for path in paths.agent_config_files() { + let Some(snapshot) = Snapshot::read(path)? else { + continue; + }; + let document = JsoncDoc::parse(&snapshot.content)?; + if let Some(raw) = inline_agents(document.raw())?.get(id) { + sources.push(InlineAgentSource { + path: path.clone(), + resolved_path: snapshot.path.clone(), + raw: raw.clone(), + }); + } + } + Ok(sources) +} + +fn scan_markdown_for_id( + paths: &crate::paths::ConfigPaths, + id: &str, +) -> Result, AppError> { + let mut sources = Vec::new(); + for root in paths.agent_dirs() { + if !root.exists() { + continue; + } + let path = root.join(id).with_extension("md"); + match fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(AppError::io("read", &path, error)), + Ok(_) => {} + } + let snapshot = Snapshot::read(&path)?.ok_or_else(agent_changed)?; + let raw = snapshot.content; + let document = MarkdownAgentDocument::parse(&raw)?; + sources.push(MarkdownAgentSource { + storage: AgentStorage::GlobalMarkdown, + path, + resolved_path: snapshot.path, + raw, + frontmatter: document.frontmatter, + prompt: document.prompt, + }); + } + Ok(sources) +} + +fn read_single(paths: &crate::paths::ConfigPaths, id: &str) -> Result { + paths.validate_resource_root()?; + let inline = scan_inline_for_id(paths, id)?; + let markdown = scan_markdown_for_id(paths, id)?; + if inline.is_empty() && markdown.is_empty() { + return Err(not_found(id)); + } + definition_from_sources( + id, + if inline.is_empty() { + None + } else { + Some(&inline) + }, + if markdown.is_empty() { + None + } else { + Some(&markdown) + }, + ) +} + /// Scans all agent sources for `model` references used by deletion protection. pub fn model_references( paths: &crate::paths::ConfigPaths, ) -> Result, AppError> { - let document = JsoncDoc::read(&paths.opencode_file(), OPENCODE_SCHEMA)?; - let inline = inline_agents(document.raw())?; + paths.validate_resource_root()?; + let inline = scan_inline(paths)?; let markdown = scan_markdown(paths)?; let mut references = Vec::new(); - for (id, raw) in inline { - if let Some(model) = raw - .get("model") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - { - references.push(crate::refs::ModelReference { - source: crate::refs::ReferenceSource::OpenCodeInlineAgent, - location: format!("agents.{id}.model"), - model_ref: ModelRef::parse(model).map_err(|_| invalid_model_ref(model))?, - }); + for (id, sources) in inline { + for source in sources { + if let Some(model) = source + .raw + .get("model") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + references.push(crate::refs::ModelReference { + source: crate::refs::ReferenceSource::OpenCodeInlineAgent, + location: format!("agents.{id}.model"), + model_ref: ModelRef::parse(model).map_err(|_| invalid_model_ref(model))?, + }); + } } } for (id, sources) in markdown { @@ -205,15 +312,26 @@ pub fn model_references( pub fn create( paths: &crate::paths::ConfigPaths, request: AgentCreate, +) -> Result { + paths.ensure_global_write()?; + create_inner(paths, request, |_| {}) +} + +fn create_inner( + paths: &crate::paths::ConfigPaths, + request: AgentCreate, + mut before_publish: impl FnMut(&Path), ) -> Result { validate_agent_id(&request.id)?; validate_model(&request.fields)?; let (fields, prompt, _) = normalize_mutation(&request.fields, request.prompt, Vec::new())?; match request.storage { - AgentStorage::Inline => create_inline(paths, &request.id, fields, prompt)?, + AgentStorage::Inline => { + create_inline(paths, &request.id, fields, prompt, &mut before_publish)? + } AgentStorage::GlobalMarkdown => { - let path = markdown_path(paths, request.storage, &request.id)?; + let path = markdown_path(paths, request.storage, &request.id, false)?; if path.exists() { return Err(invalid(format!( "agent '{}' already exists at {}", @@ -223,10 +341,21 @@ pub fn create( } let mut document = MarkdownAgentDocument::empty(); document.apply_fields(&fields, prompt)?; - write_file(&path, document.serialize().as_bytes())?; + let parent = Directory::ensure(path.parent().unwrap())?; + parent.create_file_checked( + path.file_name().unwrap().to_str().unwrap(), + &document.serialize(), + || { + before_publish(&path); + if markdown_path(paths, request.storage, &request.id, false)? != path { + return Err(agent_changed()); + } + resource_file::absent(&path) + }, + )?; } } - get(paths, &request.id) + read_single(paths, &request.id) } pub fn update( @@ -234,38 +363,86 @@ pub fn update( id: &str, storage: AgentStorage, mutation: AgentMutation, +) -> Result { + paths.ensure_global_write()?; + update_inner(paths, id, storage, mutation, |_| {}) +} + +fn update_inner( + paths: &crate::paths::ConfigPaths, + id: &str, + storage: AgentStorage, + mutation: AgentMutation, + mut before_replace: impl FnMut(&Path), ) -> Result { validate_agent_id(id)?; validate_model(&mutation.fields)?; + let expected_path = mutation.expected_path.as_deref().ok_or_else(|| { + invalid("agent update requires an expected source path and content; reload before saving") + })?; + let expected_content = mutation.expected_content.as_deref().ok_or_else(|| { + invalid("agent update requires an expected source path and content; reload before saving") + })?; let (fields, prompt, clear_fields) = normalize_mutation(&mutation.fields, mutation.prompt, mutation.clear_fields)?; match storage { - AgentStorage::Inline => update_inline(paths, id, fields, prompt, clear_fields)?, + AgentStorage::Inline => { + let path = paths.opencode_file(); + let (snapshot, mut document) = read_inline(&path)?; + let existing = inline_agents(document.raw())? + .get(id) + .ok_or_else(|| not_found(id))?; + validate_expected( + snapshot.as_ref().unwrap(), + &existing.to_string(), + expected_path, + expected_content, + )?; + update_inline(&mut document, id, fields, prompt, clear_fields)?; + persist_inline(&path, snapshot, &document, &mut before_replace)?; + } AgentStorage::GlobalMarkdown => { - let path = markdown_path(paths, storage, id)?; - if !path.exists() { - return Err(not_found(id)); - } - let content = - read_text(&path)?.ok_or_else(|| AppError::io("read", &path, fs_not_found()))?; - let mut document = MarkdownAgentDocument::parse(&content)?; + let path = markdown_path(paths, storage, id, false)?; + let snapshot = Snapshot::read(&path)?.ok_or_else(|| not_found(id))?; + validate_expected( + &snapshot, + &snapshot.content, + expected_path, + expected_content, + )?; + let mut document = MarkdownAgentDocument::parse(&snapshot.content)?; document.apply_fields(&fields, prompt)?; document.remove_fields(&clear_fields); if clear_fields.iter().any(|field| field == "prompt") { document.prompt.clear(); } - write_file(&path, document.serialize().as_bytes())?; + resource_file::replace(&[(snapshot, document.serialize())], |resolved| { + before_replace(resolved); + if markdown_path(paths, storage, id, false)? != path { + return Err(agent_changed()); + } + Ok(()) + })?; } } - get(paths, id) + read_single(paths, id) } -fn fs_not_found() -> std::io::Error { - std::io::Error::new( - std::io::ErrorKind::NotFound, - "file disappeared while editing", - ) +fn agent_changed() -> AppError { + AppError::configuration("Agent source changed; reload before saving") +} + +fn validate_expected( + snapshot: &Snapshot, + content: &str, + expected_path: &Path, + expected_content: &str, +) -> Result<(), AppError> { + if snapshot.path != expected_path || content != expected_content { + return Err(agent_changed()); + } + Ok(()) } pub fn delete( @@ -274,6 +451,7 @@ pub fn delete( storage: AgentStorage, references: &AgentReferenceIndex, ) -> Result { + paths.ensure_global_write()?; validate_agent_id(id)?; if built_in_agent_ids().contains(id) { return Err(AppError::references( @@ -300,11 +478,13 @@ pub fn delete( match storage { AgentStorage::Inline => delete_inline(paths, id)?, AgentStorage::GlobalMarkdown => { - let path = markdown_path(paths, storage, id)?; - if !path.exists() { - return Err(not_found(id)); - } - fs::remove_file(&path).map_err(|error| AppError::io("remove", &path, error))?; + let path = markdown_path(paths, storage, id, true)?; + let snapshot = Snapshot::read(&path)?.ok_or_else(|| not_found(id))?; + let parent = Directory::ensure(path.parent().unwrap())?; + snapshot.validate()?; + parent.validate()?; + let resolved = parent.resolved.join(path.file_name().unwrap()); + fs::remove_file(&resolved).map_err(|error| AppError::io("remove", &path, error))?; } } @@ -326,9 +506,10 @@ fn create_inline( id: &str, fields: Map, prompt: Option, + before_publish: impl FnMut(&Path), ) -> Result<(), AppError> { let opencode_file = paths.opencode_file(); - let mut document = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; + let (snapshot, mut document) = read_inline(&opencode_file)?; let agents = inline_agents(document.raw())?; if agents.contains_key(id) { return Err(invalid(format!("inline agent '{id}' already exists"))); @@ -338,18 +519,16 @@ fn create_inline( value.insert("system".to_owned(), Value::String(prompt)); } document.patch(&["agents", id], Some(Value::Object(value)))?; - document.save(&opencode_file) + persist_inline(&opencode_file, snapshot, &document, before_publish) } fn update_inline( - paths: &crate::paths::ConfigPaths, + document: &mut JsoncDoc, id: &str, fields: Map, prompt: Option, clear_fields: Vec, ) -> Result<(), AppError> { - let opencode_file = paths.opencode_file(); - let mut document = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; let agents = inline_agents(document.raw())?; let existing = agents.get(id).ok_or_else(|| not_found(id))?; if !existing.is_object() { @@ -375,17 +554,50 @@ fn update_inline( } document.patch(&["agents", id, key], None)?; } - document.save(&opencode_file) + Ok(()) } fn delete_inline(paths: &crate::paths::ConfigPaths, id: &str) -> Result<(), AppError> { let opencode_file = paths.opencode_file(); - let mut document = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; + let (snapshot, mut document) = read_inline(&opencode_file)?; if !inline_agents(document.raw())?.contains_key(id) { return Err(not_found(id)); } document.patch(&["agents", id], None)?; - document.save(&opencode_file) + persist_inline(&opencode_file, snapshot, &document, |_| {}) +} + +fn read_inline(path: &Path) -> Result<(Option, JsoncDoc), AppError> { + let snapshot = Snapshot::read(path)?; + let document = match &snapshot { + Some(snapshot) => JsoncDoc::parse(&snapshot.content)?, + None => JsoncDoc::bootstrap(OPENCODE_SCHEMA), + }; + Ok((snapshot, document)) +} + +fn persist_inline( + path: &Path, + snapshot: Option, + document: &JsoncDoc, + mut before_publish: impl FnMut(&Path), +) -> Result<(), AppError> { + if let Some(snapshot) = snapshot { + resource_file::replace(&[(snapshot, document.content().to_owned())], |resolved| { + before_publish(resolved); + Ok(()) + }) + } else { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| invalid("invalid config filename"))?; + let parent = Directory::ensure(path.parent().unwrap())?; + parent.create_file_checked(name, document.content(), || { + before_publish(path); + resource_file::absent(path) + }) + } } fn inline_agents(document: &Map) -> Result<&Map, AppError> { @@ -405,11 +617,9 @@ fn scan_markdown( paths: &crate::paths::ConfigPaths, ) -> Result>, AppError> { let mut results = BTreeMap::>::new(); - scan_markdown_root( - AgentStorage::GlobalMarkdown, - &paths.global_agents_dir(), - &mut results, - )?; + for root in paths.agent_dirs() { + scan_markdown_root(AgentStorage::GlobalMarkdown, root, &mut results)?; + } Ok(results) } @@ -422,18 +632,21 @@ fn scan_markdown_root( return Ok(()); } let mut files = Vec::new(); - collect_markdown_files(root, &mut files)?; + let mut visited = BTreeSet::new(); + collect_markdown_files(root, &mut files, &mut visited, 0)?; files.sort(); for path in files { let relative = path.strip_prefix(root).map_err(|error| { AppError::configuration(format!("resolve {}: {error}", path.display())) })?; let id = markdown_id(relative)?; - let raw = read_text(&path)?.unwrap_or_default(); + let snapshot = Snapshot::read(&path)?.ok_or_else(agent_changed)?; + let raw = snapshot.content; let document = MarkdownAgentDocument::parse(&raw)?; results.entry(id).or_default().push(MarkdownAgentSource { storage, path, + resolved_path: snapshot.path, raw, frontmatter: document.frontmatter, prompt: document.prompt, @@ -446,6 +659,7 @@ fn markdown_path( paths: &crate::paths::ConfigPaths, storage: AgentStorage, id: &str, + for_deletion: bool, ) -> Result { let root = match storage { AgentStorage::Inline => { @@ -453,7 +667,29 @@ fn markdown_path( } AgentStorage::GlobalMarkdown => paths.global_agents_dir(), }; - Ok(root.join(id).with_extension("md")) + let mut targets = BTreeMap::new(); + let mut logical_paths = BTreeSet::new(); + for root in paths.agent_dirs() { + let path = root.join(id).with_extension("md"); + match fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(AppError::io("read", &path, error)), + Ok(_) => {} + } + let canonical = + fs::canonicalize(&path).map_err(|error| AppError::io("resolve", &path, error))?; + logical_paths.insert(path.clone()); + targets.insert(canonical, path); + } + if targets.len() > 1 || (for_deletion && logical_paths.len() > 1) { + return Err(invalid(format!( + "agent '{id}' has multiple Markdown paths; resolve ambiguous aliases before editing" + ))); + } + Ok(targets + .into_values() + .next() + .unwrap_or_else(|| root.join(id).with_extension("md"))) } /// Lists every `.md` file under the given agents roots. Used by model replacement. @@ -461,17 +697,35 @@ pub fn collect_markdown_files_in_roots( paths: &crate::paths::ConfigPaths, ) -> Result, AppError> { let mut files = Vec::new(); - collect_markdown_files(&paths.global_agents_dir(), &mut files)?; + let mut visited = BTreeSet::new(); + paths.validate_resource_root()?; + for root in paths.agent_dirs() { + collect_markdown_files(root, &mut files, &mut visited, 0)?; + } files.sort(); Ok(files) } fn definition_from_sources( id: &str, - inline: Option<&Value>, + inline: Option<&Vec>, markdown: Option<&Vec>, ) -> Result { - let inline = inline.cloned().map(|raw| InlineAgentSource { raw }); + let inline_sources = inline.cloned().unwrap_or_default(); + let mut inline = None; + for source in &inline_sources { + if !source.raw.is_object() { + return Err(invalid(format!("inline agent '{id}' must be an object"))); + } + let aggregate = inline.get_or_insert_with(|| InlineAgentSource { + path: source.path.clone(), + resolved_path: source.resolved_path.clone(), + raw: Value::Object(Map::new()), + }); + deep_merge(&mut aggregate.raw, source.raw.clone()); + aggregate.path = source.path.clone(); + aggregate.resolved_path = source.resolved_path.clone(); + } let markdown = markdown.cloned().unwrap_or_default(); let source = match (inline.is_some(), markdown.is_empty()) { (true, true) => AgentSource::Inline, @@ -510,6 +764,7 @@ fn definition_from_sources( id: id.to_owned(), source, inline, + inline_sources, markdown, effective, overrides, @@ -539,15 +794,26 @@ fn deep_merge(destination: &mut Value, overlay: Value) { } } -fn collect_markdown_files(root: &Path, files: &mut Vec) -> Result<(), AppError> { - if !root.exists() { +fn collect_markdown_files( + root: &Path, + files: &mut Vec, + visited: &mut BTreeSet, + depth: usize, +) -> Result<(), AppError> { + if depth >= 128 || !root.exists() { + return Ok(()); + } + let Ok(canonical) = fs::canonicalize(root) else { + return Ok(()); + }; + if !visited.insert(canonical) { return Ok(()); } for entry in fs::read_dir(root).map_err(|error| AppError::io("read", root, error))? { let entry = entry.map_err(|error| AppError::io("read", root, error))?; let path = entry.path(); if path.is_dir() { - collect_markdown_files(&path, files)?; + collect_markdown_files(&path, files, visited, depth + 1)?; } else if path.extension().is_some_and(|extension| extension == "md") { files.push(path); } @@ -610,15 +876,7 @@ fn validate_model(fields: &Map) -> Result<(), AppError> { let base = model.split('#').next().unwrap_or(""); let variant = model.split('#').nth(1); let variant_ok = variant.is_none() || variant.is_some_and(|value| !value.is_empty()); - variant_ok - && model.matches('#').count() <= 1 - && base.matches('/').count() == 1 - && base.split_once('/').is_some_and(|(provider, name)| { - !provider.is_empty() - && !name.is_empty() - && !provider.contains(char::is_whitespace) - && !name.contains(char::is_whitespace) - }) + variant_ok && model.matches('#').count() <= 1 && ModelRef::parse(base).is_ok() }); if valid { Ok(()) @@ -670,3 +928,108 @@ fn normalize_mutation( } Ok((fields, prompt, clear_fields)) } + +#[cfg(all(test, unix))] +#[path = "agents/safety_tests.rs"] +mod safety_tests; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_model_accepts_valid_selectors() { + let cases = [ + "openrouter/anthropic/claude", + "openrouter/anthropic/claude#high", + "openai/gpt-4o", + "openai/gpt-4o#fast", + ]; + for model in cases { + let mut fields = Map::new(); + fields.insert("model".to_owned(), Value::String(model.to_owned())); + assert!( + validate_model(&fields).is_ok(), + "expected '{model}' to be valid" + ); + } + } + + #[test] + fn validate_model_rejects_invalid_selectors() { + let cases = [ + "openrouter/anthropic/claude#", + "openrouter/anthropic/claude#high#low", + "no_provider", + "provider with space/model", + "provider/model with space", + "", + ]; + for model in cases { + let mut fields = Map::new(); + fields.insert("model".to_owned(), Value::String(model.to_owned())); + assert!( + validate_model(&fields).is_err(), + "expected '{model}' to be invalid" + ); + } + } + + #[cfg(unix)] + #[test] + fn collect_markdown_files_handles_directory_cycles() { + let temp_dir = + std::env::temp_dir().join(format!("opencode-mom-dir-cycle-{}", uuid::Uuid::new_v4())); + let sub = temp_dir.join("sub"); + fs::create_dir_all(&sub).unwrap(); + std::os::unix::fs::symlink(&temp_dir, sub.join("loop")).unwrap(); + fs::write(sub.join("test.md"), "---\nmodel: openai/gpt-4o\n---\nbody").unwrap(); + + let mut files = Vec::new(); + let mut visited = BTreeSet::new(); + let result = collect_markdown_files(&temp_dir, &mut files, &mut visited, 0); + let _ = fs::remove_dir_all(&temp_dir); + + assert!(result.is_ok()); + assert_eq!(files.len(), 1); + } + + #[test] + fn create_and_read_single_agent_avoids_cascading_failure_from_broken_markdown() { + let temp_home = + std::env::temp_dir().join(format!("opencode-mom-single-read-{}", uuid::Uuid::new_v4())); + let paths = crate::paths::ConfigPaths::for_home(&temp_home); + fs::create_dir_all(paths.global_agents_dir()).unwrap(); + fs::create_dir_all(paths.opencode_file().parent().unwrap()).unwrap(); + + // Write a broken markdown agent in the agents dir + fs::write( + paths.global_agents_dir().join("broken.md"), + "---\n: invalid yaml frontmatter\n---\nbody", + ) + .unwrap(); + + // list should fail due to broken.md + assert!( + list(&paths).is_err(), + "list should fail due to broken markdown" + ); + + // create a new inline agent should succeed and not cascade into broken.md + let request = AgentCreate { + id: "good-agent".to_owned(), + storage: AgentStorage::Inline, + fields: serde_json::from_value(serde_json::json!({"model": "openai/gpt-4o"})).unwrap(), + prompt: Some("hello".to_owned()), + }; + let created = create(&paths, request); + let _ = fs::remove_dir_all(&temp_home); + + assert!( + created.is_ok(), + "create should succeed despite unrelated broken markdown: {:?}", + created.err() + ); + assert_eq!(created.unwrap().id, "good-agent"); + } +} diff --git a/src-tauri/src/agents/safety_tests.rs b/src-tauri/src/agents/safety_tests.rs new file mode 100644 index 0000000..cd59ca0 --- /dev/null +++ b/src-tauri/src/agents/safety_tests.rs @@ -0,0 +1,219 @@ +use std::os::unix::fs::symlink; + +use super::*; + +struct Fixture { + home: PathBuf, + paths: crate::paths::ConfigPaths, + logical: PathBuf, + target: PathBuf, + outside: PathBuf, + content: String, + content_snapshot: String, +} + +impl Fixture { + fn new(storage: AgentStorage) -> Self { + let home = std::env::temp_dir().join(format!( + "opencode-mom-agent-safety-{}", + uuid::Uuid::new_v4() + )); + let paths = crate::paths::ConfigPaths::for_home(&home); + let (logical, content, content_snapshot) = match storage { + AgentStorage::Inline => ( + paths.opencode_file(), + r#"{"agents":{"helper":{"description":"original"}},"keep":true}"#.to_owned(), + serde_json::json!({"description": "original"}).to_string(), + ), + AgentStorage::GlobalMarkdown => ( + paths.global_agents_dir().join("helper.md"), + "---\ndescription: original\n---\nbody".to_owned(), + "---\ndescription: original\n---\nbody".to_owned(), + ), + }; + let target = home.join("target/source"); + let outside = home.join("outside"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, &content).unwrap(); + fs::write(&outside, &content).unwrap(); + fs::create_dir_all(logical.parent().unwrap()).unwrap(); + symlink(&target, &logical).unwrap(); + Self { + home, + paths, + logical, + target, + outside, + content, + content_snapshot, + } + } + + fn mutation(&self) -> AgentMutation { + AgentMutation { + fields: serde_json::from_value(serde_json::json!({"description": "edited"})).unwrap(), + expected_path: Some(fs::canonicalize(&self.target).unwrap()), + expected_content: Some(self.content_snapshot.clone()), + ..Default::default() + } + } + + fn retarget(&self, path: &Path) { + fs::remove_file(path).unwrap(); + symlink(&self.outside, path).unwrap(); + } + + fn assert_unchanged(&self) { + assert_eq!(fs::read_to_string(&self.target).unwrap(), self.content); + assert_eq!(fs::read_to_string(&self.outside).unwrap(), self.content); + let recovery = fs::read_dir(self.target.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert!(!recovery + .iter() + .any(|path| path.extension().is_some_and(|ext| ext == "tmp"))); + for backup in recovery + .iter() + .filter(|path| path.extension().is_some_and(|ext| ext == "bak")) + { + assert_eq!(fs::read_to_string(backup).unwrap(), self.content); + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.home); + } +} + +#[test] +fn update_rejects_logical_retarget_after_snapshot_before_publication() { + for storage in [AgentStorage::Inline, AgentStorage::GlobalMarkdown] { + let fixture = Fixture::new(storage); + let error = update_inner( + &fixture.paths, + "helper", + storage, + fixture.mutation(), + |_| { + fixture.retarget(&fixture.logical); + }, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + fixture.assert_unchanged(); + } +} + +#[test] +fn update_rejects_resolved_target_replaced_by_identical_content_symlink() { + for storage in [AgentStorage::Inline, AgentStorage::GlobalMarkdown] { + let fixture = Fixture::new(storage); + let error = update_inner( + &fixture.paths, + "helper", + storage, + fixture.mutation(), + |resolved| { + fixture.retarget(resolved); + }, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + fixture.assert_unchanged(); + } +} + +#[test] +fn update_rejects_external_content_change_after_snapshot() { + for storage in [AgentStorage::Inline, AgentStorage::GlobalMarkdown] { + let fixture = Fixture::new(storage); + let changed = fixture.content.replace("original", "external"); + let error = update_inner( + &fixture.paths, + "helper", + storage, + fixture.mutation(), + |resolved| { + fs::write(resolved, &changed).unwrap(); + }, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + assert_eq!(fs::read_to_string(&fixture.target).unwrap(), changed); + assert_eq!( + fs::read_to_string(&fixture.outside).unwrap(), + fixture.content + ); + } +} + +#[test] +fn markdown_update_rejects_duplicate_alias_retarget_before_publication() { + let fixture = Fixture::new(AgentStorage::GlobalMarkdown); + let singular = fixture.home.join(".config/opencode/agent/helper.md"); + fs::create_dir_all(singular.parent().unwrap()).unwrap(); + symlink(&fixture.target, &singular).unwrap(); + assert!(update_inner( + &fixture.paths, + "helper", + AgentStorage::GlobalMarkdown, + fixture.mutation(), + |_| { + fixture.retarget(&singular); + } + ) + .is_err()); + fixture.assert_unchanged(); +} + +#[test] +fn creation_refuses_raced_destination_symlink_for_both_storages() { + for storage in [AgentStorage::Inline, AgentStorage::GlobalMarkdown] { + let fixture = Fixture::new(storage); + fs::remove_file(&fixture.logical).unwrap(); + assert!(create_inner( + &fixture.paths, + AgentCreate { + id: "helper".to_owned(), + storage, + fields: Map::new(), + prompt: Some("new".to_owned()), + }, + |logical| { + symlink(&fixture.outside, logical).unwrap(); + } + ) + .is_err()); + fixture.assert_unchanged(); + } +} + +#[test] +fn creation_refuses_parent_directory_retarget_for_both_storages() { + for storage in [AgentStorage::Inline, AgentStorage::GlobalMarkdown] { + let fixture = Fixture::new(storage); + fs::remove_file(&fixture.logical).unwrap(); + let outside = fixture.home.join("outside-directory"); + fs::create_dir(&outside).unwrap(); + assert!(create_inner( + &fixture.paths, + AgentCreate { + id: "helper".to_owned(), + storage, + fields: Map::new(), + prompt: Some("new".to_owned()), + }, + |logical| { + let parent = logical.parent().unwrap(); + fs::rename(parent, parent.with_extension("held")).unwrap(); + symlink(&outside, parent).unwrap(); + } + ) + .is_err()); + fixture.assert_unchanged(); + assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); + } +} diff --git a/src-tauri/src/agents_md.rs b/src-tauri/src/agents_md.rs index d654346..9d86e03 100644 --- a/src-tauri/src/agents_md.rs +++ b/src-tauri/src/agents_md.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::BTreeSet; use serde_json::{Map, Value}; @@ -186,15 +186,11 @@ fn frontmatter_key_order<'a>(source: &str, keys: impl Iterator>(); let mut ordered = Vec::new(); for line in source.lines() { - if line.starts_with([' ', '\t', '-']) { - continue; - } - let Some((key, _)) = line.split_once(':') else { + let Some(key) = top_level_key(line) else { continue; }; - let key = key.trim(); - if available.contains(key) && !ordered.iter().any(|known| known == key) { - ordered.push(key.to_owned()); + if available.contains(&key) && !ordered.iter().any(|known| known == &key) { + ordered.push(key); } } for key in available { @@ -301,7 +297,15 @@ fn top_level_key(line: &str) -> Option { return None; } let (key, _) = line.split_once(':')?; - let key = key.trim(); + let mut key = key.trim(); + if (key.starts_with('"') && key.ends_with('"')) + || (key.starts_with('\'') && key.ends_with('\'')) + { + if key.len() < 2 { + return None; + } + key = &key[1..key.len() - 1]; + } (!key.is_empty() && key .chars() @@ -362,3 +366,62 @@ fn render_scalar(value: &Value) -> String { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quoted_key_prompt_only_update_preserves_section_without_duplicates() { + let input = "---\n\"description\": \"text\"\n---\noriginal prompt"; + let mut doc = MarkdownAgentDocument::parse(input).expect("parse markdown document"); + doc.prompt = "updated prompt".to_owned(); + let serialized = doc.serialize(); + + assert_eq!( + serialized.matches("description").count(), + 1, + "serialized document has duplicate description keys: {serialized}" + ); + assert!(serialized.contains("\"description\": \"text\"")); + assert!(serialized.ends_with("updated prompt")); + + let reparsed = + MarkdownAgentDocument::parse(&serialized).expect("re-parse markdown document"); + assert_eq!( + reparsed + .frontmatter + .get("description") + .and_then(|v| v.as_str()), + Some("text") + ); + assert_eq!(reparsed.prompt, "updated prompt"); + } + + #[test] + fn single_quoted_key_prompt_only_update_preserves_section_without_duplicates() { + let input = "---\n'description': 'text'\n---\noriginal prompt"; + let mut doc = MarkdownAgentDocument::parse(input).expect("parse markdown document"); + doc.prompt = "updated prompt".to_owned(); + let serialized = doc.serialize(); + + assert_eq!( + serialized.matches("description").count(), + 1, + "serialized document has duplicate description keys: {serialized}" + ); + assert!(serialized.contains("'description': 'text'")); + assert!(serialized.ends_with("updated prompt")); + + let reparsed = + MarkdownAgentDocument::parse(&serialized).expect("re-parse markdown document"); + assert_eq!( + reparsed + .frontmatter + .get("description") + .and_then(|v| v.as_str()), + Some("text") + ); + assert_eq!(reparsed.prompt, "updated prompt"); + } +} diff --git a/src-tauri/src/document.rs b/src-tauri/src/document.rs index 66f35f2..8dc024f 100644 --- a/src-tauri/src/document.rs +++ b/src-tauri/src/document.rs @@ -1,10 +1,11 @@ use std::fs; +use std::io::Write; use std::path::Path; use serde_json::{Map, Value}; use crate::error::AppError; -use crate::jsonc::{parse_jsonc_object, patch}; +use crate::jsonc::{parse_jsonc_object, patch_with_effective}; pub const OPENCODE_SCHEMA: &str = "https://opencode.ai/config.json"; pub const SLIM_SCHEMA: &str = @@ -12,15 +13,101 @@ pub const SLIM_SCHEMA: &str = pub const OMO_SCHEMA: &str = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json"; -/// Writes bytes to a file, creating parent directories as needed. Plain sequential write, -/// no atomicity, locking, or backups by design. +struct TempFileGuard<'a> { + path: &'a Path, + active: bool, +} + +impl<'a> TempFileGuard<'a> { + fn new(path: &'a Path) -> Self { + Self { path, active: true } + } + + fn defuse(&mut self) { + self.active = false; + } +} + +impl<'a> Drop for TempFileGuard<'a> { + fn drop(&mut self) { + if self.active { + let _ = fs::remove_file(self.path); + } + } +} + +/// Writes bytes to a file atomically via a temporary file in the same directory, +/// creating parent directories as needed and preserving file permissions. pub fn write_file(path: &Path, bytes: &[u8]) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { + let parent = match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => { fs::create_dir_all(parent).map_err(|error| AppError::io("create", parent, error))?; + parent + } + _ => Path::new("."), + }; + + let original_permissions = match fs::metadata(path) { + Ok(metadata) => Some(metadata.permissions()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(AppError::io("inspect", path, error)), + }; + + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("document"); + let temporary = parent.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4())); + let mut guard = TempFileGuard::new(&temporary); + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mode = match &original_permissions { + Some(perms) => { + use std::os::unix::fs::PermissionsExt; + perms.mode() + } + None => 0o644, + }; + options.mode(mode); + } + + let mut file = options + .open(&temporary) + .map_err(|error| AppError::io("create", &temporary, error))?; + + file.write_all(bytes) + .map_err(|error| AppError::io("write", &temporary, error))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = match original_permissions { + Some(perms) => perms, + None => fs::Permissions::from_mode(0o644), + }; + file.set_permissions(perms) + .map_err(|error| AppError::io("set permissions", &temporary, error))?; + } + #[cfg(not(unix))] + { + if let Some(perms) = original_permissions { + file.set_permissions(perms) + .map_err(|error| AppError::io("set permissions", &temporary, error))?; } } - fs::write(path, bytes).map_err(|error| AppError::io("write", path, error)) + + file.sync_all() + .map_err(|error| AppError::io("sync", &temporary, error))?; + drop(file); + + fs::rename(&temporary, path).map_err(|error| AppError::io("rename", path, error))?; + guard.defuse(); + + Ok(()) } /// Reads a file as UTF-8 text; `None` when the file does not exist. @@ -66,10 +153,10 @@ impl JsoncDoc { } pub fn patch(&mut self, path: &[&str], value: Option) -> Result<(), AppError> { - self.content = patch(&self.content, path, value) - .map_err(|_| AppError::validation("malformed JSONC document"))?; - self.raw = parse_jsonc_object(&self.content) + let (content, raw) = patch_with_effective(&self.content, path, value) .map_err(|_| AppError::validation("malformed JSONC document"))?; + self.content = content; + self.raw = raw; Ok(()) } @@ -85,3 +172,61 @@ impl JsoncDoc { &self.raw } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn write_file_atomically_creates_and_overwrites() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("nested").join("test.txt"); + + write_file(&file_path, b"initial content").unwrap(); + assert_eq!( + read_text(&file_path).unwrap(), + Some("initial content".to_owned()) + ); + + write_file(&file_path, b"updated content").unwrap(); + assert_eq!( + read_text(&file_path).unwrap(), + Some("updated content".to_owned()) + ); + } + + #[cfg(unix)] + #[test] + fn write_file_preserves_existing_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("perm.txt"); + + write_file(&file_path, b"test").unwrap(); + fs::set_permissions(&file_path, fs::Permissions::from_mode(0o600)).unwrap(); + + write_file(&file_path, b"new content").unwrap(); + let metadata = fs::metadata(&file_path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + + #[test] + fn jsonc_doc_patch_updates_both_content_and_raw() { + let input = "{\n // schema\n \"name\": \"initial\"\n}"; + let mut doc = JsoncDoc::parse(input).unwrap(); + doc.patch(&["name"], Some(json!("updated"))).unwrap(); + + assert_eq!(doc.raw().get("name"), Some(&json!("updated"))); + assert!(doc.content().contains("\"name\": \"updated\"")); + assert!(doc.content().contains("// schema")); + } + + #[test] + fn jsonc_doc_patch_rejects_shadowed_duplicate_key() { + let input = r#"{"name": "first", "name": "second"}"#; + let mut doc = JsoncDoc::parse(input).unwrap(); + let result = doc.patch(&["name"], Some(json!("third"))); + assert!(result.is_err()); + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 07b638b..3b37b76 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -1,8 +1,7 @@ use serde::Serialize; -/// Error codes exposed to the frontend. `busy` and `conflict` no longer occur because the -/// backend performs plain sequential file writes, but the codes stay reserved for the -/// frontend type contract. +/// Error codes exposed to the frontend. `busy` reports an ongoing guarded operation; +/// `conflict` remains reserved in the frontend contract but is not emitted here. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ErrorCode { @@ -10,6 +9,7 @@ pub enum ErrorCode { ReferencesBlocked, ValidationFailed, ConfigurationFailed, + Busy, } /// The single error type for every backend operation. It serializes directly into the diff --git a/src-tauri/src/groups.rs b/src-tauri/src/groups.rs index 2fdd7b0..352d242 100644 --- a/src-tauri/src/groups.rs +++ b/src-tauri/src/groups.rs @@ -1,8 +1,9 @@ +use std::path::PathBuf; use uuid::Uuid; use crate::document::{JsoncDoc, OMO_SCHEMA, OPENCODE_SCHEMA, SLIM_SCHEMA}; use crate::error::AppError; -use crate::models::{self, AppConfig, AppSelectionState, GroupType, ModelGroup}; +use crate::models::{self, AppConfig, GroupType, ModelGroup}; use crate::paths::ConfigPaths; use crate::projection; @@ -33,6 +34,7 @@ pub fn save_group( paths: &ConfigPaths, mut group: ModelGroup, ) -> Result<(ModelGroup, AppConfig), AppError> { + let _write = models::config_write_lock(); let mut config = load(paths)?; if config .groups @@ -75,6 +77,7 @@ pub fn copy_group( id: Uuid, name: Option, ) -> Result<(ModelGroup, AppConfig), AppError> { + let _write = models::config_write_lock(); let mut config = load(paths)?; let source = config .groups @@ -111,6 +114,7 @@ pub fn copy_group( /// Removes a group from opencode-mom's own config only. Target config files are left /// untouched, so their mappings stay behind as residual configuration. pub fn delete_group(paths: &ConfigPaths, id: Uuid) -> Result { + let _write = models::config_write_lock(); let mut config = load(paths)?; let index = config .groups @@ -119,7 +123,8 @@ pub fn delete_group(paths: &ConfigPaths, id: Uuid) -> Result Result Result<(), AppError> { + let _write = models::config_write_lock(); let mut config = load(paths)?; let group = find_enabled_group(&config, group_id)?.clone(); if config.state.selected_group_id == Some(group_id) { @@ -158,16 +164,14 @@ pub fn switch_group(paths: &ConfigPaths, group_id: Uuid) -> Result<(), AppError> } } - config.state = AppSelectionState { - selected_group_id: Some(group.id), - selected_group_name: Some(group.name.clone()), - preferences: config.state.preferences, - }; + config.state.selected_group_id = Some(group.id); + config.state.selected_group_name = Some(group.name.clone()); models::save_config(&paths.config_file(), &config) } -/// Persists a group mutation plus all dependent projections in plain sequential writes. -/// Order matters only cosmetically: every write is independent. +/// Persists a group mutation plus all dependent projections using a two-phase commit. +/// Phase 1 (Plan / Preflight): in-memory read and transform of all target documents. +/// Phase 2 (Publish): saves all planned documents and finally commits AppConfig. fn persist_group_lifecycle( paths: &ConfigPaths, previous: Option<&ModelGroup>, @@ -176,6 +180,10 @@ fn persist_group_lifecycle( ) -> Result<(), AppError> { let selected = config.state.selected_group_id == Some(group.id); + let mut slim_plan: Option<(PathBuf, JsoncDoc)> = None; + let mut omo_plan: Option<(PathBuf, JsoncDoc)> = None; + let mut opencode_plan: Option<(PathBuf, JsoncDoc)> = None; + if group.group_type == GroupType::Slim || switches_away_from_slim(previous, group) { let slim_file = paths.slim_file(); let source = JsoncDoc::read(&slim_file, SLIM_SCHEMA)?; @@ -198,7 +206,7 @@ fn persist_group_lifecycle( } else if let Some(previous) = previous.filter(|item| item.group_type == GroupType::Slim) { document = projection::delete_slim_preset(&document, &previous.name)?; } - document.save(&slim_file)?; + slim_plan = Some((slim_file, document)); } let switched_from_omo = selected @@ -214,7 +222,7 @@ fn persist_group_lifecycle( } else { source.clone() }; - document.save(&omo_file)?; + omo_plan = Some((omo_file, document)); } let switched_from_native = selected @@ -227,7 +235,7 @@ fn persist_group_lifecycle( let opencode_file = paths.opencode_file(); let source = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; let (document, _) = projection::project_opencode(group, &source)?; - document.save(&opencode_file)?; + opencode_plan = Some((opencode_file, document)); } else if switched_from_native { if let Some(previous) = previous.filter(|item| projection::has_effective_opencode_overrides(item)) @@ -235,10 +243,20 @@ fn persist_group_lifecycle( let opencode_file = paths.opencode_file(); let source = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; let document = projection::remove_opencode_overrides(previous, &source)?; - document.save(&opencode_file)?; + opencode_plan = Some((opencode_file, document)); } } + if let Some((path, doc)) = slim_plan { + doc.save(&path)?; + } + if let Some((path, doc)) = omo_plan { + doc.save(&path)?; + } + if let Some((path, doc)) = opencode_plan { + doc.save(&path)?; + } + models::save_config(&paths.config_file(), &config) } @@ -329,3 +347,95 @@ fn unique_copy_group_name(source: &str, groups: &[ModelGroup]) -> String { }) .expect("unbounded copy suffix iterator") } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn save_group_two_phase_commit_aborts_before_writing_when_target_is_corrupt() { + let temp_dir = tempfile::tempdir().unwrap(); + let paths = ConfigPaths::for_home(temp_dir.path()); + + fs::create_dir_all(paths.opencode_file().parent().unwrap()).unwrap(); + fs::write( + paths.opencode_file(), + r#"{ + "$schema": "https://opencode.ai/config.json", + "providers": { + "acme": { + "models": { + "fast": {} + } + } + } +}"#, + ) + .unwrap(); + + fs::create_dir_all(paths.slim_file().parent().unwrap()).unwrap(); + fs::write(paths.slim_file(), "{\n \"presets\": {}\n}\n").unwrap(); + + fs::create_dir_all(paths.omo_file().parent().unwrap()).unwrap(); + fs::write(paths.omo_file(), "{\n}\n").unwrap(); + + let slim_group = ModelGroup { + id: Uuid::new_v4(), + name: "test-slim".to_owned(), + description: "test".to_owned(), + group_type: GroupType::Slim, + open_code_agent_overrides: Vec::new(), + slim_agent_overrides: Some(vec![crate::models::AgentModelBinding { + agent_name: "reviewer".to_owned(), + model_ref: "acme/fast".to_owned(), + variant: None, + }]), + omo_agent_overrides: None, + omo_category_mappings: None, + is_enabled: true, + updated_at: time::OffsetDateTime::now_utc(), + }; + + let (saved_group, _) = save_group(&paths, slim_group).unwrap(); + switch_group(&paths, saved_group.id).unwrap(); + + let slim_content_before = fs::read_to_string(paths.slim_file()).unwrap(); + assert!(slim_content_before.contains("test-slim")); + assert!(slim_content_before.contains("acme/fast")); + + // Corrupt OMO file so Phase 1 parsing will fail. + fs::write(paths.omo_file(), "{").unwrap(); + + // Switch selected group type from Slim to OMO. + let mut omo_group = saved_group.clone(); + omo_group.group_type = GroupType::Omo; + omo_group.omo_agent_overrides = Some(vec![crate::models::AgentModelBinding { + agent_name: "reviewer".to_owned(), + model_ref: "acme/fast".to_owned(), + variant: None, + }]); + + let result = save_group(&paths, omo_group); + assert!( + result.is_err(), + "save_group must fail on corrupt OMO target" + ); + + // Slim file must be completely untouched - preset preserved! + let slim_content_after = fs::read_to_string(paths.slim_file()).unwrap(); + assert_eq!( + slim_content_after, slim_content_before, + "Slim preset must remain unmodified when commit aborts in Phase 1" + ); + + // AppConfig must still hold the original Slim group. + let config = models::load_config(&paths.config_file()).unwrap(); + let group_in_config = config + .groups + .iter() + .find(|g| g.id == saved_group.id) + .expect("group exists in config"); + assert_eq!(group_in_config.group_type, GroupType::Slim); + } +} diff --git a/src-tauri/src/jsonc.rs b/src-tauri/src/jsonc.rs index bcd2ddb..6ea8c84 100644 --- a/src-tauri/src/jsonc.rs +++ b/src-tauri/src/jsonc.rs @@ -22,15 +22,50 @@ pub fn parse_jsonc_object(input: &str) -> Result, JsoncError> } pub fn patch(input: &str, path: &[&str], value: Option) -> Result { + patch_with_effective(input, path, value).map(|(output, _)| output) +} + +pub fn patch_with_effective( + input: &str, + path: &[&str], + value: Option, +) -> Result<(String, Map), JsoncError> { if path.is_empty() || path.iter().any(|segment| segment.is_empty()) { return Err(JsoncError::MalformedJson); } let root = CstRootNode::parse(input, &options()).map_err(|_| JsoncError::MalformedJson)?; let object = root.object_value().ok_or(JsoncError::MalformedJson)?; - patch_object(&object, path, value); + patch_object(&object, path, value.clone()); let output = root.to_string(); - parse_jsonc_object(&output)?; - Ok(output) + let effective = parse_jsonc_object(&output)?; + + match &value { + Some(expected) => { + if get_path(&effective, path) != Some(expected) { + return Err(JsoncError::MalformedJson); + } + } + None => { + if get_path(&effective, path).is_some() { + return Err(JsoncError::MalformedJson); + } + } + } + + Ok((output, effective)) +} + +fn get_path<'a>(mut current: &'a Map, path: &[&str]) -> Option<&'a Value> { + for (i, segment) in path.iter().enumerate() { + if i == path.len() - 1 { + return current.get(*segment); + } + match current.get(*segment) { + Some(Value::Object(map)) => current = map, + _ => return None, + } + } + None } fn patch_object(object: &CstObject, path: &[&str], value: Option) { @@ -85,3 +120,46 @@ fn options() -> ParseOptions { allow_unary_plus_numbers: false, } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn duplicate_key_rejects_shadowed_modification() { + let input = r#"{"x": 1, "x": 2}"#; + let result = patch(input, &["x"], Some(json!(3))); + assert_eq!(result, Err(JsoncError::MalformedJson)); + } + + #[test] + fn duplicate_key_rejects_shadowed_deletion() { + let input = r#"{"x": 1, "x": 2}"#; + let result = patch(input, &["x"], None); + assert_eq!(result, Err(JsoncError::MalformedJson)); + } + + #[test] + fn normal_patch_and_patch_with_effective() { + let input = "{\n // keep comment\n \"x\": 1\n}"; + let (output, effective) = patch_with_effective(input, &["x"], Some(json!(3))).unwrap(); + assert_eq!(output, "{\n // keep comment\n \"x\": 3\n}"); + assert_eq!(effective.get("x"), Some(&json!(3))); + + let (output_deleted, effective_deleted) = + patch_with_effective(&output, &["x"], None).unwrap(); + assert_eq!(effective_deleted.get("x"), None); + assert!(!output_deleted.contains("\"x\"")); + } + + #[test] + fn nested_duplicate_key_rejects_shadowed_modification_and_deletion() { + let input = r#"{"a": {"x": 1, "x": 2}}"#; + let modify_result = patch(input, &["a", "x"], Some(json!(3))); + assert_eq!(modify_result, Err(JsoncError::MalformedJson)); + + let delete_result = patch(input, &["a", "x"], None); + assert_eq!(delete_result, Err(JsoncError::MalformedJson)); + } +} diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 55a2315..4e200b7 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -77,6 +77,7 @@ fn validate_effective_server( } pub fn list(paths: &ConfigPaths) -> Result { + paths.validate_resource_root()?; let mut result = McpList::default(); let mut entries = BTreeMap::)>::new(); for path in paths.global_config_files() { @@ -184,6 +185,7 @@ pub fn get(paths: &ConfigPaths, name: &str) -> Result { } pub fn delete(paths: &ConfigPaths, name: &str) -> Result<(), AppError> { + paths.ensure_global_write()?; delete_inner(paths, name, |_| {}, |_| {}) } @@ -380,6 +382,7 @@ fn saved_server(name: String, config: Map, source_paths: Vec Result { + paths.ensure_global_write()?; validate_draft(&draft.name, &draft.config)?; let mut documents = managed_documents(paths)?; for (_, _, document) in &documents { @@ -415,6 +418,7 @@ pub fn create(paths: &ConfigPaths, draft: McpDraft) -> Result Result { + paths.ensure_global_write()?; validate_draft(&draft.name, &draft.config)?; let mut documents = managed_documents(paths)?; let index = documents diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 85e13bc..cfe35e6 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; use std::fs; +use std::io::Write; use std::path::Path; +use std::sync::{Mutex, MutexGuard}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -169,7 +171,17 @@ pub struct AppSelectionState { pub selected_group_id: Option, pub selected_group_name: Option, #[serde(default)] + pub selected_project_id: Option, + #[serde(default)] pub preferences: AppPreferences, + #[serde(default)] + pub island_enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectFolder { + pub id: Uuid, + pub path: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] @@ -178,6 +190,8 @@ pub struct AppConfig { #[serde(default)] pub groups: Vec, #[serde(default)] + pub projects: Vec, + #[serde(default)] pub state: AppSelectionState, } @@ -199,21 +213,146 @@ impl AppConfig { /// Reads the application config file. A missing file is an empty default configuration. pub fn load_config(path: &Path) -> Result { - if !path.exists() { - return Ok(AppConfig::default()); - } - let content = fs::read_to_string(path).map_err(|error| AppError::io("read", path, error))?; - let config = serde_json::from_str(&content).map_err(|error| { + let content = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(AppConfig::default()) + } + Err(error) => return Err(AppError::io("read", path, error)), + }; + let mut config: AppConfig = serde_json::from_slice(&content).map_err(|error| { AppError::validation(format!("application config: invalid JSON: {error}")) })?; + config.state.selected_project_id = config + .state + .selected_project_id + .filter(|id| config.projects.iter().any(|project| project.id == *id)); Ok(config) } -/// Serializes the application config and writes it in one plain write. +pub fn add_project(config_file: &Path, path: &str) -> Result { + let _write = config_write_lock(); + let canonical = crate::paths::canonical_project_directory(Path::new(path))?; + let path = canonical + .to_str() + .ok_or_else(|| AppError::validation("project path must be valid UTF-8"))? + .to_owned(); + let mut config = load_config(config_file)?; + let project = match config.projects.iter().find(|project| project.path == path) { + Some(project) => project.clone(), + None => { + let project = ProjectFolder { + id: Uuid::new_v4(), + path, + }; + config.projects.push(project.clone()); + project + } + }; + config.state.selected_project_id = Some(project.id); + save_config(config_file, &config)?; + Ok(project) +} + +pub fn remove_project(config_file: &Path, id: Uuid) -> Result<(), AppError> { + let _write = config_write_lock(); + let mut config = load_config(config_file)?; + let index = config + .projects + .iter() + .position(|project| project.id == id) + .ok_or_else(|| AppError::not_found("project not found"))?; + config.projects.remove(index); + if config.state.selected_project_id == Some(id) { + config.state.selected_project_id = None; + } + save_config(config_file, &config) +} + +pub fn select_project(config_file: &Path, id: Option) -> Result<(), AppError> { + let _write = config_write_lock(); + let mut config = load_config(config_file)?; + if id.is_some_and(|id| !config.projects.iter().any(|project| project.id == id)) { + return Err(AppError::not_found("project not found")); + } + config.state.selected_project_id = id; + save_config(config_file, &config) +} + +// ponytail: one app-config writer; split by path only if write contention matters. +static CONFIG_WRITE: Mutex<()> = Mutex::new(()); + +pub(crate) fn config_write_lock() -> MutexGuard<'static, ()> { + CONFIG_WRITE + .lock() + .unwrap_or_else(|error| error.into_inner()) +} + +/// Publishes the application config atomically without recovery backups. pub fn save_config(path: &Path, config: &AppConfig) -> Result<(), AppError> { let data = serde_json::to_vec_pretty(config) .map_err(|error| AppError::validation(format!("application config: {error}")))?; - crate::document::write_file(path, &data) + let target = match fs::canonicalize(path) { + Ok(target) => target, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + crate::resource_file::absent(path)?; + path.to_owned() + } + Err(error) => return Err(AppError::io("resolve application config", path, error)), + }; + let directory = target + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + fs::create_dir_all(directory).map_err(|error| AppError::io("create", directory, error))?; + let permissions = match fs::metadata(&target) { + Ok(metadata) if metadata.is_file() && !metadata.permissions().readonly() => { + Some(metadata.permissions()) + } + Ok(_) => { + return Err(AppError::configuration(format!( + "application config is not a writable regular file: {}", + path.display() + ))) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(AppError::io("inspect application config", path, error)), + }; + #[cfg(unix)] + let directory_handle = + fs::File::open(directory).map_err(|error| AppError::io("open", directory, error))?; + let temporary = directory.join(format!(".config-{}.pending", Uuid::new_v4())); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| AppError::io("create", &temporary, error))?; + let result = (|| { + file.write_all(&data) + .map_err(|error| AppError::io("write", &temporary, error))?; + if let Some(permissions) = permissions { + file.set_permissions(permissions) + .map_err(|error| AppError::io("set permissions", &temporary, error))?; + } + file.sync_all() + .map_err(|error| AppError::io("sync", &temporary, error))?; + drop(file); + fs::rename(&temporary, &target) + .map_err(|error| AppError::io("publish application config", path, error))?; + #[cfg(unix)] + if directory_handle.sync_all().is_err() { + // Publication committed; callers must not roll back a successful save. + eprintln!("Application config saved, but directory sync failed"); + } + Ok(()) + })(); + let _ = fs::remove_file(&temporary); + result } #[cfg(test)] diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs index 1ce4985..ee55cf8 100644 --- a/src-tauri/src/models/tests.rs +++ b/src-tauri/src/models/tests.rs @@ -11,6 +11,9 @@ fn legacy_config_without_preferences_yields_defaults() { assert_eq!(config.state.preferences, AppPreferences::default()); assert_eq!(config.state.preferences.theme, ThemePreference::Dark); assert_eq!(config.state.preferences.locale, LocalePreference::En); + assert!(!config.state.island_enabled); + assert!(config.projects.is_empty()); + assert_eq!(config.state.selected_project_id, None); } #[test] @@ -38,6 +41,31 @@ fn default_config_serializes_preferences() { value["state"]["preferences"], json!({ "theme": "dark", "locale": "en" }) ); + assert_eq!(value["projects"], json!([])); + assert_eq!(value["state"]["selectedProjectId"], Value::Null); + assert_eq!(value["state"]["islandEnabled"], false); +} + +#[test] +fn project_selection_roundtrip_preserves_frozen_group_schema() { + let id = Uuid::new_v4(); + let config: AppConfig = serde_json::from_value(json!({ + "projects": [{"id": id, "path": "/project"}], + "state": {"selectedGroupID": id, "selectedProjectId": id} + })) + .unwrap(); + let encoded = serde_json::to_value(&config).unwrap(); + assert_eq!(encoded["state"]["selectedGroupID"], json!(id)); + assert_eq!(encoded["state"]["selectedProjectId"], json!(id)); + assert!(encoded["projects"][0].get("name").is_none()); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + config + ); + assert!(serde_json::from_value::( + json!({"projects": [{"id":"invalid", "path":"/project"}]}) + ) + .is_err()); } #[test] @@ -45,3 +73,188 @@ fn empty_object_deserializes_to_defaults() { let config: AppConfig = serde_json::from_value(json!({})).expect("empty object"); assert_eq!(config, AppConfig::default()); } + +#[test] +fn island_flag_is_independent_of_preferences() { + let mut config: AppConfig = serde_json::from_value(json!({ + "state": { "islandEnabled": true } + })) + .unwrap(); + config.state.preferences = AppPreferences { + theme: ThemePreference::Light, + locale: LocalePreference::Zh, + }; + let encoded = serde_json::to_value(&config).unwrap(); + assert_eq!(encoded["state"]["islandEnabled"], true); + assert!(encoded["state"]["preferences"] + .get("islandEnabled") + .is_none()); + assert!( + serde_json::from_value::(encoded) + .unwrap() + .state + .island_enabled + ); +} + +struct ConfigDirectory(tempfile::TempDir); + +impl ConfigDirectory { + fn new() -> Self { + Self(tempfile::tempdir().unwrap()) + } + + fn path(&self) -> &Path { + self.0.path() + } +} + +#[test] +fn save_config_creates_and_replaces_without_backups() { + let directory = ConfigDirectory::new(); + let path = directory.path().join("nested/config.json"); + let mut config = AppConfig::default(); + save_config(&path, &config).unwrap(); + assert_eq!(load_config(&path).unwrap(), config); + config.state.island_enabled = true; + config.state.preferences.locale = LocalePreference::Zh; + save_config(&path, &config).unwrap(); + assert_eq!(load_config(&path).unwrap(), config); + assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); +} + +#[cfg(unix)] +#[test] +fn save_config_publishes_new_bytes_without_truncating_the_previous_file() { + use std::io::Read; + use std::os::unix::fs::PermissionsExt; + let directory = ConfigDirectory::new(); + let path = directory.path().join("config.json"); + let original = serde_json::to_vec_pretty(&AppConfig::default()).unwrap(); + fs::write(&path, &original).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + let mut previous = fs::File::open(&path).unwrap(); + let mut changed = AppConfig::default(); + changed.state.island_enabled = true; + save_config(&path, &changed).unwrap(); + let mut previous_bytes = Vec::new(); + previous.read_to_end(&mut previous_bytes).unwrap(); + assert_eq!(previous_bytes, original); + assert_eq!(load_config(&path).unwrap(), changed); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); +} + +#[cfg(unix)] +#[test] +fn save_config_staging_failure_keeps_the_previous_bytes() { + use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; + } + let directory = ConfigDirectory::new(); + let path = directory.path().join("config.json"); + let original = serde_json::to_vec_pretty(&AppConfig::default()).unwrap(); + fs::write(&path, &original).unwrap(); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o500)).unwrap(); + let mut changed = AppConfig::default(); + changed.state.island_enabled = true; + let result = save_config(&path, &changed); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + result.unwrap_err().code, + crate::error::ErrorCode::ConfigurationFailed + ); + assert_eq!(fs::read(&path).unwrap(), original); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); +} + +#[test] +fn save_config_rejects_non_regular_destination_without_artifacts() { + let directory = ConfigDirectory::new(); + let path = directory.path().join("config.json"); + fs::create_dir(&path).unwrap(); + fs::write(path.join("keep"), "retained").unwrap(); + assert_eq!( + save_config(&path, &AppConfig::default()).unwrap_err().code, + crate::error::ErrorCode::ConfigurationFailed + ); + assert_eq!(fs::read_to_string(path.join("keep")).unwrap(), "retained"); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); +} + +#[cfg(unix)] +#[test] +fn save_config_keeps_existing_symlink_and_target_permissions() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let directory = ConfigDirectory::new(); + let target = directory.path().join("target/config.json"); + fs::create_dir(target.parent().unwrap()).unwrap(); + fs::write(&target, "{}").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + let alias = directory.path().join("config.json"); + symlink(&target, &alias).unwrap(); + let mut config = AppConfig::default(); + config.state.island_enabled = true; + save_config(&alias, &config).unwrap(); + assert!(fs::symlink_metadata(&alias) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(load_config(&target).unwrap(), config); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 2); + assert_eq!(fs::read_dir(target.parent().unwrap()).unwrap().count(), 1); +} + +#[cfg(unix)] +#[test] +fn save_config_partial_write_failure_preserves_original_bytes() { + const CHILD_DIRECTORY: &str = "MOM_CONFIG_WRITE_FAULT_DIRECTORY"; + let Some(directory) = std::env::var_os(CHILD_DIRECTORY) else { + let directory = ConfigDirectory::new(); + fs::write(directory.path().join("config.json"), "{}").unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "models::tests::save_config_partial_write_failure_preserves_original_bytes", + "--nocapture", + ]) + .env(CHILD_DIRECTORY, directory.path()) + .status() + .unwrap(); + assert!(status.success()); + return; + }; + let directory = std::path::PathBuf::from(directory); + let path = directory.join("config.json"); + let original = fs::read(&path).unwrap(); + let limit = libc::rlimit { + rlim_cur: 64, + rlim_max: 64, + }; + // The file-size fault is process-local; never constrain the parent test runner. + assert_ne!( + unsafe { libc::signal(libc::SIGXFSZ, libc::SIG_IGN) }, + libc::SIG_ERR + ); + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_FSIZE, &limit) }, 0); + let error = save_config(&path, &AppConfig::default()).unwrap_err(); + assert_eq!(error.code, crate::error::ErrorCode::ConfigurationFailed); + assert!(error.message.starts_with("write ")); + assert_eq!(fs::read(&path).unwrap(), original); + assert_eq!(fs::read_dir(directory).unwrap().count(), 1); + // Control: a raw in-place write loses the original under the same fault. + assert!(fs::write( + &path, + &serde_json::to_vec_pretty(&AppConfig::default()).unwrap() + ) + .is_err()); + assert_ne!(fs::read(&path).unwrap(), original); +} diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index 384c82d..13c5a5a 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -1,5 +1,7 @@ use std::env; +use std::fs; use std::path::{Path, PathBuf}; +use uuid::Uuid; use crate::error::AppError; @@ -10,8 +12,11 @@ pub struct ConfigPaths { user_config_dir: PathBuf, opencode: PathBuf, global_config_files: Vec, + managed_config_files: Vec, + global_resource_dirs: Vec, global_skill_dirs: Vec, - working_directory: PathBuf, + agent_dirs: Vec, + project_root: Option, } impl ConfigPaths { @@ -71,12 +76,17 @@ impl ConfigPaths { let mut global_skill_dirs = vec![ home.join(".claude/skills"), home.join(".agents/skills"), + global_dir.join("skill"), global_dir.join("skills"), ]; + let mut agent_dirs = vec![global_dir.join("agent"), global_dir.join("agents")]; if let Some(directory) = &directory { global_config_files.push(directory.join("opencode.json")); global_config_files.push(directory.join("opencode.jsonc")); + global_skill_dirs.push(directory.join("skill")); global_skill_dirs.push(directory.join("skills")); + agent_dirs.push(directory.join("agent")); + agent_dirs.push(directory.join("agents")); } if let Some(config) = &config { global_config_files.push(config.clone()); @@ -90,17 +100,27 @@ impl ConfigPaths { }; deduplicate(&mut global_config_files); deduplicate(&mut global_skill_dirs); + deduplicate(&mut agent_dirs); + let mut global_resource_dirs = agent_dirs.clone(); + global_resource_dirs.extend(global_skill_dirs.iter().cloned()); let opencode = resolve_opencode_file(config, directory, &user_config_dir); Self { home, user_config_dir, opencode, + managed_config_files: global_config_files.clone(), global_config_files, + global_resource_dirs, global_skill_dirs, - working_directory, + agent_dirs, + project_root: None, } } + pub(crate) fn home_dir(&self) -> &Path { + &self.home + } + pub fn app_config_dir(&self) -> PathBuf { self.home.join(".config").join("opencode-mom") } @@ -127,6 +147,110 @@ impl ConfigPaths { self.user_config_dir.join("opencode").join("agents") } + pub fn agent_dirs(&self) -> &[PathBuf] { + &self.agent_dirs + } + + pub fn agent_config_files(&self) -> &[PathBuf] { + if self.is_project() { + &self.global_config_files + } else { + std::slice::from_ref(&self.opencode) + } + } + + pub fn is_project(&self) -> bool { + self.project_root.is_some() + } + + pub fn validate_resource_root(&self) -> Result<(), AppError> { + if let Some(root) = &self.project_root { + if canonical_project_directory(root)? != *root { + return Err(AppError::configuration(format!( + "project root changed: {}", + root.display() + ))); + } + self.ensure_project_boundary(root)?; + } + Ok(()) + } + + pub fn validate_project_directory(&self, path: &Path) -> Result { + let root = canonical_project_directory(path)?; + self.ensure_project_boundary(&root)?; + Ok(root) + } + + fn ensure_project_boundary(&self, root: &Path) -> Result<(), AppError> { + let configs = [ + "opencode.json", + "opencode.jsonc", + ".opencode/opencode.json", + ".opencode/opencode.jsonc", + ] + .map(|relative| resolved_resource_path(&root.join(relative))); + let directories = [ + ".opencode/agent", + ".opencode/agents", + ".opencode/skill", + ".opencode/skills", + ] + .map(|relative| resolved_resource_path(&root.join(relative))); + let overlap = || AppError::validation("Project resources overlap Global configuration"); + for directory in &self.global_resource_dirs { + if is_within_boundary(directory, root) + || configs + .iter() + .any(|path| is_within_boundary(directory, path)) + || directories.iter().any(|path| { + is_within_boundary(directory, path) || is_within_boundary(path, directory) + }) + { + return Err(overlap()); + } + } + for file in &self.managed_config_files { + let file = resolved_resource_path(file); + if configs.contains(&file) { + return Err(overlap()); + } + } + Ok(()) + } + + pub fn ensure_global_write(&self) -> Result<(), AppError> { + if self.is_project() { + return Err(AppError::validation("Project resources are read-only")); + } + Ok(()) + } + + pub fn for_project(&self, project_id: Option) -> Result { + let Some(id) = project_id else { + return Ok(self.clone()); + }; + let config = crate::models::load_config(&self.config_file())?; + let project = config + .projects + .iter() + .find(|project| project.id == id) + .ok_or_else(|| AppError::not_found("project not found"))?; + let root = PathBuf::from(&project.path); + let mut paths = self.clone(); + paths.global_config_files = vec![ + root.join("opencode.json"), + root.join("opencode.jsonc"), + root.join(".opencode/opencode.json"), + root.join(".opencode/opencode.jsonc"), + ]; + paths.agent_dirs = vec![root.join(".opencode/agent"), root.join(".opencode/agents")]; + paths.global_skill_dirs = vec![root.join(".opencode/skill"), root.join(".opencode/skills")]; + paths.project_root = Some(root); + paths.validate_resource_root()?; + Ok(paths) + } + pub fn global_config_files(&self) -> &[PathBuf] { &self.global_config_files } @@ -139,23 +263,74 @@ impl ConfigPaths { self.user_config_dir.join("opencode/skills") } - pub fn resolve_skill_path(&self, source: &str) -> PathBuf { + pub fn resolve_skill_path(&self, source: &str) -> Option { if let Some(relative) = source .strip_prefix("~/") .or_else(|| source.strip_prefix("~\\")) { - return self.home.join(relative); + return Some(self.home.join(relative)); } if source == "~" { - return self.home.clone(); + return Some(self.home.clone()); } let path = PathBuf::from(source); - if path.is_absolute() { - path + path.is_absolute().then_some(path) + } +} + +pub fn reject_project_write(project_id: Option) -> Result<(), AppError> { + if project_id.is_some() { + return Err(AppError::validation("Project resources are read-only")); + } + Ok(()) +} + +pub fn canonical_project_directory(path: &Path) -> Result { + if !path.is_absolute() { + return Err(AppError::validation("project path must be absolute")); + } + let root = fs::canonicalize(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + AppError::not_found(format!("project root not found: {}", path.display())) } else { - self.working_directory.join(path) + AppError::io("resolve project", path, error) } + })?; + if root.parent().is_none() { + return Err(AppError::validation( + "project path must not be a filesystem root", + )); + } + if !root.is_dir() { + return Err(AppError::validation("project path must be a directory")); + } + if root.to_str().is_none() { + return Err(AppError::validation("project path must be valid UTF-8")); + } + let mut entries = + fs::read_dir(&root).map_err(|error| AppError::io("read project", &root, error))?; + if let Some(entry) = entries.next() { + entry.map_err(|error| AppError::io("read project", &root, error))?; } + Ok(root) +} + +fn resolved_resource_path(path: &Path) -> PathBuf { + match fs::canonicalize(path) { + Ok(path) => path, + // ponytail: inaccessible aliases use ancestors; require resolved handles before project writes. + Err(_) => match (path.parent(), path.file_name()) { + (Some(parent), Some(name)) => resolved_resource_path(parent).join(name), + _ => path.to_owned(), + }, + } +} + +/// Checks whether `path` is within `base` directory (inclusive of base itself). +pub fn is_within_boundary(base: &Path, path: &Path) -> bool { + let resolved_base = resolved_resource_path(base); + let resolved_path = resolved_resource_path(path); + resolved_path.starts_with(&resolved_base) } /// Resolve the opencode config file the CLI actually uses: explicit `OPENCODE_CONFIG` diff --git a/src-tauri/src/paths/tests.rs b/src-tauri/src/paths/tests.rs index 5130820..5200e4f 100644 --- a/src-tauri/src/paths/tests.rs +++ b/src-tauri/src/paths/tests.rs @@ -2,10 +2,10 @@ use super::*; #[test] fn opencode_file_prefers_jsonc_then_falls_back_to_json() { - let tmp = std::env::temp_dir().join(format!("opencode-mom-paths-{}", std::process::id())); - let dir = tmp.join(".config").join("opencode"); + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".config").join("opencode"); std::fs::create_dir_all(&dir).unwrap(); - let paths = || ConfigPaths::for_home(&tmp); + let paths = || ConfigPaths::for_home(tmp.path()); // No file at all -> fall back to .json (opencode reads it too). assert_eq!(paths().opencode_file(), dir.join("opencode.json")); // Only opencode.json exists -> use it (the bug this guards against). @@ -17,12 +17,12 @@ fn opencode_file_prefers_jsonc_then_falls_back_to_json() { std::fs::write(dir.join("oh-my-opencode-slim.jsonc"), "{}").unwrap(); assert_eq!(paths().slim_file(), dir.join("oh-my-opencode-slim.json")); // Explicit OPENCODE_CONFIG always wins. - let explicit = tmp.join("custom.json"); + let explicit = tmp.path().join("custom.json"); assert_eq!( - ConfigPaths::with_parts(tmp.clone(), Some(explicit.clone()), None).opencode_file(), + ConfigPaths::with_parts(tmp.path().to_path_buf(), Some(explicit.clone()), None) + .opencode_file(), explicit ); - std::fs::remove_dir_all(&tmp).unwrap(); } #[test] @@ -48,7 +48,7 @@ fn arbitrary_explicit_config_is_last_and_relative_paths_use_working_directory() explicit, ] ); - assert_eq!(paths.resolve_skill_path("relative"), cwd.join("relative")); + assert_eq!(paths.resolve_skill_path("relative"), None); } #[test] @@ -76,3 +76,51 @@ fn duplicate_explicit_source_keeps_its_last_precedence() { 1 ); } + +#[test] +fn missing_project_root_is_not_found_but_files_are_invalid() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("file"); + fs::write(&file, "not a directory").unwrap(); + assert_eq!( + canonical_project_directory(&root.join("missing")) + .unwrap_err() + .code, + crate::error::ErrorCode::NotFound + ); + assert_eq!( + canonical_project_directory(&file).unwrap_err().code, + crate::error::ErrorCode::ValidationFailed + ); +} + +#[test] +fn filesystem_root_is_invalid_without_imposing_a_project_depth_requirement() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let canonical = fs::canonicalize(root).unwrap(); + let volume_root = canonical.ancestors().last().unwrap(); + assert_eq!( + canonical_project_directory(volume_root).unwrap_err().code, + crate::error::ErrorCode::ValidationFailed + ); + assert_eq!(canonical_project_directory(root).unwrap(), canonical); +} + +#[test] +fn is_within_boundary_detects_descendant_and_escapes() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let child = root.join("child/sub"); + fs::create_dir_all(&child).unwrap(); + + assert!(is_within_boundary(root, &child)); + assert!(is_within_boundary(root, root)); + + let outside = tmp.path().parent().unwrap(); + assert!(!is_within_boundary(root, outside)); + + let escape = root.join("../escape"); + assert!(!is_within_boundary(root, &escape)); +} diff --git a/src-tauri/src/projection.rs b/src-tauri/src/projection.rs index 298c92c..5759e35 100644 --- a/src-tauri/src/projection.rs +++ b/src-tauri/src/projection.rs @@ -16,7 +16,7 @@ pub fn project_slim_active( &mut document, &group.name, group.slim_agent_overrides.as_deref(), - ); + )?; document.patch(&["preset"], Some(Value::String(group.name.clone())))?; Ok(document) } @@ -28,7 +28,7 @@ pub fn update_slim_preset( overrides: Option<&[AgentModelBinding]>, ) -> Result { let mut document = source.clone(); - merge_slim_preset(&mut document, name, overrides); + merge_slim_preset(&mut document, name, overrides)?; Ok(document) } @@ -77,7 +77,11 @@ fn active_slim_preset(document: &JsoncDoc) -> Option<&str> { document.raw().get("preset").and_then(Value::as_str) } -fn merge_slim_preset(document: &mut JsoncDoc, name: &str, overrides: Option<&[AgentModelBinding]>) { +fn merge_slim_preset( + document: &mut JsoncDoc, + name: &str, + overrides: Option<&[AgentModelBinding]>, +) -> Result<(), crate::error::AppError> { let bindings: Vec<(&str, Value)> = overrides .unwrap_or_default() .iter() @@ -88,17 +92,14 @@ fn merge_slim_preset(document: &mut JsoncDoc, name: &str, overrides: Option<&[Ag .collect(); if bindings.is_empty() { if slim_preset(document, name).is_none() { - document - .patch(&["presets", name], Some(Value::Object(Map::new()))) - .expect("a parsed JSONC document accepts a preset creation"); + document.patch(&["presets", name], Some(Value::Object(Map::new())))?; } - return; + return Ok(()); } for (agent, value) in bindings { - document - .patch(&["presets", name, agent], Some(value)) - .expect("a parsed JSONC document accepts a preset patch"); + document.patch(&["presets", name, agent], Some(value))?; } + Ok(()) } fn binding_value(binding: &AgentModelBinding) -> Value { diff --git a/src-tauri/src/providers.rs b/src-tauri/src/providers.rs index ab2a609..bf057f3 100644 --- a/src-tauri/src/providers.rs +++ b/src-tauri/src/providers.rs @@ -82,8 +82,15 @@ pub fn is_custom_provider(opencode_file: &Path, provider_id: &str) -> Result Result<(), AppError> { - if !is_custom_provider(opencode_file, provider_id)? { +fn ensure_custom_provider_in_doc( + root: &Map, + provider_id: &str, +) -> Result<(), AppError> { + let is_custom = root + .get("providers") + .and_then(Value::as_object) + .map_or(false, |providers| providers.contains_key(provider_id)); + if !is_custom { return Err(AppError::validation(format!( "model operations are only allowed for custom providers: {provider_id}" ))); @@ -112,7 +119,7 @@ pub fn create_provider( return Err(AppError::validation("provider package is required")); } let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; - let providers = providers_from_root(&document.raw().clone())?; + let providers = providers_from_root(document.raw())?; if providers.iter().any(|item| item.name == provider.name) { return Err(AppError::validation(format!( "provider already exists: {}", @@ -133,7 +140,7 @@ pub fn update_provider( ) -> Result { validate_provider(&provider)?; let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; - let providers = providers_from_root(&document.raw().clone())?; + let providers = providers_from_root(document.raw())?; let current = providers .iter() .find(|item| item.name == provider.name) @@ -151,13 +158,13 @@ pub fn update_provider( pub fn delete_provider(opencode_file: &Path, provider_id: &str) -> Result<(), AppError> { validate_id(provider_id, IdKind::Provider)?; - let provider = get_provider(opencode_file, provider_id)?; + let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; + let provider = provider_from_root_value(document.raw(), provider_id)?; if !provider.models.is_empty() { return Err(AppError::validation(format!( "provider still contains models: {provider_id}" ))); } - let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; document.patch(&["providers", provider_id], None)?; document.save(opencode_file)?; Ok(()) @@ -171,13 +178,9 @@ pub fn create_model( validate_id(provider_id, IdKind::Provider)?; validate_id(&model.id, IdKind::Model)?; let model = with_model_name(model); - ensure_custom_provider(opencode_file, provider_id)?; let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; - let providers = providers_from_root(&document.raw().clone())?; - let provider = providers - .iter() - .find(|item| item.name == provider_id) - .ok_or_else(|| AppError::not_found(format!("provider not found: {provider_id}")))?; + ensure_custom_provider_in_doc(document.raw(), provider_id)?; + let provider = provider_from_root_value(document.raw(), provider_id)?; if provider.models.contains_key(&model.id) { return Err(AppError::validation(format!( "model already exists: {}/{}", @@ -204,19 +207,19 @@ pub fn update_model( model.id ))); } - ensure_custom_provider(opencode_file, &model_ref.provider_id)?; let model = with_model_name(model); let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; - ensure_model_exists(&document.raw(), model_ref)?; + ensure_custom_provider_in_doc(document.raw(), &model_ref.provider_id)?; + ensure_model_exists(document.raw(), model_ref)?; apply_patches(&mut document, model_field_patches(model_ref, &model))?; document.save(opencode_file)?; Ok(model) } pub fn delete_model(opencode_file: &Path, model_ref: &ModelRef) -> Result<(), AppError> { - ensure_custom_provider(opencode_file, &model_ref.provider_id)?; let mut document = JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?; - ensure_model_exists(&document.raw(), model_ref)?; + ensure_custom_provider_in_doc(document.raw(), &model_ref.provider_id)?; + ensure_model_exists(document.raw(), model_ref)?; let path = model_path_vec(model_ref); let segments: Vec<&str> = path.iter().map(String::as_str).collect(); document.patch(&segments, None)?; @@ -396,19 +399,45 @@ pub fn model_value(model: &ModelDef) -> Value { } fn provider_field_patches(provider: &ProviderDef) -> Vec<(Vec, Option)> { - let base = vec!["providers".to_owned(), provider.name.clone()]; - [ - ("package", provider.package.clone().map(Value::String)), - ("settings", provider.settings.clone().and_then(to_value)), - ("headers", provider.headers.clone().and_then(to_value)), - ] - .into_iter() - .map(|(field, value)| { - let mut path = base.clone(); - path.push(field.to_owned()); - (path, value) - }) - .collect() + let mut patches = vec![ + ( + vec![ + "providers".to_owned(), + provider.name.clone(), + "package".to_owned(), + ], + provider.package.clone().map(Value::String), + ), + ( + vec![ + "providers".to_owned(), + provider.name.clone(), + "headers".to_owned(), + ], + provider.headers.clone().and_then(to_value), + ), + ]; + if let Some(ref s) = provider.settings { + patches.push(( + vec![ + "providers".to_owned(), + provider.name.clone(), + "settings".to_owned(), + "apiKey".to_owned(), + ], + s.api_key.clone().map(Value::String), + )); + patches.push(( + vec![ + "providers".to_owned(), + provider.name.clone(), + "settings".to_owned(), + "baseURL".to_owned(), + ], + s.base_url.clone().map(Value::String), + )); + } + patches } const MODEL_FIELDS: &[&str] = &[ @@ -522,3 +551,88 @@ fn ensure_model_exists(root: &Map, model_ref: &ModelRef) -> Resul ))) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + struct TempDir(std::path::PathBuf); + impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir() + .join(format!("opencode-mom-provider-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn update_provider_preserves_unknown_settings_fields() { + let temp_dir = TempDir::new(); + let opencode_file = temp_dir.0.join("opencode.jsonc"); + fs::write( + &opencode_file, + r#"{ + "providers": { + "custom_llm": { + "package": "@pkg/old", + "settings": { + "apiKey": "sk-12345", + "baseURL": "https://old.api.com", + "vendorExtra": { + "customOption": "preserved_value" + } + }, + "models": {} + } + } +}"#, + ) + .unwrap(); + + let mut provider = get_provider(&opencode_file, "custom_llm").unwrap(); + provider.package = Some("@pkg/new".to_owned()); + update_provider(&opencode_file, provider).unwrap(); + + let updated_doc = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA).unwrap(); + let raw = updated_doc.raw(); + let provider_val = raw + .get("providers") + .and_then(Value::as_object) + .and_then(|p| p.get("custom_llm")) + .and_then(Value::as_object) + .unwrap(); + + assert_eq!( + provider_val.get("package").and_then(Value::as_str), + Some("@pkg/new") + ); + + let settings = provider_val + .get("settings") + .and_then(Value::as_object) + .unwrap(); + assert_eq!( + settings.get("apiKey").and_then(Value::as_str), + Some("sk-12345") + ); + assert_eq!( + settings.get("baseURL").and_then(Value::as_str), + Some("https://old.api.com") + ); + let vendor_extra = settings + .get("vendorExtra") + .and_then(Value::as_object) + .unwrap(); + assert_eq!( + vendor_extra.get("customOption").and_then(Value::as_str), + Some("preserved_value") + ); + } +} diff --git a/src-tauri/src/refs.rs b/src-tauri/src/refs.rs index 50a0784..5f2acc0 100644 --- a/src-tauri/src/refs.rs +++ b/src-tauri/src/refs.rs @@ -1,11 +1,15 @@ use serde_json::Value; use crate::agents::{self, AgentReference, AgentReferenceIndex, AgentReferenceKind}; +use crate::document::JsoncDoc; use crate::error::AppError; use crate::models::AppConfig; use crate::paths::ConfigPaths; use crate::providers::ModelRef; +// OMO stores OpenCode-specific mappings under the literal `opencode` object. +const OMO_OPENCODE_BLOCK: &str = "opencode"; + /// Where a model reference was found. Displayed to users when deletions are blocked. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ReferenceSource { @@ -118,10 +122,89 @@ pub fn collect_model_references(paths: &ConfigPaths) -> Result Result, AppError> { + let mut references = Vec::new(); + if let Some(content) = crate::document::read_text(&paths.slim_file())? { + let document = JsoncDoc::parse(&content)?; + scan_slim(&document, &mut references)?; + } + if let Some(content) = crate::document::read_text(&paths.omo_file())? { + let document = JsoncDoc::parse(&content)?; + scan_omo(&document, &mut references)?; + } + references.sort(); + Ok(references) +} + +fn scan_slim(document: &JsoncDoc, references: &mut Vec) -> Result<(), AppError> { + let Some(presets) = document.raw().get("presets") else { + return Ok(()); + }; + let presets = presets + .as_object() + .ok_or_else(|| AppError::validation("Slim presets must be an object"))?; + for (preset, entries) in presets { + let entries = entries.as_object().ok_or_else(|| { + AppError::validation(format!("Slim presets.{preset} must be an object")) + })?; + for (agent, entry) in entries { + let entry = entry.as_object().ok_or_else(|| { + AppError::validation(format!("Slim presets.{preset}.{agent} must be an object")) + })?; + if let Some(model) = entry.get("model").and_then(Value::as_str) { + if !model.is_empty() { + references.push(ModelReference { + source: ReferenceSource::SlimPreset, + location: format!("presets.{preset}.{agent}.model"), + model_ref: ModelRef::parse(model)?, + }); + } + } + } + } + Ok(()) +} + +fn scan_omo(document: &JsoncDoc, references: &mut Vec) -> Result<(), AppError> { + let Some(opencode) = document.raw().get(OMO_OPENCODE_BLOCK) else { + return Ok(()); + }; + let opencode = opencode + .as_object() + .ok_or_else(|| AppError::validation("OMO opencode must be an object"))?; + for (key, source) in [ + ("agents", ReferenceSource::OmoAgent), + ("categories", ReferenceSource::OmoCategory), + ] { + let Some(entries) = opencode.get(key) else { + continue; + }; + let entries = entries + .as_object() + .ok_or_else(|| AppError::validation(format!("OMO opencode.{key} must be an object")))?; + for (name, entry) in entries { + let entry = entry.as_object().ok_or_else(|| { + AppError::validation(format!("OMO opencode.{key}.{name} must be an object")) + })?; + if let Some(model) = entry.get("model").and_then(Value::as_str) { + if !model.is_empty() { + references.push(ModelReference { + source, + location: format!("{OMO_OPENCODE_BLOCK}.{key}.{name}.model"), + model_ref: ModelRef::parse(model)?, + }); + } + } + } + } + Ok(()) +} + fn add_group_references( references: &mut Vec, bindings: impl IntoIterator, @@ -295,6 +378,9 @@ fn scan_permission_references( } } for (key, nested) in object { + if owner_prefix.is_empty() && key == "agents" { + continue; + } let nested_prefix = if owner_prefix.is_empty() { key.clone() } else { @@ -304,18 +390,64 @@ fn scan_permission_references( } } -/// Convenience wrapper used by provider deletion protection. -pub fn provider_is_referenced( - paths: &ConfigPaths, - provider_id: &str, -) -> Result, AppError> { - Ok(collect_model_references(paths)?.for_provider(provider_id)) -} +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + struct TestEnv { + home: PathBuf, + paths: ConfigPaths, + } + + impl TestEnv { + fn new() -> Self { + let home = std::env::temp_dir() + .join(format!("opencode-mom-refs-test-{}", uuid::Uuid::new_v4())); + let paths = ConfigPaths::for_home(&home); + fs::create_dir_all(paths.config_file().parent().unwrap()).unwrap(); + fs::create_dir_all(paths.opencode_file().parent().unwrap()).unwrap(); + Self { home, paths } + } + } -/// Convenience wrapper used by model deletion protection. -pub fn model_is_referenced( - paths: &ConfigPaths, - model_ref: &ModelRef, -) -> Result, AppError> { - Ok(collect_model_references(paths)?.for_model(model_ref)) + impl Drop for TestEnv { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.home); + } + } + + #[test] + fn inline_agent_with_subagent_permission_produces_single_reference() { + let env = TestEnv::new(); + fs::write(env.paths.config_file(), r#"{"groups":[]}"#).unwrap(); + let opencode_content = serde_json::json!({ + "agents": { + "caller": { + "permissions": [ + { + "action": "subagent", + "resource": "worker" + } + ] + } + } + }); + fs::write(env.paths.opencode_file(), opencode_content.to_string()).unwrap(); + + let references = + collect_agent_references(&env.paths).expect("collect agent references succeeds"); + let worker_refs = references + .get("worker") + .expect("worker should be referenced"); + + assert_eq!( + worker_refs.len(), + 1, + "worker should only be referenced once: {worker_refs:?}" + ); + assert_eq!(worker_refs[0].owner, "agents.caller.inline.permissions[0]"); + assert_eq!(worker_refs[0].kind, AgentReferenceKind::PermissionTask); + } } diff --git a/src-tauri/src/replacement.rs b/src-tauri/src/replacement.rs deleted file mode 100644 index f04410d..0000000 --- a/src-tauri/src/replacement.rs +++ /dev/null @@ -1,96 +0,0 @@ -use serde_json::Value; - -use crate::document::JsoncDoc; -use crate::error::AppError; -use crate::paths::ConfigPaths; -use crate::providers::ModelRef; - -// OMO stores OpenCode-specific mappings under the literal `opencode` object. -const OMO_OPENCODE_BLOCK: &str = "opencode"; - -/// Scans Slim and OMO files for model references used by deletion protection. -pub fn scan_plugin_references( - paths: &ConfigPaths, -) -> Result, AppError> { - let mut references = Vec::new(); - if let Some(content) = crate::document::read_text(&paths.slim_file())? { - let document = JsoncDoc::parse(&content)?; - scan_slim(&document, &mut references)?; - } - if let Some(content) = crate::document::read_text(&paths.omo_file())? { - let document = JsoncDoc::parse(&content)?; - scan_omo(&document, &mut references)?; - } - references.sort(); - Ok(references) -} - -fn scan_slim( - document: &JsoncDoc, - references: &mut Vec, -) -> Result<(), AppError> { - let Some(presets) = document.raw().get("presets") else { - return Ok(()); - }; - let presets = presets - .as_object() - .ok_or_else(|| AppError::validation("Slim presets must be an object"))?; - for (preset, entries) in presets { - let entries = entries.as_object().ok_or_else(|| { - AppError::validation(format!("Slim presets.{preset} must be an object")) - })?; - for (agent, entry) in entries { - let entry = entry.as_object().ok_or_else(|| { - AppError::validation(format!("Slim presets.{preset}.{agent} must be an object")) - })?; - if let Some(model) = entry.get("model").and_then(Value::as_str) { - if !model.is_empty() { - references.push(crate::refs::ModelReference { - source: crate::refs::ReferenceSource::SlimPreset, - location: format!("presets.{preset}.{agent}.model"), - model_ref: ModelRef::parse(model)?, - }); - } - } - } - } - Ok(()) -} - -fn scan_omo( - document: &JsoncDoc, - references: &mut Vec, -) -> Result<(), AppError> { - let Some(opencode) = document.raw().get(OMO_OPENCODE_BLOCK) else { - return Ok(()); - }; - let opencode = opencode - .as_object() - .ok_or_else(|| AppError::validation("OMO opencode must be an object"))?; - for (key, source) in [ - ("agents", crate::refs::ReferenceSource::OmoAgent), - ("categories", crate::refs::ReferenceSource::OmoCategory), - ] { - let Some(entries) = opencode.get(key) else { - continue; - }; - let entries = entries - .as_object() - .ok_or_else(|| AppError::validation(format!("OMO opencode.{key} must be an object")))?; - for (name, entry) in entries { - let entry = entry.as_object().ok_or_else(|| { - AppError::validation(format!("OMO opencode.{key}.{name} must be an object")) - })?; - if let Some(model) = entry.get("model").and_then(Value::as_str) { - if !model.is_empty() { - references.push(crate::refs::ModelReference { - source, - location: format!("{OMO_OPENCODE_BLOCK}.{key}.{name}.model"), - model_ref: ModelRef::parse(model)?, - }); - } - } - } - } - Ok(()) -} diff --git a/src-tauri/src/skills.rs b/src-tauri/src/skills.rs index 9eecfaa..8114321 100644 --- a/src-tauri/src/skills.rs +++ b/src-tauri/src/skills.rs @@ -160,6 +160,7 @@ fn discover_until( cache: Option<&Mutex>, deadline: Instant, ) -> Result<(SkillList, BTreeMap), AppError> { + paths.validate_resource_root()?; let mut result = SkillList::default(); let mut entries = BTreeMap::new(); let mut snapshots = BTreeMap::new(); @@ -202,9 +203,9 @@ fn discover_until( } } } - } else { + } else if let Some(root) = paths.resolve_skill_path(&source) { scan_root( - &paths.resolve_skill_path(&source), + &root, only, &mut entries, &mut snapshots, @@ -259,6 +260,7 @@ fn validate_content(content: &str) -> Result<(), AppError> { } pub fn create(paths: &ConfigPaths, draft: SkillDraft) -> Result { + paths.ensure_global_write()?; create_inner(paths, draft, |_| Ok(())) } @@ -307,6 +309,7 @@ fn create_inner( } pub fn update(paths: &ConfigPaths, draft: SkillUpdate) -> Result { + paths.ensure_global_write()?; update_inner(paths, draft, || {}) } @@ -373,6 +376,9 @@ fn update_inner( } fn configured_sources(paths: &ConfigPaths, diagnostics: &mut Vec) -> Vec { + if paths.is_project() { + return Vec::new(); + } let mut sources = Vec::new(); for path in paths.global_config_files() { let parsed = (|| { diff --git a/src-tauri/src/skills/remote_tests.rs b/src-tauri/src/skills/remote_tests.rs index c6974cc..5db826b 100644 --- a/src-tauri/src/skills/remote_tests.rs +++ b/src-tauri/src/skills/remote_tests.rs @@ -327,7 +327,7 @@ fn create_final_discovery_bypasses_warm_cache_and_removes_owned_directory_on_sou } "configured source" => { home.write("cwd/last/Wanted.md", "configured winner"); - home.configure(&[&server.url, "last"]); + home.configure(&[&server.url, "~/cwd/last"]); } _ => unreachable!(), } From 821c90448641e3b2eee4ff95015c5548129887cf Mon Sep 17 00:00:00 2001 From: seho Date: Tue, 20 Oct 2026 23:12:43 +0800 Subject: [PATCH 55/97] feat(backend/cli): add service target management, installation check, and token usage transport --- src-tauri/src/opencode_cli/AGENTS.md | 57 +- src-tauri/src/opencode_cli/active.rs | 83 +- src-tauri/src/opencode_cli/active/tests.rs | 204 ++-- src-tauri/src/opencode_cli/binary.rs | 104 +- src-tauri/src/opencode_cli/installation.rs | 885 ++++++++++++++++++ .../src/opencode_cli/installation/tests.rs | 866 +++++++++++++++++ src-tauri/src/opencode_cli/mod.rs | 5 + src-tauri/src/opencode_cli/models.rs | 135 +-- src-tauri/src/opencode_cli/models/tests.rs | 47 + src-tauri/src/opencode_cli/reload.rs | 36 +- src-tauri/src/opencode_cli/reload/tests.rs | 39 +- src-tauri/src/opencode_cli/target.rs | 558 +++++++++++ src-tauri/src/opencode_cli/target/tests.rs | 804 ++++++++++++++++ src-tauri/src/opencode_cli/target/windows.rs | 215 +++++ src-tauri/src/opencode_cli/token_usage.rs | 30 +- .../src/opencode_cli/token_usage/tests.rs | 19 +- .../src/opencode_cli/token_usage/transport.rs | 385 +++++++- .../token_usage/transport/tests.rs | 331 ++++++- src-tauri/src/power/AGENTS.md | 13 +- src-tauri/src/power/mod.rs | 242 +++-- src-tauri/src/power/tests.rs | 451 ++++++++- 21 files changed, 5092 insertions(+), 417 deletions(-) create mode 100644 src-tauri/src/opencode_cli/installation.rs create mode 100644 src-tauri/src/opencode_cli/installation/tests.rs create mode 100644 src-tauri/src/opencode_cli/models/tests.rs create mode 100644 src-tauri/src/opencode_cli/target.rs create mode 100644 src-tauri/src/opencode_cli/target/tests.rs create mode 100644 src-tauri/src/opencode_cli/target/windows.rs diff --git a/src-tauri/src/opencode_cli/AGENTS.md b/src-tauri/src/opencode_cli/AGENTS.md index 577205f..ad69273 100644 --- a/src-tauri/src/opencode_cli/AGENTS.md +++ b/src-tauri/src/opencode_cli/AGENTS.md @@ -2,7 +2,7 @@ ## Overview -Integrates with the local OpenCode CLI for binary discovery, model catalogs, reload, and running-session counts. Token usage bootstraps the managed V2 service when needed, then scans authenticated local HTTP pages with shared deadlines, bounded metadata, and fork-aware usage attribution. `mod.rs` exposes the functions and DTOs consumed by backend commands and the power monitor. +Integrates with the CLI for binary discovery, installation inspection and explicit stable V2 upgrades. One selected local service powers model catalogs, reload, active counts, usage and Mini Island: validated Shared Service by default, or an explicit fixed loopback V2 endpoint without fallback/bootstrap. Usage scans bounded HTTP pages with fork-aware attribution; Island uses the same pinned transport asynchronously. ## Directory Structure @@ -12,17 +12,28 @@ opencode_cli/ # Local CLI and managed-service integration ├── mod.rs # Public function and DTO re-exports ├── binary.rs # Ordered binary candidates and resolver ├── command.rs # Deadline- and byte-bounded stdout collector -├── models.rs # CLI model refs merged with local provider metadata -├── reload.rs # CLI reload and exit-status errors +├── installation.rs # Local ownership detection, stable V2 checks and guarded upgrades +├── installation/ # Installation test submodule +│ └── tests.rs # Fake binaries/managers/installers, HTTP bounds and upgrade guards +├── target.rs # Endpoint persistence, revision, short transitions and in-flight reservations +├── target/ # URL/password/private-file and monitor transition tests +├── models.rs # V2 model API refs merged with local provider metadata +├── models/ # Catalog identity and configured disabled metadata tests +├── reload.rs # Bounded non-retried HTTP reload ├── active.rs # Strict V2 running-session map parser +├── island.rs # Crate-private DTOs, async connection and guarded writes +├── island/ # Bounded Mini Island V2 API lane +│ ├── projection.rs # Recent roots, active children, pending forms and public text +│ ├── events.rs # Bounded SSE frames and native event classification +│ └── tests.rs # Synthetic HTTP, DTO/privacy, answer guards and SSE ├── token_usage.rs # Paging, budgets, worker scan, fork attribution ├── command/ # Collector test submodule │ └── tests.rs # EOF, timeout, limits, child ownership, FD cleanup ├── reload/ # Reload test submodule -│ └── tests.rs # Nonzero-status reporting +│ └── tests.rs # 204 success, private errors and no retries ├── active/ # Active-session test submodule │ └── tests.rs # Shape validation, redaction, count and bounds -└── token_usage/ # Usage tests and private HTTP transport +└── token_usage/ # Usage tests and internally shared HTTP transport ├── tests.rs # Paging, forks, limits, fake HTTP, ignored live scan ├── transport.rs # Registration, identity, bootstrap, HTTP requests └── transport/ # Transport test submodule @@ -33,31 +44,43 @@ opencode_cli/ # Local CLI and managed-service integration | Responsibility | File | Description | | --- | --- | --- | -| Public boundary | `mod.rs` | Re-exports CLI operations, `ModelCatalogEntry`, and `TokenUsageRecord`. | +| Public boundary | `mod.rs` | Re-exports CLI operations, `ModelCatalogEntry`, `TokenUsageRecord`, `OpenCodeInstallation`, and `OpenCodeUpdateCheck`; installation operations stay crate-private. | | Binary discovery | `binary.rs` | `opencode_candidates` and `resolve_opencode_binary`; final bare command resolves through PATH. | +| Installation | `installation.rs` | Inspects exact executable ownership without service access; checks the fixed stable V2 endpoint and explicitly runs guarded native upgrades for proven Unix curl/npm/bun/pnpm/yarn installations. | | Bounded collection | `command.rs` | `collect_output` drains stdout without blocking indefinitely and reaps only its direct child. | -| Model catalog | `models.rs` | Combines CLI refs with configured models; local config supplies metadata and provider filtering. | -| Reload | `reload.rs` | Tries candidates until spawn succeeds; unsuccessful exit prefers stderr, then stdout. | -| Active work | `active.rs` | `opencode api session.active`, strict map validation, and running-session count. | +| Service target | `target.rs` | Captured private path/registration/directory, atomic saves with Unix 0600 plaintext or Windows current-account DPAPI credentials, normalized loopback URLs, password-retention rules, revisions and monitor transition guards. | +| Model catalog | `models.rs` | GET `/api/model` with captured `location[directory]`, enabled `providerID/modelID` refs plus configured models/metadata. | +| Reload | `reload.rs` | POST `/api/location/reload`, bounded five-second dispatch, 204 success, no retry. | +| Active work | `active.rs` | GET `/api/session/active`, shared strict map/object parser and running-session count. | +| Mini Island | `island.rs`, `island/` | Async managed V2 requests, public camelCase DTOs, bounded snapshots/messages, guarded writes and live-only SSE. | | Usage attribution | `token_usage.rs` | `token_usage_records_via_cli` scans timelines and returns only `{time, input, output}`. | -| Managed transport | `token_usage/transport.rs` | Validated registration, Basic auth, health identity checks, optional CLI bootstrap, bounded HTTP pages. | +| Managed transport | `token_usage/transport.rs` | Validated registration, Basic auth, health identity checks, optional CLI bootstrap, bounded HTTP pages and credential-private async RequestBuilder. | ## Conventions - **Candidate order**: Reuse `opencode_candidates`: existing trimmed `OPENCODE_BIN`, known install paths, then bare `opencode`. Skip missing explicit paths but still try the final PATH candidate. -- **Fallback behavior**: Active sessions, reload, and bootstrap stop candidate fallback after a successful spawn, even if the operation then fails; model listing also retries command failures. The resolver checks existence, not executable validity. +- **Installation safety**: An invalid explicit `OPENCODE_BIN` never falls back for installation operations. Automatic upgrade requires strictly proven Unix curl/npm/bun/pnpm/yarn ownership and current stable V2 >=2.0.25; Windows/Homebrew/Vite Plus/older V2/unproven/manual/bundled sources remain manual. Execute only `selected_binary upgrade --method ` with expected path/version and latest stable target guards, reject concurrent upgrades with `busy`, revalidate ownership before dispatch and read back the same path. Never use sudo, upgrade bundled binaries, or start/stop/restart the OpenCode service; CLI version is not running backend version. Installer cleanup signals only its isolated process group. Tests use fake processes and local HTTP, never real upgrades. +- **Target authority**: Initialize `target` before power/Island startup. Missing private config selects Shared; invalid/unreadable/nonregular/oversized config fails closed without crashing management. Fixed URLs accept only HTTP literal 127.0.0.1/[::1] with positive explicit port or exact localhost normalized without DNS. No credentials/path/query/fragment, remote hosts, shortened/numeric loopback, fallback or explicit bootstrap. Status probes never bootstrap. +- **Target credentials**: Persist the endpoint and backend-only credential in `opencode-service.json`, outside AppConfig. Unix uses a regular account-owned 0600 plaintext file capped at 8 KiB. Windows uses numeric-byte `encryptedPassword` protected by native current-account DPAPI (32 KiB file/8 KiB ciphertext), with no plaintext fallback; credential decryption failures fail closed. Normal Shared registration credentials are handled separately. Never expose passwords to frontend DTOs/errors/logs/events. Neither Unix permissions nor DPAPI protect against account compromise, same-account malicious processes or administrators. Windows-native DPAPI tests have not run on this macOS host; native acceptance remains outside automated host tests and Windows ACL protection is not claimed verified. +- **Target saves**: Use create_new owned UUID temporary and atomic publication; sync the final Unix directory, treating post-commit sync failures as private warnings rather than leaving runtime behind disk. Null URL clears Shared/password; omitted password retains only on unchanged normalized URL, empty clears. The global transition mutex also guards PowerService::set and IslandService::set_enabled inside main-thread dispatch. Saves validate the selection, prepare pure monitor guards, persist and publish the new selection/revision, then infallibly invalidate old consumers under the same transition/monitor guards; failed preparation/persistence leaves monitors unchanged. Lock order is transition -> monitor effects/shared/cache -> target state write, dropped before commit. No native/network/await work runs under these guards; finish restarts readers/native work only after transition release and before the five-second status probe. A stale/unavailable post-save probe returns disconnected public status for the committed selection, never a failed save. Normal status/read revision checks remain fail-closed. Bootstrap/reload/Island writes acquire a runtime-counted RAII reservation under transition+revision check, then release the mutex before network/await/native dispatch; target saves return busy while reserved. Island blocking workers own their reservation even when their async caller is dropped. +- **Bootstrap fallback**: Only Shared may bootstrap missing/unreachable/identity-mismatched registration; invalid/auth/HTTP errors never bootstrap. CLI candidates stop after successful spawn even when bootstrap fails. The resolver checks existence, not executable validity. - **Collector ownership**: Bounded operations pipe stdout and null stdin/stderr. Success requires both successful exit and stdout EOF; nonzero exit discards stdout immediately. On failure, kill/reap only the direct CLI child, never a process group that might contain the service. -- **Timeout scope**: Active queries use 5 seconds/1 MiB; bootstrap uses at most 10 seconds/32 KiB within the usage deadline. `reload.rs` and `models.rs` currently use `Command::output` without collector timeout/byte bounds. +- **Timeout scope**: Active queries use 5 seconds/1 MiB; bootstrap at most 10 seconds/32 KiB within the operation deadline. Catalog uses 20 seconds overall, five-second requests/8 MiB; reload uses 20 seconds overall and five-second POST. Blocking catalog/status/saves run in spawn_blocking. - **Active-session contract**: Accept only an object envelope with a `data` map of unique `ses_` IDs with nonempty ASCII alphanumeric/underscore/hyphen suffixes and object values of type `running`. Count children too; malformed responses and nonzero exits are errors, not idle. -- **Error privacy**: Active, collector, and usage paths retain error kind/category/location or status, not response bodies, session identifiers, credentials, or raw serde/HTTP error text. Reload/model failure diagnostics intentionally include CLI stderr/stdout. -- **Registration trust**: Read the regular, at-most-8-KiB `opencode/service.json` under `XDG_STATE_HOME` or `~/.local/state`. Require a UUID, positive PID, V2 version, bounded password, and explicit-port HTTP URL on literal `127.0.0.1` or `[::1]` only. -- **Managed identity**: Use Basic auth user `opencode`, disable proxies/redirects, and match `/api/info` PID/version to registration. Bootstrap once for missing, unreachable, or identity-mismatched registration, then reread/recheck; invalid registration or unauthorized health fails closed. +- **Error privacy**: All service operations retain only error kind/category/location/status, never response bodies, identifiers, credentials or raw serde/HTTP/CLI stderr/stdout errors. Read failures are not idle. +- **Registration trust**: Read the regular, at-most-8-KiB `opencode/service.json` under absolute `XDG_STATE_HOME` or the captured ConfigPaths home `~/.local/state`, never inferred config parents. Require a UUID, positive PID, bounded valid major-2 semantic version, nonempty bounded password, and explicit-port HTTP URL on literal `127.0.0.1` or `[::1]` only. An uninitialized runtime fails closed; library tests inject runtimes/paths rather than touching live registration. +- **Managed identity**: With a password use Basic auth user `opencode`; a passwordless fixed endpoint sends no Authorization on health/read/reload/async requests. No Bearer/UI pairing inference or 401/403 fallback; only direct OpenCode backend endpoints, not UI proxies. Disable proxies/redirects/retries, strictly parse object `/api/info` with positive PID/bounded valid V2 version. Shared requires exact registration PID/version; fixed captures the probed identity without registration. Requests pin endpoint/auth/identity/revision and writes recheck health before dispatch; an external restart between health and dispatch remains unpreventable without server support. A reachable info route is not proof of protected-route access; no implicit restart for a CLI version mismatch. +- **Island transport**: Run initial connect/health/bootstrap only in `spawn_blocking`; subsequent requests/write-health/chunks are async and cancellation drops local in-flight work. Blocking bootstrap is not cancellable and keeps its reservation until actual worker completion. A cancelled POST may already have been accepted by the server; never claim withdrawal or retry. Credentials stay inside transport, async clients disable all automatic retries, write-health plus dispatch share a five-second deadline, and bodies/retained snapshots cap at 2 MiB. The domain spawns no background task. +- **Island snapshots**: Fetch only the latest 20 root candidates and the global validated running map; fetch missing active details including children. Check per-session pending permission/form lists for both active sessions and recent roots, never location-scoped global pending lists. Four in-flight reads, 15-second snapshot ceiling, 100 total sessions/pending requests; bounds fail explicitly instead of returning partial data. Public directory is `location.directory`; active state wins over last failed outcome and `connected` never determines idle. +- **Island interaction**: Keep original request/session/form IDs and field keys. Permission replies permit only `once`/`always`/`reject`; Always requires explicit user consent and rereads the per-session pending list, requiring the exact session/request identity and nonempty server-provided `save` rules. Missing save projects to `[]`; supplied rules use the same bounded strings/plaintext checks as resources. Stale, invalid, or unsavable Always fails validation before POST, never falls back to Once, and sends only `{decision}`; the server owns rule/project binding, with no custom rules or MOM permission persistence. GET and POST stay in one cancellation-owned future, never a queued background write. Send posts to `/api/session/:id/prompt` with caller `msg_` ID and queue delivery (`/message` is read-only). Duplicate message IDs return HTTP 409 as a rejected write, never success; keep the stable ID and never automatically resend. Question replies reread form detail `state.status=pending` and validate required/custom options before posting `{answer}`. Only top-level form `metadata.kind=question` string/multiselect forms are answerable; inert `hidden=false`/`when=[]` are accepted, multiselect `minItems=0/1` maps to optional/required, and other constraints fail closed. Empty optional strings/selections are omitted before option validation; required blanks remain rejected. No metadata/discriminator fallback; fieldless/unsupported forms stay read-only. Writes are never retried; failures retain generic acceptance ambiguity, not raw bodies. +- **Island public data**: Retain only `user.text` and assistant content of type `text`, excluding synthetic/hidden/internal flags at both value and metadata levels, and all reasoning/tool/provider state. Fetch one newest 20-message page, project chronological DTOs, cap each UTF-8 text at 16 KiB, and mark truncation. IDs/plaintext/date numbers are validated; malformed V1/unverified shapes fail closed. +- **Island events**: `/api/event` uses native `{id,type,created,location,data}` JSON with object `data` (the client JSON-decodes the SSE frame once), live only with no last-event replay or plugin hooks. Parse fragmented multiline/comment/CRLF frames with a 64-KiB frame cap and 2-MiB chunk cap. Native execution/session status/idle/pending lifecycles, `server.connected` recovery, and `location.shutdown` invalidate snapshots; snapshot-only session/permission/location events do not assume a shared ID shape (`session.created` owns `data.session.id`). Public text, `session.message.content.updated`, and inbox delivery require `data.sessionID` and invalidate messages, never per-token snapshots. `form.created` owns its session under `data.form`; non-session global forms are ignored. Reconnect, debounce, cache, and service lifecycle belong to the native Island consumer. - **HTTP paging**: Fetch sessions at limit 1000 and messages at limit 100, initially ascending; thereafter follow cursors until an empty page or no next cursor. Encode session IDs as path segments and cursors as query values; reject duplicate IDs and cursor loops. - **Scan limits**: Share one 60-second deadline across connection, pages, bodies, workers, and aggregation. Use at most four timeline workers, 32 MiB per page, 300,000 metadata items/64 MiB retained metadata, and 100,000 records; failures never return partial usage. - **Recorded usage**: Include recorded root `tokens.input/output` only for non-pending assistant/compaction messages, including terminal cancellation/error cases. Missing usage is not zero billing; exclude reasoning/cache fields and enforce safe JavaScript integer/date bounds. - **Fork ownership**: Validate origin chains, before/through boundaries, native copy eligibility, and copied metadata projections before excluding inherited prefixes; missing origins/boundaries, cycles, and mismatches fail the scan. -- **Catalog editing**: Keep config-provided models listed even when disabled; config metadata is authoritative. Apply the provider filter after collecting refs, not as a positional V2 CLI argument. -- **Test fixtures**: Tests are `tests.rs` submodules; active/command/reload shell fixtures are Unix-gated, while usage tests use `FakeApi` and synthetic registration/auth. Keep `process::read_only_live_scan` ignored unless explicitly reviewing and testing the managed target. +- **Catalog editing**: GET `/api/model` returns `{location,data: Model.Info[]}`; use enabled entries' `providerID/modelID`, not `id` or providerInfo. Keep configured models including disabled ones; config metadata is authoritative and provider filtering remains client-side. +- **Test fixtures**: Service tests use injected snapshots/paths, dual FakeApi servers and synthetic Basic credentials, never global environment or real API/bootstrap. Collector/bootstrap fake shells remain Unix-gated. Keep `process::read_only_live_scan` ignored. ## Anti-patterns diff --git a/src-tauri/src/opencode_cli/active.rs b/src-tauri/src/opencode_cli/active.rs index 417cb65..f9f6b4e 100644 --- a/src-tauri/src/opencode_cli/active.rs +++ b/src-tauri/src/opencode_cli/active.rs @@ -1,17 +1,14 @@ use std::collections::HashSet; use std::fmt; use std::marker::PhantomData; -use std::path::Path; -use std::process::{Child, Command, Output, Stdio}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; use crate::error::AppError; -use super::binary::opencode_candidates; -use super::command::collect_output; +use super::token_usage::transport::{self, ManagedConnection}; const ACTIVE_SESSIONS_TIMEOUT: Duration = Duration::from_secs(5); const ACTIVE_SESSIONS_MAX_BYTES: u64 = 1024 * 1024; @@ -29,7 +26,7 @@ enum ActiveSession { Running, } -struct JsonObject(T); +pub(super) struct JsonObject(pub(super) T); impl<'de, T: Deserialize<'de>> Deserialize<'de> for JsonObject { fn deserialize>(deserializer: D) -> Result { @@ -52,19 +49,26 @@ impl<'de, T: Deserialize<'de>> Deserialize<'de> for JsonObject { } fn count_running_sessions<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + running_sessions(deserializer).map(|ids| ids.len()) +} + +pub(super) fn running_sessions<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { struct RunningSessions; impl<'de> Visitor<'de> for RunningSessions { - type Value = usize; + type Value = HashSet; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("a map of running session IDs") } - fn visit_map(self, mut map: M) -> Result + fn visit_map(self, mut map: M) -> Result where M: MapAccess<'de>, { @@ -81,69 +85,34 @@ where } map.next_value::>()?; } - Ok(ids.len()) + Ok(ids) } } deserializer.deserialize_map(RunningSessions) } -fn active_sessions_command(binary: &Path) -> Command { - let mut command = Command::new(binary); - command - .args(["api", "session.active"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - command -} - -/// Queries only the server resolved by the CLI, which may auto-start its local service. +/// Queries only the selected local service; Shared mode may bootstrap through the CLI. /// This is not a machine-wide inventory of OpenCode processes or servers. pub fn active_sessions_via_cli() -> Result { - let candidates = opencode_candidates(); - let mut last_error = None; - for (index, binary) in candidates.iter().enumerate() { - if !binary.exists() && index + 1 < candidates.len() { - continue; - } - match active_sessions_command(binary).spawn() { - Ok(child) => return collect_active_sessions(child, ACTIVE_SESSIONS_TIMEOUT), - Err(error) => last_error = Some(error.kind()), - } - } - Err(AppError::configuration(format!( - "failed to run opencode CLI for session.active ({last_error:?}). Install OpenCode V2 and ensure it is on PATH, or set OPENCODE_BIN." - ))) + let deadline = Instant::now() + ACTIVE_SESSIONS_TIMEOUT; + active_sessions(&transport::connect(deadline)?, deadline) } -fn collect_active_sessions(child: Child, timeout: Duration) -> Result { - active_sessions_result(collect_output( - child, - timeout, +pub(super) fn active_sessions( + connection: &ManagedConnection, + deadline: Instant, +) -> Result { + let envelope: JsonObject = connection.get_json( + &["session", "active"], + &[], + deadline, ACTIVE_SESSIONS_MAX_BYTES, "opencode session.active", "1 MiB", - )?) -} - -fn active_sessions_result(output: Output) -> Result { - if !output.status.success() { - return Err(AppError::configuration(format!( - "opencode session.active failed ({}). This requires OpenCode V2 with the session.active API; update the CLI if this endpoint is unsupported.", - output.status - ))); - } - let envelope: JsonObject = - serde_json::from_slice(&output.stdout).map_err(|error| { - // Do not expose identifiers, response contents, or serde's untrusted error text. - AppError::configuration(format!( - "invalid opencode session.active response: expected the V2 data map of running sessions ({:?} at line {}, column {}).", - error.classify(), error.line(), error.column() - )) - })?; + )?; Ok(envelope.0.data) } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests; diff --git a/src-tauri/src/opencode_cli/active/tests.rs b/src-tauri/src/opencode_cli/active/tests.rs index cc86d3b..4c3ed75 100644 --- a/src-tauri/src/opencode_cli/active/tests.rs +++ b/src-tauri/src/opencode_cli/active/tests.rs @@ -1,95 +1,50 @@ -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::os::unix::process::ExitStatusExt; -use std::path::{Path, PathBuf}; -use std::process::{ExitStatus, Output}; use std::time::{Duration, Instant}; use serde_json::{json, Value}; -use super::{ - active_sessions_command, active_sessions_result, collect_active_sessions, - ACTIVE_SESSIONS_MAX_BYTES, ACTIVE_SESSIONS_TIMEOUT, -}; +use super::*; +use crate::opencode_cli::token_usage::tests::{connection, healthy_response, respond, FakeApi}; -struct FakeCli(PathBuf); - -impl FakeCli { - fn new(script: &str) -> Self { - let directory = - std::env::temp_dir().join(format!("opencode-mom-active-test-{}", uuid::Uuid::new_v4())); - fs::create_dir(&directory).unwrap(); - let path = directory.join("opencode"); - fs::write( - &path, - format!( - "#!/bin/sh\n[ \"$#\" -eq 2 ] && [ \"$1\" = api ] && [ \"$2\" = session.active ] || exit 99\n{script}\n" - ), - ) - .unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); - Self(path) - } - - fn collect(&self, timeout: Duration) -> Result { - collect_active_sessions(active_sessions_command(&self.0).spawn().unwrap(), timeout) - } -} - -impl Drop for FakeCli { - fn drop(&mut self) { - let _ = fs::remove_dir_all(self.0.parent().unwrap()); - } -} - -fn successful_output(value: Value) -> Output { - Output { - status: ExitStatus::from_raw(0), - stdout: serde_json::to_vec(&value).unwrap(), - stderr: Vec::new(), - } +fn parse(bytes: &[u8]) -> Result { + transport::decode_json::>(bytes, "opencode session.active") + .map(|value| value.0.data) } #[test] -fn active_sessions_counts_every_running_session_including_children() { - let cli = FakeCli::new( - "printf '%s' '{\"data\":{\"ses_parent\":{\"type\":\"running\"},\"ses_child\":{\"type\":\"running\",\"parentID\":\"ses_parent\"},\"ses_other\":{\"type\":\"running\"}}}'", - ); - assert_eq!(cli.collect(ACTIVE_SESSIONS_TIMEOUT).unwrap(), 3); +fn active_sessions_counts_children_and_accepts_empty_map_only_as_idle() { + let api = FakeApi::new(|target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + assert_eq!(target, "/api/session/active"); + respond(stream, 200, "", br#"{"data":{"ses_parent":{"type":"running"},"ses_child":{"type":"running","parentID":"ses_parent"}}}"#); + } + }); assert_eq!( - active_sessions_command(Path::new("fake-opencode")) - .get_args() - .collect::>(), - ["api", "session.active"] + active_sessions(&connection(&api), Instant::now() + ACTIVE_SESSIONS_TIMEOUT).unwrap(), + 2 ); + assert_eq!(parse(br#"{"data":{}}"#).unwrap(), 0); } #[test] -fn active_sessions_empty_data_map_is_idle() { - let cli = FakeCli::new("printf '%s' '{\"data\":{}}'"); - assert_eq!(cli.collect(ACTIVE_SESSIONS_TIMEOUT).unwrap(), 0); -} - -#[test] -fn active_sessions_rejects_missing_malformed_and_unknown_state_without_leaking() { +fn active_sessions_rejects_unverified_shapes_ids_duplicates_and_states_without_leaking() { for value in [ json!({}), json!([{}]), - json!([{"ses_SECRET": {"type": "running"}}]), - json!({ "data": null }), - json!({ "data": [] }), - json!({ "data": 0 }), - json!({ "data": { "ses_SECRET": {} } }), - json!({ "data": { "ses_SECRET": null } }), - json!({ "data": { "ses_SECRET": "running" } }), - json!({ "data": { "ses_SECRET": ["running"] } }), - json!({ "data": { "ses_SECRET": { "type": "idle" } } }), - json!({ "data": { "ses_SECRET": { "type": "retry" } } }), - json!({ "data": { "ses_SECRET": { "type": "SECRET_UNKNOWN" } } }), - json!({ "data": { "ses_SECRET": { "type": 1 } } }), - json!({ "data": { "ses_good": { "type": "running" }, "ses_SECRET": { "type": "busy" } } }), + json!({"data": null}), + json!({"data": []}), + json!({"data": 0}), + json!({"data":{"ses_SECRET":{}}}), + json!({"data":{"ses_SECRET":null}}), + json!({"data":{"ses_SECRET":"running"}}), + json!({"data":{"ses_SECRET":["running"]}}), + json!({"data":{"ses_SECRET":{"type":"idle"}}}), + json!({"data":{"ses_SECRET":{"type":"retry"}}}), + json!({"data":{"ses_SECRET":{"type":"SECRET_UNKNOWN"}}}), + json!({"data":{"ses_SECRET":{"type":1}}}), ] { - let error = active_sessions_result(successful_output(value)).unwrap_err(); + let error = parse(&serde_json::to_vec(&value).unwrap()).unwrap_err(); assert!(error.message.contains("invalid opencode session.active")); assert!(!error.message.contains("SECRET")); assert!(error.detail.is_none()); @@ -102,20 +57,13 @@ fn active_sessions_rejects_missing_malformed_and_unknown_state_without_leaking() "{\"data\":{\"ses_SECRET\":{\"type\":\"running\"},\"ses_SECRET\":{\"type\":\"running\"}}}", "{\"data\":{\"ses_SECRET\":{\"type\":\"running\",\"type\":\"running\"}}}", ] { - let error = active_sessions_result(Output { - status: ExitStatus::from_raw(0), - stdout: payload.as_bytes().to_vec(), - stderr: Vec::new(), - }) - .unwrap_err(); - assert!(!error.message.contains("SECRET")); + assert!(!parse(payload.as_bytes()) + .unwrap_err() + .message + .contains("SECRET")); } -} - -#[test] -fn active_sessions_rejects_unsafe_session_ids_without_leaking() { for id in [ - "SECRET_wrong_prefix", + "SECRET_prefix", "ses_", "ses_SECRET/../path", "ses_SECRET\nline", @@ -123,45 +71,57 @@ fn active_sessions_rejects_unsafe_session_ids_without_leaking() { "ses_SECRET\u{1b}[31m", "ses_SECRETé", ] { - let value = json!({ "data": { id: { "type": "running" } } }); - let error = active_sessions_result(successful_output(value)).unwrap_err(); - assert!(error.message.contains("invalid opencode session.active")); - assert!(!error.message.contains("SECRET")); + let value: Value = json!({"data":{id:{"type":"running"}}}); + assert!(!parse(&serde_json::to_vec(&value).unwrap()) + .unwrap_err() + .message + .contains("SECRET")); } } #[test] -fn active_sessions_nonzero_is_error_even_with_idle_output_and_redacts_output() { - let cli = FakeCli::new( - "printf '%s' '{\"data\":{},\"SECRET_STDOUT\":true}'; printf '%s' 'SECRET_STDERR' >&2; exit 7", - ); - let error = cli.collect(ACTIVE_SESSIONS_TIMEOUT).unwrap_err(); - assert!(error.message.contains("session.active failed")); - assert!(error.message.contains('7')); - assert!(!error.message.contains("SECRET")); -} - -#[test] -fn active_sessions_process_timeout_is_bounded() { - let cli = FakeCli::new("printf '%s' '{\"data\":{}}'; exec sleep 5"); - let started = Instant::now(); - let error = cli.collect(Duration::from_millis(100)).unwrap_err(); - assert!(error.message.contains("session.active timed out")); - assert!(started.elapsed() < Duration::from_secs(2)); -} - -#[test] -fn active_sessions_oversized_stdout_is_error() { - let cli = FakeCli::new(&format!( - "exec /usr/bin/head -c {} /dev/zero", - ACTIVE_SESSIONS_MAX_BYTES + 1 - )); - let started = Instant::now(); - let error = cli.collect(ACTIVE_SESSIONS_TIMEOUT).unwrap_err(); +fn readonly_http_errors_are_not_idle_and_responses_are_bounded() { + for status in [401, 403, 500] { + let api = FakeApi::new(move |target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + respond(stream, status, "", br#"{"data":{},"SECRET":true}"#); + } + }); + let error = active_sessions(&connection(&api), Instant::now() + ACTIVE_SESSIONS_TIMEOUT) + .unwrap_err(); + assert!(error.message.contains(&status.to_string())); + assert!(!error.message.contains("SECRET")); + } + let api = FakeApi::new(|target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + respond( + stream, + 200, + "", + &vec![b' '; ACTIVE_SESSIONS_MAX_BYTES as usize + 1], + ); + } + }); assert!( - error.message.contains("response exceeds the 1 MiB limit"), - "{}", - error.message + active_sessions(&connection(&api), Instant::now() + ACTIVE_SESSIONS_TIMEOUT) + .unwrap_err() + .message + .contains("1 MiB") ); - assert!(started.elapsed() < Duration::from_secs(2)); + let api = FakeApi::new(|target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + std::thread::sleep(Duration::from_millis(250)); + respond(stream, 200, "", br#"{"data":{}}"#); + } + }); + let connection = connection(&api); + let now = Instant::now(); + assert!(active_sessions(&connection, now + Duration::from_millis(50)).is_err()); + assert!(now.elapsed() < Duration::from_millis(200)); } diff --git a/src-tauri/src/opencode_cli/binary.rs b/src-tauri/src/opencode_cli/binary.rs index 5c5c64c..8c6a000 100644 --- a/src-tauri/src/opencode_cli/binary.rs +++ b/src-tauri/src/opencode_cli/binary.rs @@ -1,5 +1,6 @@ use std::env; -use std::path::PathBuf; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; /// Candidate opencode binaries, in priority order: /// 1. `$OPENCODE_BIN` explicit override (if the file exists) @@ -16,10 +17,13 @@ pub(super) fn opencode_candidates() -> Vec { } } - // npm global installs (Windows) + // npm's Windows shim also supports the modern @opencode/cli package. + #[cfg(windows)] if let Ok(appdata) = env::var("APPDATA") { - candidates - .push(PathBuf::from(appdata).join("npm/node_modules/opencode-ai/bin/opencode.exe")); + let npm = PathBuf::from(appdata).join("npm"); + candidates.push(npm.join("opencode.exe")); + candidates.push(npm.join("opencode.cmd")); + candidates.push(npm.join("node_modules/opencode-ai/bin/opencode.exe")); } // Bundled CLI with the OpenChamber desktop app (Windows) if let Ok(local_appdata) = env::var("LOCALAPPDATA") { @@ -30,10 +34,20 @@ pub(super) fn opencode_candidates() -> Vec { } // Home directories (macOS / Linux) if let Some(home) = dirs::home_dir() { - candidates.push(home.join(".opencode/bin/opencode")); - candidates.push(home.join(".local/bin/opencode")); - candidates.push(home.join(".npm-global/bin/opencode")); - candidates.push(home.join(".cargo/bin/opencode")); + let name = if cfg!(windows) { + "opencode.exe" + } else { + "opencode" + }; + for directory in [ + ".opencode/bin", + ".local/bin", + ".npm-global/bin", + ".bun/bin", + ".cargo/bin", + ] { + candidates.push(home.join(directory).join(name)); + } } candidates.push(PathBuf::from("/opt/homebrew/bin/opencode")); candidates.push(PathBuf::from("/usr/local/bin/opencode")); @@ -51,3 +65,77 @@ pub fn resolve_opencode_binary() -> PathBuf { .find(|p| p.exists()) .unwrap_or_else(|| PathBuf::from("opencode")) } + +/// Resolve the exact executable without canonicalizing its logical installation path. +pub(super) fn resolve_path_command(command: &Path) -> Option { + resolve_path_command_in( + command, + env::var_os("PATH").as_deref(), + &env::current_dir().ok()?, + ) +} + +pub(super) fn resolve_path_command_in( + command: &Path, + path: Option<&OsStr>, + cwd: &Path, +) -> Option { + if command.as_os_str().is_empty() { + return None; + } + if command.is_absolute() || command.components().count() > 1 { + let absolute = if command.is_absolute() { + command.to_path_buf() + } else { + cwd.join(command) + }; + return executable(&absolute).then_some(absolute); + } + for directory in env::split_paths(path?) { + let directory = if directory.is_absolute() { + directory + } else { + cwd.join(directory) + }; + let candidate = directory.join(command); + #[cfg(not(windows))] + if executable(&candidate) { + return Some(candidate); + } + #[cfg(windows)] + { + if candidate.extension().is_some() { + if executable(&candidate) { + return Some(candidate); + } + continue; + } + // These are the native/shim extensions supported by Command on Windows. + for extension in ["exe", "com", "cmd", "bat"] { + let candidate = candidate.with_extension(extension); + if executable(&candidate) { + return Some(candidate); + } + } + } + } + None +} + +fn executable(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} diff --git a/src-tauri/src/opencode_cli/installation.rs b/src-tauri/src/opencode_cli/installation.rs new file mode 100644 index 0000000..c81a1cb --- /dev/null +++ b/src-tauri/src/opencode_cli/installation.rs @@ -0,0 +1,885 @@ +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, ErrorCode}; + +use super::binary::{opencode_candidates, resolve_path_command}; +use super::command::collect_output; + +const LATEST_URL: &str = "https://opencode.ai/update/api/latest/cli/npm"; +const PACKAGE: &str = "@opencode/cli"; +const FORMULA: &str = "anomalyco/tap/opencode-v2"; +const LOCAL_TIMEOUT: Duration = Duration::from_secs(15); +const PROCESS_TIMEOUT: Duration = Duration::from_secs(3); +const HTTP_TIMEOUT: Duration = Duration::from_secs(15); +// Verified native updater at 434f7b2a3670dd932794688bee5e34029e546630; earlier V2 builds stay manual. +const MIN_NATIVE_UPGRADE: [u32; 3] = [2, 0, 25]; +// Native updater can wait 15 minutes for its flock, then run two five-minute curl operations. +const UPGRADE_TIMEOUT: Duration = Duration::from_secs(26 * 60); +static UPGRADE_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCodeInstallation { + pub binary_path: Option, + pub version: Option, + pub method: String, + pub can_upgrade: bool, + pub upgrade_reason: Option, + pub manual_command: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCodeUpdateCheck { + pub installation: OpenCodeInstallation, + pub latest_version: String, + pub update_available: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Version { + text: String, + numbers: [u32; 3], + unstable: bool, +} + +fn version(text: &str) -> Option { + let text = text.trim().strip_prefix('v').unwrap_or(text.trim()); + if text.is_empty() || text.len() > 96 || !text.is_ascii() { + return None; + } + let (core, suffix) = text + .split_once('-') + .map_or((text, None), |(core, suffix)| (core, Some(suffix))); + let mut numbers = [0; 3]; + let mut parts = core.split('.'); + for number in &mut numbers { + let part = parts.next()?; + if part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || (part.len() > 1 && part.starts_with('0')) + { + return None; + } + *number = part.parse().ok()?; + } + if parts.next().is_some() { + return None; + } + if let Some(suffix) = suffix { + if suffix.split('.').any(|part| { + part.is_empty() + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + || (part.len() > 1 + && part.starts_with('0') + && part.bytes().all(|byte| byte.is_ascii_digit())) + }) { + return None; + } + } + Some(Version { + text: text.to_owned(), + numbers, + unstable: suffix.is_some(), + }) +} + +fn stable_v2(text: &str) -> Result { + version(text) + .filter(|version| version.numbers[0] == 2 && !version.unstable) + .ok_or_else(|| AppError::validation("A stable OpenCode V2 version is required")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Source { + Curl { + bash: PathBuf, + curl: PathBuf, + home: PathBuf, + }, + Manager { + method: &'static str, + manager: PathBuf, + root: PathBuf, + bin: PathBuf, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Detected { + info: OpenCodeInstallation, + logical: Option, + canonical: Option, + source: Option, +} + +fn manual_command(method: &str) -> Option { + Some( + match method { + "curl" => "curl -fsSL https://opencode.ai/v2/install | bash", + "npm" => "npm install -g @opencode/cli@2", + "bun" => "bun install -g --trust @opencode/cli@2", + "pnpm" => "pnpm add -g --allow-build=@opencode/cli @opencode/cli@2", + "yarn" => "yarn global add @opencode/cli@2", + "vite-plus" => "vp install -g @opencode/cli@2", + "brew" => "brew upgrade anomalyco/tap/opencode-v2", + _ => return None, + } + .to_owned(), + ) +} + +fn not_found() -> Detected { + Detected { + info: OpenCodeInstallation { + binary_path: None, + version: None, + method: "not-found".to_owned(), + can_upgrade: false, + upgrade_reason: Some("not_found".to_owned()), + manual_command: None, + }, + logical: None, + canonical: None, + source: None, + } +} + +fn selected_binary() -> Option { + selected_binary_with( + std::env::var_os("OPENCODE_BIN").as_deref(), + opencode_candidates(), + resolve_path_command, + ) +} + +fn selected_binary_with( + explicit: Option<&std::ffi::OsStr>, + candidates: impl IntoIterator, + mut resolve: impl FnMut(&Path) -> Option, +) -> Option { + if let Some(explicit) = explicit { + // An invalid override must never redirect an upgrade to another installation. + return resolve(Path::new(explicit.to_str()?.trim())); + } + candidates + .into_iter() + .find_map(|candidate| resolve(&candidate)) +} + +fn sanitized(command: &mut Command) { + command.env_clear(); + for key in [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LC_ALL", + "SystemRoot", + "COMSPEC", + "APPDATA", + "LOCALAPPDATA", + "NPM_CONFIG_PREFIX", + "BUN_INSTALL", + "PNPM_HOME", + ] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + command + .env("OPENCODE_DISABLE_AUTOUPDATE", "1") + .env("HOMEBREW_NO_AUTO_UPDATE", "1") + .env("HOMEBREW_NO_INSTALL_FROM_API", "1") + .env("HOMEBREW_NO_ANALYTICS", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); +} + +fn executable_command(path: &Path) -> Command { + #[cfg(all(test, unix))] + if tests::is_fake_executable(path) { + let mut command = Command::new("/bin/sh"); + command.arg(path); + return command; + } + Command::new(path) +} + +fn read_output( + command: &mut Command, + deadline: Instant, + max_bytes: u64, + operation: &str, +) -> Result, AppError> { + let timeout = deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero()) + .ok_or_else(|| AppError::configuration(format!("{operation} timed out")))? + .min(PROCESS_TIMEOUT); + sanitized(command); + let child = command.spawn().map_err(|error| { + AppError::configuration(format!("{operation} could not start: {:?}", error.kind())) + })?; + let output = collect_output(child, timeout, max_bytes, operation, "bounded output")?; + if !output.status.success() { + return Err(AppError::configuration(format!( + "{operation} failed (exit {:?})", + output.status.code() + ))); + } + Ok(output.stdout) +} + +fn read_version(path: &Path, deadline: Instant) -> Result, AppError> { + let bytes = read_output( + executable_command(path).arg("--version"), + deadline, + 1024, + "OpenCode version query", + )?; + Ok(std::str::from_utf8(&bytes).ok().and_then(version)) +} + +fn query_path(manager: &Path, args: &[&str], deadline: Instant) -> Option { + let bytes = read_output( + executable_command(manager).args(args), + deadline, + 8192, + "OpenCode manager ownership query", + ) + .ok()?; + let text = std::str::from_utf8(&bytes).ok()?.trim(); + if text.is_empty() || text.contains(['\n', '\r', '\0']) { + return None; + } + let path = PathBuf::from(text); + path.is_absolute().then_some(path) +} + +fn bounded_file(path: &Path, max_bytes: u64) -> Option> { + if !fs::symlink_metadata(path).ok()?.file_type().is_file() { + return None; + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + } + let file = options.open(path).ok()?; + let metadata = file.metadata().ok()?; + if !metadata.is_file() || metadata.len() > max_bytes { + return None; + } + let mut bytes = Vec::new(); + file.take(max_bytes + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() as u64 <= max_bytes).then_some(bytes) +} + +fn package_bin(root: &Path) -> Option { + let root = root.canonicalize().ok()?; + let package = root.join("@opencode/cli"); + let bytes = bounded_file(&package.join("package.json"), 64 * 1024)?; + let metadata: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + if metadata.get("name").and_then(|value| value.as_str()) != Some(PACKAGE) { + return None; + } + let bin = metadata.get("bin"); + let bin = bin.and_then(|value| { + value + .as_str() + .or_else(|| value.get("opencode").and_then(|value| value.as_str())) + })?; + let relative = Path::new(bin); + if relative.as_os_str().is_empty() + || relative + .components() + .any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) + { + return None; + } + let path = package.join(relative).canonicalize().ok()?; + path.starts_with(root).then_some(path) +} + +fn package_owns(root: &Path, binary: &Path) -> bool { + package_bin(root).as_deref() == Some(binary) +} + +fn global_bin_owns(root: &Path, bin: &Path, logical: &Path, canonical: &Path) -> bool { + if package_owns(root, canonical) + && ["opencode", "opencode.exe"] + .iter() + .any(|name| bin.join(name).canonicalize().ok().as_deref() == Some(canonical)) + { + return true; + } + // Windows npm shims are readable installations, never automatic upgrade candidates. + if cfg!(windows) && logical == bin.join("opencode.cmd") && package_bin(root).is_some() { + return npm_native_shim(logical); + } + false +} + +fn npm_native_shim(path: &Path) -> bool { + bounded_file(path, 16 * 1024) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .is_some_and(|text| { + text.contains("\"%dp0%\\node_modules\\@opencode\\cli\\bin\\opencode.exe\"") + }) +} + +fn bundled(path: &Path) -> bool { + for ancestor in path.ancestors().skip(1) { + let Some(name) = ancestor.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if name == "Contents" + && ancestor + .parent() + .is_some_and(|parent| parent.extension() == Some(std::ffi::OsStr::new("app"))) + { + return true; + } + if name.eq_ignore_ascii_case("resources") + && ancestor + .parent() + .and_then(|parent| parent.file_name()) + .and_then(|name| name.to_str()) + .is_some_and(|name| { + ["opencode", "openchamber", "@openchamberelectron"] + .iter() + .any(|app| name.eq_ignore_ascii_case(app)) + }) + { + return true; + } + } + false +} + +fn official_curl_path(logical: &Path, canonical: &Path, home: &Path) -> bool { + let name = if cfg!(windows) { + "opencode.exe" + } else { + "opencode" + }; + let expected = home.join(".opencode/bin").join(name); + logical == expected + && fs::symlink_metadata(logical) + .ok() + .is_some_and(|metadata| metadata.file_type().is_file()) + && home + .canonicalize() + .ok() + .is_some_and(|home| canonical == home.join(".opencode/bin").join(name)) +} + +fn brew_formula(path: &Path, deadline: Instant) -> Option { + let cellar = path.ancestors().find(|ancestor| { + ancestor.file_name() == Some(std::ffi::OsStr::new("opencode-v2")) + && ancestor + .parent() + .is_some_and(|parent| parent.file_name() == Some(std::ffi::OsStr::new("Cellar"))) + })?; + let manager = cellar.parent()?.parent()?.join("bin/brew"); + let manager = resolve_path_command(&manager)?; + let prefix = query_path(&manager, &["--prefix", FORMULA], deadline)? + .canonicalize() + .ok()?; + if prefix.join("bin/opencode").canonicalize().ok().as_deref() != Some(path) + || !prefix.starts_with(cellar) + { + return Some(false); + } + let bytes = read_output( + executable_command(&manager).args(["info", "--json=v2", "--installed", FORMULA]), + deadline, + 64 * 1024, + "Homebrew ownership query", + ) + .ok()?; + let metadata: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + Some(metadata.get("formulae")?.as_array()?.iter().any(|formula| { + formula.get("name").and_then(|value| value.as_str()) == Some("opencode-v2") + && formula.get("full_name").and_then(|value| value.as_str()) == Some(FORMULA) + && formula + .get("installed") + .and_then(|value| value.as_array()) + .is_some_and(|values| !values.is_empty()) + })) +} + +fn identify_source( + logical: &Path, + canonical: &Path, + home: Option<&Path>, + deadline: Instant, +) -> (String, Option, &'static str) { + identify_source_with(logical, canonical, home, deadline, resolve_path_command) +} + +fn identify_source_with( + logical: &Path, + canonical: &Path, + home: Option<&Path>, + deadline: Instant, + mut resolve: impl FnMut(&Path) -> Option, +) -> (String, Option, &'static str) { + if bundled(logical) || bundled(canonical) { + return ("bundled".to_owned(), None, "bundled"); + } + if let Some(home) = home.filter(|home| official_curl_path(logical, canonical, home)) { + #[cfg(unix)] + if let (Some(bash), Some(curl)) = (resolve(Path::new("bash")), resolve(Path::new("curl"))) { + return ( + "curl".to_owned(), + Some(Source::Curl { + bash, + curl, + home: home.to_path_buf(), + }), + "", + ); + } + return ( + "curl".to_owned(), + None, + if cfg!(unix) { + "manager_unavailable" + } else { + "unsupported" + }, + ); + } + if let Some(proven) = brew_formula(canonical, deadline) { + // Homebrew has no verified version-pinned upgrade for this formula. + return ( + if proven { "brew" } else { "unknown" }.to_owned(), + None, + if proven { "unsupported" } else { "ambiguous" }, + ); + } + if !canonical + .ancestors() + .any(|path| path.file_name() == Some(std::ffi::OsStr::new("node_modules"))) + && !(cfg!(windows) && logical.file_name() == Some(std::ffi::OsStr::new("opencode.cmd"))) + { + return ("manual".to_owned(), None, "unsupported"); + } + if let Some(manager) = resolve(Path::new("npm")) { + if let (Some(root), Some(prefix)) = ( + query_path(&manager, &["root", "-g"], deadline), + query_path(&manager, &["prefix", "-g"], deadline), + ) { + let expected_root = prefix.join(if cfg!(windows) { + "node_modules" + } else { + "lib/node_modules" + }); + let bin = if cfg!(windows) { + prefix + } else { + prefix.join("bin") + }; + if root.canonicalize().ok() == expected_root.canonicalize().ok() + && global_bin_owns(&root, &bin, logical, canonical) + { + return ( + "npm".to_owned(), + cfg!(unix).then_some(Source::Manager { + method: "npm", + manager, + root, + bin, + }), + if cfg!(unix) { "" } else { "unsupported" }, + ); + } + } + } + // Other manager shims/layouts must match both their queried global root and package bin. + for (name, method, root_args, bin_args) in [ + ("pnpm", "pnpm", &["root", "-g"][..], &["bin", "-g"][..]), + ( + "yarn", + "yarn", + &["global", "dir"][..], + &["global", "bin"][..], + ), + ("bun", "bun", &[][..], &["pm", "bin", "-g"][..]), + ] { + let Some(manager) = resolve(Path::new(name)) else { + continue; + }; + let Some(bin) = query_path(&manager, bin_args, deadline) else { + continue; + }; + let root = if method == "bun" { + bin.parent() + .map(|parent| parent.join("install/global/node_modules")) + } else { + query_path(&manager, root_args, deadline).map(|path| { + if method == "yarn" { + path.join("node_modules") + } else { + path + } + }) + }; + if let Some(root) = root.filter(|root| global_bin_owns(root, &bin, logical, canonical)) { + return ( + method.to_owned(), + cfg!(unix).then_some(Source::Manager { + method, + manager, + root, + bin, + }), + if cfg!(unix) { "" } else { "unsupported" }, + ); + } + } + ("unknown".to_owned(), None, "ambiguous") +} + +fn inspect_path(logical: PathBuf, home: Option<&Path>) -> Result { + let deadline = Instant::now() + LOCAL_TIMEOUT; + let canonical = logical.canonicalize().map_err(|error| { + AppError::configuration(format!( + "OpenCode binary is unavailable: {:?}", + error.kind() + )) + })?; + let current = read_version(&logical, deadline)?; + let (method, source, source_reason) = identify_source(&logical, &canonical, home, deadline); + let reason = if method == "bundled" { + "bundled" + } else { + match ¤t { + None => "unsupported", + Some(current) if current.numbers[0] != 2 => "not_v2", + Some(current) if current.unstable => "unstable", + Some(current) if current.numbers < MIN_NATIVE_UPGRADE => "unsupported", + Some(_) => source_reason, + } + }; + let binary_path = logical + .to_str() + .ok_or_else(|| AppError::configuration("OpenCode binary path is not valid UTF-8"))? + .to_owned(); + Ok(Detected { + info: OpenCodeInstallation { + binary_path: Some(binary_path), + version: current.map(|version| version.text), + manual_command: manual_command(&method), + method, + can_upgrade: reason.is_empty(), + upgrade_reason: (!reason.is_empty()).then(|| reason.to_owned()), + }, + logical: Some(logical), + canonical: Some(canonical), + source, + }) +} + +fn inspect_selected() -> Result { + match selected_binary() { + Some(path) => inspect_path(path, dirs::home_dir().as_deref()), + None => Ok(not_found()), + } +} + +/// Local discovery only; never contacts or starts the OpenCode service. +pub fn get_installation() -> Result { + Ok(inspect_selected()?.info) +} + +fn http_client() -> Result { + Client::builder() + .timeout(HTTP_TIMEOUT) + .connect_timeout(Duration::from_secs(5)) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .build() + .map_err(|_| AppError::configuration("OpenCode update HTTP initialization failed")) +} + +fn fetch_bytes(client: &Client, url: &str, max_bytes: u64) -> Result, AppError> { + let response = client + .get(url) + .header(reqwest::header::USER_AGENT, "opencode-mom") + .send() + .map_err(|_| AppError::configuration("OpenCode update request failed"))?; + if !response.status().is_success() { + return Err(AppError::configuration(format!( + "OpenCode update request failed (HTTP {})", + response.status().as_u16() + ))); + } + if response + .content_length() + .is_some_and(|length| length > max_bytes) + { + return Err(AppError::configuration( + "OpenCode update response exceeds its size limit", + )); + } + let mut bytes = Vec::new(); + response + .take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|_| AppError::configuration("OpenCode update response could not be read"))?; + if bytes.len() as u64 > max_bytes { + return Err(AppError::configuration( + "OpenCode update response exceeds its size limit", + )); + } + Ok(bytes) +} + +#[derive(Deserialize)] +struct Latest { + channel: String, + name: String, + distribution: String, + version: String, + metadata: LatestMetadata, + active: bool, +} + +#[derive(Deserialize)] +struct LatestMetadata { + package: String, +} + +fn latest_version(bytes: &[u8]) -> Result { + let invalid = || { + AppError::configuration("OpenCode update metadata is not an active stable V2 CLI release") + }; + let latest: Latest = serde_json::from_slice(bytes).map_err(|_| invalid())?; + if latest.channel != "latest" + || latest.name != "cli" + || latest.distribution != "npm" + || !latest.active + || latest.metadata.package != PACKAGE + { + return Err(invalid()); + } + stable_v2(&latest.version).map_err(|_| invalid()) +} + +fn fetch_latest() -> Result { + latest_version(&fetch_bytes(&http_client()?, LATEST_URL, 16 * 1024)?) +} + +pub fn check_for_update() -> Result { + let installation = get_installation()?; + let latest = fetch_latest()?; + let update_available = installation + .version + .as_deref() + .and_then(version) + .is_some_and(|current| { + current.numbers[0] == 2 && !current.unstable && latest.numbers > current.numbers + }); + Ok(OpenCodeUpdateCheck { + installation, + latest_version: latest.text, + update_available, + }) +} + +fn upgrade_command(detected: &Detected, target: &str) -> Result { + let target = stable_v2(target)?.text; + let source = detected + .source + .as_ref() + .ok_or_else(|| AppError::validation("OpenCode installation ownership is unproven"))?; + let binary = detected + .logical + .as_ref() + .ok_or_else(|| AppError::validation("OpenCode binary is unavailable"))?; + let method = match source { + Source::Curl { .. } => "curl", + Source::Manager { method, .. } => *method, + }; + let mut command = executable_command(binary); + command.args(["upgrade", &target, "--method", method]); + sanitized(&mut command); + command.stdout(Stdio::null()); + if let Source::Curl { home, .. } = source { + command.env("HOME", home); + } + Ok(command) +} + +#[cfg(unix)] +fn run_upgrade(command: &mut Command, timeout: Duration) -> Result<(), AppError> { + use std::os::unix::process::CommandExt; + + command.process_group(0); + let mut child = command.spawn().map_err(|error| { + AppError::configuration(format!( + "OpenCode upgrade could not start: {:?}", + error.kind() + )) + })?; + let group = child.id() as libc::pid_t; + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => { + return Err(AppError::configuration(format!( + "OpenCode upgrade failed (exit {:?}); use the installation's manual command", + status.code() + ))) + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(AppError::configuration(format!( + "OpenCode upgrade wait failed: {:?}", + error.kind() + ))); + } + Ok(None) => {} + } + if Instant::now() >= deadline { + // The group leader is still unreaped: its PID cannot be reused. Never signal the service. + let _ = unsafe { libc::kill(-group, libc::SIGKILL) }; + let _ = child.kill(); + let _ = child.wait(); + return Err(AppError::configuration( + "OpenCode upgrade timed out; check the selected installation before trying again", + )); + } + std::thread::sleep( + Duration::from_millis(20).min(deadline.saturating_duration_since(Instant::now())), + ); + } +} + +#[cfg(not(unix))] +fn run_upgrade(_: &mut Command, _: Duration) -> Result<(), AppError> { + Err(AppError::configuration( + "Automatic OpenCode upgrade is unsupported on this platform", + )) +} + +fn execute_upgrade(detected: &Detected, target: &str) -> Result<(), AppError> { + run_upgrade(&mut upgrade_command(detected, target)?, UPGRADE_TIMEOUT) +} + +fn verify_snapshot( + detected: &Detected, + expected_path: &str, + expected_version: &str, +) -> Result<(), AppError> { + if detected.info.binary_path.as_deref() != Some(expected_path) + || detected.info.version.as_deref() != Some(expected_version) + { + return Err(AppError::validation( + "OpenCode installation changed; refresh before upgrading", + )); + } + if !detected.info.can_upgrade || detected.source.is_none() { + return Err(AppError::validation(format!( + "OpenCode automatic upgrade is unavailable ({}); use the installation's manual command", + detected + .info + .upgrade_reason + .as_deref() + .unwrap_or("unsupported") + ))); + } + Ok(()) +} + +fn guarded_upgrade( + expected_path: &str, + expected_version: &str, + target: &str, + mut inspect: impl FnMut(Option<&Path>) -> Result, + latest: impl FnOnce() -> Result, + execute: impl FnOnce(&Detected, &str) -> Result<(), AppError>, +) -> Result { + let target = stable_v2(target)?; + let current = stable_v2(expected_version)?; + if current.numbers < MIN_NATIVE_UPGRADE { + return Err(AppError::validation( + "This OpenCode V2 version requires a manual upgrade", + )); + } + if target.numbers <= current.numbers { + return Err(AppError::validation( + "OpenCode upgrades cannot downgrade or reinstall the current version", + )); + } + let before = inspect(None)?; + verify_snapshot(&before, expected_path, expected_version)?; + if stable_v2(&latest()?.text)?.text != target.text { + return Err(AppError::validation( + "OpenCode target is no longer the latest stable V2 release; check again", + )); + } + let refreshed = inspect(None)?; + verify_snapshot(&refreshed, expected_path, expected_version)?; + if before.logical != refreshed.logical + || before.canonical != refreshed.canonical + || before.source != refreshed.source + { + return Err(AppError::validation( + "OpenCode installation source changed; refresh before upgrading", + )); + } + execute(&refreshed, &target.text)?; + let after = inspect(before.logical.as_deref())?; + if after.logical != before.logical || after.info.version.as_deref() != Some(&target.text) { + return Err(AppError::configuration("OpenCode upgrade finished, but the selected binary does not report the target version; no fallback installation was used")); + } + Ok(after.info) +} + +pub fn upgrade( + expected_binary_path: &str, + expected_version: &str, + target_version: &str, +) -> Result { + let _guard = UPGRADE_LOCK + .try_lock() + .map_err(|_| AppError::new(ErrorCode::Busy, "OpenCode upgrade is already in progress"))?; + guarded_upgrade( + expected_binary_path, + expected_version, + target_version, + |path| match path { + Some(path) => inspect_path(path.to_path_buf(), dirs::home_dir().as_deref()), + None => inspect_selected(), + }, + fetch_latest, + execute_upgrade, + ) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/opencode_cli/installation/tests.rs b/src-tauri/src/opencode_cli/installation/tests.rs new file mode 100644 index 0000000..3ceec93 --- /dev/null +++ b/src-tauri/src/opencode_cli/installation/tests.rs @@ -0,0 +1,866 @@ +use std::cell::Cell; +use std::collections::VecDeque; +use std::ffi::OsStr; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; + +use super::*; +use crate::opencode_cli::binary::resolve_path_command_in; + +#[cfg(unix)] +thread_local! { + static FAKE_EXECUTABLES: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +#[cfg(unix)] +pub(super) fn is_fake_executable(path: &Path) -> bool { + let Ok(path) = path.canonicalize() else { + return false; + }; + FAKE_EXECUTABLES.with(|paths| paths.borrow().contains(&path)) +} + +fn latest(text: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "channel": "latest", "name": "cli", "distribution": "npm", "version": text, + "metadata": { "package": "@opencode/cli" }, "active": true, + })) + .unwrap() +} + +#[test] +fn versions_and_latest_metadata_require_bounded_stable_v2_identity() { + assert_eq!(version(" v2.0.25\n").unwrap().text, "2.0.25"); + assert!(version("2.0.25-beta.1").unwrap().unstable); + assert!(stable_v2("2.0.10").unwrap().numbers > stable_v2("2.0.9").unwrap().numbers); + for invalid in [ + "", + "2.0", + "2.0.0.1", + "02.0.0", + "2.00.1", + "OpenCode 2.0.25", + "2.0.25\n3.0.0", + "2.0.25+build", + "2.0.25-beta..1", + "2.0.25-beta.01", + "2.0.25;echo token", + "4294967296.0.0", + ] { + assert!(version(invalid).is_none(), "{invalid}"); + } + assert!(version(&format!("2.0.0-{}", "x".repeat(96))).is_none()); + assert_eq!( + latest_version(&latest("2.17.300")).unwrap().text, + "2.17.300" + ); + for invalid in ["1.9.9", "3.0.0", "2.0.25-beta.1", "2.0.25+build"] { + assert!(stable_v2(invalid).is_err()); + assert!(latest_version(&latest(invalid)).is_err()); + } + for (key, bad) in [ + ("channel", serde_json::json!("beta")), + ("name", serde_json::json!("desktop")), + ("distribution", serde_json::json!("docker")), + ("active", serde_json::json!(false)), + ("metadata", serde_json::json!({"package": "opencode-ai"})), + ] { + let mut value: serde_json::Value = serde_json::from_slice(&latest("2.0.25")).unwrap(); + value[key] = bad; + assert!( + latest_version(&serde_json::to_vec(&value).unwrap()).is_err(), + "{key}" + ); + } + assert!(latest_version(b"{}").is_err()); + assert_eq!(LATEST_URL, "https://opencode.ai/update/api/latest/cli/npm"); +} + +#[test] +fn explicit_override_never_falls_back_and_dtos_keep_the_exact_wire_contract() { + let fallback = PathBuf::from("/unrelated/opencode"); + for explicit in ["", "/missing/opencode"] { + assert_eq!( + selected_binary_with(Some(OsStr::new(explicit)), [fallback.clone()], |path| (path + == fallback) + .then(|| fallback.clone())), + None + ); + } + assert_eq!( + selected_binary_with(None, [fallback.clone()], |path| (path == fallback) + .then(|| fallback.clone())), + Some(fallback) + ); + assert_eq!( + serde_json::to_value(not_found().info).unwrap(), + serde_json::json!({ + "binaryPath": null, "version": null, "method": "not-found", "canUpgrade": false, + "upgradeReason": "not_found", "manualCommand": null, + }) + ); + let check = serde_json::to_value(OpenCodeUpdateCheck { + installation: not_found().info, + latest_version: "2.0.25".into(), + update_available: false, + }) + .unwrap(); + assert_eq!(check["latestVersion"], "2.0.25"); + assert_eq!(check["updateAvailable"], false); +} + +fn detected(current: &str) -> Detected { + let path = PathBuf::from("/home/test/.opencode/bin/opencode"); + Detected { + info: OpenCodeInstallation { + binary_path: Some(path.to_string_lossy().into_owned()), + version: Some(current.into()), + method: "curl".into(), + can_upgrade: true, + upgrade_reason: None, + manual_command: manual_command("curl"), + }, + logical: Some(path.clone()), + canonical: Some(path), + source: Some(Source::Curl { + bash: PathBuf::from("/bin/bash"), + curl: PathBuf::from("/usr/bin/curl"), + home: PathBuf::from("/home/test"), + }), + } +} + +#[test] +fn upgrade_rejects_stale_target_downgrade_v1_v3_prerelease_and_unverified_old_v2() { + let before = detected("2.0.25"); + for (path, current, target, upstream) in [ + ("/different/opencode", "2.0.25", "2.0.26", "2.0.26"), + ( + "/home/test/.opencode/bin/opencode", + "2.0.26", + "2.0.27", + "2.0.27", + ), + ( + "/home/test/.opencode/bin/opencode", + "2.0.25", + "2.0.26", + "2.0.27", + ), + ( + "/home/test/.opencode/bin/opencode", + "2.0.25", + "2.0.25", + "2.0.25", + ), + ( + "/home/test/.opencode/bin/opencode", + "2.0.25", + "2.0.24", + "2.0.24", + ), + ( + "/home/test/.opencode/bin/opencode", + "2.0.25", + "3.0.0", + "3.0.0", + ), + ( + "/home/test/.opencode/bin/opencode", + "1.9.9", + "2.0.26", + "2.0.26", + ), + ( + "/home/test/.opencode/bin/opencode", + "2.0.24", + "2.0.26", + "2.0.26", + ), + ( + "/home/test/.opencode/bin/opencode", + "2.0.25", + "2.0.26-beta.1", + "2.0.26-beta.1", + ), + ( + "/home/test/.opencode/bin/opencode", + "2.0.25", + "2.0.26", + "3.0.0", + ), + ] { + assert!(guarded_upgrade( + path, + current, + target, + |_| Ok(before.clone()), + || Ok(version(upstream).unwrap()), + |_, _| panic!("must not upgrade") + ) + .is_err()); + } +} + +#[test] +fn upgrade_revalidates_source_after_http_and_refuses_manual_or_missing_ownership() { + let before = detected("2.0.25"); + for change in 0..5 { + let mut changed = before.clone(); + match change { + 0 => changed.canonical = Some(PathBuf::from("/unrelated/opencode")), + 1 => { + changed.source = Some(Source::Manager { + method: "npm", + manager: "/npm".into(), + root: "/global/node_modules".into(), + bin: "/global/bin".into(), + }) + } + 2 => changed.source = None, + 3 => changed.info.can_upgrade = false, + _ => changed.info.version = Some("2.0.26".into()), + } + let mut reads = VecDeque::from([before.clone(), changed]); + assert!(guarded_upgrade( + before.info.binary_path.as_deref().unwrap(), + "2.0.25", + "2.0.26", + |_| Ok(reads.pop_front().unwrap()), + || stable_v2("2.0.26"), + |_, _| panic!("must not upgrade") + ) + .is_err()); + } +} + +#[test] +fn upgrade_reads_back_only_the_original_logical_binary_and_exact_target() { + let before = detected("2.0.25"); + for (current, changed_path) in [ + ("2.0.26", false), + ("2.0.25", false), + ("2.0.27", false), + ("2.0.26", true), + ] { + let mut after = detected(current); + after.canonical = Some(PathBuf::from("/new/version/opencode")); + if changed_path { + after.logical = Some("/unrelated/opencode".into()); + } + let mut reads = VecDeque::from([before.clone(), before.clone(), after]); + let calls = Cell::new(0); + let result = guarded_upgrade( + before.info.binary_path.as_deref().unwrap(), + "2.0.25", + "2.0.26", + |path| { + assert_eq!( + path, + if reads.len() == 1 { + before.logical.as_deref() + } else { + None + } + ); + Ok(reads.pop_front().unwrap()) + }, + || stable_v2("2.0.26"), + |_, target| { + assert_eq!(target, "2.0.26"); + calls.set(calls.get() + 1); + Ok(()) + }, + ); + assert_eq!(result.is_ok(), current == "2.0.26" && !changed_path); + assert_eq!(calls.get(), 1); + } + let calls = Cell::new(0); + let error = guarded_upgrade( + before.info.binary_path.as_deref().unwrap(), + "2.0.25", + "2.0.26", + |_| { + calls.set(calls.get() + 1); + Ok(before.clone()) + }, + || stable_v2("2.0.26"), + |_, _| Err(AppError::configuration("fake upgrade failed")), + ) + .unwrap_err(); + assert_eq!(error.message, "fake upgrade failed"); + assert_eq!(calls.get(), 2); +} + +#[test] +fn concurrent_upgrade_returns_busy_before_discovery_or_network() { + let _guard = UPGRADE_LOCK.lock().unwrap(); + assert_eq!( + upgrade("/never/executed", "2.0.25", "2.0.26") + .unwrap_err() + .code, + ErrorCode::Busy + ); +} + +#[test] +fn native_command_pins_selected_binary_target_and_method_with_sanitized_environment() { + for method in ["curl", "npm", "bun", "pnpm", "yarn"] { + let mut value = detected("2.0.25"); + if method != "curl" { + value.source = Some(Source::Manager { + method, + manager: "/never-execute-manager".into(), + root: "/global/node_modules".into(), + bin: "/global/bin".into(), + }); + } + let command = upgrade_command(&value, "v2.0.26").unwrap(); + assert_eq!(command.get_program(), value.logical.as_ref().unwrap()); + assert_eq!( + command.get_args().collect::>(), + ["upgrade", "2.0.26", "--method", method] + ); + for (key, _) in command.get_envs() { + let key = key.to_string_lossy().to_ascii_uppercase(); + assert!( + !key.contains("TOKEN") + && !key.contains("PASSWORD") + && !key.contains("API_KEY") + && key != "BASH_ENV" + && key != "NODE_OPTIONS" + ); + } + assert!(command + .get_envs() + .any(|(key, value)| key == "OPENCODE_DISABLE_AUTOUPDATE" + && value == Some(OsStr::new("1")))); + assert!(upgrade_command(&value, "2.0.26;echo secret").is_err()); + } + let mut unknown = detected("2.0.25"); + unknown.source = None; + assert!(upgrade_command(&unknown, "2.0.26").is_err()); + assert_eq!( + manual_command("bun").unwrap(), + "bun install -g --trust @opencode/cli@2" + ); + assert_eq!( + manual_command("pnpm").unwrap(), + "pnpm add -g --allow-build=@opencode/cli @opencode/cli@2" + ); +} + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "opencode-mom-installation-test-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn file(&self, relative: &str, bytes: impl AsRef<[u8]>) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, bytes).unwrap(); + path + } + #[cfg(unix)] + fn executable(&self, relative: &str, script: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = self.file(relative, script); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + // Fresh macOS shebang executables stall before script entry under parallel load; read them with /bin/sh. + FAKE_EXECUTABLES.with(|paths| paths.borrow_mut().push(path.canonicalize().unwrap())); + path + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + #[cfg(unix)] + if let Ok(root) = self.0.canonicalize() { + FAKE_EXECUTABLES + .with(|paths| paths.borrow_mut().retain(|path| !path.starts_with(&root))); + } + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn modern_native_package_bin_requires_exact_metadata_not_substrings_or_bundle_ancestry() { + let fixture = Fixture::new(); + let root = fixture.0.join("node_modules"); + let binary = fixture + .file("node_modules/@opencode/cli/bin/opencode.exe", b"native") + .canonicalize() + .unwrap(); + let metadata = fixture.file("node_modules/@opencode/cli/package.json", + br#"{"name":"@opencode/cli","bin":{"opencode":"./bin/opencode.exe"},"optionalDependencies":{"@opencode/cli-native":"2.0.25"}}"#); + assert!(package_owns(&root, &binary)); + assert!(!bundled(&binary)); + let unrelated = fixture + .file("node_modules/@opencode/cli-native/bin/opencode", b"native") + .canonicalize() + .unwrap(); + assert!(!package_owns(&root, &unrelated)); + for bad in [ + br#"{"name":"opencode-ai","bin":"bin/opencode.exe"}"#.as_slice(), + br#"{"name":"@opencode/cli","bin":"../../../outside"}"#, + ] { + fs::write(&metadata, bad).unwrap(); + assert!(!package_owns(&root, &binary)); + } + let shim = fixture.file( + "global/opencode.cmd", + br#"@ECHO off +"%dp0%\node_modules\@opencode\cli\bin\opencode.exe" %* +"#, + ); + assert!(npm_native_shim(&shim)); + fs::write( + &shim, + br#""%dp0%\node_modules\opencode-ai\bin\opencode.exe" %*"#, + ) + .unwrap(); + assert!(!npm_native_shim(&shim)); +} + +#[cfg(unix)] +#[test] +fn metadata_cannot_block_on_fifo_or_follow_symlink() { + use std::os::unix::{ffi::OsStrExt, fs::symlink}; + let fixture = Fixture::new(); + let fifo = fixture.0.join("fifo"); + let name = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + let started = Instant::now(); + assert!(bounded_file(&fifo, 1024).is_none()); + assert!(started.elapsed() < Duration::from_secs(1)); + let real = fixture.file("real", b"{}"); + let link = fixture.0.join("link"); + symlink(real, &link).unwrap(); + assert!(bounded_file(&link, 1024).is_none()); +} + +#[cfg(unix)] +#[test] +fn curl_detection_is_exact_and_only_verified_stable_native_versions_can_upgrade() { + use std::os::unix::fs::symlink; + let fixture = Fixture::new(); + for (text, expected, reason) in [ + ("1.9.9", Some("1.9.9"), Some("not_v2")), + ("2.0.24", Some("2.0.24"), Some("unsupported")), + ("2.0.25-beta.1", Some("2.0.25-beta.1"), Some("unstable")), + ("OpenCode 2.0.25", None, Some("unsupported")), + ("2.0.25", Some("2.0.25"), None), + ] { + let binary = fixture.executable(".opencode/bin/opencode", &format!( + "#!/bin/sh\n[ \"$1\" = --version ] || exit 99\n[ \"$OPENCODE_DISABLE_AUTOUPDATE\" = 1 ] || exit 98\nprintf '%s' '{text}'\n")); + let info = inspect_path(binary, Some(&fixture.0)).unwrap().info; + assert_eq!(info.method, "curl"); + assert_eq!(info.version.as_deref(), expected); + assert_eq!(info.upgrade_reason.as_deref(), reason); + assert_eq!(info.can_upgrade, reason.is_none()); + } + let binary = fixture.0.join(".opencode/bin/opencode"); + let result = identify_source_with( + &binary, + &binary.canonicalize().unwrap(), + Some(&fixture.0), + Instant::now() + LOCAL_TIMEOUT, + |_| None, + ); + assert_eq!( + (result.0.as_str(), result.2), + ("curl", "manager_unavailable") + ); + fs::remove_file(&binary).unwrap(); + let outside = fixture.executable("outside/opencode", "#!/bin/sh\nexit 0\n"); + symlink(&outside, &binary).unwrap(); + assert!(!official_curl_path( + &binary, + &binary.canonicalize().unwrap(), + &fixture.0 + )); + fs::remove_file(&binary).unwrap(); + fs::remove_dir(fixture.0.join(".opencode/bin")).unwrap(); + symlink(fixture.0.join("outside"), fixture.0.join(".opencode/bin")).unwrap(); + assert!(!official_curl_path( + &binary, + &binary.canonicalize().unwrap(), + &fixture.0 + )); +} + +#[test] +fn bundled_and_manual_sources_fail_closed_without_manager_queries() { + let fixture = Fixture::new(); + for (relative, method, reason) in [ + ( + "OpenCode.app/Contents/Resources/opencode", + "bundled", + "bundled", + ), + ( + "Programs/@openchamberelectron/resources/opencode-cli/opencode.exe", + "bundled", + "bundled", + ), + ( + "opencode-npm-brew/.opencode/bin/opencode", + "manual", + "unsupported", + ), + ] { + let binary = fixture.file(relative, b"fake").canonicalize().unwrap(); + let result = identify_source_with( + &binary, + &binary, + None, + Instant::now() + LOCAL_TIMEOUT, + |_| panic!("must not query manager"), + ); + assert_eq!((result.0.as_str(), result.2), (method, reason)); + assert!(result.1.is_none()); + } + let logical = fixture.0.join("OpenCode.app/Contents/Resources/opencode"); + let canonical = fixture + .0 + .join("global/node_modules/@opencode/cli/bin/opencode.exe"); + let result = identify_source_with( + &logical, + &canonical, + None, + Instant::now() + LOCAL_TIMEOUT, + |_| panic!("bundled links must not query manager"), + ); + assert_eq!((result.0.as_str(), result.2), ("bundled", "bundled")); +} + +#[cfg(unix)] +#[test] +fn each_manager_requires_queried_global_root_bin_and_native_package_metadata() { + use std::os::unix::fs::symlink; + for method in ["npm", "pnpm", "yarn", "bun"] { + let fixture = Fixture::new(); + let (root_rel, bin_rel, root_query, bin_query) = match method { + "npm" => ( + "global/lib/node_modules", + "global/bin", + "root -g", + "prefix -g", + ), + "pnpm" => ("global/node_modules", "global/bin", "root -g", "bin -g"), + "yarn" => ( + "global/node_modules", + "global/bin", + "global dir", + "global bin", + ), + _ => ( + "global/install/global/node_modules", + "global/bin", + "unused", + "pm bin -g", + ), + }; + let root = fixture.0.join(root_rel); + let bin = fixture.0.join(bin_rel); + let binary = fixture + .executable( + &format!("{root_rel}/@opencode/cli/bin/opencode.exe"), + "#!/bin/sh\nprintf '2.0.25\\n'\n", + ) + .canonicalize() + .unwrap(); + let metadata = fixture.file( + &format!("{root_rel}/@opencode/cli/package.json"), + br#"{"name":"@opencode/cli","bin":{"opencode":"./bin/opencode.exe"}}"#, + ); + fs::create_dir_all(&bin).unwrap(); + let logical = bin.join("opencode"); + symlink(&binary, &logical).unwrap(); + let root_reply = if method == "yarn" { + fixture.0.join("global") + } else { + root.clone() + }; + let bin_reply = if method == "npm" { + fixture.0.join("global") + } else { + bin.clone() + }; + let manager = fixture.executable("manager.sh", &format!( + "#!/bin/sh\n[ \"$OPENCODE_DISABLE_AUTOUPDATE\" = 1 ] || exit 98\ncase \"$*\" in\n'{root_query}') printf '%s\\n' '{}' ;;\n'{bin_query}') printf '%s\\n' '{}' ;;\n*) exit 99 ;;\nesac\n", + root_reply.display(), bin_reply.display())); + let identify = |binary: &Path| { + identify_source_with( + &logical, + binary, + None, + Instant::now() + LOCAL_TIMEOUT, + |path| (path == Path::new(method)).then(|| manager.clone()), + ) + }; + let result = identify(&binary); + assert_eq!(result.0, method); + assert_eq!(result.2, ""); + assert!(matches!(result.1, Some(Source::Manager { method: found, .. }) if found == method)); + let unrelated = fixture + .file("other/node_modules/@opencode/cli/bin/opencode", b"fake") + .canonicalize() + .unwrap(); + assert!(identify(&unrelated).1.is_none()); + fs::write( + metadata, + br#"{"name":"opencode-ai","bin":"bin/opencode.exe"}"#, + ) + .unwrap(); + assert!(identify(&binary).1.is_none()); + } +} + +#[cfg(unix)] +#[test] +fn brew_requires_formula_identity_but_never_offers_unpinned_automatic_upgrade() { + use std::os::unix::fs::symlink; + let fixture = Fixture::new(); + let binary = fixture + .executable( + "homebrew/Cellar/opencode-v2/2.0.25/bin/opencode", + "#!/bin/sh\nprintf '2.0.25\\n'\n", + ) + .canonicalize() + .unwrap(); + let prefix = fixture.0.join("homebrew/Cellar/opencode-v2/2.0.25"); + let manager = fixture.executable("homebrew/bin/brew", &format!( + "#!/bin/sh\ncase \"$1\" in\n--prefix) printf '%s\\n' '{}' ;;\ninfo) printf '%s' '{{\"formulae\":[{{\"name\":\"opencode-v2\",\"full_name\":\"anomalyco/tap/opencode-v2\",\"installed\":[{{}}]}}]}}' ;;\n*) exit 99 ;;\nesac\n", prefix.display())); + let logical = fixture.0.join("homebrew/bin/opencode"); + symlink(&binary, &logical).unwrap(); + assert_eq!( + brew_formula(&binary, Instant::now() + LOCAL_TIMEOUT), + Some(true) + ); + let info = inspect_path(logical, None).unwrap().info; + assert_eq!(info.method, "brew"); + assert!(!info.can_upgrade); + assert_eq!(info.upgrade_reason.as_deref(), Some("unsupported")); + fs::write(manager, format!("#!/bin/sh\n[ \"$1\" = --prefix ] && printf '%s\\n' '{}' && exit 0\nprintf '%s' '{{\"formulae\":[{{\"name\":\"opencode-v2\",\"full_name\":\"other/tap/opencode-v2\",\"installed\":[{{}}]}}]}}'\n", prefix.display())).unwrap(); + assert_eq!( + brew_formula(&binary, Instant::now() + LOCAL_TIMEOUT), + Some(false) + ); +} + +#[cfg(unix)] +#[test] +fn local_queries_are_bounded_and_private_and_resolution_preserves_logical_symlinks() { + use std::os::unix::fs::symlink; + let fixture = Fixture::new(); + let bad = fixture.executable( + "nonzero", + "#!/bin/sh\nprintf 'private api-key' >&2\nexit 7\n", + ); + let error = read_version(&bad, Instant::now() + PROCESS_TIMEOUT).unwrap_err(); + assert!(error.message.contains('7')); + assert!(!error.message.contains("private")); + let timeout = fixture.executable("timeout", "#!/bin/sh\nexec sleep 60\n"); + let started = Instant::now(); + assert!(read_version(&timeout, started + Duration::from_millis(100)) + .unwrap_err() + .message + .contains("timed out")); + assert!(started.elapsed() < Duration::from_secs(2)); + let oversized = fixture.executable( + "oversized", + "#!/bin/sh\nwhile :; do printf 1234567890123456789012345678901234567890; done\n", + ); + assert!(read_version(&oversized, Instant::now() + PROCESS_TIMEOUT) + .unwrap_err() + .message + .contains("limit")); + fixture.file("first/opencode", b"not executable"); + let executable = fixture.executable("second/opencode", "#!/bin/sh\nexit 0\n"); + let path = std::env::join_paths([fixture.0.join("first"), fixture.0.join("second")]).unwrap(); + assert_eq!( + resolve_path_command_in(Path::new("opencode"), Some(&path), &fixture.0), + Some(executable.clone()) + ); + let link = fixture.0.join("first/alias"); + symlink(&executable, &link).unwrap(); + assert_eq!(resolve_path_command_in(&link, None, &fixture.0), Some(link)); + assert_eq!( + resolve_path_command_in(Path::new("second/opencode"), None, &fixture.0), + Some(executable) + ); +} + +#[cfg(unix)] +#[test] +fn fake_native_upgrade_observes_exact_args_null_stdin_and_same_path_readback() { + let fixture = Fixture::new(); + let state = fixture.file("version", b"2.0.25"); + let marker = fixture.0.join("upgraded"); + let binary = fixture.executable(".opencode/bin/opencode", &format!( + "#!/bin/sh\n[ \"$OPENCODE_DISABLE_AUTOUPDATE\" = 1 ] || exit 98\nif [ \"$1\" = --version ]; then exec /bin/cat '{}'; fi\n[ \"$*\" = 'upgrade 2.0.26 --method curl' ] || exit 97\nif read input; then exit 96; fi\nprintf 'private stdout'\nprintf 'private stderr' >&2\nprintf 2.0.26 > '{}'\nprintf ok > '{}'\n", + state.display(), state.display(), marker.display())); + let before = inspect_path(binary.clone(), Some(&fixture.0)).unwrap(); + let info = guarded_upgrade( + before.info.binary_path.as_deref().unwrap(), + "2.0.25", + "2.0.26", + |path| inspect_path(path.unwrap_or(&binary).to_path_buf(), Some(&fixture.0)), + || stable_v2("2.0.26"), + |value, target| run_upgrade(&mut upgrade_command(value, target)?, PROCESS_TIMEOUT), + ) + .unwrap(); + assert_eq!(info.version.as_deref(), Some("2.0.26")); + assert_eq!(fs::read(marker).unwrap(), b"ok"); +} + +#[cfg(unix)] +#[test] +fn native_upgrade_failures_discard_diagnostics_and_success_does_not_signal_reaped_group() { + let fixture = Fixture::new(); + let binary = fixture.executable( + "fake-cli", + "#!/bin/sh\nprintf 'private stdout'\nprintf 'private stderr' >&2\nexit 7\n", + ); + let mut value = detected("2.0.25"); + value.logical = Some(binary.clone()); + let command = || upgrade_command(&value, "2.0.26").unwrap(); + let error = run_upgrade(&mut command(), PROCESS_TIMEOUT).unwrap_err(); + assert!(error.message.contains('7')); + assert!(!error.message.contains("private")); + let pidfile = fixture.0.join("owned.pid"); + fs::write( + &binary, + format!( + "#!/bin/sh\nsleep 60 >/dev/null 2>&1 &\nprintf '%s' \"$!\" > '{}'\nexit 0\n", + pidfile.display() + ), + ) + .unwrap(); + run_upgrade(&mut command(), PROCESS_TIMEOUT).unwrap(); + let pid: i32 = fs::read_to_string(pidfile).unwrap().parse().unwrap(); + let alive = unsafe { libc::kill(pid, 0) }; + let _ = unsafe { libc::kill(pid, libc::SIGKILL) }; + assert_eq!( + alive, 0, + "normal success must not signal a reaped process group" + ); +} + +#[cfg(unix)] +#[test] +fn upgrade_timeout_cleans_its_group_without_touching_an_existing_service() { + use std::process::Child; + struct Sleep(Child); + impl Drop for Sleep { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let outside = Sleep(Command::new("/bin/sleep").arg("60").spawn().unwrap()); + let fixture = Fixture::new(); + let pidfile = fixture.0.join("owned.pid"); + let binary = fixture.executable( + "fake-cli", + &format!( + "#!/bin/sh\nsleep 60 &\nprintf '%s' \"$!\" > '{}'\nwait\n", + pidfile.display() + ), + ); + let mut value = detected("2.0.25"); + value.logical = Some(binary); + let mut command = upgrade_command(&value, "2.0.26").unwrap(); + let started = Instant::now(); + let error = run_upgrade(&mut command, Duration::from_secs(1)).unwrap_err(); + assert!(error.message.contains("timed out")); + assert!(started.elapsed() < Duration::from_secs(3)); + assert_eq!(unsafe { libc::kill(outside.0.id() as libc::pid_t, 0) }, 0); + let pid: i32 = fs::read_to_string(pidfile).unwrap().parse().unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + while unsafe { libc::kill(pid, 0) } == 0 && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); +} + +fn response(status: &str, headers: &str, body: &[u8]) -> String { + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{headers}Connection: close\r\n\r\n{}", + body.len(), + String::from_utf8_lossy(body) + ) +} + +fn fake_http(reply: String, delay: Duration) -> (String, thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/fixture", listener.local_addr().unwrap()); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + assert_eq!(stream.read(&mut byte).unwrap(), 1); + request.push(byte[0]); + assert!(request.len() < 8192); + } + thread::sleep(delay); + let _ = stream.write_all(reply.as_bytes()); + String::from_utf8(request).unwrap() + }); + (url, worker) +} + +#[test] +fn metadata_http_is_bounded_private_nonredirecting_and_timeout_errors_hide_bodies() { + let client = http_client().unwrap(); + let (url, worker) = fake_http(response("200 OK", "", &latest("2.0.26")), Duration::ZERO); + assert_eq!( + latest_version(&fetch_bytes(&client, &url, 16 * 1024).unwrap()) + .unwrap() + .text, + "2.0.26" + ); + let request = worker.join().unwrap().to_ascii_lowercase(); + assert!(!request.contains("authorization:") && !request.contains("cookie:")); + for reply in [ + response( + "302 Found", + "Location: http://127.0.0.1:1/untrusted\r\n", + b"private", + ), + response("200 OK", "", &vec![b'x'; 1025]), + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n".to_owned() + &"x".repeat(1025), + ] { + let (url, worker) = fake_http(reply, Duration::ZERO); + let error = fetch_bytes(&client, &url, 1024).unwrap_err(); + assert!(!error.message.contains("private")); + worker.join().unwrap(); + } + let client = Client::builder() + .timeout(Duration::from_millis(100)) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let (url, worker) = fake_http( + response("200 OK", "", b"private"), + Duration::from_millis(250), + ); + let started = Instant::now(); + let error = fetch_bytes(&client, &url, 1024).unwrap_err(); + assert!(!error.message.contains("private")); + assert!(started.elapsed() < Duration::from_secs(2)); + worker.join().unwrap(); +} diff --git a/src-tauri/src/opencode_cli/mod.rs b/src-tauri/src/opencode_cli/mod.rs index 58f57fa..d72c1a3 100644 --- a/src-tauri/src/opencode_cli/mod.rs +++ b/src-tauri/src/opencode_cli/mod.rs @@ -1,12 +1,17 @@ mod active; mod binary; mod command; +pub(crate) mod installation; +pub(crate) mod island; mod models; mod reload; +pub(crate) mod target; mod token_usage; pub use active::active_sessions_via_cli; pub use binary::resolve_opencode_binary; +pub use installation::{OpenCodeInstallation, OpenCodeUpdateCheck}; pub use models::{list_models_via_cli, ModelCatalogEntry}; pub use reload::reload_via_cli; +pub use target::{OpenCodeServiceStatus, OpenCodeServiceTarget}; pub use token_usage::{token_usage_records_via_cli, TokenUsageRecord}; diff --git a/src-tauri/src/opencode_cli/models.rs b/src-tauri/src/opencode_cli/models.rs index f4b6a7e..22cf0b1 100644 --- a/src-tauri/src/opencode_cli/models.rs +++ b/src-tauri/src/opencode_cli/models.rs @@ -1,17 +1,15 @@ use std::collections::BTreeSet; -use std::path::Path; -use std::process::Command; +use std::time::{Duration, Instant}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::error::AppError; use crate::paths::ConfigPaths; -use super::binary::opencode_candidates; +use super::token_usage::transport::{self, ManagedConnection}; -/// Frontend-facing catalog entry. V2's `opencode models` prints one `provider/model` -/// ref per line; rich metadata comes from the local config's `providers` block. +/// Frontend-facing catalog entry; local config remains authoritative for metadata. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ModelCatalogEntry { @@ -25,76 +23,79 @@ pub struct ModelCatalogEntry { pub variants: Option, } -fn run_opencode_models() -> Result { - let mut last_error: Option = None; - let candidates = opencode_candidates(); - let total = candidates.len(); - for (index, binary) in candidates.iter().enumerate() { - // Skip probing non-existent paths, except the final bare `opencode` - // candidate which resolves through PATH at spawn time. - if !binary.exists() && index + 1 < total { - continue; - } - match run_with_binary(binary) { - Ok(stdout) => return Ok(stdout), - Err(e) => last_error = Some(format!("{}: {e}", binary.display())), - } - } - Err(AppError::configuration(format!( - "failed to run opencode CLI — tried {}, none worked. Install the opencode CLI and ensure it is on PATH, or set OPENCODE_BIN.", - last_error.unwrap_or_else(|| "no candidates found".to_owned()) - ))) +#[derive(Deserialize)] +struct Catalog { + data: Vec, } -/// Spawn `opencode models` with the given binary and return stdout. -fn run_with_binary(binary: &Path) -> Result { - // std::process handles the platform specifics of launching `opencode`: - // Windows searches PATHEXT (skipping extension-less shims) and runs .cmd/.bat - // through cmd.exe; Unix uses execvp which natively runs shebang scripts/bins. - let mut cmd = Command::new(binary); - cmd.arg("models"); - - let output = cmd.output().map_err(|e| { - AppError::configuration(format!( - "failed to spawn opencode binary at {}: {e}", - binary.display() - )) - })?; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiModel { + id: String, + #[serde(rename = "providerID")] + provider_id: String, + #[serde(rename = "modelID")] + model_id: String, + enabled: bool, +} - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - let detail = if stderr.is_empty() { stdout } else { stderr }; - return Err(AppError::configuration(format!( - "opencode models failed ({}): {}", - output.status, detail - ))); +pub(super) fn model_refs( + connection: &ManagedConnection, + deadline: Instant, +) -> Result, AppError> { + let directory = connection.directory().and_then(|path| path.to_str()); + let query: Vec<_> = directory + .into_iter() + .map(|directory| ("location[directory]", directory)) + .collect(); + let catalog: super::active::JsonObject = connection.get_json( + &["model"], + &query, + deadline, + 8 * 1024 * 1024, + "model catalog", + "8 MiB", + )?; + let mut refs = BTreeSet::new(); + for model in catalog.0.data { + if [&model.id, &model.provider_id, &model.model_id] + .into_iter() + .any(|value| { + value.is_empty() + || value.len() > 4096 + || value.contains(char::is_whitespace) + || value.chars().any(char::is_control) + }) + || model.provider_id.contains('/') + { + return Err(AppError::configuration( + "Invalid OpenCode model catalog identity", + )); + } + if model.enabled { + refs.insert(format!("{}/{}", model.provider_id, model.model_id)); + } } - - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + Ok(refs) } -/// Parses plain `opencode models` output: one `provider/model` ref per line. -fn parse_model_refs(stdout: &str) -> BTreeSet { - stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.contains(char::is_whitespace)) - .map(str::to_owned) - .collect() +/// Lists enabled models from the selected local service plus all configured models. +pub fn list_models_via_cli( + paths: &ConfigPaths, + provider_filter: Option<&str>, +) -> Result, AppError> { + let deadline = Instant::now() + Duration::from_secs(20); + let connection = transport::connect(deadline)?; + list_models(paths, provider_filter, &connection, deadline) } -/// Lists models via the local opencode binary, merged with the local config. -/// -/// The CLI provides the enabled catalog refs; config-provided models (including -/// disabled ones) are always present so they stay editable. `provider_filter` -/// narrows the result client-side because V2's CLI takes no positional argument. -pub fn list_models_via_cli( +fn list_models( paths: &ConfigPaths, provider_filter: Option<&str>, + connection: &ManagedConnection, + deadline: Instant, ) -> Result, AppError> { - let stdout = run_opencode_models()?; - let mut refs: BTreeSet = parse_model_refs(&stdout); + let mut refs = model_refs(connection, deadline)?; let config_providers = crate::providers::list_providers(&paths.opencode_file())?; let custom_ids = crate::providers::custom_provider_ids(&paths.opencode_file())?; @@ -136,5 +137,9 @@ pub fn list_models_via_cli( .and_then(|variants| serde_json::to_value(variants).ok()), }); } + connection.ensure_current()?; Ok(catalog) } + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/opencode_cli/models/tests.rs b/src-tauri/src/opencode_cli/models/tests.rs new file mode 100644 index 0000000..08ff556 --- /dev/null +++ b/src-tauri/src/opencode_cli/models/tests.rs @@ -0,0 +1,47 @@ +use super::*; +use crate::opencode_cli::token_usage::tests::{connection, healthy_response, respond, FakeApi}; + +#[test] +fn model_route_uses_provider_model_identity_and_preserves_disabled_config_metadata() { + let home = std::env::temp_dir().join(format!("mom-models-{}", uuid::Uuid::new_v4())); + let paths = ConfigPaths::for_home(&home); + std::fs::create_dir_all(paths.opencode_file().parent().unwrap()).unwrap(); + std::fs::write(paths.opencode_file(), br#"{"providers":{"custom":{"models":{"disabled":{"id":"disabled","name":"Configured disabled","disabled":true,"limit":{"context":123}}}}}}"#).unwrap(); + let api = FakeApi::new(|target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + assert!(target.starts_with("/api/model")); + respond(stream, 200, "", br#"{"location":{"directory":"/fixture"},"data":[{"id":"unrelated","providerID":"api","modelID":"enabled","enabled":true},{"id":"other","providerID":"api","modelID":"disabled","enabled":false}]}"#); + } + }); + let connection = connection(&api); + let catalog = list_models( + &paths, + None, + &connection, + Instant::now() + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!( + catalog + .iter() + .map(|model| model.model_ref.as_str()) + .collect::>(), + ["api/enabled", "custom/disabled"] + ); + assert_eq!(catalog[1].name, "Configured disabled"); + assert_eq!(catalog[1].limit.as_ref().unwrap()["context"], 123); + assert_eq!( + list_models( + &paths, + Some("custom"), + &connection, + Instant::now() + Duration::from_secs(1) + ) + .unwrap() + .len(), + 1 + ); + std::fs::remove_dir_all(home).unwrap(); +} diff --git a/src-tauri/src/opencode_cli/reload.rs b/src-tauri/src/opencode_cli/reload.rs index 038ec37..cb3696e 100644 --- a/src-tauri/src/opencode_cli/reload.rs +++ b/src-tauri/src/opencode_cli/reload.rs @@ -1,39 +1,13 @@ -use std::process::Command; +use std::time::{Duration, Instant}; use crate::error::AppError; -use super::binary::opencode_candidates; +use super::token_usage::transport; pub fn reload_via_cli() -> Result<(), AppError> { - let candidates = opencode_candidates(); - let mut last_error = None; - for (index, binary) in candidates.iter().enumerate() { - if !binary.exists() && index + 1 < candidates.len() { - continue; - } - match Command::new(binary).arg("reload").output() { - Ok(output) => return reload_result(output), - Err(error) => last_error = Some(format!("{}: {error}", binary.display())), - } - } - Err(AppError::configuration(format!( - "failed to run opencode CLI — tried {}, none worked. Install the opencode CLI and ensure it is on PATH, or set OPENCODE_BIN.", - last_error.unwrap_or_else(|| "no candidates found".to_owned()) - ))) + let deadline = Instant::now() + Duration::from_secs(20); + transport::connect(deadline)?.reload(deadline) } -fn reload_result(output: std::process::Output) -> Result<(), AppError> { - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - let detail = if stderr.is_empty() { stdout } else { stderr }; - return Err(AppError::configuration(format!( - "opencode reload failed ({}): {}", - output.status, detail - ))); - } - Ok(()) -} - -#[cfg(all(test, unix))] +#[cfg(test)] mod tests; diff --git a/src-tauri/src/opencode_cli/reload/tests.rs b/src-tauri/src/opencode_cli/reload/tests.rs index 0bddff4..86e725c 100644 --- a/src-tauri/src/opencode_cli/reload/tests.rs +++ b/src-tauri/src/opencode_cli/reload/tests.rs @@ -1,15 +1,32 @@ -use std::os::unix::process::ExitStatusExt; -use std::process::{ExitStatus, Output}; +use std::time::{Duration, Instant}; -use super::reload_result; +use crate::opencode_cli::token_usage::tests::{connection, healthy_response, respond, FakeApi}; #[test] -fn reload_nonzero_reports_failure() { - let error = reload_result(Output { - status: ExitStatus::from_raw(7 << 8), - stdout: Vec::new(), - stderr: b"reload failed".to_vec(), - }) - .unwrap_err(); - assert!(error.message.contains("reload failed")); +fn reload_accepts_204_and_never_retries_or_exposes_errors() { + for status in [204, 401, 500] { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let requested = calls.clone(); + let api = FakeApi::new(move |target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + assert_eq!(target, "/api/location/reload"); + requested.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + respond( + stream, + status, + "", + if status == 204 { b"" } else { b"PRIVATE_ERROR" }, + ); + } + }); + let result = connection(&api).reload(Instant::now() + Duration::from_secs(1)); + if status == 204 { + result.unwrap(); + } else { + assert!(!result.unwrap_err().message.contains("PRIVATE")); + } + assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1); + } } diff --git a/src-tauri/src/opencode_cli/target.rs b/src-tauri/src/opencode_cli/target.rs new file mode 100644 index 0000000..f01e4cb --- /dev/null +++ b/src-tauri/src/opencode_cli/target.rs @@ -0,0 +1,558 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, ErrorCode}; +use crate::paths::ConfigPaths; + +#[cfg(not(windows))] +const MAX_BYTES: u64 = 8192; +#[cfg(windows)] +const MAX_BYTES: u64 = windows::MAX_FILE_BYTES as u64; + +#[cfg(any(windows, test))] +mod windows; +static RUNTIME: OnceLock> = OnceLock::new(); +static TRANSITION: Mutex<()> = Mutex::new(()); + +#[derive(Clone, Deserialize, Serialize, Default)] +#[serde(deny_unknown_fields)] +pub(super) struct Selection { + #[serde(deserialize_with = "nullable_string")] + pub(super) url: Option, + #[serde(deserialize_with = "nullable_string")] + pub(super) password: Option, +} + +fn nullable_string<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::deserialize(deserializer) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCodeServiceTarget { + pub mode: String, + pub url: Option, + pub has_password: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCodeServiceStatus { + pub target: OpenCodeServiceTarget, + pub connected: bool, + pub url: Option, + pub pid: Option, + pub version: Option, + pub error: Option, +} + +impl Selection { + pub(super) fn public(&self) -> OpenCodeServiceTarget { + OpenCodeServiceTarget { + mode: if self.url.is_some() { + "explicit" + } else { + "shared" + } + .into(), + url: self.url.clone(), + has_password: self.password.is_some(), + } + } +} + +struct State { + selection: Result, + revision: u64, +} + +pub(crate) struct TargetRuntime { + path: PathBuf, + pub(super) registration: PathBuf, + pub(super) directory: PathBuf, + state: RwLock, + in_flight: AtomicUsize, +} + +pub(super) struct SideEffectReservation(Option>); + +impl Drop for SideEffectReservation { + fn drop(&mut self) { + if let Some(runtime) = &self.0 { + runtime.in_flight.fetch_sub(1, Ordering::SeqCst); + } + } +} + +#[derive(Clone)] +pub(super) struct Snapshot { + pub(super) selection: Selection, + pub(super) revision: u64, + runtime: Option>, +} + +impl Snapshot { + pub(super) fn reserve_side_effect( + &self, + deadline: std::time::Instant, + ) -> Result { + let _transition = transition_until(deadline)?; + self.ensure_current()?; + if let Some(runtime) = &self.runtime { + runtime.in_flight.fetch_add(1, Ordering::SeqCst); + } + Ok(SideEffectReservation(self.runtime.clone())) + } + + pub(super) fn ensure_current(&self) -> Result<(), AppError> { + if let Some(runtime) = &self.runtime { + let state = runtime + .state + .read() + .map_err(|_| failure("state unavailable"))?; + if state.revision != self.revision { + return Err(failure("target changed; retry the operation")); + } + } + Ok(()) + } + + pub(super) fn directory(&self) -> Option<&Path> { + self.runtime + .as_ref() + .map(|runtime| runtime.directory.as_path()) + } + + pub(super) fn registration(&self) -> Result<&Path, AppError> { + self.runtime + .as_ref() + .map(|runtime| runtime.registration.as_path()) + .ok_or_else(|| failure("registration path unavailable")) + } + + pub(super) fn detached(selection: Selection) -> Self { + Self { + selection, + revision: 0, + runtime: None, + } + } +} + +pub(crate) fn transition() -> Result, AppError> { + TRANSITION + .lock() + .map_err(|_| failure("transition unavailable")) +} + +pub(super) fn transition_until( + deadline: std::time::Instant, +) -> Result, AppError> { + loop { + if std::time::Instant::now() >= deadline { + return Err(failure("transition timed out")); + } + match TRANSITION.try_lock() { + Ok(guard) => return Ok(guard), + Err(std::sync::TryLockError::Poisoned(_)) => { + return Err(failure("transition unavailable")) + } + Err(std::sync::TryLockError::WouldBlock) => { + std::thread::sleep(std::time::Duration::from_millis(5)) + } + } + } +} + +fn failure(reason: &str) -> AppError { + AppError::configuration(format!("OpenCode service target: {reason}.")) +} + +pub(super) fn normalize_url(value: &str) -> Result { + let invalid = || { + AppError::validation("Use HTTP localhost, 127.0.0.1 or [::1] with an explicit positive port and no path, credentials, query or fragment") + }; + if value.len() > 256 || value.chars().any(char::is_control) { + return Err(invalid()); + } + let authority = value.strip_prefix("http://").ok_or_else(invalid)?; + let authority = authority.strip_suffix('/').unwrap_or(authority); + let (host, port) = authority.rsplit_once(':').ok_or_else(invalid)?; + if !matches!(host, "localhost" | "127.0.0.1" | "[::1]") + || port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(invalid()); + } + let port = port + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(invalid)?; + Ok(format!( + "http://{}:{port}", + if host == "localhost" { + "127.0.0.1" + } else { + host + } + )) +} + +fn password_valid(password: &str) -> bool { + password.len() <= 4096 && !password.chars().any(char::is_control) +} + +fn updated( + old: Option<&Selection>, + url: Option, + password: Option, +) -> Result { + let Some(url) = url else { + return Ok(Selection::default()); + }; + let url = normalize_url(&url)?; + if password + .as_deref() + .is_some_and(|password| !password_valid(password)) + { + return Err(AppError::validation( + "Service password must be at most 4096 bytes without control characters", + )); + } + let password = match password { + Some(password) => (!password.is_empty()).then_some(password), + None => old + .filter(|old| old.url.as_deref() == Some(&url)) + .and_then(|old| old.password.clone()), + }; + Ok(Selection { + url: Some(url), + password, + }) +} + +fn load(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); + } + let file = match options.open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Selection::default()) + } + Err(_) => return Err(failure("private configuration unreadable")), + }; + let metadata = file + .metadata() + .map_err(|_| failure("private configuration unreadable"))?; + if !metadata.is_file() || metadata.len() > MAX_BYTES { + return Err(failure("configuration must be a bounded regular file")); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(failure("configuration reparse points are not supported")); + } + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o777 != 0o600 { + return Err(failure( + "configuration must be account-owned with mode 0600", + )); + } + } + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| failure("private configuration unreadable"))?; + if bytes.len() as u64 > MAX_BYTES { + return Err(failure("configuration byte limit exceeded")); + } + #[cfg(windows)] + return windows::decode(&bytes, windows::unprotect); + #[cfg(not(windows))] + { + let selection: super::active::JsonObject = + serde_json::from_slice(&bytes).map_err(|_| failure("invalid private configuration"))?; + validate_selection(selection.0) + } +} + +fn validate_selection(selection: Selection) -> Result { + match &selection.url { + Some(url) => { + let url = normalize_url(url).map_err(|_| failure("invalid configured endpoint"))?; + if selection + .password + .as_deref() + .is_some_and(|password| password.is_empty() || !password_valid(password)) + { + return Err(failure("invalid configured password")); + } + Ok(Selection { + url: Some(url), + password: selection.password, + }) + } + None if selection.password.is_none() => Ok(selection), + None => Err(failure("shared target cannot contain a password")), + } +} + +fn persist(path: &Path, selection: &Selection) -> Result<(), AppError> { + let directory = path + .parent() + .ok_or_else(|| failure("configuration directory unavailable"))?; + fs::create_dir_all(directory).map_err(|_| failure("configuration directory unavailable"))?; + #[cfg(unix)] + let directory_handle = + File::open(directory).map_err(|_| failure("configuration directory unavailable"))?; + #[cfg(not(windows))] + let bytes = + serde_json::to_vec(selection).map_err(|_| failure("configuration serialization failed"))?; + #[cfg(windows)] + let bytes = windows::encode(selection, windows::protect)?; + if bytes.len() as u64 > MAX_BYTES { + return Err(failure("configuration byte limit exceeded")); + } + let temporary = directory.join(format!( + ".opencode-service-{}.pending", + uuid::Uuid::new_v4() + )); + let mut owned = false; + let result = (|| { + let mut file = private_file(&temporary)?; + owned = true; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| failure("private configuration write failed"))?; + drop(file); + #[cfg(not(windows))] + fs::rename(&temporary, path) + .map_err(|_| failure("private configuration publication failed"))?; + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + let source: Vec = temporary.as_os_str().encode_wide().chain(Some(0)).collect(); + let destination: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + if unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + return Err(failure("private configuration publication failed")); + } + } + #[cfg(unix)] + if directory_handle.sync_all().is_err() { + // Publication already committed; keep disk and runtime selection consistent. + eprintln!("OpenCode service target saved, but directory sync failed"); + } + Ok(()) + })(); + // Only this create_new UUID path is ours; never remove somebody else's temporary file. + if owned { + let _ = fs::remove_file(&temporary); + } + result +} + +fn private_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options + .open(path) + .map_err(|_| failure("private temporary file creation failed"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if file + .set_permissions(fs::Permissions::from_mode(0o600)) + .is_err() + { + let _ = fs::remove_file(path); + return Err(failure("private file permissions failed")); + } + } + Ok(file) +} + +impl TargetRuntime { + pub(super) fn new(path: PathBuf, registration: PathBuf, directory: PathBuf) -> Arc { + Arc::new(Self { + state: RwLock::new(State { + selection: load(&path), + revision: 1, + }), + in_flight: AtomicUsize::new(0), + path, + registration, + directory, + }) + } + + pub(super) fn snapshot(self: &Arc) -> Result { + let state = self + .state + .read() + .map_err(|_| failure("state unavailable"))?; + Ok(Snapshot { + selection: state.selection.clone()?, + revision: state.revision, + runtime: Some(self.clone()), + }) + } + + #[cfg(test)] + pub(super) fn set( + self: &Arc, + url: Option, + password: Option, + preflight: impl FnOnce() -> Result<(), AppError>, + ) -> Result { + self.set_with_refresh(url, password, preflight, |()| ()) + .map(|(snapshot, ())| snapshot) + } + + pub(super) fn set_with_refresh( + self: &Arc, + url: Option, + password: Option, + prepare: impl FnOnce() -> Result, + commit: impl FnOnce(G) -> R, + ) -> Result<(Snapshot, R), AppError> { + let _transition = transition()?; + if self.in_flight.load(Ordering::SeqCst) != 0 { + return Err(AppError::new( + ErrorCode::Busy, + "OpenCode service operation still in flight; wait before changing the target", + )); + } + let selection = { + let state = self + .state + .read() + .map_err(|_| failure("state unavailable"))?; + updated(state.selection.as_ref().ok(), url, password)? + }; + let guards = prepare()?; + let mut state = self + .state + .write() + .map_err(|_| failure("state unavailable"))?; + persist(&self.path, &selection)?; + state.selection = Ok(selection.clone()); + state.revision += 1; + let snapshot = Snapshot { + selection, + revision: state.revision, + runtime: Some(self.clone()), + }; + drop(state); + // Only local invalidation is allowed here; native work/readers wait until finish. + let refresh = commit(guards); + Ok((snapshot, refresh)) + } +} + +pub(crate) fn initialize(paths: &ConfigPaths) -> Result<(), AppError> { + let registration = + registration_path(paths, std::env::var_os("XDG_STATE_HOME").map(PathBuf::from)); + let directory = + std::env::current_dir().map_err(|_| failure("working directory unavailable"))?; + RUNTIME + .set(TargetRuntime::new( + paths.app_config_dir().join("opencode-service.json"), + registration, + directory, + )) + .map_err(|_| failure("already initialized")) +} + +fn registration_path(paths: &ConfigPaths, state_home: Option) -> PathBuf { + state_home + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| paths.home_dir().join(".local/state")) + .join("opencode/service.json") +} + +pub(crate) fn runtime() -> Result<&'static Arc, AppError> { + RUNTIME.get().ok_or_else(|| failure("not initialized")) +} + +pub(super) fn snapshot() -> Result { + runtime()?.snapshot() +} + +pub(crate) fn get_status() -> Result { + super::token_usage::transport::service_status( + snapshot()?, + std::time::Instant::now() + std::time::Duration::from_secs(5), + ) +} + +pub(crate) fn set_target( + url: Option, + password: Option, + prepare: impl FnOnce() -> Result, + commit: impl FnOnce(G) -> R, + finish: impl FnOnce(R), +) -> Result { + let (snapshot, refresh) = runtime()?.set_with_refresh(url, password, prepare, commit)?; + Ok(finish_target(snapshot, refresh, finish)) +} + +fn finish_target( + snapshot: Snapshot, + refresh: R, + finish: impl FnOnce(R), +) -> OpenCodeServiceStatus { + let target = snapshot.selection.public(); + finish(refresh); + super::token_usage::transport::service_status( + snapshot, + std::time::Instant::now() + std::time::Duration::from_secs(5), + ) + .unwrap_or_else(|_| OpenCodeServiceStatus { + target, + connected: false, + url: None, + pid: None, + version: None, + error: Some("OpenCode service target changed or connection status unavailable".into()), + }) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/opencode_cli/target/tests.rs b/src-tauri/src/opencode_cli/target/tests.rs new file mode 100644 index 0000000..a7e2e57 --- /dev/null +++ b/src-tauri/src/opencode_cli/target/tests.rs @@ -0,0 +1,804 @@ +use std::time::{Duration, Instant}; + +use serde_json::json; + +use super::*; +use crate::opencode_cli::token_usage::transport; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("mom-target-{}", uuid::Uuid::new_v4())); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn runtime(&self) -> Arc { + TargetRuntime::new( + self.0.join("target.json"), + self.0.join("registration.json"), + self.0.clone(), + ) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn only_exact_local_http_endpoints_with_positive_ports_are_accepted() { + for (url, expected) in [ + ("http://localhost:80/", "http://127.0.0.1:80"), + ("http://127.0.0.1:01234", "http://127.0.0.1:1234"), + ("http://[::1]:65535", "http://[::1]:65535"), + ] { + assert_eq!(normalize_url(url).unwrap(), expected); + } + for url in [ + "http://127.1:1234", + "http://2130706433:1234", + "http://0x7f000001:1234", + "http://127.0.0.2:1234", + "http://localhost.:1234", + "http://LOCALHOST:1234", + "https://127.0.0.1:1234", + "http://example.com:1234", + "http://127.0.0.1", + "http://localhost:0", + "http://localhost:65536", + "http://localhost:+80", + "http://PRIVATE@localhost:1234", + "http://localhost:1234/api", + "http://localhost:1234/?PRIVATE", + "http://localhost:1234/#PRIVATE", + " http://localhost:1234", + "http://localhost:1234\n", + "http://[::ffff:127.0.0.1]:1234", + ] { + let error = normalize_url(url).unwrap_err(); + assert!(!error.message.contains("PRIVATE")); + assert!(error.detail.is_none()); + } +} + +#[test] +fn private_save_retains_only_same_normalized_endpoint_and_publishes_after_persistence() { + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let original = runtime.snapshot().unwrap(); + let saved = runtime + .set( + Some("http://localhost:1234".into()), + Some("PRIVATE_PASSWORD".into()), + || Ok(()), + ) + .unwrap(); + assert_eq!( + saved.selection.url.as_deref(), + Some("http://127.0.0.1:1234") + ); + assert!(original.ensure_current().is_err()); + let json = serde_json::to_value(saved.selection.public()).unwrap(); + assert_eq!( + json, + json!({"mode":"explicit", "url":"http://127.0.0.1:1234", "hasPassword":true}) + ); + assert!(!json.to_string().contains("PRIVATE_PASSWORD")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&runtime.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + let retained = runtime + .set(Some("http://localhost:1234/".into()), None, || Ok(())) + .unwrap(); + assert_eq!( + retained.selection.password.as_deref(), + Some("PRIVATE_PASSWORD") + ); + let cleared = runtime + .set( + Some("http://localhost:1234".into()), + Some(String::new()), + || Ok(()), + ) + .unwrap(); + assert!(cleared.selection.password.is_none()); + runtime + .set( + Some("http://localhost:1234".into()), + Some("private".into()), + || Ok(()), + ) + .unwrap(); + let changed = runtime + .set(Some("http://localhost:1235".into()), None, || Ok(())) + .unwrap(); + assert!(changed.selection.password.is_none()); + let shared = runtime + .set(None, Some("PRIVATE_IGNORED".into()), || Ok(())) + .unwrap(); + assert!(shared.selection.password.is_none()); + assert_eq!( + serde_json::to_value(shared.selection.public()).unwrap(), + json!({"mode":"shared", "url":null, "hasPassword":false}) + ); + #[cfg(not(windows))] + assert_eq!( + serde_json::to_value(load(&runtime.path).unwrap()).unwrap(), + json!({"url":null,"password":null}) + ); + #[cfg(windows)] + assert_eq!( + serde_json::from_slice::(&fs::read(&runtime.path).unwrap()).unwrap(), + json!({"url":null,"encryptedPassword":null}) + ); + fs::remove_file(&runtime.path).unwrap(); + fs::create_dir(&runtime.path).unwrap(); + assert!(runtime + .set(Some("http://localhost:1234".into()), None, || Ok(())) + .is_err()); + shared.ensure_current().unwrap(); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1); +} + +#[test] +fn malformed_oversized_nonprivate_and_nonregular_configuration_fail_closed() { + let fixture = Fixture::new(); + let path = fixture.0.join("target.json"); + for bytes in [ + b"PRIVATE_INVALID_JSON".to_vec(), + vec![b'x'; 8193], + br#"{"url":null,"password":"PRIVATE"}"#.to_vec(), + br#"{"url":"http://PRIVATE:1234","password":null}"#.to_vec(), + br#"[null,null]"#.to_vec(), + br#"{}"#.to_vec(), + br#"{"url":"http://localhost:1234","url":null,"password":null}"#.to_vec(), + ] { + let mut file = private_file(&path).unwrap(); + file.write_all(&bytes).unwrap(); + drop(file); + let error = load(&path).err().unwrap(); + assert!(!error.message.contains("PRIVATE")); + assert!(fixture.runtime().snapshot().is_err()); + fs::remove_file(&path).unwrap(); + } + #[cfg(unix)] + { + use std::ffi::CString; + use std::os::unix::{ffi::OsStrExt, fs::PermissionsExt}; + fs::write(&path, b"{}").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(load(&path).is_err()); + fs::remove_file(&path).unwrap(); + let name = CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + let now = Instant::now(); + assert!(load(&path).is_err()); + assert!(now.elapsed() < Duration::from_millis(200)); + } + for password in ["PRIVATE\nPASSWORD".into(), "x".repeat(4097)] { + assert!(updated(None, Some("http://localhost:1234".into()), Some(password)).is_err()); + } +} + +#[test] +fn missing_shared_status_does_not_bootstrap_and_always_serializes_nulls() { + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let status = transport::service_status( + runtime.snapshot().unwrap(), + Instant::now() + Duration::from_secs(1), + ) + .unwrap(); + let json = serde_json::to_value(status).unwrap(); + assert_eq!(json["connected"], false); + for key in ["url", "pid", "version"] { + assert!(json.get(key).unwrap().is_null()); + } + assert!(json["target"]["url"].is_null()); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); +} + +#[test] +fn fixed_offline_save_succeeds_and_current_directory_is_captured_in_catalog_query() { + use crate::opencode_cli::models; + use crate::opencode_cli::token_usage::tests::{healthy_response, respond, FakeApi}; + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let saved = runtime + .set(Some(url), Some("private".into()), || Ok(())) + .unwrap(); + let status = transport::service_status(saved, Instant::now() + Duration::from_secs(1)).unwrap(); + assert!(!status.connected && status.target.has_password); + assert!(load(&runtime.path).unwrap().url.is_some()); + let directory = runtime.directory.to_str().unwrap().to_owned(); + let api = FakeApi::new(move |target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + let url = reqwest::Url::parse(&format!("http://127.0.0.1:1234{target}")).unwrap(); + assert!(matches!(url.path(), "/api/model" | "/api/location/reload")); + assert_eq!( + url.query_pairs().collect::>(), + [("location[directory]".into(), directory.clone().into())] + ); + if url.path() == "/api/model" { + respond(stream, 200, "", br#"{"location":{},"data":[]}"#); + } else { + respond(stream, 204, "", b""); + } + } + }); + let snapshot = runtime + .set(Some(api.url()), Some("private".into()), || Ok(())) + .unwrap(); + let connection = transport::connect_target( + snapshot, + &runtime.registration, + Instant::now() + Duration::from_secs(1), + |_| panic!("explicit must not bootstrap"), + ) + .unwrap(); + assert!( + models::model_refs(&connection, Instant::now() + Duration::from_secs(1)) + .unwrap() + .is_empty() + ); + connection + .reload(Instant::now() + Duration::from_secs(1)) + .unwrap(); +} + +#[test] +fn stale_long_read_and_old_async_connection_cannot_return_or_write_after_target_change() { + use crate::opencode_cli::active; + use crate::opencode_cli::token_usage::tests::{healthy_response, respond, FakeApi}; + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let changed = runtime.clone(); + let api = FakeApi::new(move |target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + changed.set(None, None, || Ok(())).unwrap(); + respond(stream, 200, "", br#"{"data":{}}"#); + } + }); + let snapshot = runtime + .set(Some(api.url()), Some("private".into()), || Ok(())) + .unwrap(); + let connection = transport::connect_target( + snapshot, + &runtime.registration, + Instant::now() + Duration::from_secs(1), + |_| panic!("no bootstrap"), + ) + .unwrap(); + let async_connection = transport::connect_target( + runtime.snapshot().unwrap(), + &runtime.registration, + Instant::now() + Duration::from_secs(1), + |_| panic!("no bootstrap"), + ) + .unwrap() + .into_async() + .unwrap(); + let error = + active::active_sessions(&connection, Instant::now() + Duration::from_secs(1)).unwrap_err(); + assert!(error.message.contains("target changed")); + assert!(connection + .reload(Instant::now() + Duration::from_secs(1)) + .is_err()); + assert!(connection.into_async().is_err()); + assert!(async_connection + .request(reqwest::Method::POST, &["location", "reload"]) + .is_err()); +} + +#[test] +fn dispatch_lock_wait_is_bounded_by_operation_deadline() { + let guard = transition().unwrap(); + let now = Instant::now(); + let worker = std::thread::spawn(|| { + transition_until(Instant::now() + Duration::from_millis(30)) + .err() + .unwrap() + }); + assert!(worker.join().unwrap().message.contains("timed out")); + assert!(now.elapsed() < Duration::from_millis(200)); + drop(guard); +} + +#[test] +fn reservation_blocks_save_without_holding_transition_and_rejects_stale_dispatch() { + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let snapshot = runtime.snapshot().unwrap(); + let reservation = snapshot + .reserve_side_effect(Instant::now() + Duration::from_secs(1)) + .unwrap(); + drop(transition().unwrap()); + let started = Instant::now(); + let error = runtime + .set_with_refresh( + None, + None, + || -> Result<(), AppError> { panic!("busy save must not prepare") }, + |()| panic!("busy save must not commit"), + ) + .err() + .unwrap(); + assert_eq!(error.code, ErrorCode::Busy); + assert!(started.elapsed() < Duration::from_millis(200)); + assert!(!runtime.path.exists()); + drop(reservation); + runtime.set(None, None, || Ok(())).unwrap(); + assert!(snapshot + .reserve_side_effect(Instant::now() + Duration::from_secs(1)) + .is_err()); +} + +#[test] +fn in_flight_reload_post_rejects_target_save_before_prepare() { + use crate::opencode_cli::token_usage::tests::{healthy_response, respond, FakeApi}; + + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let changed = runtime.clone(); + let posts = Arc::new(AtomicUsize::new(0)); + let calls = posts.clone(); + let api = FakeApi::new(move |target, stream| { + if target == "/api/info" { + healthy_response(stream); + } else { + assert!(target.starts_with("/api/location/reload?")); + calls.fetch_add(1, Ordering::SeqCst); + let error = changed + .set_with_refresh( + None, + None, + || -> Result<(), AppError> { panic!("in-flight POST must not prepare") }, + |()| panic!("in-flight POST must not commit"), + ) + .err() + .unwrap(); + assert_eq!(error.code, ErrorCode::Busy); + respond(stream, 204, "", b""); + } + }); + let snapshot = runtime + .set(Some(api.url()), Some("private".into()), || Ok(())) + .unwrap(); + let connection = transport::connect_target( + snapshot.clone(), + &runtime.registration, + Instant::now() + Duration::from_secs(1), + |_| panic!("explicit must not bootstrap"), + ) + .unwrap(); + connection + .reload(Instant::now() + Duration::from_secs(1)) + .unwrap(); + snapshot.ensure_current().unwrap(); + assert_eq!(posts.load(Ordering::SeqCst), 1); +} + +#[test] +fn cancelled_blocking_bootstrap_still_reserves_target_until_worker_finishes() { + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let snapshot = runtime.snapshot().unwrap(); + let registration = runtime.registration.clone(); + let deadline = Instant::now() + Duration::from_secs(5); + let reservation = snapshot.reserve_side_effect(deadline).unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let worker = tauri::async_runtime::spawn_blocking(move || { + let result = { + let _reservation = reservation; + transport::connect_target(snapshot, ®istration, deadline, |_| { + entered_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + Err(AppError::configuration("synthetic bootstrap complete")) + }) + }; + finished_tx.send(()).unwrap(); + result.map(|_| ()) + }); + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(runtime.in_flight.load(Ordering::SeqCst), 2); + worker.abort(); + drop(worker); + let error = runtime + .set_with_refresh( + None, + None, + || -> Result<(), AppError> { panic!("in-flight bootstrap must not prepare") }, + |()| panic!("in-flight bootstrap must not commit"), + ) + .err() + .unwrap(); + assert_eq!(error.code, ErrorCode::Busy); + drop(transition().unwrap()); + release_tx.send(()).unwrap(); + finished_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + runtime.set(None, None, || Ok(())).unwrap(); +} + +#[test] +fn registration_uses_captured_home_and_absolute_state_override_not_config_parents() { + let fixture = Fixture::new(); + let home = fixture.0.join("home"); + let paths = ConfigPaths::with_resource_parts( + home.clone(), + Some(fixture.0.join("custom/config.json")), + Some(fixture.0.join("elsewhere")), + fixture.0.join("moved/config"), + fixture.0.clone(), + ); + assert_eq!( + registration_path(&paths, None), + home.join(".local/state/opencode/service.json") + ); + assert_eq!( + registration_path(&paths, Some("relative".into())), + home.join(".local/state/opencode/service.json") + ); + let state = fixture.0.join("state"); + assert_eq!( + registration_path(&paths, Some(state.clone())), + state.join("opencode/service.json") + ); +} + +#[test] +fn windows_disk_contract_keeps_only_ciphertext_and_preserves_password_selection_rules() { + let selection = updated( + None, + Some("http://localhost:1234".into()), + Some("local-secret".into()), + ) + .unwrap(); + let bytes = windows::encode(&selection, |password| { + assert_eq!(password, b"local-secret"); + Ok(vec![7, 8, 9]) + }) + .unwrap(); + let disk: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + disk, + json!({"url":"http://127.0.0.1:1234", "encryptedPassword":[7,8,9]}) + ); + assert!(!String::from_utf8(bytes.clone()) + .unwrap() + .contains("local-secret")); + assert!(disk.get("password").is_none()); + let restored = windows::decode(&bytes, |cipher| { + assert_eq!(cipher, [7, 8, 9]); + Ok(b"local-secret".to_vec()) + }) + .unwrap(); + assert_eq!(restored.password.as_deref(), Some("local-secret")); + let retained = updated(Some(&restored), Some("http://localhost:1234/".into()), None).unwrap(); + assert_eq!(retained.password, restored.password); + for cleared in [ + updated( + Some(&restored), + Some("http://localhost:1234".into()), + Some(String::new()), + ) + .unwrap(), + updated(Some(&restored), Some("http://localhost:1235".into()), None).unwrap(), + updated(Some(&restored), None, None).unwrap(), + ] { + assert!(cleared.password.is_none()); + let bytes = windows::encode(&cleared, |_| panic!("no crypto for absent password")).unwrap(); + assert!( + windows::decode(&bytes, |_| panic!("no crypto for absent password")) + .unwrap() + .password + .is_none() + ); + } + assert_eq!( + serde_json::to_value(restored.public()).unwrap(), + json!({"mode":"explicit", "url":"http://127.0.0.1:1234", "hasPassword":true}) + ); +} + +#[test] +fn windows_encrypted_config_rejects_legacy_malformed_and_oversized_blobs_before_crypto() { + for value in [ + json!({"url":"http://localhost:1234", "password":"local-secret"}), + json!({"url":"http://localhost:1234", "encryptedPassword":[]}), + json!({"url":"http://localhost:1234", "encryptedPassword":[256]}), + json!({"url":"http://localhost:1234", "encryptedPassword":[-1]}), + json!({"url":"http://localhost:1234", "encryptedPassword":"local-secret"}), + json!({"url":"http://localhost:1234", "encryptedPassword":vec![0; 8193]}), + json!({"url":null, "encryptedPassword":[1]}), + json!({"url":"http://remote:1234", "encryptedPassword":[1]}), + json!({"url":"http://localhost:1234"}), + ] { + let bytes = serde_json::to_vec(&value).unwrap(); + let error = windows::decode(&bytes, |_| panic!("invalid config must not decrypt")) + .err() + .unwrap(); + assert!(!error.message.contains("local-secret")); + assert!(error.detail.is_none()); + } + let bytes = vec![b' '; windows::MAX_FILE_BYTES + 1]; + assert!(windows::decode(&bytes, |_| panic!("oversized file must not decrypt")).is_err()); + assert!(windows::decode( + br#"{"url":"http://localhost:1234","encryptedPassword":[1],"encryptedPassword":null}"#, + |_| panic!("duplicate must not decrypt") + ) + .is_err()); + let max = serde_json::to_vec( + &json!({"url":"http://localhost:1234", "encryptedPassword":vec![0; 8192]}), + ) + .unwrap(); + assert!(windows::decode(&max, |cipher| { + assert_eq!(cipher.len(), 8192); + Ok(b"valid".to_vec()) + }) + .is_ok()); +} + +#[test] +fn windows_crypto_failure_and_invalid_plaintext_never_fallback_or_leak_diagnostics() { + let selection = Selection { + url: Some("http://localhost:1234".into()), + password: Some("local-secret".into()), + }; + let error = windows::encode(&selection, |_| { + Err(AppError::configuration("PRIVATE_PROTECT_ERROR")) + }) + .unwrap_err(); + assert!(!error.message.contains("PRIVATE")); + for ciphertext in [Vec::new(), vec![0; 8193]] { + assert!(windows::encode(&selection, |_| Ok(ciphertext)).is_err()); + } + let bytes = br#"{"url":"http://localhost:1234","encryptedPassword":[1,2]}"#; + let error = windows::decode(bytes, |_| { + Err(AppError::configuration("PRIVATE_WRONG_USER")) + }) + .err() + .unwrap(); + assert!(error.message.contains("decryption failed")); + assert!(!error.message.contains("PRIVATE")); + for plaintext in [ + Vec::new(), + vec![b'x'; 4097], + vec![255], + b"PRIVATE\nSECRET".to_vec(), + ] { + let error = windows::decode(bytes, |_| Ok(plaintext)).err().unwrap(); + assert!(!error.message.contains("PRIVATE")); + assert!(error.detail.is_none()); + } + assert!(windows::decode(bytes, |_| Ok(vec![b'x'; 4096])).is_ok()); +} + +#[cfg(windows)] +#[test] +fn native_dpapi_roundtrip_corruption_and_legacy_repair_are_fail_closed() { + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + runtime + .set( + Some("http://localhost:1234".into()), + Some("local-secret".into()), + || Ok(()), + ) + .unwrap(); + let bytes = fs::read(&runtime.path).unwrap(); + assert!(!String::from_utf8(bytes.clone()) + .unwrap() + .contains("local-secret")); + assert_eq!( + load(&runtime.path).unwrap().password.as_deref(), + Some("local-secret") + ); + let mut disk: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + disk["encryptedPassword"] = json!([1, 2, 3, 4]); + fs::write(&runtime.path, serde_json::to_vec(&disk).unwrap()).unwrap(); + assert!(load(&runtime.path).is_err()); + assert!(windows::unprotect(&[]).is_err()); + assert!(windows::unprotect(&[1, 2, 3]).is_err()); + fs::write( + &runtime.path, + br#"{"url":"http://localhost:1234","password":"local-secret"}"#, + ) + .unwrap(); + let broken = fixture.runtime(); + assert!(broken.snapshot().is_err()); + let cleared = broken + .set( + Some("http://localhost:1234".into()), + Some(String::new()), + || Ok(()), + ) + .unwrap(); + assert!(!cleared.selection.public().has_password); + assert!(load(&runtime.path).unwrap().password.is_none()); +} + +#[test] +fn invalid_selection_prepare_failure_and_failed_persist_never_invalidate_monitors() { + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let old = runtime.snapshot().unwrap(); + let monitor = Mutex::new(17); + assert!(runtime + .set_with_refresh( + Some("http://PRIVATE:1234".into()), + None, + || -> Result<(), AppError> { panic!("invalid selection must not prepare") }, + |()| panic!("invalid selection must not commit"), + ) + .is_err()); + assert!(runtime + .set_with_refresh( + None, + None, + || { + let _guard = monitor.lock().unwrap(); + Err::<(), _>(AppError::configuration("synthetic prepare failure")) + }, + |()| panic!("failed preparation must not commit"), + ) + .is_err()); + assert!(!runtime.path.exists()); + fs::create_dir(&runtime.path).unwrap(); + assert!(runtime + .set_with_refresh( + Some("http://localhost:1234".into()), + Some("PRIVATE_PASSWORD".into()), + || Ok(monitor.lock().unwrap()), + |_| panic!("failed persistence must not commit"), + ) + .is_err()); + assert_eq!(*monitor.try_lock().unwrap(), 17); + old.ensure_current().unwrap(); + assert!(runtime.snapshot().unwrap().selection.url.is_none()); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1); +} + +#[test] +fn commit_publishes_then_invalidates_under_guards_and_finish_precedes_probe_without_transition() { + use crate::opencode_cli::token_usage::tests::{healthy_response, FakeApi}; + use std::sync::atomic::AtomicBool; + + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let old = runtime.snapshot().unwrap(); + let monitor = Mutex::new(Some(old.revision)); + let commits = AtomicUsize::new(0); + let finished = Arc::new(AtomicBool::new(false)); + let probed = Arc::new(AtomicBool::new(false)); + let finished_copy = finished.clone(); + let probed_copy = probed.clone(); + let api = FakeApi::new(move |target, stream| { + assert_eq!(target, "/api/info"); + assert!(finished_copy.load(Ordering::SeqCst)); + probed_copy.store(true, Ordering::SeqCst); + healthy_response(stream); + }); + let (saved, refresh) = runtime + .set_with_refresh( + Some(api.url()), + Some("private".into()), + || { + old.ensure_current().unwrap(); + assert!(!runtime.path.exists()); + Ok(monitor.lock().unwrap()) + }, + |mut guard| { + assert_eq!(commits.fetch_add(1, Ordering::SeqCst), 0); + assert!(TRANSITION.try_lock().is_err()); + assert!(monitor.try_lock().is_err()); + let current = runtime.snapshot().unwrap(); + assert_eq!(current.revision, old.revision + 1); + assert_eq!(load(&runtime.path).unwrap().url, current.selection.url); + assert_eq!(current.selection.password.as_deref(), Some("private")); + assert!(old.ensure_current().is_err()); + assert_eq!(*guard, Some(old.revision)); + *guard = None; + assert!(!finished.load(Ordering::SeqCst)); + assert!(!probed.load(Ordering::SeqCst)); + current.revision + }, + ) + .unwrap(); + let status = finish_target(saved, refresh, |revision| { + let _transition = transition_until(Instant::now() + Duration::from_secs(1)).unwrap(); + assert_eq!(runtime.snapshot().unwrap().revision, revision); + assert!(monitor.try_lock().unwrap().is_none()); + assert!(!probed.load(Ordering::SeqCst)); + finished.store(true, Ordering::SeqCst); + }); + assert_eq!(commits.load(Ordering::SeqCst), 1); + assert!(status.connected); + assert!(probed.load(Ordering::SeqCst)); +} + +#[test] +fn post_commit_status_race_returns_saved_public_target_not_an_error_or_later_target() { + use crate::opencode_cli::token_usage::tests::{healthy_response, FakeApi}; + + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let later_calls = Arc::new(AtomicUsize::new(0)); + let calls = later_calls.clone(); + let later = FakeApi::new(move |_, stream| { + calls.fetch_add(1, Ordering::SeqCst); + healthy_response(stream); + }); + let later_url = later.url(); + let changed = runtime.clone(); + let saved_api = FakeApi::new(move |_, stream| { + changed + .set(Some(later_url.clone()), Some("private".into()), || Ok(())) + .unwrap(); + healthy_response(stream); + }); + let (saved, ()) = runtime + .set_with_refresh( + Some(saved_api.url()), + Some("private".into()), + || Ok(()), + |()| (), + ) + .unwrap(); + let old = saved.clone(); + let status = finish_target(saved, (), |()| ()); + assert!(!status.connected); + assert_eq!(status.target.url.as_deref(), Some(saved_api.url().as_str())); + assert!(status.target.has_password); + assert!(status.url.is_none() && status.pid.is_none() && status.version.is_none()); + assert!(status.error.as_deref().unwrap().contains("target changed")); + assert!(!serde_json::to_string(&status).unwrap().contains("private")); + assert_eq!(runtime.snapshot().unwrap().selection.url, Some(later.url())); + assert_eq!(later_calls.load(Ordering::SeqCst), 0); + assert!(transport::service_status(old, Instant::now() + Duration::from_secs(1)).is_err()); +} + +#[test] +fn monitor_enable_and_disable_serialize_with_target_save_without_requiring_disabled() { + let fixture = Fixture::new(); + let runtime = fixture.runtime(); + let enabled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + for next in [true, false] { + let lock = transition().unwrap(); + let enabled_copy = enabled.clone(); + let runtime_copy = runtime.clone(); + let worker = std::thread::spawn(move || { + runtime_copy.set_with_refresh( + Some("http://localhost:1234".into()), + None, + || Ok(enabled_copy.load(Ordering::SeqCst)), + |captured| { + assert_eq!(captured, next); + assert_eq!(enabled_copy.load(Ordering::SeqCst), next); + }, + ) + }); + enabled.store(next, Ordering::SeqCst); + drop(lock); + worker.join().unwrap().unwrap(); + assert_eq!(enabled.load(Ordering::SeqCst), next); + } + assert!(runtime.path.is_file()); +} diff --git a/src-tauri/src/opencode_cli/target/windows.rs b/src-tauri/src/opencode_cli/target/windows.rs new file mode 100644 index 0000000..3fa4e05 --- /dev/null +++ b/src-tauri/src/opencode_cli/target/windows.rs @@ -0,0 +1,215 @@ +use serde::de::{self, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::{failure, nullable_string, validate_selection, AppError, Selection}; + +pub(super) const MAX_FILE_BYTES: usize = 32 * 1024; +const MAX_CIPHER_BYTES: usize = 8192; +const MAX_PASSWORD_BYTES: usize = 4096; + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct WindowsSelection { + #[serde(deserialize_with = "nullable_string")] + url: Option, + #[serde(deserialize_with = "encrypted_bytes")] + encrypted_password: Option>, +} + +fn encrypted_bytes<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + struct Bytes(Vec); + impl<'de> Deserialize<'de> for Bytes { + fn deserialize>(deserializer: D) -> Result { + struct BoundedBytes; + impl<'de> Visitor<'de> for BoundedBytes { + type Value = Bytes; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a bounded nonempty encrypted byte array") + } + fn visit_seq>(self, mut sequence: A) -> Result { + let mut bytes = Vec::new(); + while let Some(byte) = sequence.next_element::()? { + if bytes.len() == MAX_CIPHER_BYTES { + return Err(de::Error::custom( + "encrypted password byte limit exceeded", + )); + } + bytes.push(byte); + } + if bytes.is_empty() { + return Err(de::Error::custom("empty encrypted password")); + } + Ok(Bytes(bytes)) + } + } + deserializer.deserialize_seq(BoundedBytes) + } + } + Option::::deserialize(deserializer).map(|bytes| bytes.map(|bytes| bytes.0)) +} + +pub(super) fn encode( + selection: &Selection, + protect: impl FnOnce(&[u8]) -> Result, AppError>, +) -> Result, AppError> { + let selection = validate_selection(selection.clone())?; + let encrypted_password = selection + .password + .as_deref() + .map(|password| { + let bytes = + protect(password.as_bytes()).map_err(|_| failure("password protection failed"))?; + if bytes.is_empty() || bytes.len() > MAX_CIPHER_BYTES { + return Err(failure("invalid protected password size")); + } + Ok(bytes) + }) + .transpose()?; + let bytes = serde_json::to_vec(&WindowsSelection { + url: selection.url, + encrypted_password, + }) + .map_err(|_| failure("configuration serialization failed"))?; + if bytes.len() > MAX_FILE_BYTES { + return Err(failure("configuration byte limit exceeded")); + } + Ok(bytes) +} + +pub(super) fn decode( + bytes: &[u8], + unprotect: impl FnOnce(&[u8]) -> Result, AppError>, +) -> Result { + if bytes.len() > MAX_FILE_BYTES { + return Err(failure("configuration byte limit exceeded")); + } + let disk: super::super::active::JsonObject = + serde_json::from_slice(bytes).map_err(|_| failure("invalid encrypted configuration"))?; + let disk = disk.0; + let mut selection = validate_selection(Selection { + url: disk.url, + password: None, + })?; + if let Some(ciphertext) = disk.encrypted_password { + if selection.url.is_none() { + return Err(failure("shared target cannot contain a password")); + } + let mut plaintext = + unprotect(&ciphertext).map_err(|_| failure("password decryption failed"))?; + let password = if plaintext.len() > MAX_PASSWORD_BYTES { + Err(failure("invalid decrypted password size")) + } else { + std::str::from_utf8(&plaintext) + .map(str::to_owned) + .map_err(|_| failure("invalid decrypted password")) + }; + wipe(&mut plaintext); + selection.password = Some(password?); + } + validate_selection(selection) +} + +fn wipe(bytes: &mut [u8]) { + for byte in bytes { + // Volatile writes keep temporary plaintext clearing from being optimized away. + unsafe { std::ptr::write_volatile(byte, 0) }; + } + std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); +} + +#[cfg(windows)] +pub(super) fn protect(bytes: &[u8]) -> Result, AppError> { + dpapi(bytes, true) +} + +#[cfg(windows)] +pub(super) fn unprotect(bytes: &[u8]) -> Result, AppError> { + dpapi(bytes, false) +} + +#[cfg(windows)] +fn dpapi(bytes: &[u8], protecting: bool) -> Result, AppError> { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Cryptography::{ + CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, + }; + + struct LocalBlob { + blob: CRYPT_INTEGER_BLOB, + wipe: bool, + } + impl Drop for LocalBlob { + fn drop(&mut self) { + if !self.blob.pbData.is_null() { + if self.wipe { + wipe(unsafe { + std::slice::from_raw_parts_mut(self.blob.pbData, self.blob.cbData as usize) + }); + } + unsafe { LocalFree(self.blob.pbData.cast()) }; + } + } + } + + let input_limit = if protecting { + MAX_PASSWORD_BYTES + } else { + MAX_CIPHER_BYTES + }; + if bytes.is_empty() || bytes.len() > input_limit { + return Err(failure("invalid data protection input size")); + } + let mut input = bytes.to_vec(); + let blob = CRYPT_INTEGER_BLOB { + cbData: input.len() as u32, + pbData: input.as_mut_ptr(), + }; + let mut output = LocalBlob { + blob: CRYPT_INTEGER_BLOB::default(), + wipe: false, + }; + // Default user scope: no LOCAL_MACHINE, optional entropy, prompt, or description output. + let succeeded = unsafe { + if protecting { + CryptProtectData( + &blob, + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output.blob, + ) + } else { + CryptUnprotectData( + &blob, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output.blob, + ) + } + }; + wipe(&mut input); + let output_limit = if protecting { + MAX_CIPHER_BYTES + } else { + MAX_PASSWORD_BYTES + }; + if succeeded == 0 + || output.blob.pbData.is_null() + || output.blob.cbData == 0 + || output.blob.cbData as usize > output_limit + { + return Err(failure("Windows data protection failed")); + } + output.wipe = !protecting; + Ok( + unsafe { std::slice::from_raw_parts(output.blob.pbData, output.blob.cbData as usize) } + .to_vec(), + ) +} diff --git a/src-tauri/src/opencode_cli/token_usage.rs b/src-tauri/src/opencode_cli/token_usage.rs index fa3de1e..6d97c7b 100644 --- a/src-tauri/src/opencode_cli/token_usage.rs +++ b/src-tauri/src/opencode_cli/token_usage.rs @@ -11,7 +11,7 @@ use serde::{ use crate::error::AppError; -mod transport; +pub(in crate::opencode_cli) mod transport; use transport::ManagedConnection; @@ -224,9 +224,9 @@ fn inspect_session(session: &Session) -> Result<(&str, usize), AppError> { } impl Message { - fn usage(&self) -> Result, AppError> { + fn usage(&self) -> Option<&Tokens> { if !matches!(self.kind.as_str(), "assistant" | "compaction") { - return Ok(None); + return None; } let pending = match self.kind.as_str() { "compaction" => { @@ -244,10 +244,10 @@ impl Message { _ => false, }; if pending { - Ok(None) + None } else { // Absent recorded usage does not establish zero provider billing. - Ok(self.tokens.as_ref()) + self.tokens.as_ref() } } @@ -290,7 +290,6 @@ fn inspect_message(message: &Message) -> Result<(&str, usize), AppError> { if let Some(tokens) = &message.tokens { safe_sum(tokens.input, tokens.output)?; } - message.usage()?; Ok(( &message.id, size_of::() @@ -507,7 +506,7 @@ fn records( for (timeline, offset) in timelines.iter().zip(offsets) { for message in &timeline[offset..] { remaining(deadline)?; - let Some(tokens) = message.usage()? else { + let Some(tokens) = message.usage() else { continue; }; if records.len() == MAX_RECORDS { @@ -529,17 +528,26 @@ fn records( pub fn token_usage_records_via_cli() -> Result, AppError> { let deadline = Instant::now() + SCAN_TIMEOUT; - let budget = Budget::default(); let connection = transport::connect(deadline)?; + scan(&connection, deadline) +} + +pub(super) fn scan( + connection: &ManagedConnection, + deadline: Instant, +) -> Result, AppError> { + let budget = Budget::default(); let sessions = read_pages( |cursor| connection.fetch_page(None, cursor, deadline), inspect_session, &budget, deadline, )?; - let timelines = read_timelines(&connection, &sessions, &budget, deadline)?; - records(&sessions, &timelines, deadline) + let timelines = read_timelines(connection, &sessions, &budget, deadline)?; + let records = records(&sessions, &timelines, deadline)?; + connection.ensure_current()?; + Ok(records) } #[cfg(test)] -mod tests; +pub(in crate::opencode_cli) mod tests; diff --git a/src-tauri/src/opencode_cli/token_usage/tests.rs b/src-tauri/src/opencode_cli/token_usage/tests.rs index 8377539..d1da3e8 100644 --- a/src-tauri/src/opencode_cli/token_usage/tests.rs +++ b/src-tauri/src/opencode_cli/token_usage/tests.rs @@ -643,14 +643,16 @@ fn oversized_page_metadata_is_rejected_before_unbounded_allocation() { assert!(serde_json::from_value::>(page(items, None)).is_err()); } -pub(super) struct FakeApi { +pub(in crate::opencode_cli) struct FakeApi { pub(super) address: SocketAddr, stopping: Arc, thread: Option>, } impl FakeApi { - pub(super) fn new(handler: impl Fn(&str, &mut TcpStream) + Send + Sync + 'static) -> Self { + pub(in crate::opencode_cli) fn new( + handler: impl Fn(&str, &mut TcpStream) + Send + Sync + 'static, + ) -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let stopping = Arc::new(AtomicBool::new(false)); @@ -705,7 +707,7 @@ impl FakeApi { } } - pub(super) fn url(&self) -> String { + pub(in crate::opencode_cli) fn url(&self) -> String { format!("http://{}", self.address) } } @@ -718,7 +720,12 @@ impl Drop for FakeApi { } } -pub(super) fn respond(stream: &mut TcpStream, status: u16, headers: &str, body: &[u8]) { +pub(in crate::opencode_cli) fn respond( + stream: &mut TcpStream, + status: u16, + headers: &str, + body: &[u8], +) { let _ = write!( stream, "HTTP/1.1 {status} Fixture\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n", @@ -727,11 +734,11 @@ pub(super) fn respond(stream: &mut TcpStream, status: u16, headers: &str, body: let _ = stream.write_all(body); } -pub(super) fn healthy_response(stream: &mut TcpStream) { +pub(in crate::opencode_cli) fn healthy_response(stream: &mut TcpStream) { respond(stream, 200, "", br#"{"version":"2.0.22","pid":42}"#); } -pub(super) fn connection(api: &FakeApi) -> ManagedConnection { +pub(in crate::opencode_cli) fn connection(api: &FakeApi) -> ManagedConnection { transport::test_connection(&api.url(), deadline()) } diff --git a/src-tauri/src/opencode_cli/token_usage/transport.rs b/src-tauri/src/opencode_cli/token_usage/transport.rs index 2ccca78..c7c97d7 100644 --- a/src-tauri/src/opencode_cli/token_usage/transport.rs +++ b/src-tauri/src/opencode_cli/token_usage/transport.rs @@ -12,6 +12,9 @@ use super::{failure, remaining, valid_text, AppError, Deserialize, Duration, Ins use super::{PAGE_MAX_BYTES, SCAN_TIMEOUT}; use crate::opencode_cli::binary::opencode_candidates; use crate::opencode_cli::command::collect_output; +use crate::opencode_cli::target::{ + self, OpenCodeServiceStatus, Selection, SideEffectReservation, Snapshot, +}; const REGISTRATION_MAX_BYTES: u64 = 8192; const INFO_MAX_BYTES: u64 = 32 * 1024; @@ -24,7 +27,7 @@ struct Registration { version: String, url: String, pid: u32, - password: String, + password: Option, } #[derive(Deserialize)] @@ -33,13 +36,33 @@ struct Info { pid: u32, } -pub(super) struct ManagedConnection { +pub(in crate::opencode_cli) struct ManagedConnection { client: Client, url: Url, registration: Registration, + snapshot: Snapshot, } impl ManagedConnection { + pub(in crate::opencode_cli) fn into_async(self) -> Result { + self.ensure_current()?; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .connect_timeout(HEALTH_TIMEOUT) + .build() + .map_err(|_| failure("managed API client initialization failed"))?; + Ok(ManagedAsyncConnection { + client, + url: self.url, + password: self.registration.password, + pid: self.registration.pid, + version: self.registration.version, + snapshot: self.snapshot, + }) + } + fn new(client: Client, registration: Registration) -> Result { let invalid = || failure("invalid managed service registration"); let port = registration @@ -60,9 +83,11 @@ impl ManagedConnection { || url.fragment().is_some() || uuid::Uuid::parse_str(®istration.id).is_err() || registration.pid == 0 - || !valid_text(®istration.version, 128) - || !registration.version.starts_with("2.") - || !valid_text(®istration.password, 4096) + || !valid_v2_version(®istration.version) + || !registration + .password + .as_deref() + .is_some_and(|password| valid_text(password, 4096)) { return Err(invalid()); } @@ -70,6 +95,7 @@ impl ManagedConnection { client, url, registration, + snapshot: Snapshot::detached(Selection::default()), }) } @@ -79,11 +105,15 @@ impl ManagedConnection { deadline: Instant, timeout: Duration, ) -> Result { - Ok(self + self.ensure_current()?; + let request = self .client .get(url) - .basic_auth("opencode", Some(&self.registration.password)) - .timeout(remaining(deadline)?.min(timeout))) + .timeout(remaining(deadline)?.min(timeout)); + Ok(match &self.registration.password { + Some(password) => request.basic_auth("opencode", Some(password)), + None => request, + }) } fn healthy(&self, deadline: Instant) -> Result { @@ -97,10 +127,76 @@ impl ManagedConnection { } Err(error) => return Err(request_failure(error)), }; - let info: Info = response_json(response, deadline, INFO_MAX_BYTES, "health", "32 KiB")?; + let info: super::super::active::JsonObject = + response_json(response, deadline, INFO_MAX_BYTES, "health", "32 KiB")?; + let info = info.0; + validate_info(&info)?; Ok(info.pid == self.registration.pid && info.version == self.registration.version) } + pub(in crate::opencode_cli) fn ensure_current(&self) -> Result<(), AppError> { + self.snapshot.ensure_current() + } + + pub(in crate::opencode_cli) fn directory(&self) -> Option<&Path> { + self.snapshot.directory() + } + + pub(in crate::opencode_cli) fn get_json Deserialize<'de>>( + &self, + segments: &[&str], + query: &[(&str, &str)], + deadline: Instant, + max_bytes: u64, + operation: &str, + limit: &str, + ) -> Result { + let mut url = api_url(&self.url, segments)?; + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + let response = self + .request(url, deadline, HEALTH_TIMEOUT)? + .send() + .map_err(request_failure)?; + let value = response_json(response, deadline, max_bytes, operation, limit)?; + self.ensure_current()?; + Ok(value) + } + + pub(in crate::opencode_cli) fn reload(&self, deadline: Instant) -> Result<(), AppError> { + let _reservation = self.snapshot.reserve_side_effect(deadline)?; + if !self.healthy(deadline)? { + return Err(failure( + "service identity changed; reconnect before writing", + )); + } + let mut url = api_url(&self.url, &["location", "reload"])?; + if let Some(directory) = self.directory().and_then(|path| path.to_str()) { + url.query_pairs_mut() + .append_pair("location[directory]", directory); + } + let request = self + .client + .post(url) + .timeout(remaining(deadline)?.min(HEALTH_TIMEOUT)); + let request = match &self.registration.password { + Some(password) => request.basic_auth("opencode", Some(password)), + None => request, + }; + let response = request + .send() + .map_err(|_| failure("reload outcome unknown; do not automatically retry"))?; + remaining(deadline)?; + if response.status() != StatusCode::NO_CONTENT { + return Err(failure(&format!( + "reload API HTTP {}; do not automatically retry", + response.status().as_u16() + ))); + } + self.ensure_current() + } + fn page_url(&self, session_id: Option<&str>, cursor: Option<&str>) -> Result { let mut url = self.url.clone(); { @@ -140,6 +236,7 @@ impl ManagedConnection { .send() .map_err(request_failure)?; let page: Page = response_json(response, deadline, PAGE_MAX_BYTES, "page", "32 MiB")?; + self.ensure_current()?; if session_id.is_some() && page.data.len() > 100 { return Err(failure("message page item limit exceeded")); } @@ -147,6 +244,142 @@ impl ManagedConnection { } } +pub(in crate::opencode_cli) struct ManagedAsyncConnection { + client: reqwest::Client, + url: Url, + password: Option, + pid: u32, + version: String, + snapshot: Snapshot, +} + +impl ManagedAsyncConnection { + pub(in crate::opencode_cli) fn request( + &self, + method: reqwest::Method, + segments: &[&str], + ) -> Result { + self.ensure_current()?; + let url = api_url(&self.url, segments)?; + let request = self.client.request(method, url); + Ok(match &self.password { + Some(password) => request.basic_auth("opencode", Some(password)), + None => request, + }) + } + + pub(in crate::opencode_cli) async fn prepare_write( + &self, + deadline: Instant, + ) -> Result { + let reservation = self.snapshot.reserve_side_effect(deadline)?; + let mut response = self + .request(reqwest::Method::GET, &["info"])? + .timeout(remaining(deadline)?.min(HEALTH_TIMEOUT)) + .send() + .await + .map_err(request_failure)?; + if response.status() != StatusCode::OK { + return Err(failure(&format!("API HTTP {}", response.status().as_u16()))); + } + if response + .content_length() + .is_some_and(|length| length > INFO_MAX_BYTES) + { + return Err(failure("health response exceeds the 32 KiB limit")); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(request_failure)? { + if bytes.len() + chunk.len() > INFO_MAX_BYTES as usize { + return Err(failure("health response exceeds the 32 KiB limit")); + } + bytes.extend_from_slice(&chunk); + } + let info: super::super::active::JsonObject = decode_json(&bytes, "health")?; + validate_info(&info.0)?; + if info.0.pid != self.pid || info.0.version != self.version { + return Err(failure( + "service identity changed; reconnect before writing", + )); + } + remaining(deadline)?; + self.ensure_current()?; + Ok(reservation) + } + + pub(in crate::opencode_cli) fn ensure_current(&self) -> Result<(), AppError> { + // PID/version are the identity verified at connect; all async requests pin this connection. + if self.pid == 0 || !valid_v2_version(&self.version) { + return Err(failure("invalid pinned service identity")); + } + self.snapshot.ensure_current() + } +} + +fn api_url(base: &Url, segments: &[&str]) -> Result { + let mut url = base.clone(); + let mut path = url + .path_segments_mut() + .map_err(|_| failure("invalid managed API base URL"))?; + path.clear().push("api"); + for segment in segments { + if !valid_text(segment, 4096) || matches!(*segment, "." | "..") { + return Err(failure("invalid managed API path segment")); + } + path.push(segment); + } + drop(path); + Ok(url) +} + +fn validate_info(info: &Info) -> Result<(), AppError> { + if info.pid == 0 || !valid_v2_version(&info.version) { + return Err(failure("invalid V2 service identity")); + } + Ok(()) +} + +fn valid_v2_version(version: &str) -> bool { + if version.len() > 128 { + return false; + } + let identifiers = |value: &str, prerelease: bool| { + value.split('.').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && !(prerelease + && part.len() > 1 + && part.starts_with('0') + && part.bytes().all(|byte| byte.is_ascii_digit())) + }) + }; + let (version, build) = version + .split_once('+') + .map_or((version, None), |(version, build)| (version, Some(build))); + if build.is_some_and(|build| !identifiers(build, false)) { + return false; + } + let (version, prerelease) = version + .split_once('-') + .map_or((version, None), |(version, prerelease)| { + (version, Some(prerelease)) + }); + if prerelease.is_some_and(|prerelease| !identifiers(prerelease, true)) { + return false; + } + let parts: Vec<_> = version.split('.').collect(); + parts.len() == 3 + && parts[0] == "2" + && parts.iter().all(|part| { + !part.is_empty() + && (part.len() == 1 || !part.starts_with('0')) + && part.bytes().all(|byte| byte.is_ascii_digit()) + && part.parse::().is_ok() + }) +} + fn request_failure(error: reqwest::Error) -> AppError { failure(if error.is_timeout() { "API request timed out" @@ -157,7 +390,10 @@ fn request_failure(error: reqwest::Error) -> AppError { }) } -fn decode_json Deserialize<'de>>(bytes: &[u8], operation: &str) -> Result { +pub(in crate::opencode_cli) fn decode_json Deserialize<'de>>( + bytes: &[u8], + operation: &str, +) -> Result { serde_json::from_slice(bytes).map_err(|error| { // Serde and HTTP errors can include private values; retain only category/location. failure(&format!( @@ -210,14 +446,6 @@ fn response_json Deserialize<'de>>( result } -fn registration_path() -> Result { - let state = std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .or_else(|| dirs::home_dir().map(|home| home.join(".local/state"))) - .ok_or_else(|| failure("managed service state directory unavailable"))?; - Ok(state.join("opencode/service.json")) -} - fn read_registration(path: &Path, deadline: Instant) -> Result, AppError> { remaining(deadline)?; let mut options = OpenOptions::new(); @@ -285,6 +513,7 @@ fn client() -> Result { Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) .connect_timeout(HEALTH_TIMEOUT) .build() .map_err(|_| failure("managed API client initialization failed")) @@ -329,7 +558,9 @@ fn bootstrap_cli(candidates: &[PathBuf], deadline: Instant) -> Result<(), AppErr "CLI bootstrap failed; managed OpenCode V2 service is required", )); } - let _: Info = decode_json(&output.stdout, "CLI bootstrap health")?; + let info: super::super::active::JsonObject = + decode_json(&output.stdout, "CLI bootstrap health")?; + validate_info(&info.0)?; return Ok(()); } Err(failure(&format!( @@ -337,15 +568,121 @@ fn bootstrap_cli(candidates: &[PathBuf], deadline: Instant) -> Result<(), AppErr ))) } -pub(super) fn connect(deadline: Instant) -> Result { - // Large CLI API responses can be truncated by process.exit; only bootstrap uses stdout. - connect_at(®istration_path()?, deadline, |deadline| { +pub(in crate::opencode_cli) fn connect(deadline: Instant) -> Result { + connect_snapshot(target::snapshot()?, deadline) +} + +pub(in crate::opencode_cli) fn connect_snapshot( + snapshot: Snapshot, + deadline: Instant, +) -> Result { + let registration = snapshot.registration()?.to_owned(); + connect_target(snapshot, ®istration, deadline, |deadline| { bootstrap_cli(&opencode_candidates(), deadline) }) } +pub(in crate::opencode_cli) fn connect_target( + snapshot: Snapshot, + registration: &Path, + deadline: Instant, + bootstrap: impl FnOnce(Instant) -> Result<(), AppError>, +) -> Result { + let mut connection = if snapshot.selection.url.is_some() { + explicit_connection(&snapshot.selection, deadline)? + } else { + connect_at(registration, deadline, |deadline| { + let _reservation = snapshot.reserve_side_effect(deadline)?; + bootstrap(deadline) + })? + }; + snapshot.ensure_current()?; + connection.snapshot = snapshot; + Ok(connection) +} + +fn explicit_connection( + selection: &Selection, + deadline: Instant, +) -> Result { + let url = selection + .url + .as_deref() + .ok_or_else(|| failure("explicit endpoint unavailable"))?; + let url = target::normalize_url(url).map_err(|_| failure("invalid explicit endpoint"))?; + let url = Url::parse(&url).map_err(|_| failure("invalid explicit endpoint"))?; + let client = client()?; + let request = client + .get(api_url(&url, &["info"])?) + .timeout(remaining(deadline)?.min(HEALTH_TIMEOUT)); + let request = match &selection.password { + Some(password) => request.basic_auth("opencode", Some(password)), + None => request, + }; + let response = request.send().map_err(request_failure)?; + let info: super::super::active::JsonObject = + response_json(response, deadline, INFO_MAX_BYTES, "health", "32 KiB")?; + validate_info(&info.0)?; + Ok(ManagedConnection { + client, + url: url.clone(), + registration: Registration { + id: uuid::Uuid::new_v4().to_string(), + url: url.to_string(), + pid: info.0.pid, + version: info.0.version, + password: selection.password.clone(), + }, + snapshot: Snapshot::detached(selection.clone()), + }) +} + +pub(crate) fn service_status( + snapshot: Snapshot, + deadline: Instant, +) -> Result { + let target = snapshot.selection.public(); + let result = (|| { + if snapshot.selection.url.is_some() { + explicit_connection(&snapshot.selection, deadline) + } else { + let registration = read_registration(snapshot.registration()?, deadline)?; + match registration { + Some(registration) => { + let connection = ManagedConnection::new(client()?, registration)?; + if connection.healthy(deadline)? { + Ok(connection) + } else { + Err(failure("shared service unavailable or identity mismatch")) + } + } + None => Err(failure("shared service registration unavailable")), + } + } + })(); + snapshot.ensure_current()?; + Ok(match result { + Ok(connection) => OpenCodeServiceStatus { + target, + connected: true, + url: Some(connection.url.as_str().trim_end_matches('/').into()), + pid: Some(connection.registration.pid), + version: Some(connection.registration.version), + error: None, + }, + Err(error) => OpenCodeServiceStatus { + target, + connected: false, + url: None, + pid: None, + version: None, + error: Some(error.message), + }, + }) +} + #[cfg(test)] -pub(super) fn test_connection(url: &str, deadline: Instant) -> ManagedConnection { +pub(in crate::opencode_cli) fn test_connection(url: &str, deadline: Instant) -> ManagedConnection { let connection = ManagedConnection::new( client().unwrap(), Registration { @@ -353,7 +690,7 @@ pub(super) fn test_connection(url: &str, deadline: Instant) -> ManagedConnection version: "2.0.22".into(), url: url.into(), pid: 42, - password: "private".into(), + password: Some("private".into()), }, ) .unwrap(); diff --git a/src-tauri/src/opencode_cli/token_usage/transport/tests.rs b/src-tauri/src/opencode_cli/token_usage/transport/tests.rs index 16638ef..a223a1a 100644 --- a/src-tauri/src/opencode_cli/token_usage/transport/tests.rs +++ b/src-tauri/src/opencode_cli/token_usage/transport/tests.rs @@ -77,6 +77,7 @@ fn invalid_registration_never_connects_or_bootstraps_and_errors_are_redacted() { ), ("id", json!("PRIVATE_INVALID_ID")), ("version", json!("1.0.0")), + ("version", json!("2.garbage")), ("version", json!("")), ("pid", json!(0)), ("pid", json!(-1)), @@ -188,7 +189,7 @@ fn symlink_to_regular_registration_remains_readable() { .unwrap() .unwrap(); assert_eq!(registration.url, "http://127.0.0.1:1234"); - assert_eq!(registration.password, "private"); + assert_eq!(registration.password.as_deref(), Some("private")); } #[test] @@ -314,6 +315,334 @@ fn health_unauthorized_fails_closed_without_bootstrap() { assert_eq!(error.message, "opencode token usage: API HTTP 401."); } +#[test] +fn fixed_target_selects_b_for_active_catalog_reload_usage_and_async_island_never_registered_a() { + use crate::opencode_cli::target::{Selection, Snapshot}; + use crate::opencode_cli::{active, models, token_usage}; + + let a_calls = Arc::new(AtomicUsize::new(0)); + let calls = a_calls.clone(); + let a = FakeApi::new(move |_, stream| { + calls.fetch_add(1, Ordering::Relaxed); + healthy_response(stream); + }); + let fixture = Fixture::new(); + fixture.write(®istration(&a.url())); + let b_calls = Arc::new(AtomicUsize::new(0)); + let calls = b_calls.clone(); + let b = FakeApi::new(move |path, stream| { + calls.fetch_add(1, Ordering::Relaxed); + match path { + "/api/info" => healthy_response(stream), + "/api/session/active" => respond(stream, 200, "", br#"{"data":{"ses_b":{"type":"running"}}}"#), + "/api/model" => respond(stream, 200, "", br#"{"location":{"directory":"/b"},"data":[{"id":"provider/model","providerID":"provider","modelID":"model","enabled":true}]}"#), + "/api/location/reload" => respond(stream, 204, "", b""), + "/api/session?limit=1000&order=asc" => respond(stream, 200, "", br#"{"data":[],"cursor":{"next":null}}"#), + _ => panic!("unexpected fixture route"), + } + }); + let snapshot = Snapshot::detached(Selection { + url: Some(b.url()), + password: Some("private".into()), + }); + let connection = connect_target( + snapshot.clone(), + &fixture.registration(), + deadline(), + |_| panic!("explicit never bootstraps"), + ) + .unwrap(); + assert_eq!(active::active_sessions(&connection, deadline()).unwrap(), 1); + // Test the real catalog parser independently of local configuration metadata. + assert!(models::model_refs(&connection, deadline()) + .unwrap() + .contains("provider/model")); + connection.reload(deadline()).unwrap(); + assert!(token_usage::scan(&connection, deadline()) + .unwrap() + .is_empty()); + let async_connection = connection.into_async().unwrap(); + let result: Value = tauri::async_runtime::block_on(async { + async_connection + .request(reqwest::Method::GET, &["session", "active"]) + .unwrap() + .send() + .await + .unwrap() + .json() + .await + .unwrap() + }); + assert_eq!(result["data"]["ses_b"]["type"], "running"); + let status = service_status(snapshot, deadline()).unwrap(); + assert!(status.connected); + assert_eq!(status.url.as_deref(), Some(b.url().as_str())); + assert_eq!(a_calls.load(Ordering::Relaxed), 0); + assert!(b_calls.load(Ordering::Relaxed) >= 7); +} + +#[test] +fn explicit_unreachable_unauthorized_redirect_and_invalid_info_never_bootstrap_or_fallback() { + use crate::opencode_cli::target::{Selection, Snapshot}; + let fixture = Fixture::new(); + let a_calls = Arc::new(AtomicUsize::new(0)); + let count = a_calls.clone(); + let a = FakeApi::new(move |_, stream| { + count.fetch_add(1, Ordering::Relaxed); + healthy_response(stream); + }); + fixture.write(®istration(&a.url())); + for (status, body) in [ + (401, "PRIVATE_AUTH"), + (403, "PRIVATE_AUTH"), + (302, "PRIVATE_REDIRECT"), + (200, "{\"pid\":0,\"version\":\"2.0.22\"}"), + (200, "{\"pid\":42,\"version\":\"1.0.0\"}"), + (200, "{\"pid\":42,\"version\":\"2.garbage\"}"), + (200, "[42,\"2.0.22\"]"), + (200, "PRIVATE_INVALID_JSON"), + ] { + let b = FakeApi::new(move |_, stream| { + respond( + stream, + status, + "Location: http://127.0.0.1:1234\r\n", + body.as_bytes(), + ) + }); + let snapshot = Snapshot::detached(Selection { + url: Some(b.url()), + password: Some("private".into()), + }); + let error = connect_target( + snapshot.clone(), + &fixture.registration(), + deadline(), + |_| panic!("explicit never bootstraps"), + ) + .err() + .unwrap(); + assert!(!error.message.contains("PRIVATE")); + let status = service_status(snapshot, deadline()).unwrap(); + assert!(!status.connected); + assert!(status.url.is_none() && status.pid.is_none() && status.version.is_none()); + assert!(!serde_json::to_string(&status).unwrap().contains("private")); + } + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let snapshot = Snapshot::detached(Selection { + url: Some(url), + password: Some("private".into()), + }); + assert!( + connect_target(snapshot, &fixture.registration(), deadline(), |_| panic!( + "down fixed endpoint never bootstraps" + )) + .is_err() + ); + assert_eq!(a_calls.load(Ordering::Relaxed), 0); +} + +#[test] +fn v2_identity_requires_bounded_semantic_version_not_only_major_prefix() { + for version in ["2.0.0", "2.12.34-beta.1", "2.0.22+build.01"] { + assert!(valid_v2_version(version), "{version}"); + } + for version in [ + "2.garbage", + "2.0", + "2.0.0.0", + "2.01.0", + "2.0.00", + "2.0.-1", + "2.0.0-", + "2.0.0-alpha..1", + "2.0.0-01", + "2.0.0+", + "2.0.0+bad+build", + "2.4294967296.0", + "2.0.0\n", + "v2.0.0", + "1.0.0", + ] { + assert!(!valid_v2_version(version), "{version}"); + assert!(validate_info(&Info { + pid: 42, + version: version.into() + }) + .is_err()); + } + assert!(!valid_v2_version(&format!("2.0.0+{}", "x".repeat(128)))); +} + +#[test] +fn explicit_without_password_sends_no_authorization_for_health_reads_reload_and_async() { + use std::io::Read; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + // Connect, read, reload health+POST, async read, write health+POST, status. + for _ in 0..8 { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + assert!(bytes.len() <= 8192); + } + let headers = String::from_utf8(bytes).unwrap(); + assert!(!headers.to_ascii_lowercase().contains("authorization:")); + let length = headers + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + let mut body = vec![0; length]; + stream.read_exact(&mut body).unwrap(); + match headers.split_whitespace().nth(1).unwrap() { + "/api/info" => healthy_response(&mut stream), + "/api/location/reload" => respond(&mut stream, 204, "", b""), + "/api/session/ses_a/prompt" => respond(&mut stream, 200, "", b"null"), + _ => respond(&mut stream, 200, "", br#"{"data":{}}"#), + } + } + }); + let snapshot = Snapshot::detached(Selection { + url: Some(url), + password: None, + }); + let connection = connect_target(snapshot.clone(), Path::new("unused"), deadline(), |_| { + panic!("no bootstrap") + }) + .unwrap(); + let _: Value = connection + .get_json( + &["session", "active"], + &[], + deadline(), + 1024, + "read", + "1 KiB", + ) + .unwrap(); + connection.reload(deadline()).unwrap(); + let connection = connection.into_async().unwrap(); + tauri::async_runtime::block_on(async { + assert!(connection + .request(reqwest::Method::GET, &["session", "active"]) + .unwrap() + .send() + .await + .unwrap() + .status() + .is_success()); + let _reservation = connection.prepare_write(deadline()).await.unwrap(); + assert!(connection + .request(reqwest::Method::POST, &["session", "ses_a", "prompt"]) + .unwrap() + .json(&json!({"text":"hello"})) + .send() + .await + .unwrap() + .status() + .is_success()); + }); + assert!(service_status(snapshot, deadline()).unwrap().connected); + server.join().unwrap(); +} + +#[test] +fn restarted_service_rejects_reload_before_post_without_rebootstrap() { + let changed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = changed.clone(); + let posts = Arc::new(AtomicUsize::new(0)); + let count = posts.clone(); + let api = FakeApi::new(move |target, stream| { + if target == "/api/info" { + respond(stream, 200, "", &serde_json::to_vec(&json!({"pid":if flag.load(Ordering::SeqCst) {43} else {42}, "version":"2.0.22"})).unwrap()); + } else { + count.fetch_add(1, Ordering::SeqCst); + respond(stream, 204, "", b""); + } + }); + let connection = connection(&api); + changed.store(true, Ordering::SeqCst); + assert!(connection + .reload(deadline()) + .unwrap_err() + .message + .contains("identity changed")); + assert_eq!(posts.load(Ordering::SeqCst), 0); +} + +#[test] +fn public_info_does_not_claim_protected_read_ready_or_retry_with_invented_auth() { + use std::io::Read; + for status in [401, 403] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + } + let headers = String::from_utf8(bytes).unwrap(); + assert!(!headers.to_ascii_lowercase().contains("authorization:")); + if headers.split_whitespace().nth(1) == Some("/api/info") { + healthy_response(&mut stream); + } else { + respond(&mut stream, status, "", b"PRIVATE_AUTH_ERROR"); + } + } + listener.set_nonblocking(true).unwrap(); + assert!( + matches!(listener.accept(), Err(error) if error.kind() == io::ErrorKind::WouldBlock) + ); + }); + let snapshot = Snapshot::detached(Selection { + url: Some(url), + password: None, + }); + assert!( + service_status(snapshot.clone(), deadline()) + .unwrap() + .connected + ); + let connection = connect_target(snapshot, Path::new("unused"), deadline(), |_| { + panic!("no fallback") + }) + .unwrap(); + let error = connection + .get_json::( + &["session", "active"], + &[], + deadline(), + 1024, + "read", + "1 KiB", + ) + .unwrap_err(); + assert!(error.message.contains(&format!("HTTP {status}"))); + assert!(!error.message.contains("PRIVATE")); + server.join().unwrap(); + } +} + #[test] fn managed_api_does_not_send_requests_to_environment_proxies() { const PROBE_ENV: &str = "OPENCODE_MOM_HTTP_PROXY_PROBE"; diff --git a/src-tauri/src/power/AGENTS.md b/src-tauri/src/power/AGENTS.md index 9a0d43c..0bc9580 100644 --- a/src-tauri/src/power/AGENTS.md +++ b/src-tauri/src/power/AGENTS.md @@ -24,7 +24,7 @@ power/ # Lid-protection service and platform controllers | Responsibility | File | Description | | --- | --- | --- | -| Service lifecycle | `mod.rs` | `PowerService::new/get/set/shutdown`; generation-scoped `Engine`. | +| Service lifecycle | `mod.rs` | `PowerService::new/get/set/shutdown`, guarded target refresh, and generation-scoped `Engine`. | | Session monitoring | `mod.rs` | Separate query worker calls `active_sessions_via_cli`; controller worker owns timers and mutations. | | macOS controller | `macos.rs` | Stages embedded bytes, requests authorization, verifies socket/peer, and checks sequenced acknowledgments. | | Windows policy | `windows.rs` | `Policy` journals originals, applies zero AC/DC lid actions, and conditionally restores. | @@ -34,18 +34,19 @@ power/ # Lid-protection service and platform controllers ## Conventions -- **Controller boundary**: Keep native effects behind `PowerController::prepare/busy/idle/stop`; `Engine` serializes those calls, while session queries run separately with at most one in flight. -- **Generations**: Setting changes and shutdown invalidate prior work. Publish only for the current generation; stop late native effects before publishing, and expose `Checking` while the desired generation is pending. -- **Monitoring scope**: Query every 2 seconds through the CLI-connected service, including running child sessions; this is not a machine-wide process inventory. +- **Controller boundary**: Keep native effects behind `PowerController::prepare/busy/idle/stop`. A shared effects mutex serializes busy/renew/idle/stop and their generation checks/publication against target commits; never hold the shared-state mutex during controller calls. Interactive prepare holds no transition/effects/shared lock; mark `Shared.preparing` until its result and any late stop are handled. Session queries hold no mutex during HTTP and have at most one actual request in flight. +- **Generations**: Setting changes and shutdown increment both observation and control generations. Target refresh increments only observation generation, clears counts/grace, restores idle, and preserves enabled/authorization/preparation outcome. Never stop or reauthorize solely for a target change; failed/missing authorization remains an Error until explicit enablement. Publish only for the current generation and stop late control-change effects before publication. +- **Target commit**: Under the target transition mutex, call `prepare_target_refresh` to hold effects then shared locks; active authorization returns `busy` without waiting. Dropping the guard leaves state unchanged. Only after persistence and target revision publication call its infallible `invalidate`; call `finish_target_refresh` outside the transition to queue a wake. A disconnected wake channel logs a fixed safe warning and never fails the committed save. It never writes preferences. Disabled monitors remain disabled without prepare/query. +- **Monitoring scope**: Query every 2 seconds through the selected local service's bounded HTTP active endpoint, including running child sessions; this is not a machine-wide process inventory. Errors publish Unknown, never zero/idle. `PowerService::set` shares the target transition mutex. Refresh retains the old query's slot until its result arrives and rejects that result before any native effect. - **Lease timing**: Renew leases of at most 10 seconds within the 30-second grace since the last positive observation. Query failures publish `Unknown` without extending grace; a confirmed zero count restores immediately. -- **Acknowledged state**: Publish `Protected` only after `busy` succeeds and `Idle` only after restoration succeeds. Failed restoration blocks preparation/queries and retries cleanup every 2 seconds. +- **Acknowledged state**: Publish `Protected` only after `busy` succeeds and `Idle` only after restoration succeeds. Failed restoration blocks preparation/queries/busy and retries cleanup every 2 seconds. Target-only idle failures retain prior preparation, resuming queries after recovery; prior preparation failures retain their error without automatic authorization retries. - **Preference semantics**: Missing `power.json` defaults to disabled; malformed or unknown fields fail. Enabling requires a successful save, but failed preference writes must not block an in-memory opt-out and restoration. - **Durability**: Use `settings::durable_write` for app-owned power data: sync the pending file, replace the destination, and use the platform-specific durable replacement path. - **macOS authorization**: Persisted enablement does not authorize a new app run. Explicit enablement permits one authorization attempt per controller; ordinary opt-out sends `Idle` and preserves the authorized connection, whereas `Stop` ends it. - **macOS protocol**: Import `../../native/macos-helper/src/protocol.rs` rather than copying frames. Preserve token authentication, socket ownership/mode and root-peer checks, bounded IPC, and verified acknowledgments; keep restoration-unconfirmed errors visible. - **Journaled recovery**: Windows saves `windows-lid-journal.json` before policy writes; macOS recovery belongs to the helper's root journal. Verify apply/restore readback, preserve external changes, and retain unresolved recovery state. - **Windows ownership**: Record both AC/DC originals for the active scheme; restore only values still equal to the applied zero. Never activate an old scheme during recovery. Windows has no native policy TTL, so restoration depends on the serialized app timer/exit or later journal recovery. -- **Test seams**: Extend `Engine` in `tests.rs` and `Policy` in `windows/tests.rs`; Windows policy tests compile under `cfg(test)` on other hosts. macOS inline tests use local socket pairs; helper policy/watchdog tests live in the separate helper workspace. +- **Test seams**: Extend `Engine` in `tests.rs` and `Policy` in `windows/tests.rs`; service fixtures share mock effects/state and channels, never start `PowerService::new`. Channel barriers cover both native busy/renew versus commit orders and unlocked authorization/late-stop cleanup. Windows policy tests compile under `cfg(test)` on other hosts. macOS inline tests use local socket pairs; helper policy/watchdog tests live in the separate helper workspace. - **Hardware acceptance**: For native policy changes, follow `README.md` → “Manual lid protection acceptance (macOS / Windows)”; keep the trial procedure there. ## Sync diff --git a/src-tauri/src/power/mod.rs b/src-tauri/src/power/mod.rs index 316492a..fc428a4 100644 --- a/src-tauri/src/power/mod.rs +++ b/src-tauri/src/power/mod.rs @@ -10,7 +10,7 @@ use macos::NativeController; use windows::NativeController; use std::path::PathBuf; -use std::sync::{mpsc, Arc, Mutex}; +use std::sync::{mpsc, Arc, Mutex, MutexGuard}; use std::thread; use std::time::{Duration, Instant}; @@ -54,6 +54,7 @@ const SHUTDOWN_WAIT: Duration = Duration::from_secs(3); struct Desired { generation: u64, + control_generation: u64, enabled: bool, authorize: bool, shutdown: bool, @@ -64,6 +65,7 @@ struct Shared { desired: Desired, generation: u64, state: LidState, + preparing: bool, } impl Shared { @@ -84,10 +86,32 @@ impl Shared { pub struct PowerService { directory: PathBuf, shared: Arc>, + effects: Arc>, wake: mpsc::Sender<()>, done: Mutex>, } +pub struct TargetRefresh<'a> { + shared: MutexGuard<'a, Shared>, + _effects: MutexGuard<'a, ()>, +} + +impl TargetRefresh<'_> { + pub fn invalidate(mut self) { + self.shared.desired.generation += 1; + } +} + +struct Preparation(Arc>); + +impl Drop for Preparation { + fn drop(&mut self) { + if let Ok(mut shared) = self.0.lock() { + shared.preparing = false; + } + } +} + impl PowerService { pub fn new(directory: PathBuf) -> Result { let preference = settings::load(&directory); @@ -98,6 +122,7 @@ impl PowerService { let shared = Arc::new(Mutex::new(Shared { desired: Desired { generation: 1, + control_generation: 1, enabled, authorize: !cfg!(target_os = "macos"), shutdown: false, @@ -114,15 +139,22 @@ impl PowerService { active_sessions: None, error, }, + preparing: false, })); + let effects = Arc::new(Mutex::new(())); let (wake, events) = mpsc::channel(); let (finished, done) = mpsc::channel(); let worker_shared = Arc::clone(&shared); + let worker_effects = Arc::clone(&effects); let journal_dir = directory.clone(); thread::Builder::new() .name("lid-controller".into()) .spawn(move || { - let mut engine = Engine::new(NativeController::new(journal_dir), worker_shared); + let mut engine = Engine::new( + NativeController::new(journal_dir), + worker_shared, + worker_effects, + ); run_worker(&mut engine, events); let _ = finished.send(()); }) @@ -130,6 +162,7 @@ impl PowerService { Ok(Self { directory, shared, + effects, wake, done: Mutex::new(done), }) @@ -142,7 +175,35 @@ impl PowerService { .map_err(|_| AppError::configuration("Power monitor lock failed")) } + pub fn prepare_target_refresh(&self) -> Result, AppError> { + let effects = self + .effects + .lock() + .map_err(|_| AppError::configuration("Power effects lock failed"))?; + let shared = self + .shared + .lock() + .map_err(|_| AppError::configuration("Power monitor lock failed"))?; + if shared.preparing { + return Err(AppError::new( + crate::error::ErrorCode::Busy, + "Lid protection authorization is in progress; retry when it finishes", + )); + } + Ok(TargetRefresh { + _effects: effects, + shared, + }) + } + + pub fn finish_target_refresh(&self) { + if self.wake.send(()).is_err() { + eprintln!("Power monitor stopped after target refresh; native recovery/watchdog remains required"); + } + } + pub fn set(&self, enabled: bool) -> Result { + let _transition = crate::opencode_cli::target::transition()?; let mut shared = self .shared .lock() @@ -156,6 +217,7 @@ impl PowerService { } // Failed preference writes must never prevent an in-memory opt-out/restore. shared.desired.generation += 1; + shared.desired.control_generation += 1; shared.desired.enabled = enabled; shared.desired.authorize = true; shared.desired.preference_error = saved.as_ref().err().map(ToString::to_string); @@ -174,6 +236,7 @@ impl PowerService { return; } shared.desired.generation += 1; + shared.desired.control_generation += 1; shared.desired.enabled = false; shared.desired.shutdown = true; } @@ -194,9 +257,12 @@ impl PowerService { struct Engine { controller: C, shared: Arc>, + effects: Arc>, generation: u64, + control_generation: u64, prepared: bool, preparation_attempted: bool, + preparation_error: Option, cleanup_failed: bool, protection: bool, last_busy: Option, @@ -206,13 +272,16 @@ struct Engine { } impl Engine { - fn new(controller: C, shared: Arc>) -> Self { + fn new(controller: C, shared: Arc>, effects: Arc>) -> Self { Self { controller, shared, + effects, generation: 0, + control_generation: 0, prepared: false, preparation_attempted: false, + preparation_error: None, cleanup_failed: false, protection: false, last_busy: None, @@ -250,13 +319,40 @@ impl Engine { self.cleanup_failed = true; self.prepared = false; self.preparation_attempted = true; + self.preparation_error = Some(error.to_string()); self.protection = false; self.last_busy = None; self.error(error); } + fn preparation_failed(&mut self, error: AppError) { + self.prepared = false; + self.preparation_error = Some(error.to_string()); + self.error(error); + } + + fn publish_ready(&self, enabled: bool, preference_error: Option) { + let error = + preference_error.or_else(|| enabled.then(|| self.preparation_error.clone()).flatten()); + self.publish( + if error.is_some() { + LidPhase::Error + } else if !enabled { + LidPhase::Disabled + } else { + LidPhase::Checking + }, + None, + error, + ); + } + fn stale_stop(&mut self) -> bool { - if self.current() { + if self + .shared + .lock() + .is_ok_and(|shared| shared.desired.control_generation == self.control_generation) + { return false; } self.prepared = false; @@ -270,10 +366,59 @@ impl Engine { } fn tick(&mut self, now: Instant) -> bool { - let (generation, enabled, authorize, shutdown, preference_error) = { + let effects = Arc::clone(&self.effects); + let guard = effects.lock().expect("power effects lock poisoned"); + if self.tick_locked(now) { + return true; + } + let preparation = { + let mut shared = self.shared.lock().expect("power monitor lock poisoned"); + if shared.desired.generation == self.generation + && shared.desired.control_generation == self.control_generation + && shared.desired.enabled + && shared.desired.authorize + && !shared.desired.shutdown + && !self.cleanup_failed + && !self.prepared + && !self.preparation_attempted + && now >= self.query_at + { + shared.preparing = true; + self.preparation_attempted = true; + Some(Preparation(Arc::clone(&self.shared))) + } else { + None + } + }; + drop(guard); + if let Some(preparation) = preparation { + // Interactive authorization holds neither transition, effects nor shared locks. + let result = self.controller.prepare(); + let _guard = effects.lock().expect("power effects lock poisoned"); + let mut shared = self.shared.lock().expect("power monitor lock poisoned"); + if shared.desired.control_generation != self.control_generation { + drop(shared); + self.stale_stop(); + } else { + self.prepared = result.is_ok(); + self.preparation_error = result.as_ref().err().map(ToString::to_string); + shared.preparing = false; + drop(shared); + if let Err(error) = result { + self.error(error); + } + } + drop(preparation); + } + false + } + + fn tick_locked(&mut self, now: Instant) -> bool { + let (generation, control_generation, enabled, authorize, shutdown, preference_error) = { let shared = self.shared.lock().expect("power monitor lock poisoned"); ( shared.desired.generation, + shared.desired.control_generation, shared.desired.enabled, shared.desired.authorize, shared.desired.shutdown, @@ -281,15 +426,22 @@ impl Engine { ) }; if generation != self.generation { + let control_changed = control_generation != self.control_generation; self.generation = generation; - self.prepared = false; - self.preparation_attempted = false; + self.control_generation = control_generation; + if control_changed { + self.prepared = false; + self.preparation_attempted = false; + self.preparation_error = None; + } self.protection = false; self.last_busy = None; self.query_at = now; // An ordinary opt-out restores policy but keeps this run's authorized helper reusable. let restored = if shutdown { self.controller.stop() + } else if !control_changed && !enabled && !self.cleanup_failed { + Ok(()) } else { self.controller.idle() }; @@ -299,16 +451,7 @@ impl Engine { self.cleanup_failed = restored.is_err(); match restored { Err(error) => self.error(error), - Ok(()) if !enabled => self.publish( - if preference_error.is_some() { - LidPhase::Error - } else { - LidPhase::Disabled - }, - None, - preference_error.clone(), - ), - Ok(()) => self.publish(LidPhase::Checking, None, None), + Ok(()) => self.publish_ready(enabled, preference_error.clone()), } self.renew_at = now + POLL; } @@ -330,21 +473,7 @@ impl Engine { if let Err(error) = result { self.error(error); } else { - self.publish( - if preference_error.is_some() || (enabled && self.preparation_attempted) { - LidPhase::Error - } else if enabled { - LidPhase::Checking - } else { - LidPhase::Disabled - }, - None, - preference_error.clone().or_else(|| { - (enabled && self.preparation_attempted).then(|| { - "Restoration completed; enable lid protection again to retry".into() - }) - }), - ); + self.publish_ready(enabled, preference_error.clone()); } self.renew_at = now + POLL; } @@ -354,26 +483,11 @@ impl Engine { return false; } if !authorize { - self.publish( - LidPhase::Error, - None, - Some("Enable lid protection again to authorize this app run".into()), - ); - return false; - } - if !self.prepared && !self.preparation_attempted && now >= self.query_at { self.preparation_attempted = true; - let result = self.controller.prepare(); - if self.stale_stop() { - return false; - } - match result { - Ok(()) => self.prepared = true, - Err(error) => { - self.error(error); - return false; - } - } + self.preparation_error = + Some("Enable lid protection again to authorize this app run".into()); + self.publish_ready(enabled, preference_error); + return false; } if self.protection && (now >= self.renew_at || self.last_busy.is_some_and(|busy| now >= busy + GRACE)) @@ -392,9 +506,8 @@ impl Engine { return false; } self.protection = false; - self.prepared = false; match restored { - Ok(()) => self.error(error), + Ok(()) => self.preparation_failed(error), Err(restore) => self.restore_failed(AppError::configuration(format!( "{error}; restore: {restore}" ))), @@ -423,6 +536,8 @@ impl Engine { } fn start_query(&mut self, now: Instant) -> Option { + let effects = Arc::clone(&self.effects); + let _guard = effects.lock().expect("power effects lock poisoned"); let enabled = self .shared .lock() @@ -443,15 +558,27 @@ impl Engine { } fn observation(&mut self, generation: u64, result: Result, now: Instant) { + let effects = Arc::clone(&self.effects); + let _guard = effects.lock().expect("power effects lock poisoned"); + self.observation_locked(generation, result, now); + } + + fn observation_locked( + &mut self, + generation: u64, + result: Result, + now: Instant, + ) { if self.inflight != Some(generation) { return; } self.inflight = None; - self.query_at = now + POLL; if generation != self.generation || !self.current() || self.cleanup_failed || !self.prepared { + self.query_at = now; return; } + self.query_at = now + POLL; match result { Err(error) => self.publish(LidPhase::Unknown, None, Some(error.to_string())), Ok(0) => { @@ -489,10 +616,9 @@ impl Engine { } self.protection = false; self.last_busy = None; - self.prepared = false; self.renew_at = now + POLL; match restored { - Ok(()) => self.error(error), + Ok(()) => self.preparation_failed(error), Err(restore) => self.restore_failed(AppError::configuration(format!( "{error}; restore: {restore}" ))), @@ -518,6 +644,8 @@ fn run_worker(engine: &mut Engine, wake: mpsc::Receiver<( } }); if let Err(error) = query_worker { + let effects = Arc::clone(&engine.effects); + let _guard = effects.lock().expect("power effects lock poisoned"); engine.generation = engine .shared .lock() @@ -549,6 +677,8 @@ fn run_worker(engine: &mut Engine, wake: mpsc::Receiver<( wake.recv_timeout(Duration::from_millis(100)), Err(mpsc::RecvTimeoutError::Disconnected) ) { + let effects = Arc::clone(&engine.effects); + let _guard = effects.lock().expect("power effects lock poisoned"); let _ = engine.controller.stop(); break; } diff --git a/src-tauri/src/power/tests.rs b/src-tauri/src/power/tests.rs index e978384..dfd4e8f 100644 --- a/src-tauri/src/power/tests.rs +++ b/src-tauri/src/power/tests.rs @@ -5,8 +5,12 @@ struct Mock { calls: Vec, leases: Vec, fail_busy: bool, + fail_prepare: bool, fail_idle: bool, fail_stop: bool, + prepare_gate: Option, + busy_gate: Option, + stop_gate: Option, cancel_prepare: Option>>, cancel_busy: Option>>, } @@ -14,19 +18,31 @@ struct Mock { impl PowerController for Mock { fn prepare(&mut self) -> Result<(), AppError> { self.calls.push("prepare".into()); + if let Some(gate) = self.prepare_gate.take() { + gate.wait(); + } if let Some(shared) = self.cancel_prepare.take() { let mut shared = shared.lock().unwrap(); shared.desired.generation += 1; + shared.desired.control_generation += 1; shared.desired.enabled = false; } - Ok(()) + if self.fail_prepare { + Err(AppError::configuration("authorization failed")) + } else { + Ok(()) + } } fn busy(&mut self, ttl: Duration) -> Result<(), AppError> { self.calls.push("busy".into()); self.leases.push(ttl); + if let Some(gate) = self.busy_gate.take() { + gate.wait(); + } if let Some(shared) = self.cancel_busy.take() { let mut shared = shared.lock().unwrap(); shared.desired.generation += 1; + shared.desired.control_generation += 1; shared.desired.enabled = false; } if self.fail_busy { @@ -45,6 +61,9 @@ impl PowerController for Mock { } fn stop(&mut self) -> Result<(), AppError> { self.calls.push("stop".into()); + if let Some(gate) = self.stop_gate.take() { + gate.wait(); + } if self.fail_stop { Err(AppError::configuration("stop restore failed")) } else { @@ -53,10 +72,36 @@ impl PowerController for Mock { } } +struct Gate { + entered: mpsc::Sender<()>, + release: mpsc::Receiver<()>, +} + +impl Gate { + fn wait(self) { + self.entered.send(()).unwrap(); + self.release.recv_timeout(Duration::from_secs(5)).unwrap(); + } +} + +fn gate() -> (Gate, mpsc::Receiver<()>, mpsc::Sender<()>) { + let (entered, arrival) = mpsc::channel(); + let (release, departure) = mpsc::channel(); + ( + Gate { + entered, + release: departure, + }, + arrival, + release, + ) +} + fn engine(enabled: bool) -> Engine { let shared = Arc::new(Mutex::new(Shared { desired: Desired { generation: 1, + control_generation: 1, enabled, authorize: true, shutdown: false, @@ -69,8 +114,21 @@ fn engine(enabled: bool) -> Engine { active_sessions: None, error: None, }, + preparing: false, })); - Engine::new(Mock::default(), shared) + Engine::new(Mock::default(), shared, Arc::new(Mutex::new(()))) +} + +fn service(engine: &Engine) -> PowerService { + let (wake, _events) = mpsc::channel(); + let (_finished, done) = mpsc::channel(); + PowerService { + directory: std::env::temp_dir().join(format!("mom-power-{}", uuid::Uuid::new_v4())), + shared: Arc::clone(&engine.shared), + effects: Arc::clone(&engine.effects), + wake, + done: Mutex::new(done), + } } fn state(engine: &Engine) -> LidState { @@ -86,6 +144,7 @@ fn set(engine: &Engine, enabled: bool) { let mut shared = engine.shared.lock().unwrap(); shared.desired.enabled = enabled; shared.desired.generation += 1; + shared.desired.control_generation += 1; } #[test] @@ -218,6 +277,15 @@ fn startup_authorization_is_explicit_and_not_repeated() { assert_eq!(state(&engine).phase, LidPhase::Error); assert_eq!(engine.controller.calls, ["idle"]); assert!(engine.start_query(now).is_none()); + let error = state(&engine).error; + let service = service(&engine); + service.prepare_target_refresh().unwrap().invalidate(); + engine.tick(now + POLL); + assert_eq!(state(&engine).phase, LidPhase::Error); + assert_eq!(state(&engine).error, error); + assert!(!engine.shared.lock().unwrap().desired.authorize); + assert_eq!(engine.controller.calls, ["idle", "idle"]); + assert!(engine.start_query(now + POLL).is_none()); } #[test] @@ -229,6 +297,7 @@ fn shutdown_invalidates_inflight_query() { { let mut shared = engine.shared.lock().unwrap(); shared.desired.generation += 1; + shared.desired.control_generation += 1; shared.desired.enabled = false; shared.desired.shutdown = true; } @@ -299,6 +368,7 @@ fn preference_save_failure_does_not_block_disable_restore() { let service = PowerService { directory: path.clone(), shared: Arc::clone(&engine.shared), + effects: Arc::clone(&engine.effects), wake, done: Mutex::new(done), }; @@ -310,3 +380,380 @@ fn preference_save_failure_does_not_block_disable_restore() { assert!(!engine.protection); std::fs::remove_file(path).unwrap(); } + +#[test] +fn target_refresh_clears_observations_and_grace_without_reauthorizing_or_stopping() { + let now = Instant::now(); + let mut engine = engine(true); + engine.tick(now); + observe(&mut engine, Ok(4), now); + let service = service(&engine); + let control = engine.control_generation; + service.prepare_target_refresh().unwrap().invalidate(); + assert_eq!(service.get().unwrap().active_sessions, None); + assert_eq!(service.get().unwrap().phase, LidPhase::Checking); + engine.tick(now + POLL); + assert!(engine.prepared); + assert!(engine.preparation_attempted); + assert_eq!(engine.control_generation, control); + assert!(!engine.protection); + assert_eq!(engine.last_busy, None); + assert_eq!(engine.controller.calls, ["idle", "prepare", "busy", "idle"]); + observe( + &mut engine, + Err(AppError::configuration("new target unavailable")), + now + POLL, + ); + assert_eq!(state(&engine).phase, LidPhase::Unknown); + assert_eq!(state(&engine).active_sessions, None); + assert_eq!(engine.last_busy, None); + assert!(engine.prepared); + assert_eq!(engine.controller.calls, ["idle", "prepare", "busy", "idle"]); + observe(&mut engine, Ok(2), now + POLL * 2); + assert_eq!(state(&engine).active_sessions, Some(2)); + engine.tick(now + POLL * 3); + assert_eq!( + engine + .controller + .calls + .iter() + .filter(|s| *s == "prepare") + .count(), + 1 + ); + assert!(!engine.controller.calls.iter().any(|s| s == "stop")); + assert!(!service.directory.exists()); +} + +#[test] +fn target_refresh_preserves_disabled_authorization_and_preference_errors() { + let now = Instant::now(); + let mut disabled = engine(false); + disabled.tick(now); + let disabled_service = service(&disabled); + disabled_service + .prepare_target_refresh() + .unwrap() + .invalidate(); + disabled.tick(now + POLL); + assert_eq!(state(&disabled).phase, LidPhase::Disabled); + assert_eq!(disabled.controller.calls, ["idle"]); + assert!(disabled.start_query(now + POLL).is_none()); + for enabled in [false, true] { + let now = Instant::now(); + let mut engine = engine(enabled); + { + let mut shared = engine.shared.lock().unwrap(); + shared.desired.authorize = false; + shared.desired.preference_error = Some("preference failed".into()); + } + engine.tick(now); + let service = service(&engine); + let calls = engine.controller.calls.clone(); + let before = service.get().unwrap(); + let generation = engine.generation; + drop(service.prepare_target_refresh().unwrap()); + assert_eq!(engine.shared.lock().unwrap().desired.generation, generation); + assert_eq!(service.get().unwrap(), before); + service.prepare_target_refresh().unwrap().invalidate(); + engine.tick(now + POLL); + let shared = engine.shared.lock().unwrap(); + assert_eq!(shared.desired.enabled, enabled); + assert!(!shared.desired.authorize); + assert_eq!( + shared.desired.preference_error.as_deref(), + Some("preference failed") + ); + assert_eq!( + shared.snapshot().error.as_deref(), + Some("preference failed") + ); + assert_eq!(shared.snapshot().phase, LidPhase::Error); + drop(shared); + assert!(engine.start_query(now + POLL).is_none()); + assert!(!engine + .controller + .calls + .iter() + .any(|s| s == "prepare" || s == "stop")); + if !enabled { + assert_eq!(engine.controller.calls, calls); + } + } +} + +#[test] +fn failed_preparation_stays_visible_across_target_refresh_and_restore_retry() { + let now = Instant::now(); + let mut engine = engine(true); + engine.controller.fail_prepare = true; + engine.tick(now); + assert!(!engine.shared.lock().unwrap().preparing); + let error = state(&engine).error; + assert_eq!(error.as_deref(), Some("authorization failed")); + let service = service(&engine); + engine.controller.fail_idle = true; + service.prepare_target_refresh().unwrap().invalidate(); + engine.tick(now + POLL); + assert_eq!(state(&engine).error.as_deref(), Some("restore failed")); + assert!(engine.start_query(now + POLL).is_none()); + engine.controller.fail_idle = false; + engine.tick(now + POLL * 2); + assert_eq!(state(&engine).phase, LidPhase::Error); + assert_eq!(state(&engine).error, error); + engine.tick(now + POLL * 3); + service.prepare_target_refresh().unwrap().invalidate(); + engine.tick(now + POLL * 4); + assert_eq!(state(&engine).phase, LidPhase::Error); + assert_eq!(state(&engine).error, error); + assert!(engine.start_query(now + POLL * 4).is_none()); + assert_eq!( + engine + .controller + .calls + .iter() + .filter(|s| *s == "prepare") + .count(), + 1 + ); + assert!(!engine.controller.calls.iter().any(|s| s == "stop")); +} + +#[test] +fn old_positive_query_keeps_its_slot_until_arrival_and_cannot_apply_after_commit() { + for tick_before_result in [false, true] { + let now = Instant::now(); + let mut engine = engine(true); + engine.tick(now); + let old = engine.start_query(now).unwrap(); + let service = service(&engine); + service.prepare_target_refresh().unwrap().invalidate(); + if tick_before_result { + engine.tick(now + POLL); + } + assert_eq!(engine.inflight, Some(old)); + assert!(engine.start_query(now + POLL).is_none()); + engine.observation(old, Ok(7), now + POLL); + assert_eq!(engine.inflight, None); + assert!(!engine.controller.calls.iter().any(|s| s == "busy")); + assert_eq!(service.get().unwrap().active_sessions, None); + engine.tick(now + POLL); + let fresh = engine.start_query(now + POLL).unwrap(); + assert_ne!(fresh, old); + engine.observation(fresh, Ok(1), now + POLL); + assert_eq!(state(&engine).active_sessions, Some(1)); + } +} + +#[test] +fn target_refresh_restore_failure_blocks_queries_and_busy_until_cleanup_recovers() { + let now = Instant::now(); + let mut engine = engine(true); + engine.tick(now); + observe(&mut engine, Ok(1), now); + let service = service(&engine); + engine.controller.fail_idle = true; + service.prepare_target_refresh().unwrap().invalidate(); + engine.tick(now + POLL); + let calls = engine.controller.calls.len(); + assert!(engine.prepared); + assert!(engine.cleanup_failed); + assert_eq!(state(&engine).phase, LidPhase::Error); + assert!(engine.start_query(now + POLL).is_none()); + engine.tick(now + POLL + POLL / 2); + assert_eq!(engine.controller.calls.len(), calls); + engine.tick(now + POLL * 2); + assert_eq!(engine.controller.calls.last().unwrap(), "idle"); + assert_eq!(engine.controller.leases.len(), 1); + engine.controller.fail_idle = false; + engine.tick(now + POLL * 3); + assert!(!engine.cleanup_failed); + assert_eq!(state(&engine).phase, LidPhase::Checking); + observe(&mut engine, Ok(2), now + POLL * 3); + assert_eq!(state(&engine).phase, LidPhase::Protected); + assert_eq!( + engine + .controller + .calls + .iter() + .filter(|s| *s == "prepare") + .count(), + 1 + ); + assert!(!engine.controller.calls.iter().any(|s| s == "stop")); +} + +#[test] +fn authorization_releases_locks_but_target_refresh_stays_busy_through_late_stop() { + let now = Instant::now(); + let mut engine = engine(true); + let mut service = service(&engine); + let (prepare, entered, release) = gate(); + let (stop, stopping, stopped) = gate(); + engine.controller.prepare_gate = Some(prepare); + engine.controller.stop_gate = Some(stop); + let worker = thread::spawn(move || { + engine.tick(now); + engine + }); + entered.recv_timeout(Duration::from_secs(5)).unwrap(); + let error = service.prepare_target_refresh().err().unwrap(); + assert_eq!(error.code, crate::error::ErrorCode::Busy); + assert!(service.effects.try_lock().is_ok()); + assert!(service.shared.try_lock().is_ok()); + let (wake, _events) = mpsc::channel(); + service.wake = wake; + let blocked = std::env::temp_dir().join(format!("mom-power-optout-{}", uuid::Uuid::new_v4())); + std::fs::write(&blocked, "not a directory").unwrap(); + service.directory = blocked.clone(); + assert!(service.set(false).is_err()); + assert!(!service.get().unwrap().enabled); + assert_eq!( + service.prepare_target_refresh().err().unwrap().code, + crate::error::ErrorCode::Busy + ); + release.send(()).unwrap(); + stopping.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(service.shared.lock().unwrap().preparing); + assert!(service.effects.try_lock().is_err()); + stopped.send(()).unwrap(); + let mut engine = worker.join().unwrap(); + assert!(!service.shared.lock().unwrap().preparing); + assert_eq!(engine.controller.calls, ["idle", "prepare", "stop"]); + drop(service.prepare_target_refresh().unwrap()); + engine.tick(now + POLL); + assert!(!engine.prepared); + std::fs::remove_file(blocked).unwrap(); +} + +#[test] +fn target_commit_and_old_busy_or_renew_are_serialized_in_both_orders() { + for renew in [false, true] { + for commit_first in [false, true] { + let now = Instant::now(); + let mut engine = engine(true); + engine.tick(now); + let generation = if renew { + observe(&mut engine, Ok(1), now); + engine.generation + } else { + engine.start_query(now).unwrap() + }; + let service = service(&engine); + let baseline = engine.controller.leases.len(); + let (busy, entered, release) = gate(); + engine.controller.busy_gate = Some(busy); + if commit_first { + let refresh = service.prepare_target_refresh().unwrap(); + let (attempting, attempt) = mpsc::channel(); + let worker = thread::spawn(move || { + attempting.send(()).unwrap(); + if renew { + engine.tick(now + POLL); + } else { + engine.observation(generation, Ok(8), now + POLL); + } + engine + }); + attempt.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(entered.try_recv().is_err()); + refresh.invalidate(); + engine = worker.join().unwrap(); + assert_eq!(engine.controller.leases.len(), baseline); + engine.controller.busy_gate = None; + } else { + let worker = thread::spawn(move || { + if renew { + engine.tick(now + POLL); + } else { + engine.observation(generation, Ok(8), now + POLL); + } + engine + }); + entered.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(service.effects.try_lock().is_err()); + let (attempting, attempt) = mpsc::channel(); + let (committed, commit) = mpsc::channel(); + thread::scope(|scope| { + scope.spawn(|| { + attempting.send(()).unwrap(); + service.prepare_target_refresh().unwrap().invalidate(); + committed.send(()).unwrap(); + }); + attempt.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(commit.try_recv().is_err()); + release.send(()).unwrap(); + commit.recv_timeout(Duration::from_secs(5)).unwrap(); + }); + engine = worker.join().unwrap(); + assert_eq!(engine.controller.leases.len(), baseline + 1); + } + engine.tick(now + POLL * 2); + assert!(!engine.protection); + assert_eq!(engine.last_busy, None); + assert_eq!(state(&engine).active_sessions, None); + assert_eq!( + engine + .controller + .calls + .iter() + .filter(|s| *s == "prepare") + .count(), + 1 + ); + assert!(!engine.controller.calls.iter().any(|s| s == "stop")); + } + } +} + +#[test] +fn target_guard_prevents_authorization_until_commit_and_releases_on_abort() { + let now = Instant::now(); + let mut engine = engine(true); + let service = service(&engine); + let (prepare, entered, release) = gate(); + engine.controller.prepare_gate = Some(prepare); + let refresh = service.prepare_target_refresh().unwrap(); + let worker = thread::spawn(move || { + engine.tick(now); + engine + }); + assert!(entered.try_recv().is_err()); + refresh.invalidate(); + entered.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!( + service.prepare_target_refresh().err().unwrap().code, + crate::error::ErrorCode::Busy + ); + release.send(()).unwrap(); + let engine = worker.join().unwrap(); + assert!(engine.prepared); + assert!(!service.shared.lock().unwrap().preparing); + let generation = engine.generation; + drop(service.prepare_target_refresh().unwrap()); + assert_eq!( + service.shared.lock().unwrap().desired.generation, + generation + ); + assert_eq!(engine.controller.calls, ["idle", "prepare"]); +} + +#[test] +fn target_refresh_finish_only_queues_wake_and_does_not_fail_a_committed_save() { + let engine = engine(false); + let mut service = service(&engine); + let (wake, events) = mpsc::channel(); + service.wake = wake; + service.prepare_target_refresh().unwrap().invalidate(); + let generation = service.shared.lock().unwrap().desired.generation; + service.finish_target_refresh(); + assert_eq!(events.try_recv(), Ok(())); + drop(events); + service.finish_target_refresh(); + assert_eq!( + service.shared.lock().unwrap().desired.generation, + generation + ); + assert!(!service.get().unwrap().enabled); + assert!(engine.controller.calls.is_empty()); +} From 4bc329cd354e539282bfb3af926e3f5152bc3b60 Mon Sep 17 00:00:00 2001 From: seho Date: Tue, 20 Oct 2026 23:13:01 +0800 Subject: [PATCH 56/97] feat(backend/island): implement macOS Mini Island native window management, geometry, and hover --- src-tauri/src/commands/island.rs | 143 ++ src-tauri/src/island/AGENTS.md | 45 + src-tauri/src/island/geometry.rs | 445 ++++++ src-tauri/src/island/hover.rs | 219 +++ src-tauri/src/island/mod.rs | 982 ++++++++++++ src-tauri/src/island/tests.rs | 1401 +++++++++++++++++ src-tauri/src/island/transition.rs | 144 ++ src-tauri/src/island/window.rs | 773 +++++++++ src-tauri/src/opencode_cli/island.rs | 345 ++++ src-tauri/src/opencode_cli/island/events.rs | 190 +++ .../src/opencode_cli/island/projection.rs | 654 ++++++++ src-tauri/src/opencode_cli/island/tests.rs | 1366 ++++++++++++++++ 12 files changed, 6707 insertions(+) create mode 100644 src-tauri/src/commands/island.rs create mode 100644 src-tauri/src/island/AGENTS.md create mode 100644 src-tauri/src/island/geometry.rs create mode 100644 src-tauri/src/island/hover.rs create mode 100644 src-tauri/src/island/mod.rs create mode 100644 src-tauri/src/island/tests.rs create mode 100644 src-tauri/src/island/transition.rs create mode 100644 src-tauri/src/island/window.rs create mode 100644 src-tauri/src/opencode_cli/island.rs create mode 100644 src-tauri/src/opencode_cli/island/events.rs create mode 100644 src-tauri/src/opencode_cli/island/projection.rs create mode 100644 src-tauri/src/opencode_cli/island/tests.rs diff --git a/src-tauri/src/commands/island.rs b/src-tauri/src/commands/island.rs new file mode 100644 index 0000000..050f5a3 --- /dev/null +++ b/src-tauri/src/commands/island.rs @@ -0,0 +1,143 @@ +use serde_json::{Map, Value}; +use tauri::{AppHandle, Manager, State}; + +use crate::error::AppError; +use crate::island::{IslandService, IslandState, IslandView}; +use crate::opencode_cli::island::IslandMessage; +use crate::paths::ConfigPaths; + +#[tauri::command] +pub(crate) fn get_island_state(service: State<'_, IslandService>) -> Result { + Ok(service.state()) +} + +#[tauri::command] +pub(crate) fn set_island_enabled( + app: AppHandle, + paths: State<'_, ConfigPaths>, + enabled: bool, +) -> Result { + let paths = paths.inner().clone(); + crate::island::on_main(&app, move |app| { + use tauri::Manager; + let service = app.state::(); + service.set_enabled(app, &paths, enabled)?; + Ok(service.state()) + }) +} + +#[tauri::command] +pub(crate) async fn refresh_island( + app: AppHandle, + service: State<'_, IslandService>, +) -> Result { + service.refresh(&app).await?; + Ok(service.state()) +} + +#[tauri::command] +pub(crate) async fn set_island_view(app: AppHandle, view: IslandView) -> Result<(), AppError> { + let generation = app.state::().enabled_generation()?; + let receiver = crate::island::on_main(&app, move |app| { + app.state::().set_view(app, view, generation) + })?; + receiver + .await + .map_err(|_| AppError::configuration("Mini Island native transition closed"))? +} + +#[tauri::command] +pub(crate) async fn island_messages( + service: State<'_, IslandService>, + session_id: String, + expected_connection_generation: u64, +) -> Result, AppError> { + service + .messages(session_id, expected_connection_generation) + .await +} + +#[tauri::command] +pub(crate) async fn island_send( + service: State<'_, IslandService>, + session_id: String, + text: String, + message_id: String, + expected_connection_generation: u64, +) -> Result<(), AppError> { + service + .request( + expected_connection_generation, + |api| async move { api.send(&session_id, &text, &message_id).await }, + true, + ) + .await +} + +#[tauri::command] +pub(crate) async fn island_reply_permission( + service: State<'_, IslandService>, + session_id: String, + request_id: String, + decision: String, + expected_connection_generation: u64, +) -> Result<(), AppError> { + service + .request( + expected_connection_generation, + |api| async move { + api.reply_permission(&session_id, &request_id, &decision) + .await + }, + true, + ) + .await +} + +#[tauri::command] +pub(crate) async fn island_reply_question( + service: State<'_, IslandService>, + session_id: String, + form_id: String, + answer: Map, + expected_connection_generation: u64, +) -> Result<(), AppError> { + service + .request( + expected_connection_generation, + |api| async move { api.reply_question(&session_id, &form_id, answer).await }, + true, + ) + .await +} + +#[tauri::command] +pub(crate) async fn island_cancel_question( + service: State<'_, IslandService>, + session_id: String, + form_id: String, + expected_connection_generation: u64, +) -> Result<(), AppError> { + service + .request( + expected_connection_generation, + |api| async move { api.cancel_question(&session_id, &form_id).await }, + true, + ) + .await +} + +#[tauri::command] +pub(crate) async fn island_interrupt( + service: State<'_, IslandService>, + session_id: String, + expected_connection_generation: u64, +) -> Result<(), AppError> { + service + .request( + expected_connection_generation, + |api| async move { api.interrupt(&session_id).await }, + true, + ) + .await +} diff --git a/src-tauri/src/island/AGENTS.md b/src-tauri/src/island/AGENTS.md new file mode 100644 index 0000000..397e5e6 --- /dev/null +++ b/src-tauri/src/island/AGENTS.md @@ -0,0 +1,45 @@ +# Mini Island Native Service + +## Overview + +macOS-only capsule window, cached IPC state, and one cancellable OpenCode SSE task. Disabled by default; disabling destroys the window and clears local private data, never interrupts sessions or rejects requests. + +## Structure + +- `mod.rs`: managed `IslandService`, shared explicit `IslandView`, generation-guarded requests, event coalescing, reconnect and shutdown. +- `window.rs`: macOS public `NSScreen` safe-area discovery, public window policy, main-thread-local native transition slot and `NSAnimationContext`/`NSWindow.animator()` frames with completion readback; no runtime class replacement or global input monitors. +- `transition.rs`: pure serial/generation/native-window identity guards, one active/latest pending lifecycle, duration selection and final-frame verification. +- `hover.rs`: public `NSTrackingArea` with a separate main-thread owner for non-key hover, weak notification/cache references, window-lifetime teardown and pure tracking-policy tests. +- `geometry.rs`: pure bounded compact/peek/expanded frames and shared finite frame acceptance in AppKit logical coordinates, with geometry tests. +- `tests.rs`: explicit view wire contract, native transition lifecycle/readback guards, stale-response exclusion, private-cache clearing, task abortion, async action-lifetime cancellation, dual synthetic-backend IPC epoch rejection and actual queued-request cancellation. + +## Conventions + +- Persist `state.islandEnabled` outside `AppPreferences`; acquire `models::config_write_lock` around config read/modify/write. +- Connect through the shared local target transport (default Shared or fixed explicit loopback V2, never explicit fallback/bootstrap). `set_enabled` acquires the target transition mutex inside native main dispatch, not while awaiting it. Target transactions take `prepare_target_refresh()` under that mutex before persistence; dropping `TargetRefresh` leaves state unchanged, while post-commit `invalidate()` clears private snapshots/API/selection and cancels old lifetimes without changing enabled/preferences/view/notch metadata. Disabled refresh is a no-op. Call `finish_target_refresh(app, generation)` only after releasing the target mutex; its native dispatch reacquires the mutex and generation-checks before reconciliation/restart. A missing window reports a safe Refresh-repair error without opening, disabling or persisting preferences. A finalization error must not fail an already-committed target save. Async reads and pre-write dispatch check captured target revision. +- Failed window setup returns an error, clears runtime enablement and persists the safe disabled default. Open rejects any still-registered `island` label before native setup: queued destruction never permits reuse or tracking reinstallation. Remain disabled on rejection; explicit enable can retry after runtime destruction removes the label, without automatic retry or waiting. Repeated enable of an already-active window remains a no-op. Readback never claims a missing window was created. Explicit Refresh while enabled without a current API replaces the monitor immediately, including initial connection/error states, with no session writes. +- Never hold cache locks across awaits, AppKit/window calls, or event emission. The task captures cache data and a weak notification slot owned by the run loop, never its own service or a permanent `AppHandle`. +- Monitor replacement and shutdown serialize task ownership before checking/updating cache generation; abort/spawn runs outside the cache lock. A stale finalizer cannot replace or abort the current monitor. State exposes required camelCase `connectionGeneration` directly from the cache generation so frontend data lifetimes also reset across equal session IDs. +- Subscribe before snapshot. Only SSE invalidations trigger coalesced snapshots; reconnect delays are 1/2/5/10 seconds. No full snapshot polling or disabled watchers. +- `island_messages` records the selected session only while expanded. Message SSE for other sessions or collapsed views is ignored; collapse/disable clears selection. The first eligible invalidation owns the fixed 250 ms deadline, never extended by subsequent tokens; Snapshot flushes publish before notifying. +- SSE is live-only with no replay; every reconnect subscribes anew and rebuilds the snapshot. Publish snapshots and connection errors only while the generation matches under the cache lock. +- Actions require a current enabled API and a separate action-lifetime watch token installed with each accepted API. Fail/replacement/stop/reconnect invalidate the API and cancel/drop the old action token under the same cache lock; connection failures do not cancel the monitor's SSE retry lifetime. Mutation-lock waits and the entire action future (including fresh GET before POST) race action cancellation, with generation guards before/after execution. Mutations are serialized and never automatically retried. Cancellation drops local work but cannot withdraw an already-issued POST. +- Messages and all five action IPCs require unsigned `expectedConnectionGeneration` from the originating state; no omitted/current-generation fallback. `request(expected, action, mutation)` and `messages(session_id, expected)` reject stale generations under the cache lock before cloning API/cancellation context, starting reads/actions or changing selection, including when the new backend reuses session/request/form IDs. Only internal explicit Refresh may capture the current context without an expected generation; view commands are unchanged. +- Emit only empty `island:changed`/`island:collapsed`/`island:pointer-enter`/`island:pointer-leave` events to `island`; state/preferences reads are cache-only. Initialize preferences from app config once and update the cache inside successful preference persistence before emitting notifications. +- View is explicitly `compact` (default on every screen), `peek` or `expanded`; pending snapshots, connection/status changes and refresh never choose a view. Compact is `max(notchWidth + 112, 200)` by `max(topInset, 28)`, peek is `400` by `topInset + 132`, expanded stays `560` by `520`. Clamp every mode to screen bounds with the same top/notch-center anchor; clamp edge-notch compact width symmetrically to keep its content-free notch safe gap aligned. The UI paints a continuous black backdrop, not a transparent notch cutout. No-notch screens retain the menu-bar fallback and show richer compact text without enlarging. Compact/peek clear selected messages, never pending requests. +- `set_island_view { view }` is async internally with the same lowercase enum-to-void wire contract. Capture enabled generation before `on_main`, which starts the operation and returns a `tokio::sync::oneshot::Receiver`; await only outside the native main thread/cache/TLS borrow. Grow keeps the old authoritative cache view until final native readback succeeds. Only the still-current explicit expanded request focuses; peek never focuses or orders front. Compact requests and authoritative blur/close immediately compact the cache, clear selection and emit empty `island:collapsed`, before any shrink and without session writes. +- One main-thread-local slot owns one active request and only the latest pending request. Each captures enabled generation, monotonically increasing serial and native pointer/window number. Pending replacement resolves the displaced request with a safe superseded error; active supersession allows the current animator to finish but excludes its readback, success, focus and rollback. Rapid reversal deliberately waits at most the current approximately 220 ms animation. Register active before entering AppKit, whose completion may be immediate. Completion releases only its own token; stale callbacks cannot clear a new active token or touch replacement windows. Disable/close/destruction invalidate/drop both receivers; a per-window weak destruction listener captures native identity rather than trusting the reusable label. Reconnect invalidates results and receivers while retaining an active animator until completion, then reconciles the authoritative view instantly. Never hold cache locks or TLS `RefCell` borrows across native calls, emissions, callbacks or awaits. +- Animate changed view sizes with public `NSAnimationContext.runAnimationGroup_completionHandler` and `NSWindow.animator().setFrame_display(frame, true)` (`RcBlock` completion): grow 0.22 s, shrink 0.19 s. Completion on the main thread performs origin correction and final readback before geometry commit/focus/resolve/changed emission. No immediate final readback after a legacy `setFrame_display_animate`, overlapping animator, timer, observer, runtime class replacement or animation framework. Startup, unchanged frames, display placement and current `NSWorkspace.accessibilityDisplayShouldReduceMotion()` use the existing instant path; query Reduce Motion for each transition, without a watcher. Real native bounds continue to control `InVisibleRect` hover and hit testing, with no permanent expanded invisible area. +- Changed/SSE/message notifications are generation-guarded content emissions only, not placements. Moved/focus events during an active transition never correct intermediate frames or start another animator; actual screen-placement/DPI changes set one dirty bit for instant reconciliation after the active animation. Idle move/DPI/focus correction is instant and never focuses. Explicit expanded focus can reenter `Focused(true)` without recursively resizing. Unchanged native frames are not rewritten; message notifications never fetch a snapshot. Notifications never order front or focus. +- AppKit calls stay on the native main thread. Use public safe-area selectors only when present; fallback below the menu bar. +- Initial display uses public `NSWindow.orderFront`, not Tauri `show()` (which makes macOS windows key); focus is requested only by explicit expansion. +- Install one `NSTrackingArea` on the content view with `ActiveAlways | InVisibleRect | MouseEnteredAndExited | MouseMoved`: WebKit's own overlay-scrollbar tracking can be key-window-only. Its separate `NSObject` owner only emits pointer notifications; it never focuses, changes view, reads the API or performs actions. Visible-rect tracking follows real resized bounds. Repeated moves notify only compact to recover missed entry; ordinary peek sends only boundaries. Retain owner/view/area in main-thread-local storage and detach before releasing the owner on replacement, close, shutdown and destroyed events. Weak cache/notification handles avoid service/AppHandle cycles; ignore disabled/expanded/retired-window callbacks. No global monitor, timer, overlay view, responder replacement or runtime class mutation. +- Before each placement, restore public `NSMainMenuWindowLevel + 2` and `CanJoinAllSpaces | FullScreenAuxiliary | Stationary | IgnoresCycle` only when different. Use the shared finite frame predicate before writes, origin correction and readback: accept only an absolute X-origin delta of at most 0.5 logical point, with Y/width/height exactly equal. Accepted quantization never repeats `setFrame`/`setFrameOrigin`; ideal notch-center placement and notch metadata stay unchanged. After a changed frame, correct a remaining unacceptable origin mismatch with public `setFrameOrigin`, then read actual `NSWindow.frame()`; read back again after initial `orderFront`. Diagnostics retain only phase/mode/window/level, requested/actual rects and maxY, `NSScreen.frame.maxY` (not visibleFrame), top deltas, horizontal-origin delta, `requested_exact` and accepted `requested_matched`. Positive screen-minus-actual top gap means below the screen edge. Reject vertical/size mismatches, nonfinite fields and larger X deltas instead of reporting requested placement as successful. Existing move/DPI/focus notifications recheck later displacement without timers. User geometry logs recorded peek requested X=655.5 versus actual X=655.0 with identical Y=942, width=400, height=170 and top gap=0; strict equality rejected this normal horizontal quantization and rolled the view back to compact. Regression tests use that odd-notch geometry without session data. The level is not a proven constraint bypass; pure tests do not establish actual AppKit attachment or hover delivery. Rust changes require a rebuilt/restarted app for product readback. +- Debug builds also overwrite `opencode-mom-island-native-frame.log` in the OS temporary directory with the latest frame readback and PID; it contains only geometry/window metadata, no sessions, messages or credentials. Release builds log only to stderr. Unchanged frames do not create diagnostic file writes. +- Debug tracking diagnostics log only options, native bounds/window flags and boundary view/connection/running count. Never log snapshots, session IDs, paths, messages or credentials. Compilation and mock event tests do not prove actual non-key AppKit delivery; restart the rebuilt app for manual hover acceptance. +- Tauri transparency enables `macOSPrivateApi` for compatibility with older Tauri. The matching Cargo feature is declared on the base dependency because tauri-build validates that table instead of merged target features; it has no non-macOS effect. Not an `NSPanel`: fullscreen Spaces, notch hit testing, focus behavior, display/wake changes need manual acceptance. Reposition on expansion/focus/move/DPI events, not timers; no dedicated wake observer. +- Tests never launch the app, power helper, or live OpenCode API. + +## Sync + +Update this guide when the native service structure or lifecycle contract changes. diff --git a/src-tauri/src/island/geometry.rs b/src-tauri/src/island/geometry.rs new file mode 100644 index 0000000..4b38ee2 --- /dev/null +++ b/src-tauri/src/island/geometry.rs @@ -0,0 +1,445 @@ +use super::IslandView; + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub(super) struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct Screen { + pub frame: Rect, + pub visible: Rect, + pub top_inset: f64, + pub notch_left: f64, + pub notch_right: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct Placement { + pub frame: Rect, + pub notch_width: f64, + pub top_inset: f64, +} + +pub(super) fn frame_matches(actual: Rect, target: Rect) -> bool { + [ + actual.x, + actual.y, + actual.width, + actual.height, + target.x, + target.y, + target.width, + target.height, + ] + .into_iter() + .all(f64::is_finite) + && (actual.x - target.x).abs() <= 0.5 + && actual.y == target.y + && actual.width == target.width + && actual.height == target.height +} + +pub(super) fn placement(screen: Screen, mode: IslandView) -> Placement { + let frame = screen.frame; + let top_inset = screen.top_inset.clamp(0.0, frame.height.max(0.0)); + let (notch_left, notch_right) = if top_inset > 0.0 { + ( + screen.notch_left.clamp(frame.x, frame.x + frame.width), + screen.notch_right.clamp(frame.x, frame.x + frame.width), + ) + } else { + (frame.x, frame.x) + }; + let notch_width = (notch_right - notch_left).max(0.0); + let top = if top_inset > 0.0 { + frame.y + frame.height + } else { + (screen.visible.y + screen.visible.height).min(frame.y + frame.height) + }; + let center = if notch_width > 0.0 { + (notch_left + notch_right) / 2.0 + } else { + frame.x + frame.width / 2.0 + }; + let mut width = match mode { + IslandView::Compact => (notch_width + 112.0).max(200.0), + IslandView::Peek => 400.0, + IslandView::Expanded => 560.0, + } + .min(frame.width); + if mode == IslandView::Compact && notch_width > 0.0 { + width = width.min( + 2.0 * (center - frame.x) + .min(frame.x + frame.width - center) + .max(0.0), + ); + } + let height = match mode { + IslandView::Compact => top_inset.max(28.0), + IslandView::Peek => top_inset + 132.0, + IslandView::Expanded => 520.0, + } + .min((top - frame.y).max(0.0)); + Placement { + frame: Rect { + x: (center - width / 2.0).clamp(frame.x, frame.x + frame.width - width), + y: top - height, + width, + height, + }, + notch_width, + top_inset, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logged_odd_notch_frames_accept_only_half_point_horizontal_quantization() { + for (dx, dy) in [(0.0, 0.0), (-1712.0, -1112.0)] { + let screen = Screen { + frame: Rect { + x: dx, + y: dy, + width: 1712.0, + height: 1112.0, + }, + visible: Rect { + x: dx, + y: dy, + width: 1712.0, + height: 1074.0, + }, + top_inset: 38.0, + notch_left: 751.0 + dx, + notch_right: 960.0 + dx, + }; + let peek = placement(screen, IslandView::Peek).frame; + assert!(frame_matches( + Rect { + x: 655.0 + dx, + ..peek + }, + peek + )); + for (mode, x, y, width, height) in [ + (IslandView::Compact, 695.0, 1074.0, 321.0, 38.0), + (IslandView::Peek, 655.5, 942.0, 400.0, 170.0), + (IslandView::Expanded, 575.5, 592.0, 560.0, 520.0), + ] { + let target = placement(screen, mode); + let frame = target.frame; + assert_eq!( + frame, + Rect { + x: x + dx, + y: y + dy, + width, + height + } + ); + assert_eq!(frame.x + frame.width / 2.0, 855.5 + dx); + assert_eq!(frame.y + frame.height, 1112.0 + dy); + assert_eq!(target.notch_width, 209.0); + assert_eq!(target.top_inset, 38.0); + assert!(frame.x >= screen.frame.x); + assert!(frame.x + frame.width <= screen.frame.x + screen.frame.width); + assert!(frame.y >= screen.frame.y); + for delta in [0.0, -0.5, 0.5] { + assert!(frame_matches( + Rect { + x: frame.x + delta, + ..frame + }, + frame + )); + } + for delta in [-1.0, -0.5001, 0.5001, 1.0] { + assert!(!frame_matches( + Rect { + x: frame.x + delta, + ..frame + }, + frame + )); + } + for delta in [-1.0, -0.01, 0.01, 1.0] { + assert!(!frame_matches( + Rect { + y: frame.y + delta, + ..frame + }, + frame + )); + } + for actual in [ + Rect { + width: frame.width + 0.5, + ..frame + }, + Rect { + height: frame.height + 0.5, + ..frame + }, + ] { + assert!(!frame_matches(actual, frame)); + } + } + } + } + + #[test] + fn frame_matching_rejects_nonfinite_actual_and_target_fields() { + let frame = Rect { + x: 655.5, + y: 942.0, + width: 400.0, + height: 170.0, + }; + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for invalid in [ + Rect { x: value, ..frame }, + Rect { y: value, ..frame }, + Rect { + width: value, + ..frame + }, + Rect { + height: value, + ..frame + }, + ] { + assert!(!frame_matches(invalid, frame)); + assert!(!frame_matches(frame, invalid)); + assert!(!frame_matches(invalid, invalid)); + } + } + } + + #[test] + fn notch_modes_use_native_points_and_share_center_and_top() { + let screen = Screen { + frame: Rect { + x: -1512.0, + y: 300.0, + width: 1512.0, + height: 982.0, + }, + visible: Rect::default(), + top_inset: 32.0, + notch_left: -846.0, + notch_right: -666.0, + }; + let small = placement(screen, IslandView::Compact); + let peek = placement(screen, IslandView::Peek); + let large = placement(screen, IslandView::Expanded); + assert_eq!( + small.frame, + Rect { + x: -902.0, + y: 1250.0, + width: 292.0, + height: 32.0 + } + ); + assert_eq!( + peek.frame, + Rect { + x: -956.0, + y: 1118.0, + width: 400.0, + height: 164.0 + } + ); + assert_eq!( + large.frame, + Rect { + x: -1036.0, + y: 762.0, + width: 560.0, + height: 520.0 + } + ); + // AppKit frames are logical points even when a secondary display has a different DPI. + assert_eq!(screen.notch_left - small.frame.x, 56.0); + assert_eq!(small.frame.x + small.frame.width - screen.notch_right, 56.0); + for target in [small, peek, large] { + assert_eq!(target.frame.x + target.frame.width / 2.0, -756.0); + assert_eq!(target.frame.y + target.frame.height, 1282.0); + assert_eq!(target.notch_width, 180.0); + assert_eq!(target.top_inset, 32.0); + for scale in [1.0, 1.25, 2.0] { + let logical = tauri::LogicalSize::new(target.frame.width, target.frame.height); + let physical = logical.to_physical::(scale); + assert_eq!(physical.to_logical::(scale), logical); + } + } + } + + #[test] + fn no_notch_modes_share_center_and_menu_bar_top_at_negative_origins() { + let screen = Screen { + frame: Rect { + x: -1920.0, + y: -1080.0, + width: 1920.0, + height: 1080.0, + }, + visible: Rect { + x: -1920.0, + y: -1080.0, + width: 1920.0, + height: 1056.0, + }, + top_inset: 0.0, + notch_left: 0.0, + notch_right: 0.0, + }; + for (mode, width, height) in [ + (IslandView::Compact, 200.0, 28.0), + (IslandView::Peek, 400.0, 132.0), + (IslandView::Expanded, 560.0, 520.0), + ] { + let target = placement(screen, mode); + assert_eq!( + target.frame, + Rect { + x: -960.0 - width / 2.0, + y: -24.0 - height, + width, + height, + } + ); + assert_eq!(target.notch_width, 0.0); + assert_eq!(target.top_inset, 0.0); + for scale in [1.0, 1.25, 2.0] { + let logical = tauri::LogicalSize::new(width, height); + assert_eq!( + logical.to_physical::(scale).to_logical::(scale), + logical + ); + } + } + } + + #[test] + fn fallback_is_below_menu_bar_and_clamps_tiny_screen() { + let screen = Screen { + frame: Rect { + x: 200.0, + y: -800.0, + width: 240.0, + height: 180.0, + }, + visible: Rect { + x: 200.0, + y: -800.0, + width: 240.0, + height: 156.0, + }, + top_inset: 0.0, + notch_left: 0.0, + notch_right: 0.0, + }; + assert_eq!( + placement(screen, IslandView::Compact).frame, + Rect { + x: 220.0, + y: -672.0, + width: 200.0, + height: 28.0 + } + ); + assert_eq!( + placement(screen, IslandView::Peek).frame, + Rect { + x: 200.0, + y: -776.0, + width: 240.0, + height: 132.0, + } + ); + assert_eq!( + placement(screen, IslandView::Expanded).frame, + screen.visible + ); + assert_eq!(placement(screen, IslandView::Compact).top_inset, 0.0); + } + + #[test] + fn all_modes_stay_in_bounds_and_wide_notches_set_compact_width() { + for (width, height, notch_left, notch_right) in [ + (640.0, 700.0, -820.0, -520.0), + (240.0, 100.0, -870.0, -690.0), + (150.0, 20.0, -900.0, -750.0), + ] { + let screen = Screen { + frame: Rect { + x: -900.0, + y: -800.0, + width, + height, + }, + visible: Rect::default(), + top_inset: 32.0, + notch_left, + notch_right, + }; + for mode in [IslandView::Compact, IslandView::Peek, IslandView::Expanded] { + let target = placement(screen, mode).frame; + assert!(target.x >= screen.frame.x); + assert!(target.y >= screen.frame.y); + assert!(target.x + target.width <= screen.frame.x + width); + assert_eq!(target.y + target.height, screen.frame.y + height); + if width <= 240.0 { + assert_eq!(target.width, width); + assert!(target.height <= height); + } else { + assert_eq!( + target.width, + match mode { + IslandView::Compact => 412.0, + IslandView::Peek => 400.0, + IslandView::Expanded => 560.0, + } + ); + } + } + } + } + + #[test] + fn edge_notch_compact_clamps_symmetrically_without_moving_the_gap() { + let screen = Screen { + frame: Rect { + x: -600.0, + y: -900.0, + width: 600.0, + height: 700.0, + }, + visible: Rect::default(), + top_inset: 20.0, + notch_left: -600.0, + notch_right: -420.0, + }; + let target = placement(screen, IslandView::Compact); + assert_eq!( + target.frame, + Rect { + x: -600.0, + y: -228.0, + width: 180.0, + height: 28.0, + } + ); + assert_eq!(target.frame.x + target.frame.width / 2.0, -510.0); + assert_eq!(target.frame.y + target.frame.height, -200.0); + assert_eq!(target.notch_width, 180.0); + } +} diff --git a/src-tauri/src/island/hover.rs b/src-tauri/src/island/hover.rs new file mode 100644 index 0000000..809fcb7 --- /dev/null +++ b/src-tauri/src/island/hover.rs @@ -0,0 +1,219 @@ +use std::cell::{Cell, RefCell}; +use std::sync::{Mutex, Weak}; + +use objc2::{ + define_class, msg_send, rc::Retained, AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, +}; +use objc2_app_kit::{NSEvent, NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow}; +use objc2_foundation::{NSObject, NSObjectProtocol, NSRect}; +use tauri::{AppHandle, Emitter, Manager}; + +use super::{lock, Cache, IslandService, IslandView}; +use crate::error::AppError; + +struct HoverIvars { + notifications: Weak>>, + cache: Weak>, + window_number: isize, + active: Cell, + inside: Cell, +} + +define_class!( + #[unsafe(super = NSObject)] + #[name = "MOMIslandHoverObserver"] + #[thread_kind = MainThreadOnly] + #[ivars = HoverIvars] + struct HoverObserver; + + unsafe impl NSObjectProtocol for HoverObserver {} + + impl HoverObserver { + #[unsafe(method(mouseEntered:))] + fn mouse_entered(&self, _event: &NSEvent) { + self.notify(true, false); + } + + #[unsafe(method(mouseExited:))] + fn mouse_exited(&self, _event: &NSEvent) { + self.notify(false, false); + } + + #[unsafe(method(mouseMoved:))] + fn mouse_moved(&self, _event: &NSEvent) { + self.notify(true, true); + } + } +); + +impl HoverObserver { + fn notify(&self, entered: bool, moved: bool) { + let ivars = self.ivars(); + if !ivars.active.get() { + return; + } + let Some(cache) = ivars.cache.upgrade() else { + return; + }; + let (enabled, view) = { + let cache = lock(&cache); + (cache.enabled, cache.view) + }; + let edge = ivars.inside.replace(entered) != entered; + if !should_notify(enabled, view, edge, moved) { + return; + } + let Some(notifications) = ivars.notifications.upgrade() else { + return; + }; + let Some(app) = lock(¬ifications).clone() else { + return; + }; + let Some(window) = app.get_webview_window("island") else { + return; + }; + let Ok(pointer) = window.ns_window() else { + return; + }; + let native = unsafe { &*pointer.cast::() }; + if native.windowNumber() != ivars.window_number || !native.isVisible() { + return; + } + #[cfg(debug_assertions)] + if edge { + let (running, connection) = { + let cache = lock(&cache); + ( + cache + .snapshot + .sessions + .iter() + .filter(|session| session.status == "running") + .count(), + cache.connection, + ) + }; + eprintln!( + "Mini Island pointer: entered={entered}, view={view:?}, key={}, running={running}, connection={connection:?}", + native.isKeyWindow(), + ); + } + let event = if entered { + "island:pointer-enter" + } else { + "island:pointer-leave" + }; + if app.emit_to("island", event, ()).is_err() { + eprintln!("failed to notify Mini Island pointer boundary"); + } + } +} + +struct Tracking { + view: Retained, + area: Retained, + owner: Retained, +} + +impl Drop for Tracking { + fn drop(&mut self) { + // NSTrackingArea does not retain its owner; detach before releasing the observer. + self.owner.ivars().active.set(false); + self.view.removeTrackingArea(&self.area); + } +} + +thread_local! { + static TRACKING: RefCell> = const { RefCell::new(None) }; +} + +fn options() -> NSTrackingAreaOptions { + NSTrackingAreaOptions::MouseEnteredAndExited + | NSTrackingAreaOptions::MouseMoved + | NSTrackingAreaOptions::ActiveAlways + | NSTrackingAreaOptions::InVisibleRect +} + +fn should_notify(enabled: bool, view: IslandView, edge: bool, moved: bool) -> bool { + enabled && view != IslandView::Expanded && (edge || (moved && view == IslandView::Compact)) +} + +pub(super) fn install(app: &AppHandle) -> Result<(), AppError> { + let failure = || AppError::configuration("Mini Island pointer tracking unavailable"); + let mtm = MainThreadMarker::new().ok_or_else(failure)?; + let window = app.get_webview_window("island").ok_or_else(failure)?; + let pointer = window.ns_window().map_err(|_| failure())?; + let native = unsafe { &*pointer.cast::() }; + let view = native.contentView().ok_or_else(failure)?; + let service = app.state::(); + let this = HoverObserver::alloc(mtm).set_ivars(HoverIvars { + notifications: service.notifications.clone(), + cache: std::sync::Arc::downgrade(&service.cache), + window_number: native.windowNumber(), + active: Cell::new(true), + inside: Cell::new(false), + }); + let owner: Retained = unsafe { msg_send![super(this), init] }; + let area = unsafe { + NSTrackingArea::initWithRect_options_owner_userInfo( + NSTrackingArea::alloc(), + NSRect::default(), + options(), + Some(&owner), + None, + ) + }; + remove(); + view.addTrackingArea(&area); + #[cfg(debug_assertions)] + eprintln!( + "Mini Island tracking: window={}, key={}, mouse_moved={}, ignores_mouse={}, options={:#x}, bounds={:?}", + native.windowNumber(), + native.isKeyWindow(), + native.acceptsMouseMovedEvents(), + native.ignoresMouseEvents(), + area.options().bits(), + view.bounds(), + ); + TRACKING.with(|slot| *slot.borrow_mut() = Some(Tracking { view, area, owner })); + Ok(()) +} + +pub(super) fn remove() { + if MainThreadMarker::new().is_some() { + TRACKING.with(|slot| { + let old = slot.borrow_mut().take(); + drop(old); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracking_covers_inactive_windows_and_tracks_actual_resized_bounds() { + let options = options(); + assert!(options.contains(NSTrackingAreaOptions::ActiveAlways)); + assert!(options.contains(NSTrackingAreaOptions::InVisibleRect)); + assert!(options.contains(NSTrackingAreaOptions::MouseEnteredAndExited)); + assert!(options.contains(NSTrackingAreaOptions::MouseMoved)); + assert!(!options.intersects( + NSTrackingAreaOptions::ActiveInKeyWindow | NSTrackingAreaOptions::AssumeInside + )); + } + + #[test] + fn notifications_require_live_collapsed_surface_and_recover_missing_entry() { + for view in [IslandView::Compact, IslandView::Peek, IslandView::Expanded] { + assert!(!should_notify(false, view, true, true)); + } + assert!(!should_notify(true, IslandView::Expanded, true, true)); + assert!(should_notify(true, IslandView::Compact, true, false)); + assert!(should_notify(true, IslandView::Peek, true, false)); + assert!(should_notify(true, IslandView::Compact, false, true)); + assert!(!should_notify(true, IslandView::Peek, false, true)); + assert!(!should_notify(true, IslandView::Compact, false, false)); + } +} diff --git a/src-tauri/src/island/mod.rs b/src-tauri/src/island/mod.rs new file mode 100644 index 0000000..90b63bc --- /dev/null +++ b/src-tauri/src/island/mod.rs @@ -0,0 +1,982 @@ +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; +use tokio::sync::watch; + +use crate::error::AppError; +use crate::models::{self, AppPreferences}; +use crate::opencode_cli::island::{self as api, IslandApi, IslandChange, IslandSnapshot}; +use crate::paths::ConfigPaths; + +#[cfg(any(target_os = "macos", test))] +mod geometry; +#[cfg(target_os = "macos")] +mod hover; +#[cfg(any(target_os = "macos", test))] +mod transition; +#[cfg(target_os = "macos")] +mod window; + +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum IslandView { + #[default] + Compact, + Peek, + Expanded, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IslandState { + supported: bool, + enabled: bool, + connection: &'static str, + connection_generation: u64, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + sessions: Vec, + permissions: Vec, + questions: Vec, + preferences: AppPreferences, + notch_width: f64, + top_inset: f64, +} + +#[derive(Default)] +struct Cache { + generation: u64, + enabled: bool, + view: IslandView, + selected_session: Option, + connection: Option<&'static str>, + error: Option, + snapshot: IslandSnapshot, + api: Option, + api_generation: u64, + cancel: Option>, + action_cancel: Option>, + notch_width: f64, + top_inset: f64, + revision: u64, + published: u64, + preferences: AppPreferences, +} + +impl Cache { + fn current(&self, generation: u64) -> bool { + self.enabled && self.generation == generation + } + + fn set_view( + &mut self, + generation: u64, + view: IslandView, + ) -> Result<(IslandView, Option), AppError> { + if !self.current(generation) { + return Err(disabled()); + } + let previous = (self.view, self.selected_session.clone()); + self.view = view; + if view != IslandView::Expanded { + self.selected_session = None; + } + Ok(previous) + } + + fn collapse(&mut self) { + self.view = IslandView::Compact; + self.selected_session = None; + } + + fn invalidate_api(&mut self) { + self.api = None; + self.api_generation = self.api_generation.wrapping_add(1); + if let Some(cancel) = self.action_cancel.take() { + let _ = cancel.send(true); + } + } + + fn start_actions(&mut self) { + self.invalidate_api(); + self.action_cancel = Some(watch::channel(false).0); + } + + fn stop(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.enabled = false; + self.collapse(); + self.connection = None; + self.error = None; + self.snapshot = IslandSnapshot::default(); + self.invalidate_api(); + self.notch_width = 0.0; + self.top_inset = 0.0; + if let Some(cancel) = self.cancel.take() { + let _ = cancel.send(true); + } + } + + fn publish(&mut self, generation: u64, revision: u64, snapshot: IslandSnapshot) -> bool { + if !self.current(generation) || revision < self.published { + return false; + } + self.published = revision; + self.snapshot = snapshot; + self.connection = Some("connected"); + self.error = None; + true + } + + fn fail(&mut self, generation: u64) -> bool { + if !self.current(generation) { + return false; + } + self.invalidate_api(); + self.snapshot = IslandSnapshot::default(); + self.connection = Some("error"); + self.error = Some("OpenCode connection unavailable; retrying".into()); + true + } + + fn window_failed(&mut self, generation: u64) -> bool { + if !self.current(generation) { + return false; + } + self.stop(); + self.connection = Some("error"); + self.error = Some("Mini Island window could not be created".into()); + true + } + + fn visible_message(&self, session_id: &str) -> bool { + self.enabled + && self.view == IslandView::Expanded + && self.selected_session.as_deref() == Some(session_id) + } + + fn reconnect(&mut self) -> Result { + if !self.enabled { + return Err(disabled()); + } + self.generation = self.generation.wrapping_add(1); + self.invalidate_api(); + self.snapshot = IslandSnapshot::default(); + self.connection = Some("connecting"); + self.error = None; + if let Some(cancel) = self.cancel.take() { + let _ = cancel.send(true); + } + Ok(self.generation) + } +} + +#[derive(Default)] +struct Coalescer { + snapshot: bool, + message_session: Option, + deadline: Option, +} + +impl Coalescer { + fn record(&mut self, event: IslandChange, cache: &Cache, now: tokio::time::Instant) { + match event { + IslandChange::Snapshot => self.snapshot = true, + IslandChange::Messages { session_id } if cache.visible_message(&session_id) => { + self.message_session = Some(session_id); + } + IslandChange::Messages { .. } => return, + } + self.deadline + .get_or_insert(now + Duration::from_millis(250)); + } + + fn notify(&self, cache: &Cache) -> bool { + self.snapshot + || self + .message_session + .as_deref() + .is_some_and(|id| cache.visible_message(id)) + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|error| error.into_inner()) +} + +fn disabled() -> AppError { + AppError::validation("Mini Island is disabled or its connection changed") +} + +async fn race(first: A, second: B) -> Result { + let mut first = std::pin::pin!(first); + let mut second = std::pin::pin!(second); + std::future::poll_fn(|context| { + if let std::task::Poll::Ready(value) = first.as_mut().poll(context) { + return std::task::Poll::Ready(Ok(value)); + } + second.as_mut().poll(context).map(Err) + }) + .await +} + +async fn cancellable( + cancel: &mut watch::Receiver, + future: impl Future, +) -> Result { + if *cancel.borrow() { + return Err(disabled()); + } + match race(cancel.changed(), future).await { + Ok(_) => Err(disabled()), + Err(value) => Ok(value), + } +} + +pub(crate) struct IslandService { + cache: Arc>, + task: Mutex>>, + // ponytail: serialize user mutations globally; use per-session locks only if contention matters. + mutations: tokio::sync::Mutex<()>, + notifications: Weak>>, +} + +pub(crate) struct TargetRefresh<'a>(MutexGuard<'a, Cache>); + +impl TargetRefresh<'_> { + pub(crate) fn invalidate(mut self) -> Option { + if !self.0.enabled { + return None; + } + self.0.selected_session = None; + self.0.reconnect().ok() + } +} + +impl Default for IslandService { + fn default() -> Self { + Self { + cache: Arc::new(Mutex::new(Cache::default())), + task: Mutex::new(None), + mutations: tokio::sync::Mutex::new(()), + notifications: Weak::new(), + } + } +} + +impl IslandService { + #[cfg(test)] + pub(crate) fn is_enabled(&self) -> bool { + lock(&self.cache).enabled + } + + pub(crate) fn state(&self) -> IslandState { + let cache = lock(&self.cache); + IslandState { + supported: cfg!(target_os = "macos"), + enabled: cache.enabled, + connection: cache.connection.unwrap_or("disabled"), + connection_generation: cache.generation, + error: cache.error.clone(), + sessions: cache.snapshot.sessions.clone(), + permissions: cache.snapshot.permissions.clone(), + questions: cache.snapshot.questions.clone(), + preferences: cache.preferences, + notch_width: cache.notch_width, + top_inset: cache.top_inset, + } + } + + pub(crate) fn update_preferences(&self, preferences: AppPreferences) { + lock(&self.cache).preferences = preferences; + } + + pub(crate) fn prepare_target_refresh(&self) -> TargetRefresh<'_> { + // Block old publications until persistence either fails or invalidates this connection. + TargetRefresh(lock(&self.cache)) + } + + pub(crate) fn finish_target_refresh( + &self, + app: &AppHandle, + generation: Option, + ) -> Result<(), AppError> { + let Some(generation) = generation else { + return Ok(()); + }; + on_main(app, move |app| { + let service = app.state::(); + let _transition = crate::opencode_cli::target::transition()?; + if !lock(&service.cache).current(generation) { + return Ok(()); + } + #[cfg(target_os = "macos")] + { + if app.get_webview_window("island").is_none() { + let mut cache = lock(&service.cache); + if cache.fail(generation) { + cache.error = + Some("Mini Island window unavailable; use Refresh to retry".into()); + } + return Err(AppError::configuration( + "Mini Island window unavailable; use Refresh to retry", + )); + } + window::invalidate(false); + window::reconcile(app, true); + service.start_monitor(generation); + changed(app); + } + Ok(()) + }) + } + + pub(crate) fn set_enabled( + &self, + app: &AppHandle, + paths: &ConfigPaths, + enabled: bool, + ) -> Result<(), AppError> { + let _transition = crate::opencode_cli::target::transition()?; + if enabled && !cfg!(target_os = "macos") { + return Err(AppError::validation( + "Mini Island is only supported on macOS", + )); + } + { + let _write = models::config_write_lock(); + let mut config = models::load_config(&paths.config_file())?; + config.state.island_enabled = enabled; + models::save_config(&paths.config_file(), &config)?; + self.update_preferences(config.state.preferences); + } + if let Err(error) = self.apply_enabled(app, enabled) { + if persist_enabled(paths, false).is_err() { + eprintln!("failed to persist disabled Mini Island after window setup failure"); + } + return Err(error); + } + Ok(()) + } + + fn apply_enabled(&self, app: &AppHandle, enabled: bool) -> Result<(), AppError> { + let active = lock(&self.cache).enabled; + if enabled && active && app.get_webview_window("island").is_some() { + return Ok(()); + } + self.shutdown(); + #[cfg(target_os = "macos")] + { + if !enabled { + window::close(app); + return Ok(()); + } + let (generation, mode) = { + let mut cache = lock(&self.cache); + cache.enabled = true; + cache.connection = Some("connecting"); + (cache.generation, cache.view) + }; + match window::open(app, mode) { + Ok(placement) => { + let mut cache = lock(&self.cache); + if !cache.current(generation) { + return Err(disabled()); + } + cache.notch_width = placement.notch_width; + cache.top_inset = placement.top_inset; + } + Err(error) => { + window::close(app); + lock(&self.cache).window_failed(generation); + eprintln!("Mini Island setup failed; main app remains available"); + return Err(error); + } + } + self.start_monitor(generation); + } + #[cfg(not(target_os = "macos"))] + let _ = (app, enabled); + Ok(()) + } + + #[cfg(target_os = "macos")] + fn start_monitor(&self, generation: u64) { + // AppHandle retains managed state; borrow the run loop's handle weakly to avoid a cycle. + let cache = self.cache.clone(); + let notifications = self.notifications.clone(); + self.replace_monitor(generation, move |receiver| { + monitor(notifications, cache, generation, receiver) + }); + } + + #[cfg(any(target_os = "macos", test))] + fn replace_monitor + Send + 'static>( + &self, + generation: u64, + run: impl FnOnce(watch::Receiver) -> F, + ) -> bool { + let mut task = lock(&self.task); + let (cancel, receiver) = watch::channel(false); + { + let mut cache = lock(&self.cache); + if !cache.current(generation) { + return false; + } + if let Some(previous) = cache.cancel.replace(cancel) { + let _ = previous.send(true); + } + } + if let Some(previous) = task.take() { + previous.abort(); + } + let next = tauri::async_runtime::spawn(run(receiver)); + { + let cache = lock(&self.cache); + if cache.current(generation) { + *task = Some(next); + return true; + } + } + next.abort(); + false + } + + pub(crate) fn shutdown(&self) { + let (generation, task) = { + let mut task = lock(&self.task); + let mut cache = lock(&self.cache); + cache.stop(); + (cache.generation, task.take()) + }; + if let Some(task) = task { + task.abort(); + } + #[cfg(target_os = "macos")] + { + window::invalidate(true); + hover::remove(); + if objc2::MainThreadMarker::new().is_none() { + if let Some(app) = self + .notifications + .upgrade() + .and_then(|slot| lock(&slot).clone()) + { + let cache = Arc::downgrade(&self.cache); + let _ = app.run_on_main_thread(move || { + let retired = cache.upgrade().map_or(true, |cache| { + let cache = lock(&cache); + !cache.enabled && cache.generation == generation + }); + if retired { + window::invalidate(true); + hover::remove(); + } + }); + } + } + } + #[cfg(not(target_os = "macos"))] + let _ = generation; + } + + pub(crate) fn enabled_generation(&self) -> Result { + let cache = lock(&self.cache); + if !cache.enabled { + return Err(disabled()); + } + Ok(cache.generation) + } + + pub(crate) fn set_view( + &self, + app: &AppHandle, + view: IslandView, + generation: u64, + ) -> Result>, AppError> { + if !lock(&self.cache).current(generation) { + return Err(disabled()); + } + #[cfg(target_os = "macos")] + { + window::request(app, view, generation) + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + lock(&self.cache).set_view(generation, view)?; + let (sender, receiver) = tokio::sync::oneshot::channel(); + let _ = sender.send(Ok(())); + Ok(receiver) + } + } + + fn context( + &self, + expected_connection_generation: Option, + ) -> Result<(u64, u64, IslandApi, watch::Receiver), AppError> { + let cache = lock(&self.cache); + if !cache.enabled + || expected_connection_generation.is_some_and(|expected| expected != cache.generation) + { + return Err(disabled()); + } + let api = cache + .api + .clone() + .ok_or_else(|| AppError::configuration("Mini Island is not connected"))?; + let cancel = cache + .action_cancel + .as_ref() + .ok_or_else(disabled)? + .subscribe(); + Ok((cache.generation, cache.api_generation, api, cancel)) + } + + fn connected_generation(&self, generation: u64, api_generation: u64) -> bool { + let cache = lock(&self.cache); + cache.current(generation) && cache.api.is_some() && cache.api_generation == api_generation + } + + pub(crate) async fn request( + &self, + expected_connection_generation: u64, + action: F, + mutation: bool, + ) -> Result + where + F: FnOnce(IslandApi) -> Fut, + Fut: Future>, + { + let context = self.context(Some(expected_connection_generation))?; + self.request_with_context(context, action, mutation).await + } + + async fn request_with_context( + &self, + context: (u64, u64, IslandApi, watch::Receiver), + action: F, + mutation: bool, + ) -> Result + where + F: FnOnce(IslandApi) -> Fut, + Fut: Future>, + { + let mut cancel = context.3; + let _mutation = if mutation { + Some(cancellable(&mut cancel, self.mutations.lock()).await?) + } else { + None + }; + if !self.connected_generation(context.0, context.1) || *cancel.borrow() { + return Err(disabled()); + } + let result = cancellable(&mut cancel, action(context.2)).await?; + if !self.connected_generation(context.0, context.1) || *cancel.borrow() { + return Err(disabled()); + } + result + } + + pub(crate) async fn messages( + &self, + session_id: String, + expected_connection_generation: u64, + ) -> Result, AppError> { + if session_id.len() > 256 + || !session_id.strip_prefix("ses_").is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) + { + return Err(AppError::validation("Invalid Island session identifier")); + } + let context = self.context(Some(expected_connection_generation))?; + { + let mut cache = lock(&self.cache); + if !cache.current(context.0) || cache.api_generation != context.1 { + return Err(disabled()); + } + if cache.view == IslandView::Expanded { + cache.selected_session = Some(session_id.clone()); + } + } + self.request_with_context( + context, + |api| async move { api.messages(&session_id).await }, + false, + ) + .await + } + + fn retry_connection(&self, app: &AppHandle, generation: u64) -> Result<(), AppError> { + let _transition = crate::opencode_cli::target::transition()?; + if !lock(&self.cache).current(generation) { + return Err(disabled()); + } + #[cfg(target_os = "macos")] + { + if app.get_webview_window("island").is_none() { + let result = self.apply_enabled(app, true); + if result.is_err() { + if let Some(paths) = app.try_state::() { + if persist_enabled(&paths, false).is_err() { + eprintln!("failed to persist disabled Mini Island after refresh setup failure"); + } + } + } + return result; + } + let generation = { + let mut cache = lock(&self.cache); + if !cache.current(generation) { + return Err(disabled()); + } + cache.reconnect()? + }; + window::invalidate(false); + window::reconcile(app, true); + self.start_monitor(generation); + changed(app); + } + #[cfg(not(target_os = "macos"))] + let _ = app; + Ok(()) + } + + pub(crate) async fn refresh(&self, app: &AppHandle) -> Result<(), AppError> { + let has_window = app.get_webview_window("island").is_some(); + let retry = { + let cache = lock(&self.cache); + if !cache.enabled { + return Err(disabled()); + } + (cache.api.is_none() || !has_window).then_some(cache.generation) + }; + if let Some(generation) = retry { + return on_main(app, move |app| { + app.state::() + .retry_connection(app, generation) + }); + } + let (generation, api_generation, api, mut cancel) = self.context(None)?; + let revision = next_revision(&self.cache, generation)?; + let result = cancellable(&mut cancel, api.snapshot()).await?; + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(_) => { + { + let mut cache = lock(&self.cache); + if !cache.current(generation) + || cache.api_generation != api_generation + || *cancel.borrow() + { + return Err(disabled()); + } + cache.fail(generation); + } + return on_main(app, move |app| { + app.state::() + .retry_connection(app, generation) + }); + } + }; + let published = { + let mut cache = lock(&self.cache); + if !cache.current(generation) + || cache.api.is_none() + || cache.api_generation != api_generation + || *cancel.borrow() + { + return Err(disabled()); + } + cache.publish(generation, revision, snapshot) + }; + if published { + changed(app); + } + Ok(()) + } +} + +impl Drop for IslandService { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn next_revision(cache: &Mutex, generation: u64) -> Result { + let mut cache = lock(cache); + if !cache.current(generation) { + return Err(disabled()); + } + cache.revision += 1; + Ok(cache.revision) +} + +fn persist_enabled(paths: &ConfigPaths, enabled: bool) -> Result<(), AppError> { + let _write = models::config_write_lock(); + let mut config = models::load_config(&paths.config_file())?; + config.state.island_enabled = enabled; + models::save_config(&paths.config_file(), &config) +} + +fn changed(app: &AppHandle) { + #[cfg(target_os = "macos")] + { + let Some(service) = app.try_state::() else { + return; + }; + let generation = { + let cache = lock(&service.cache); + if !cache.enabled { + return; + } + cache.generation + }; + let handle = app.clone(); + if app + .run_on_main_thread(move || { + let Some(service) = handle.try_state::() else { + return; + }; + let current = lock(&service.cache).current(generation); + if current && handle.get_webview_window("island").is_some() { + window::changed(&handle); + } + }) + .is_err() + { + eprintln!("failed to dispatch Mini Island window notification"); + } + } + #[cfg(not(target_os = "macos"))] + let _ = app; +} + +#[cfg(target_os = "macos")] +async fn monitor( + notifications: Weak>>, + cache: Arc>, + generation: u64, + mut cancel: watch::Receiver, +) { + let delays = [1, 2, 5, 10]; + let mut failures = 0usize; + loop { + if *cancel.borrow() || !lock(&cache).current(generation) { + return; + } + let result = + match cancellable(&mut cancel, connected(¬ifications, &cache, generation)).await { + Ok(result) => result, + Err(_) => return, + }; + if !lock(&cache).current(generation) { + return; + } + if result.is_err() { + let mut state = lock(&cache); + if !state.current(generation) { + return; + } + if state.connection == Some("connected") { + failures = 0; + } + state.fail(generation); + drop(state); + notify(¬ifications); + } + let delay = delays[failures.min(delays.len() - 1)]; + failures = failures.saturating_add(1); + if cancellable(&mut cancel, tokio::time::sleep(Duration::from_secs(delay))) + .await + .is_err() + { + return; + } + } +} + +#[cfg(target_os = "macos")] +async fn connected( + notifications: &Weak>>, + cache: &Mutex, + generation: u64, +) -> Result<(), AppError> { + let api = api::connect().await?; + // Subscribe before the snapshot so pending events cannot fall into a startup gap. + let mut events = api.events().await?; + let revision = next_revision(cache, generation)?; + let snapshot = api.snapshot().await?; + let api_generation = { + let mut state = lock(cache); + if !state.publish(generation, revision, snapshot) { + return Err(disabled()); + } + state.start_actions(); + state.api = Some(api.clone()); + state.api_generation + }; + notify(notifications); + let mut pending = Coalescer::default(); + loop { + if let Some(wake) = pending.deadline { + match race(tokio::time::sleep_until(wake), events.next()).await { + Err(event) => { + let state = lock(cache); + if !state.current(generation) || state.api_generation != api_generation { + return Err(disabled()); + } + pending.record(event?, &state, tokio::time::Instant::now()); + continue; + } + Ok(_) => {} + } + if pending.snapshot { + let revision = next_revision(cache, generation)?; + let snapshot = api.snapshot().await?; + let mut state = lock(cache); + if !state.current(generation) + || state.api_generation != api_generation + || state.api.is_none() + { + return Err(disabled()); + } + state.publish(generation, revision, snapshot); + } + let notify_visible = { + let state = lock(cache); + if !state.current(generation) + || state.api_generation != api_generation + || state.api.is_none() + { + return Err(disabled()); + } + pending.notify(&state) + }; + if notify_visible { + notify(notifications); + } + pending = Coalescer::default(); + } + let event = events.next().await?; + let state = lock(cache); + if !state.current(generation) || state.api_generation != api_generation { + return Err(disabled()); + } + pending.record(event, &state, tokio::time::Instant::now()); + } +} + +#[cfg(target_os = "macos")] +fn notify(notifications: &Weak>>) { + let handle = notifications.upgrade().and_then(|slot| lock(&slot).clone()); + if let Some(app) = handle { + changed(&app); + } +} + +pub(crate) fn setup( + app: &AppHandle, + paths: &ConfigPaths, + notifications: Weak>>, +) { + let mut service = IslandService::default(); + service.notifications = notifications; + app.manage(service); + match models::load_config(&paths.config_file()) { + Ok(config) => { + let service = app.state::(); + service.update_preferences(config.state.preferences); + #[cfg(target_os = "macos")] + if config.state.island_enabled { + let _transition = crate::opencode_cli::target::transition(); + if service.apply_enabled(app, true).is_err() { + if persist_enabled(paths, false).is_err() { + eprintln!("failed to persist disabled Mini Island after startup failure"); + } + } + } + } + Err(_) => eprintln!("Mini Island preferences unavailable; feature left disabled"), + } +} + +pub(crate) fn on_main( + app: &AppHandle, + action: impl FnOnce(&AppHandle) -> Result + Send + 'static, +) -> Result { + #[cfg(target_os = "macos")] + if objc2::MainThreadMarker::new().is_none() { + let (sender, receiver) = std::sync::mpsc::channel(); + let handle = app.clone(); + app.run_on_main_thread(move || { + let _ = sender.send(action(&handle)); + }) + .map_err(|_| AppError::configuration("Mini Island native dispatcher unavailable"))?; + return receiver + .recv() + .map_err(|_| AppError::configuration("Mini Island native dispatcher closed"))?; + } + action(app) +} + +pub(crate) fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) { + if window.label() != "island" { + return; + } + #[cfg(target_os = "macos")] + if matches!(event, tauri::WindowEvent::Destroyed) { + // The per-window listener captures native identity; a label cannot identify a retired window. + return; + } + #[cfg(target_os = "macos")] + if !window::event_is_current(window) { + return; + } + let app = window.app_handle(); + let Some(service) = app.try_state::() else { + return; + }; + if !lock(&service.cache).enabled { + return; + } + match event { + tauri::WindowEvent::Focused(false) => { + #[cfg(target_os = "macos")] + let pending = window::wants_noncompact(); + #[cfg(not(target_os = "macos"))] + let pending = false; + if lock(&service.cache).view != IslandView::Compact || pending { + if let Ok(generation) = service.enabled_generation() { + let _ = service.set_view(app, IslandView::Compact, generation); + } + } + } + tauri::WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + if let Ok(generation) = service.enabled_generation() { + let _ = service.set_view(app, IslandView::Compact, generation); + } + } + #[cfg(target_os = "macos")] + tauri::WindowEvent::ScaleFactorChanged { .. } + | tauri::WindowEvent::Moved(_) + | tauri::WindowEvent::Focused(true) => { + window::reconcile( + app, + matches!(event, tauri::WindowEvent::ScaleFactorChanged { .. }), + ); + } + _ => {} + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/island/tests.rs b/src-tauri/src/island/tests.rs new file mode 100644 index 0000000..e153421 --- /dev/null +++ b/src-tauri/src/island/tests.rs @@ -0,0 +1,1401 @@ +use super::*; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::thread; + +struct FakeApi { + address: SocketAddr, + requests: Arc, + stopping: Arc, + thread: Option>, +} + +impl FakeApi { + fn new(label: &'static str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(AtomicUsize::new(0)); + let count = requests.clone(); + let stopping = Arc::new(AtomicBool::new(false)); + let stop = stopping.clone(); + let thread = thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + if stop.load(Ordering::SeqCst) { + break; + } + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + assert!(bytes.len() <= 8192); + } + let request = String::from_utf8(bytes).unwrap(); + assert!(request + .to_ascii_lowercase() + .contains("authorization: basic b3blbmnvzgu6chjpdmf0zq==")); + count.fetch_add(1, Ordering::SeqCst); + let target = request.split_whitespace().nth(1).unwrap(); + let body = if target == "/api/info" { + serde_json::json!({"version": "2.0.22", "pid": 42}) + } else if target.starts_with("/api/session/ses_a/message") { + serde_json::json!({"data": [{"id": "msg_a", "type": "user", "time": {"created": 1}, "text": label}]}) + } else { + serde_json::json!({"data": []}) + }.to_string(); + let _ = write!( + stream, + "HTTP/1.1 200 Fixture\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + } + }); + Self { + address, + requests, + stopping, + thread: Some(thread), + } + } + + fn api(&self) -> IslandApi { + api::test_api(&format!("http://{}", self.address)) + } + + fn count(&self) -> usize { + self.requests.load(Ordering::SeqCst) + } +} + +impl Drop for FakeApi { + fn drop(&mut self) { + self.stopping.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(self.address); + self.thread.take().unwrap().join().unwrap(); + } +} + +fn transition_window(pointer: usize) -> transition::WindowId { + transition::WindowId { + pointer, + number: pointer as isize, + } +} + +#[test] +fn native_transition_keeps_one_active_and_only_latest_pending() { + let mut slot = transition::Slot::default(); + let window = transition_window(1); + let (active, start, _) = slot.enqueue(7, window, IslandView::Expanded); + assert!(start); + let (pending, start, displaced) = slot.enqueue(7, window, IslandView::Peek); + assert!(!start); + assert!(displaced.is_none()); + assert!(pending.serial > active.serial); + let (latest, start, displaced) = slot.enqueue(7, window, IslandView::Compact); + assert!(!start); + assert!(latest.serial > pending.serial); + assert_eq!(displaced.unwrap().token, pending); + assert_eq!(slot.active.as_ref().unwrap().token, active); + assert!(slot.next().is_none()); + assert!(!slot.current(active, true, 7, window)); + assert_eq!(slot.release(active).unwrap().token, active); + assert_eq!(slot.next(), Some(latest)); + assert!(slot.current(latest, true, 7, window)); +} + +#[test] +fn authoritative_collapse_excludes_old_focus_rollback_and_success() { + let mut cache = Cache { + enabled: true, + ..Default::default() + }; + let mut slot = transition::Slot::default(); + let window = transition_window(1); + let (grow, _, _) = slot.enqueue(cache.generation, window, IslandView::Expanded); + assert_eq!(cache.view, IslandView::Compact); + cache.selected_session = Some("ses_a".into()); + let (collapse, _, _) = slot.enqueue(cache.generation, window, IslandView::Compact); + cache.collapse(); + let mut effects = Vec::new(); + if slot.current(grow, cache.enabled, cache.generation, window) { + effects.extend(["success", "focus", "rollback"]); + } + assert!(effects.is_empty()); + assert_eq!(cache.view, IslandView::Compact); + assert!(cache.selected_session.is_none()); + slot.release(grow).unwrap(); + assert_eq!(slot.next(), Some(collapse)); +} + +#[test] +fn native_invalidation_resolves_receivers_and_old_completion_cannot_clear_replacement() { + let mut slot = transition::Slot::default(); + let old_window = transition_window(1); + let new_window = transition_window(2); + assert!(!slot.current( + transition::Token { + generation: 7, + serial: 0, + window: old_window + }, + false, + 7, + old_window, + )); + let (sender, mut receiver) = tokio::sync::oneshot::channel::>(); + let (old, _, _) = slot.enqueue(7, old_window, sender); + let (sender, mut pending) = tokio::sync::oneshot::channel::>(); + slot.enqueue(7, old_window, sender); + let (active, queued) = slot.invalidate(true); + for entry in active.into_iter().chain(queued) { + let _ = entry.value.send(Err(disabled())); + } + assert!(receiver.try_recv().unwrap().is_err()); + assert!(pending.try_recv().unwrap().is_err()); + let (sender, _) = tokio::sync::oneshot::channel(); + let (new, _, _) = slot.enqueue(8, new_window, sender); + assert!(!slot.current(old, true, 8, old_window)); + assert!(!slot.current(old, true, 7, new_window)); + assert!(!slot.current(new, false, 8, new_window)); + assert!(!slot.current(new, true, 8, old_window)); + assert!(slot.release(old).is_none()); + assert_eq!(slot.active.as_ref().unwrap().token, new); + assert!(slot.current(new, true, 8, new_window)); +} + +#[test] +fn reconnect_invalidates_results_but_waits_for_old_animator_before_reconciliation() { + let mut slot = transition::Slot::default(); + let window = transition_window(1); + let (old, _, _) = slot.enqueue(7, window, IslandView::Expanded); + let (_, pending) = slot.invalidate(false); + assert!(pending.is_none()); + assert!(!slot.current(old, true, 8, window)); + let (new, start, _) = slot.enqueue(8, window, IslandView::Peek); + assert!(!start); + assert!(slot.dirty); + slot.release(old).unwrap(); + assert_eq!(slot.next(), Some(new)); + assert!(slot.current(new, true, 8, window)); +} + +#[test] +fn midpoint_and_current_readback_failure_never_succeed() { + let target = geometry::Rect { + x: 655.5, + y: 942.0, + width: 400.0, + height: 170.0, + }; + let midpoint = geometry::Rect { + x: 675.0, + y: 1008.0, + width: 360.0, + height: 104.0, + }; + let mut slot = transition::Slot::default(); + let window = transition_window(1); + let (token, _, _) = slot.enqueue(7, window, IslandView::Peek); + assert!(!slot.serial_matches(transition::Token { + serial: token.serial + 1, + ..token + })); + assert!(slot.current(token, true, 7, window)); + assert!(transition::verified_frame(midpoint, target).is_err()); + assert!(slot.active.is_some()); + let actual = geometry::Rect { + height: target.height + 1.0, + ..target + }; + let result = transition::verified_frame(actual, target); + assert!(result.is_err()); + assert!(transition::verified_frame(target, target).is_ok()); + assert!(slot.release(token).is_some()); +} + +#[test] +fn notifications_and_moved_do_not_start_an_animator_and_display_change_is_one_dirty_bit() { + let mut slot = transition::Slot::default(); + let window = transition_window(1); + let (token, _, _) = slot.enqueue(7, window, IslandView::Peek); + for _ in 0..100 { + assert!(!slot.geometry_event(false)); + assert_eq!(slot.active.as_ref().unwrap().token, token); + assert!(slot.pending.is_none()); + assert!(!slot.dirty); + } + assert!(!slot.geometry_event(true)); + assert!(!slot.geometry_event(true)); + assert!(std::mem::take(&mut slot.dirty)); + assert!(!std::mem::take(&mut slot.dirty)); + slot.release(token).unwrap(); + assert!(slot.geometry_event(false)); +} + +#[test] +fn native_motion_is_grow_shrink_or_instant_for_reduce_motion_unchanged_and_placement() { + let compact = geometry::Rect { + x: 695.0, + y: 1074.0, + width: 321.0, + height: 38.0, + }; + let peek = geometry::Rect { + x: 655.5, + y: 942.0, + width: 400.0, + height: 170.0, + }; + assert_eq!(transition::duration(compact, peek, false), Some(0.22)); + assert_eq!(transition::duration(peek, compact, false), Some(0.19)); + assert_eq!(transition::duration(compact, peek, true), None); + assert_eq!(transition::duration(peek, compact, true), None); + assert_eq!(transition::duration(peek, peek, false), None); + assert_eq!( + transition::duration( + geometry::Rect { + x: peek.x - 0.5, + ..peek + }, + peek, + false + ), + None + ); + assert_eq!( + transition::duration( + geometry::Rect { + y: peek.y + 100.0, + ..peek + }, + peek, + false + ), + None + ); +} + +fn permission_snapshot() -> IslandSnapshot { + IslandSnapshot { + permissions: vec![api::IslandPermission { + id: "per_pending".into(), + session_id: "ses_a".into(), + action: "read".into(), + resources: vec!["/private/project".into()], + save: Vec::new(), + message: None, + }], + ..Default::default() + } +} + +#[test] +fn compact_is_default_and_pending_connection_updates_never_change_view() { + let mut cache = Cache::default(); + assert_eq!(cache.view, IslandView::Compact); + cache.enabled = true; + let generation = cache.generation; + assert!(cache.publish(generation, 1, permission_snapshot())); + assert_eq!(cache.view, IslandView::Compact); + assert!(cache.fail(generation)); + assert_eq!(cache.view, IslandView::Compact); + let generation = cache.reconnect().unwrap(); + assert_eq!(cache.view, IslandView::Compact); + assert!(cache.publish(generation, 2, permission_snapshot())); + assert_eq!(cache.view, IslandView::Compact); + for (revision, view) in [(3, IslandView::Peek), (4, IslandView::Expanded)] { + cache.set_view(generation, view).unwrap(); + assert!(cache.publish(generation, revision, permission_snapshot())); + assert_eq!(cache.view, view); + } +} + +#[test] +fn explicit_views_clear_hidden_selection_but_never_reply_to_pending() { + let mut cache = Cache { + enabled: true, + connection: Some("connected"), + snapshot: permission_snapshot(), + ..Default::default() + }; + let generation = cache.generation; + assert_eq!( + cache.set_view(generation, IslandView::Peek).unwrap().0, + IslandView::Compact + ); + assert_eq!(cache.view, IslandView::Peek); + assert!(cache.selected_session.is_none()); + assert!(!cache.visible_message("ses_a")); + assert!(cache.publish(generation, 1, permission_snapshot())); + assert_eq!(cache.view, IslandView::Peek); + for view in [IslandView::Peek, IslandView::Compact] { + cache.set_view(generation, IslandView::Expanded).unwrap(); + cache.selected_session = Some("ses_a".into()); + assert!(cache.visible_message("ses_a")); + cache.set_view(generation, view).unwrap(); + assert_eq!(cache.view, view); + assert!(cache.selected_session.is_none()); + assert!(!cache.visible_message("ses_a")); + assert_eq!(cache.snapshot.permissions.len(), 1); + assert_eq!(cache.snapshot.permissions[0].id, "per_pending"); + } +} + +#[test] +fn blur_collapse_and_disable_keep_generation_and_privacy_guards() { + let (cancel, receiver) = watch::channel(false); + let mut cache = Cache { + enabled: true, + connection: Some("connected"), + snapshot: permission_snapshot(), + cancel: Some(cancel), + ..Default::default() + }; + let generation = cache.generation; + for view in [IslandView::Peek, IslandView::Expanded] { + cache.set_view(generation, view).unwrap(); + cache.set_view(generation, IslandView::Compact).unwrap(); + assert_eq!(cache.view, IslandView::Compact); + assert_eq!(cache.snapshot.permissions.len(), 1); + assert!(!*receiver.borrow()); + } + cache.set_view(generation, IslandView::Expanded).unwrap(); + cache.selected_session = Some("ses_a".into()); + cache.stop(); + assert_eq!(cache.view, IslandView::Compact); + assert!(cache.selected_session.is_none()); + assert!(cache.snapshot.permissions.is_empty()); + assert!(*receiver.borrow()); + assert!(cache.set_view(generation, IslandView::Peek).is_err()); + cache.enabled = true; + assert!(cache.set_view(generation, IslandView::Expanded).is_err()); + assert_eq!(cache.view, IslandView::Compact); + assert!(cache.set_view(cache.generation, IslandView::Peek).is_ok()); +} + +#[test] +fn pending_resolution_publishes_before_notify_without_changing_explicit_view() { + let now = tokio::time::Instant::now(); + let mut cache = Cache { + enabled: true, + generation: 7, + connection: Some("connected"), + ..Default::default() + }; + let mut pending = Coalescer::default(); + pending.record(IslandChange::Snapshot, &cache, now); + pending.record( + IslandChange::Snapshot, + &cache, + now + Duration::from_millis(240), + ); + assert_eq!(pending.deadline, Some(now + Duration::from_millis(250))); + assert_eq!(cache.view, IslandView::Compact); + assert!(cache.publish(7, 1, permission_snapshot())); + assert!(pending.notify(&cache)); + assert_eq!(cache.view, IslandView::Compact); + cache.set_view(7, IslandView::Peek).unwrap(); + + let mut resolved = Coalescer::default(); + resolved.record(IslandChange::Snapshot, &cache, now); + assert!(cache.publish(7, 3, IslandSnapshot::default())); + assert!(resolved.notify(&cache)); + assert_eq!(cache.view, IslandView::Peek); + assert!(!cache.publish(7, 2, permission_snapshot())); + assert_eq!(cache.view, IslandView::Peek); + + assert!(cache.publish(7, 4, permission_snapshot())); + assert!(cache.fail(7)); + assert_eq!(cache.view, IslandView::Peek); + let generation = cache.reconnect().unwrap(); + assert!(!cache.publish(7, 5, permission_snapshot())); + assert_eq!(cache.view, IslandView::Peek); + assert!(cache.publish(generation, 6, IslandSnapshot::default())); + assert_eq!(cache.view, IslandView::Peek); + assert!(cache.publish(generation, 7, permission_snapshot())); + assert_eq!(cache.view, IslandView::Peek); + cache.stop(); + cache.enabled = true; + cache.connection = Some("connecting"); + assert!(!cache.publish(generation, 8, permission_snapshot())); + assert_eq!(cache.view, IslandView::Compact); +} + +#[test] +fn view_ipc_accepts_only_the_three_explicit_lowercase_values() { + for (value, view) in [ + ("compact", IslandView::Compact), + ("peek", IslandView::Peek), + ("expanded", IslandView::Expanded), + ] { + assert_eq!( + serde_json::from_value::(serde_json::json!(value)).unwrap(), + view + ); + } + for invalid in [ + serde_json::json!("attention"), + serde_json::json!("Compact"), + serde_json::json!(""), + serde_json::json!(true), + serde_json::json!(null), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } +} + +#[test] +fn disable_clears_private_snapshot_and_rejects_stale_generation() { + let (cancel, receiver) = watch::channel(false); + let mut cache = Cache { + enabled: true, + view: IslandView::Expanded, + selected_session: Some("ses_private".into()), + generation: 7, + connection: Some("connected"), + cancel: Some(cancel), + snapshot: IslandSnapshot { + sessions: vec![api::IslandSession { + id: "ses_private".into(), + title: "private".into(), + directory: "/private/project".into(), + updated_at: 1, + parent_id: None, + status: "running".into(), + }], + ..Default::default() + }, + ..Default::default() + }; + cache.stop(); + assert!(*receiver.borrow()); + assert!(!cache.enabled); + assert_eq!(cache.view, IslandView::Compact); + assert!(cache.selected_session.is_none()); + assert!(cache.snapshot.sessions.is_empty()); + assert!(cache.snapshot.permissions.is_empty()); + assert!(cache.snapshot.questions.is_empty()); + assert!(cache.api.is_none()); + assert!(!cache.publish(7, 1, IslandSnapshot::default())); + cache.enabled = true; + assert!(!cache.publish(7, 2, IslandSnapshot::default())); + assert!(cache.publish(8, 3, IslandSnapshot::default())); + assert!(!cache.publish(8, 2, IslandSnapshot::default())); +} + +#[test] +fn window_failure_is_disabled_retryable_and_generation_guarded() { + let mut cache = Cache { + enabled: true, + connection: Some("connecting"), + ..Default::default() + }; + let generation = cache.generation; + assert!(cache.window_failed(generation)); + assert!(!cache.enabled); + assert!(cache.cancel.is_none()); + assert!(cache.api.is_none()); + assert_eq!(cache.connection, Some("error")); + assert!(cache.reconnect().is_err()); + cache.enabled = true; + cache.connection = Some("connecting"); + assert!(!cache.window_failed(generation)); + assert!(cache.enabled); + let retry_generation = cache.reconnect().unwrap(); + assert_eq!(cache.connection, Some("connecting")); + assert!(cache.error.is_none()); + assert!(!cache.publish(generation, 1, IslandSnapshot::default())); + assert!(cache.publish(retry_generation, 2, IslandSnapshot::default())); +} + +#[test] +fn coalescer_ignores_hidden_messages_and_keeps_first_deadline() { + let now = tokio::time::Instant::now(); + let mut cache = Cache { + enabled: true, + ..Default::default() + }; + let mut pending = Coalescer::default(); + let messages = |id: &str| IslandChange::Messages { + session_id: id.into(), + }; + pending.record(messages("ses_a"), &cache, now); + assert!(pending.deadline.is_none()); + cache + .set_view(cache.generation, IslandView::Expanded) + .unwrap(); + cache.selected_session = Some("ses_a".into()); + pending.record(messages("ses_b"), &cache, now); + assert!(pending.deadline.is_none()); + pending.record(messages("ses_a"), &cache, now); + let deadline = pending.deadline; + pending.record(messages("ses_a"), &cache, now + Duration::from_millis(240)); + assert_eq!(pending.deadline, deadline); + assert_eq!(deadline, Some(now + Duration::from_millis(250))); + assert!(!pending.snapshot); + assert!(pending.notify(&cache)); + cache.collapse(); + assert!(!pending.notify(&cache)); + pending.record( + IslandChange::Snapshot, + &cache, + now + Duration::from_millis(249), + ); + assert!(pending.snapshot); + assert!(pending.notify(&cache)); + assert_eq!(pending.deadline, deadline); +} + +#[test] +fn message_invalidations_do_not_fetch_snapshots_or_change_bounds() { + let mut cache = Cache { + enabled: true, + view: IslandView::Expanded, + selected_session: Some("ses_a".into()), + connection: Some("connected"), + snapshot: permission_snapshot(), + ..Default::default() + }; + let screen = geometry::Screen { + frame: geometry::Rect { + x: -1512.0, + y: 0.0, + width: 1512.0, + height: 982.0, + }, + visible: geometry::Rect::default(), + top_inset: 32.0, + notch_left: -846.0, + notch_right: -666.0, + }; + let frame = geometry::placement(screen, cache.view); + let mut pending = Coalescer::default(); + pending.record( + IslandChange::Messages { + session_id: "ses_a".into(), + }, + &cache, + tokio::time::Instant::now(), + ); + assert!(pending.notify(&cache)); + assert!(!pending.snapshot); + assert_eq!(geometry::placement(screen, cache.view), frame); + cache.collapse(); + assert_eq!(cache.view, IslandView::Compact); + assert!(!pending.notify(&cache)); + let mut hidden = Coalescer::default(); + hidden.record( + IslandChange::Messages { + session_id: "ses_a".into(), + }, + &cache, + tokio::time::Instant::now(), + ); + assert!(hidden.deadline.is_none()); + assert!(!hidden.snapshot); +} + +#[test] +fn state_preferences_are_cached_without_config_reads() { + let service = IslandService::default(); + let preferences = AppPreferences { + theme: models::ThemePreference::Light, + locale: models::LocalePreference::Zh, + }; + service.update_preferences(preferences); + assert_eq!(service.state().preferences, preferences); + service.shutdown(); + assert_eq!(service.state().preferences, preferences); + assert!(!service.state().enabled); +} + +#[test] +fn disabled_message_reads_never_start_a_task() { + tauri::async_runtime::block_on(async { + let service = IslandService::default(); + assert!(service.messages("ses_a".into(), 0).await.is_err()); + assert!(service.context(None).is_err()); + assert!(lock(&service.task).is_none()); + assert!(lock(&service.cache).cancel.is_none()); + assert!(lock(&service.cache).selected_session.is_none()); + }); +} + +#[test] +fn old_connection_failure_cannot_overwrite_disabled_or_reenabled_state() { + let service = IslandService::default(); + assert!(lock(&service.task).is_none()); + assert!(lock(&service.cache).cancel.is_none()); + assert!(service.context(None).is_err()); + let mut cache = lock(&service.cache); + cache.enabled = true; + let old_generation = cache.generation; + cache.stop(); + assert!(!cache.fail(old_generation)); + assert_eq!(cache.connection, None); + assert!(cache.error.is_none()); + cache.enabled = true; + cache.connection = Some("connecting"); + assert!(!cache.fail(old_generation)); + assert_eq!(cache.connection, Some("connecting")); + assert!(cache.error.is_none()); + let current_generation = cache.generation; + assert!(cache.fail(current_generation)); + assert_eq!(cache.connection, Some("error")); +} + +#[test] +fn shutdown_aborts_owned_task_without_touching_sessions() { + tauri::async_runtime::block_on(async { + let service = IslandService::default(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (dropped, done) = tokio::sync::oneshot::channel(); + struct OnDrop(Option>); + impl Drop for OnDrop { + fn drop(&mut self) { + let _ = self.0.take().unwrap().send(()); + } + } + *lock(&service.task) = Some(tauri::async_runtime::spawn(async move { + let _drop = OnDrop(Some(dropped)); + started.send(()).unwrap(); + std::future::pending::<()>().await; + })); + ready.await.unwrap(); + service.shutdown(); + tokio::time::timeout(Duration::from_secs(1), done) + .await + .unwrap() + .unwrap(); + assert!(lock(&service.task).is_none()); + assert!(service.context(None).is_err()); + }); +} + +#[test] +fn public_state_is_camel_case_and_disabled_by_default() { + let state = IslandState { + supported: cfg!(target_os = "macos"), + enabled: false, + connection: "disabled", + connection_generation: 0, + error: None, + sessions: Vec::new(), + permissions: Vec::new(), + questions: Vec::new(), + preferences: AppPreferences::default(), + notch_width: 0.0, + top_inset: 0.0, + }; + let value = serde_json::to_value(state).unwrap(); + assert_eq!(value["connection"], "disabled"); + assert_eq!(value["enabled"], false); + assert_eq!(value["connectionGeneration"], 0); + assert_eq!(value["notchWidth"], 0.0); + assert_eq!(value["topInset"], 0.0); + assert!(value.get("error").is_none()); + assert!(value.get("notch_width").is_none()); +} + +#[test] +fn disable_cancels_in_flight_future_and_drops_its_private_data() { + tauri::async_runtime::block_on(async { + let (sender, mut receiver) = watch::channel(false); + let (started, ready) = tokio::sync::oneshot::channel(); + let (dropped, done) = tokio::sync::oneshot::channel(); + struct OnDrop(Option>); + impl Drop for OnDrop { + fn drop(&mut self) { + let _ = self.0.take().unwrap().send(()); + } + } + let task = tauri::async_runtime::spawn(async move { + cancellable(&mut receiver, async move { + let _private = OnDrop(Some(dropped)); + started.send(()).unwrap(); + std::future::pending::<()>().await; + }) + .await + }); + ready.await.unwrap(); + sender.send(true).unwrap(); + assert!(tokio::time::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap() + .is_err()); + done.await.unwrap(); + }); +} + +#[test] +fn api_failure_wakes_and_drops_fresh_read_before_post_without_stopping_monitor() { + tauri::async_runtime::block_on(async { + let (monitor, monitor_receiver) = watch::channel(false); + let cache = Arc::new(Mutex::new(Cache { + enabled: true, + cancel: Some(monitor), + ..Default::default() + })); + let (generation, api_generation, mut cancel) = { + let mut cache = lock(&cache); + cache.start_actions(); + ( + cache.generation, + cache.api_generation, + cache.action_cancel.as_ref().unwrap().subscribe(), + ) + }; + let (started, ready) = tokio::sync::oneshot::channel(); + let (get_response, get) = tokio::sync::oneshot::channel::<()>(); + let (post, posted) = tokio::sync::oneshot::channel(); + let (dropped, done) = tokio::sync::oneshot::channel(); + struct OnDrop(Option>); + impl Drop for OnDrop { + fn drop(&mut self) { + let _ = self.0.take().unwrap().send(()); + } + } + let task = tauri::async_runtime::spawn(async move { + cancellable(&mut cancel, async move { + let _private = OnDrop(Some(dropped)); + started.send(()).unwrap(); + get.await.unwrap(); + post.send(()).unwrap(); + }) + .await + }); + ready.await.unwrap(); + { + let mut cache = lock(&cache); + assert!(cache.fail(generation)); + assert_ne!(cache.api_generation, api_generation); + assert!(cache.api.is_none()); + assert!(cache.action_cancel.is_none()); + } + assert!(tokio::time::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap() + .is_err()); + done.await.unwrap(); + assert!(get_response.send(()).is_err()); + assert!(posted.await.is_err()); + assert!(!*monitor_receiver.borrow()); + assert!(!monitor_receiver.has_changed().unwrap()); + }); +} + +#[test] +fn failed_api_cancels_queued_mutation_before_it_can_start() { + tauri::async_runtime::block_on(async { + let service = IslandService::default(); + let (generation, mut cancel) = { + let mut cache = lock(&service.cache); + cache.enabled = true; + cache.start_actions(); + ( + cache.generation, + cache.action_cancel.as_ref().unwrap().subscribe(), + ) + }; + let _held = service.mutations.lock().await; + let (started, start) = tokio::sync::oneshot::channel(); + let mut queued = std::pin::pin!(async { + let _mutation = cancellable(&mut cancel, service.mutations.lock()).await?; + started.send(()).unwrap(); + Ok::<_, AppError>(()) + }); + std::future::poll_fn(|context| { + assert!(queued.as_mut().poll(context).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + assert!(lock(&service.cache).fail(generation)); + assert!(tokio::time::timeout(Duration::from_secs(1), queued) + .await + .unwrap() + .is_err()); + assert!(start.await.is_err()); + }); +} + +#[test] +fn api_replacement_cancels_old_ready_read_but_not_monitor_or_new_actions() { + tauri::async_runtime::block_on(async { + let (monitor, mut monitor_receiver) = watch::channel(false); + let mut cache = Cache { + enabled: true, + cancel: Some(monitor), + ..Default::default() + }; + cache.start_actions(); + let generation = cache.generation; + let api_generation = cache.api_generation; + let mut old_cancel = cache.action_cancel.as_ref().unwrap().subscribe(); + let (response, get) = tokio::sync::oneshot::channel(); + let (post, posted) = tokio::sync::oneshot::channel(); + let mut old_action = std::pin::pin!(cancellable(&mut old_cancel, async move { + get.await.unwrap(); + post.send(()).unwrap(); + })); + std::future::poll_fn(|context| { + assert!(old_action.as_mut().poll(context).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + response.send(()).unwrap(); + assert!(cache.publish(generation, 2, IslandSnapshot::default())); + cache.start_actions(); + assert_eq!(cache.generation, generation); + assert_ne!(cache.api_generation, api_generation); + assert!(old_action.await.is_err()); + assert!(posted.await.is_err()); + let mut new_cancel = cache.action_cancel.as_ref().unwrap().subscribe(); + assert!(!cache.publish(generation, 1, permission_snapshot())); + assert!(cache.snapshot.permissions.is_empty()); + assert_eq!( + cancellable(&mut new_cancel, async { 42 }).await.unwrap(), + 42 + ); + assert!(cancellable(&mut monitor_receiver, async {}).await.is_ok()); + assert!(!monitor_receiver.has_changed().unwrap()); + }); +} + +#[test] +fn reconnect_and_disable_cancel_both_lifetimes_and_reject_stale_publication() { + let (monitor, monitor_receiver) = watch::channel(false); + let mut cache = Cache { + enabled: true, + cancel: Some(monitor), + ..Default::default() + }; + cache.start_actions(); + let old_generation = cache.generation; + let old_api_generation = cache.api_generation; + let old_action = cache.action_cancel.as_ref().unwrap().subscribe(); + let generation = cache.reconnect().unwrap(); + assert!(*monitor_receiver.borrow()); + assert!(*old_action.borrow()); + assert!(cache.cancel.is_none()); + assert!(cache.action_cancel.is_none()); + assert!(cache.api.is_none()); + assert_ne!(cache.api_generation, old_api_generation); + assert!(!cache.publish(old_generation, 1, permission_snapshot())); + let (monitor, monitor_receiver) = watch::channel(false); + cache.cancel = Some(monitor); + assert!(cache.publish(generation, 2, permission_snapshot())); + cache.start_actions(); + let action = cache.action_cancel.as_ref().unwrap().subscribe(); + assert!(!cache.fail(old_generation)); + assert!(!*monitor_receiver.borrow()); + assert!(!*action.borrow()); + assert_eq!(cache.snapshot.permissions.len(), 1); + cache.stop(); + assert!(*monitor_receiver.borrow()); + assert!(*action.borrow()); + assert!(cache.cancel.is_none()); + assert!(cache.action_cancel.is_none()); + assert!(cache.api.is_none()); + assert!(cache.snapshot.permissions.is_empty()); + assert!(!cache.publish(generation, 3, permission_snapshot())); + assert!(cache.reconnect().is_err()); +} + +#[test] +fn target_refresh_guard_drop_leaves_connection_and_private_state_unchanged() { + let service = IslandService::default(); + let (monitor, receiver) = watch::channel(false); + let action = { + let mut cache = lock(&service.cache); + cache.enabled = true; + cache.generation = 7; + cache.view = IslandView::Expanded; + cache.selected_session = Some("ses_a".into()); + cache.connection = Some("connected"); + cache.snapshot = permission_snapshot(); + cache.cancel = Some(monitor); + cache.start_actions(); + cache.action_cancel.as_ref().unwrap().subscribe() + }; + let before = serde_json::to_value(service.state()).unwrap(); + let api_generation = lock(&service.cache).api_generation; + let guard = service.prepare_target_refresh(); + assert!(service.cache.try_lock().is_err()); + drop(guard); + assert_eq!(serde_json::to_value(service.state()).unwrap(), before); + let cache = lock(&service.cache); + assert_eq!(cache.selected_session.as_deref(), Some("ses_a")); + assert_eq!(cache.api_generation, api_generation); + assert!(!*receiver.borrow()); + assert!(!*action.borrow()); + assert!(lock(&service.task).is_none()); +} + +#[test] +fn disabled_target_refresh_is_a_noop_without_a_monitor_or_preference_change() { + let service = IslandService::default(); + service.update_preferences(AppPreferences { + theme: models::ThemePreference::Light, + locale: models::LocalePreference::Zh, + }); + let before = serde_json::to_value(service.state()).unwrap(); + assert_eq!(service.prepare_target_refresh().invalidate(), None); + assert_eq!(serde_json::to_value(service.state()).unwrap(), before); + assert!(lock(&service.task).is_none()); + assert!(lock(&service.cache).cancel.is_none()); + assert!(lock(&service.cache).action_cancel.is_none()); +} + +#[test] +fn target_refresh_preserves_enabled_view_preferences_and_geometry_but_clears_private_data() { + let service = IslandService::default(); + for view in [IslandView::Compact, IslandView::Peek, IslandView::Expanded] { + let (monitor, receiver) = watch::channel(false); + let (old_generation, api_generation, action, preferences) = { + let mut cache = lock(&service.cache); + cache.enabled = true; + cache.view = view; + cache.selected_session = Some("ses_a".into()); + cache.connection = Some("connected"); + cache.error = Some("old error".into()); + cache.snapshot = permission_snapshot(); + cache.snapshot.sessions.push(api::IslandSession { + id: "ses_a".into(), + title: "private".into(), + directory: "/private/project".into(), + updated_at: 1, + parent_id: None, + status: "running".into(), + }); + cache.snapshot.questions.push(api::IslandQuestion { + id: "form_a".into(), + session_id: "ses_a".into(), + title: "private".into(), + answerable: false, + fields: Vec::new(), + }); + cache.cancel = Some(monitor); + cache.notch_width = 209.0; + cache.top_inset = 38.0; + cache.preferences = AppPreferences { + theme: models::ThemePreference::Light, + locale: models::LocalePreference::Zh, + }; + cache.start_actions(); + ( + cache.generation, + cache.api_generation, + cache.action_cancel.as_ref().unwrap().subscribe(), + cache.preferences, + ) + }; + let generation = service.prepare_target_refresh().invalidate().unwrap(); + let state = service.state(); + assert!(state.enabled); + assert_eq!(state.connection, "connecting"); + assert_eq!(state.connection_generation, generation); + assert_ne!(generation, old_generation); + assert_eq!(state.preferences, preferences); + assert_eq!((state.notch_width, state.top_inset), (209.0, 38.0)); + assert!(state.error.is_none()); + assert!(state.sessions.is_empty()); + assert!(state.permissions.is_empty()); + assert!(state.questions.is_empty()); + let mut cache = lock(&service.cache); + assert_eq!(cache.view, view); + assert!(cache.selected_session.is_none()); + assert!(cache.api.is_none()); + assert_ne!(cache.api_generation, api_generation); + assert!(cache.cancel.is_none()); + assert!(cache.action_cancel.is_none()); + assert!(*receiver.borrow()); + assert!(*action.borrow()); + assert!(!cache.publish(old_generation, u64::MAX, permission_snapshot())); + assert!(!cache.fail(old_generation)); + assert!(cache.publish(generation, 1, permission_snapshot())); + assert!(!cache.visible_message("ses_a")); + let mut pending = Coalescer::default(); + pending.record( + IslandChange::Messages { + session_id: "ses_a".into(), + }, + &cache, + tokio::time::Instant::now(), + ); + assert!(pending.deadline.is_none()); + assert!(!pending.notify(&cache)); + assert!(lock(&service.task).is_none()); + } +} + +#[test] +fn target_refresh_cancels_in_flight_reads_and_actions_even_with_reused_session_ids() { + tauri::async_runtime::block_on(async { + let service = IslandService::default(); + let (mut read_cancel, mut action_cancel) = { + let mut cache = lock(&service.cache); + cache.enabled = true; + cache.view = IslandView::Expanded; + cache.selected_session = Some("ses_a".into()); + cache.start_actions(); + let cancel = cache.action_cancel.as_ref().unwrap(); + (cancel.subscribe(), cancel.subscribe()) + }; + let (read_response, read) = tokio::sync::oneshot::channel::<&str>(); + let (action_response, action) = tokio::sync::oneshot::channel::<()>(); + let (post, posted) = tokio::sync::oneshot::channel(); + let mut reading = std::pin::pin!(cancellable(&mut read_cancel, read)); + let mut writing = std::pin::pin!(cancellable(&mut action_cancel, async move { + action.await.unwrap(); + post.send("ses_a").unwrap(); + })); + std::future::poll_fn(|context| { + assert!(reading.as_mut().poll(context).is_pending()); + assert!(writing.as_mut().poll(context).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + let generation = service.prepare_target_refresh().invalidate().unwrap(); + read_response.send("ses_a").unwrap(); + action_response.send(()).unwrap(); + { + let mut cache = lock(&service.cache); + assert!(cache.publish(generation, 1, permission_snapshot())); + cache.start_actions(); + assert!(cache.selected_session.is_none()); + } + assert!(reading.await.is_err()); + assert!(writing.await.is_err()); + assert!(posted.await.is_err()); + let mut new_action = lock(&service.cache) + .action_cancel + .as_ref() + .unwrap() + .subscribe(); + assert_eq!( + cancellable(&mut new_action, async { "ses_a" }) + .await + .unwrap(), + "ses_a" + ); + }); +} + +#[test] +fn stale_target_finalization_cannot_replace_or_abort_the_latest_monitor() { + tauri::async_runtime::block_on(async { + let service = IslandService::default(); + lock(&service.cache).enabled = true; + let old = service.prepare_target_refresh().invalidate().unwrap(); + let latest = service.prepare_target_refresh().invalidate().unwrap(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (inspect, inspected) = tokio::sync::oneshot::channel(); + let (observed, observation) = tokio::sync::oneshot::channel(); + assert!(service.replace_monitor(latest, move |cancel| async move { + started.send(()).unwrap(); + inspected.await.unwrap(); + observed.send(*cancel.borrow()).unwrap(); + std::future::pending::<()>().await; + })); + ready.await.unwrap(); + assert!(!service.replace_monitor(old, |_| async { panic!("stale monitor started") })); + inspect.send(()).unwrap(); + assert!(!tokio::time::timeout(Duration::from_secs(1), observation) + .await + .unwrap() + .unwrap()); + assert!(lock(&service.cache).current(latest)); + assert!(lock(&service.task).is_some()); + service.shutdown(); + assert!(!service.replace_monitor(latest, |_| async { panic!("disabled monitor started") })); + assert!(lock(&service.task).is_none()); + assert!(!service.is_enabled()); + }); +} + +#[test] +fn monitor_install_rechecks_generation_after_spawn_without_holding_cache() { + tauri::async_runtime::block_on(async { + let service = IslandService::default(); + lock(&service.cache).enabled = true; + let generation = service.prepare_target_refresh().invalidate().unwrap(); + assert!(!service.replace_monitor(generation, |receiver| { + assert!(service.cache.try_lock().is_ok()); + assert_ne!( + service.prepare_target_refresh().invalidate(), + Some(generation) + ); + assert!(*receiver.borrow()); + async {} + })); + assert!(lock(&service.task).is_none()); + assert!(lock(&service.cache).cancel.is_none()); + }); +} + +#[test] +fn stale_request_boundary_rejects_before_api_lookup_action_or_selection_change() { + tauri::async_runtime::block_on(async { + let service = IslandService::default(); + { + let mut cache = lock(&service.cache); + cache.enabled = true; + cache.generation = 8; + cache.view = IslandView::Expanded; + cache.selected_session = Some("ses_current".into()); + } + let called = std::sync::atomic::AtomicUsize::new(0); + let result = service + .request( + 7, + |_| { + called.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { Ok(()) } + }, + true, + ) + .await; + assert_eq!(result.unwrap_err(), disabled()); + assert_eq!(called.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + service.messages("ses_a".into(), 7).await.unwrap_err(), + disabled() + ); + assert_eq!( + lock(&service.cache).selected_session.as_deref(), + Some("ses_current") + ); + }); +} + +#[test] +fn all_island_data_ipcs_require_an_unsigned_expected_connection_generation() { + let server_a = FakeApi::new("A"); + let server_b = FakeApi::new("B"); + let api_a = server_a.api(); + let api_b = server_b.api(); + let service = IslandService::default(); + let snapshot = { + let mut cache = lock(&service.cache); + cache.enabled = true; + cache.generation = 7; + cache.view = IslandView::Expanded; + cache.snapshot = permission_snapshot(); + cache.snapshot.sessions.push(api::IslandSession { + id: "ses_a".into(), + title: "shared".into(), + directory: "/synthetic".into(), + updated_at: 1, + parent_id: None, + status: "running".into(), + }); + cache.snapshot.questions.push(api::IslandQuestion { + id: "frm_a".into(), + session_id: "ses_a".into(), + title: "shared".into(), + answerable: true, + fields: Vec::new(), + }); + cache.connection = Some("connected"); + cache.start_actions(); + cache.api = Some(api_a); + cache.snapshot.clone() + }; + assert_eq!( + tauri::async_runtime::block_on(service.messages("ses_a".into(), 7)).unwrap()[0].text, + "A" + ); + assert_eq!(service.prepare_target_refresh().invalidate(), Some(8)); + { + let mut cache = lock(&service.cache); + assert!(cache.publish(8, 1, snapshot)); + cache.start_actions(); + cache.api = Some(api_b); + cache.selected_session = Some("ses_current".into()); + } + let initial_a = server_a.count(); + let initial_b = server_b.count(); + assert_eq!(initial_b, 1); + let app = tauri::test::mock_builder() + .manage(service) + .invoke_handler(tauri::generate_handler![ + crate::commands::island::island_messages, + crate::commands::island::island_send, + crate::commands::island::island_reply_permission, + crate::commands::island::island_reply_question, + crate::commands::island::island_cancel_question, + crate::commands::island::island_interrupt, + ]) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + let payload = serde_json::json!({ + "sessionId": "ses_a", "text": "hello", "messageId": "msg_a", + "requestId": "per_pending", "decision": "once", "formId": "frm_a", "answer": {} + }); + for command in [ + "island_messages", + "island_send", + "island_reply_permission", + "island_reply_question", + "island_cancel_question", + "island_interrupt", + ] { + for invalid in [ + None, + Some(serde_json::json!(-1)), + Some(serde_json::Value::Null), + ] { + let mut body = payload.clone(); + if let Some(invalid) = invalid { + body["expectedConnectionGeneration"] = invalid; + } + let error = tauri::test::get_ipc_response( + &webview, + tauri::webview::InvokeRequest { + cmd: command.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: tauri::ipc::InvokeBody::Json(body), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.into(), + }, + ) + .unwrap_err(); + assert!( + error + .as_str() + .unwrap() + .contains("expectedConnectionGeneration"), + "{command}: {error}" + ); + } + let mut body = payload.clone(); + body["expectedConnectionGeneration"] = serde_json::json!(7); + let error = tauri::test::get_ipc_response( + &webview, + tauri::webview::InvokeRequest { + cmd: command.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: tauri::ipc::InvokeBody::Json(body), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.into(), + }, + ) + .unwrap_err(); + assert_eq!( + error, + serde_json::to_value(disabled()).unwrap(), + "{command}" + ); + let service = app.state::(); + assert_eq!( + lock(&service.cache).selected_session.as_deref(), + Some("ses_current") + ); + assert_eq!(server_a.count(), initial_a, "{command}"); + assert_eq!(server_b.count(), initial_b, "{command}"); + } + let mut body = payload; + body["expectedConnectionGeneration"] = serde_json::json!(8); + let messages = tauri::test::get_ipc_response( + &webview, + tauri::webview::InvokeRequest { + cmd: "island_messages".into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: tauri::ipc::InvokeBody::Json(body), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.into(), + }, + ) + .unwrap() + .deserialize::() + .unwrap(); + assert_eq!(messages[0]["text"], "B"); + assert_eq!(server_b.count(), initial_b + 1); + let service = app.state::(); + let called = AtomicUsize::new(0); + let messages = tauri::async_runtime::block_on(service.request( + 8, + |api| { + called.fetch_add(1, Ordering::SeqCst); + async move { api.messages("ses_a").await } + }, + true, + )) + .unwrap(); + assert_eq!(messages[0].text, "B"); + assert_eq!(called.load(Ordering::SeqCst), 1); + assert_eq!(server_a.count(), initial_a); + assert_eq!(server_b.count(), initial_b + 2); +} + +#[test] +fn actual_request_waiting_for_mutation_lock_cannot_run_after_target_refresh() { + let server_a = FakeApi::new("A"); + let server_b = FakeApi::new("B"); + let api_a = server_a.api(); + let api_b = server_b.api(); + let service = IslandService::default(); + { + let mut cache = lock(&service.cache); + cache.enabled = true; + cache.generation = 7; + cache.connection = Some("connected"); + cache.start_actions(); + cache.api = Some(api_a); + } + let initial_a = server_a.count(); + let initial_b = server_b.count(); + tauri::async_runtime::block_on(async { + let held = service.mutations.lock().await; + let called = AtomicUsize::new(0); + let mut queued = std::pin::pin!(service.request( + 7, + |api| { + called.fetch_add(1, Ordering::SeqCst); + async move { api.interrupt("ses_a").await } + }, + true + )); + std::future::poll_fn(|context| { + assert!(queued.as_mut().poll(context).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + let generation = service.prepare_target_refresh().invalidate().unwrap(); + { + let mut cache = lock(&service.cache); + assert!(cache.publish(generation, 1, permission_snapshot())); + cache.start_actions(); + cache.api = Some(api_b); + } + drop(held); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), queued) + .await + .unwrap() + .unwrap_err(), + disabled() + ); + assert_eq!(called.load(Ordering::SeqCst), 0); + assert!(service.context(Some(generation)).is_ok()); + }); + assert_eq!(server_a.count(), initial_a); + assert_eq!(server_b.count(), initial_b); +} diff --git a/src-tauri/src/island/transition.rs b/src-tauri/src/island/transition.rs new file mode 100644 index 0000000..61f0335 --- /dev/null +++ b/src-tauri/src/island/transition.rs @@ -0,0 +1,144 @@ +use super::geometry::{frame_matches, Rect}; +use crate::error::AppError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct WindowId { + pub pointer: usize, + pub number: isize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Token { + pub generation: u64, + pub serial: u64, + pub window: WindowId, +} + +pub(super) struct Entry { + pub token: Token, + pub value: T, +} + +pub(super) struct Slot { + serial: u64, + pub active: Option>, + pub pending: Option>, + pub dirty: bool, +} + +impl Default for Slot { + fn default() -> Self { + Self { + serial: 0, + active: None, + pending: None, + dirty: false, + } + } +} + +impl Slot { + pub fn enqueue( + &mut self, + generation: u64, + window: WindowId, + value: T, + ) -> (Token, bool, Option>) { + self.serial = self + .serial + .checked_add(1) + .expect("Island transition serial exhausted"); + let token = Token { + generation, + serial: self.serial, + window, + }; + let entry = Entry { token, value }; + if self.active.is_some() { + // ponytail: rapid reversal waits <=220ms; revisit only if native retargeting is required. + (token, false, self.pending.replace(entry)) + } else { + self.active = Some(entry); + (token, true, None) + } + } + + pub fn serial_matches(&self, token: Token) -> bool { + self.serial == token.serial + } + + pub fn current(&self, token: Token, enabled: bool, generation: u64, window: WindowId) -> bool { + enabled + && generation == token.generation + && window == token.window + && self.serial_matches(token) + && self + .active + .as_ref() + .is_some_and(|entry| entry.token == token) + } + + pub fn release(&mut self, token: Token) -> Option> { + if self + .active + .as_ref() + .is_some_and(|entry| entry.token == token) + { + self.active.take() + } else { + None + } + } + + pub fn next(&mut self) -> Option { + if self.active.is_some() { + return None; + } + self.active = self.pending.take(); + self.active.as_ref().map(|entry| entry.token) + } + + pub fn invalidate(&mut self, retire: bool) -> (Option>, Option>) { + self.serial = self + .serial + .checked_add(1) + .expect("Island transition serial exhausted"); + self.dirty = !retire; + let active = if retire { self.active.take() } else { None }; + (active, self.pending.take()) + } + + pub fn geometry_event(&mut self, display_changed: bool) -> bool { + if self.active.is_some() { + self.dirty |= display_changed; + false + } else { + true + } + } +} + +pub(super) fn duration(before: Rect, target: Rect, reduced_motion: bool) -> Option { + if reduced_motion + || frame_matches(before, target) + || (before.width == target.width && before.height == target.height) + { + None + } else if target.height > before.height + || (target.height == before.height && target.width > before.width) + { + Some(0.22) + } else { + Some(0.19) + } +} + +pub(super) fn verified_frame(actual: Rect, target: Rect) -> Result<(), AppError> { + if frame_matches(actual, target) { + Ok(()) + } else { + Err(AppError::configuration( + "Mini Island window operation failed", + )) + } +} diff --git a/src-tauri/src/island/window.rs b/src-tauri/src/island/window.rs new file mode 100644 index 0000000..7d8ee13 --- /dev/null +++ b/src-tauri/src/island/window.rs @@ -0,0 +1,773 @@ +use std::cell::RefCell; +use std::sync::{Mutex, Weak}; + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2::{runtime::NSObjectProtocol, sel, MainThreadMarker}; +use objc2_app_kit::{ + NSAnimatablePropertyContainer, NSAnimationContext, NSMainMenuWindowLevel, NSScreen, NSWindow, + NSWindowCollectionBehavior, NSWorkspace, +}; +use objc2_foundation::{NSPoint, NSRect, NSSize}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; + +use super::geometry::{frame_matches, placement, Placement, Rect, Screen}; +use super::transition::{duration, verified_frame, Slot, Token, WindowId}; +use super::{disabled, lock, IslandService, IslandView}; +use crate::error::AppError; + +fn failure() -> AppError { + AppError::configuration("Mini Island window operation failed") +} + +fn superseded() -> AppError { + AppError::validation("Mini Island view transition superseded") +} + +struct Request { + view: IslandView, + explicit: bool, + instant: bool, + target: Option, + sender: Option>>, +} + +#[derive(Default)] +struct NativeSlot { + transitions: Slot, + window: Option, +} + +thread_local! { + static TRANSITIONS: RefCell = RefCell::new(NativeSlot::default()); +} + +fn native_window(app: &AppHandle) -> Result, AppError> { + let _mtm = MainThreadMarker::new().ok_or_else(failure)?; + let window = app.get_webview_window("island").ok_or_else(failure)?; + let pointer = window.ns_window().map_err(|_| failure())?; + unsafe { Retained::retain(pointer.cast::()) }.ok_or_else(failure) +} + +fn identity(native: &NSWindow) -> WindowId { + WindowId { + pointer: native as *const NSWindow as usize, + number: native.windowNumber(), + } +} + +fn current(app: &AppHandle, token: Token) -> bool { + let Some(service) = app.try_state::() else { + return false; + }; + let (enabled, generation) = { + let cache = lock(&service.cache); + (cache.enabled, cache.generation) + }; + let valid = TRANSITIONS.with(|slot| { + let slot = slot.borrow(); + slot.window == Some(token.window) + && slot + .transitions + .current(token, enabled, generation, token.window) + }); + valid + && native_window(app) + .map(|native| identity(&native) == token.window) + .unwrap_or(false) +} + +pub(super) fn invalidate(retire: bool) { + if MainThreadMarker::new().is_none() { + return; + } + let (active, pending, sender) = TRANSITIONS.with(|slot| { + let mut slot = slot.borrow_mut(); + let entries = slot.transitions.invalidate(retire); + let sender = slot + .transitions + .active + .as_mut() + .and_then(|entry| entry.value.sender.take()); + if retire { + slot.window = None; + } + (entries.0, entries.1, sender) + }); + for sender in active + .into_iter() + .chain(pending) + .filter_map(|entry| entry.value.sender) + .chain(sender) + { + let _ = sender.send(Err(disabled())); + } +} + +pub(super) fn wants_noncompact() -> bool { + TRANSITIONS.with(|slot| { + let slot = slot.borrow(); + slot.transitions + .active + .iter() + .chain(slot.transitions.pending.iter()) + .any(|entry| { + slot.transitions.serial_matches(entry.token) + && entry.value.view != IslandView::Compact + }) + }) +} + +pub(super) fn request( + app: &AppHandle, + view: IslandView, + generation: u64, +) -> Result>, AppError> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let result = enqueue( + app, + generation, + Request { + view, + explicit: true, + instant: false, + target: None, + sender: Some(sender), + }, + ); + if result.is_err() && view == IslandView::Compact { + let service = app.state::(); + let collapsed = { + let mut cache = lock(&service.cache); + if cache.current(generation) { + cache.collapse(); + true + } else { + false + } + }; + if collapsed { + invalidate(false); + let _ = app.emit_to("island", "island:collapsed", ()); + } + } + result?; + Ok(receiver) +} + +fn enqueue(app: &AppHandle, generation: u64, request: Request) -> Result<(), AppError> { + let service = app.state::(); + if !lock(&service.cache).current(generation) { + return Err(disabled()); + } + let native = native_window(app)?; + let window = identity(&native); + let compact = request.explicit && request.view == IslandView::Compact; + let (token, start, displaced) = TRANSITIONS.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.window != Some(window) { + return Err(disabled()); + } + Ok(slot.transitions.enqueue(generation, window, request)) + })?; + if let Some(sender) = displaced.and_then(|entry| entry.value.sender) { + let _ = sender.send(Err(superseded())); + } + if compact { + { + let mut cache = lock(&service.cache); + if cache.current(generation) { + cache.collapse(); + } + } + let _ = app.emit_to("island", "island:collapsed", ()); + } + if start { + start_transition(app, token); + } + Ok(()) +} + +fn start_transition(app: &AppHandle, token: Token) { + if !current(app, token) { + complete(Some(app), token, Err(superseded())); + return; + } + let Some((view, instant)) = TRANSITIONS.with(|slot| { + slot.borrow() + .transitions + .active + .as_ref() + .filter(|entry| entry.token == token) + .map(|entry| (entry.value.view, entry.value.instant)) + }) else { + return; + }; + let prepared = (|| { + let selected = screen()?; + let target = placement(selected, view); + let native = native_window(app)?; + policy(&native); + if !current(app, token) { + return Err(superseded()); + } + let before = rect(native.frame()); + let reduced_motion = + NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion(); + Ok(( + selected, + target, + native, + if instant { + None + } else { + duration(before, target.frame, reduced_motion) + }, + )) + })(); + let (selected, target, native, duration) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + complete(Some(app), token, Err(error)); + return; + } + }; + TRANSITIONS.with(|slot| { + if let Some(entry) = slot + .borrow_mut() + .transitions + .active + .as_mut() + .filter(|entry| entry.token == token) + { + entry.value.target = Some(target); + } + }); + if let Some(duration) = duration { + let notifications = app.state::().notifications.clone(); + let retained = native.clone(); + let completion = RcBlock::new(move || { + let _keep_alive = &retained; + animation_completed(¬ifications, token, selected, target, view); + }); + let changes = RcBlock::new(move |context: std::ptr::NonNull| { + unsafe { context.as_ref() }.setDuration(duration); + if current(app, token) { + native + .animator() + .setFrame_display(native_rect(target.frame), true); + } + }); + // The active token is installed before AppKit: a no-op completion can be synchronous. + NSAnimationContext::runAnimationGroup_completionHandler(&changes, Some(&completion)); + } else { + let result = if current(app, token) { + place_instant(&native, selected, target, view, Some((app, token))).map(|()| target) + } else { + Err(superseded()) + }; + complete(Some(app), token, result); + } +} + +fn animation_completed( + notifications: &Weak>>, + token: Token, + selected: Screen, + target: Placement, + view: IslandView, +) { + let app = notifications.upgrade().and_then(|slot| lock(&slot).clone()); + debug_assert!(MainThreadMarker::new().is_some()); + let result = match &app { + Some(app) if current(app, token) => native_window(app).and_then(|native| { + correct_and_readback( + &native, + selected, + target, + view, + "after-animation", + Some((app, token)), + )?; + Ok(target) + }), + _ => Err(superseded()), + }; + complete(app.as_ref(), token, result); +} + +fn complete(app: Option<&AppHandle>, token: Token, mut result: Result) { + let request = TRANSITIONS.with(|slot| { + slot.borrow() + .transitions + .active + .as_ref() + .filter(|entry| entry.token == token) + .map(|entry| (entry.value.view, entry.value.explicit)) + }); + let Some((view, explicit)) = request else { + return; + }; + if let Some(app) = app.filter(|app| current(app, token)) { + let dirty = + TRANSITIONS.with(|slot| std::mem::take(&mut slot.borrow_mut().transitions.dirty)); + if result.is_ok() && dirty { + result = resize_owned(app, view, Some(token)); + } + if !current(app, token) { + result = Err(superseded()); + } + if let Ok(target) = result { + let service = app.state::(); + let previous = { + let mut cache = lock(&service.cache); + if cache.current(token.generation) { + let previous = cache.set_view(token.generation, view); + cache.notch_width = target.notch_width; + cache.top_inset = target.top_inset; + previous + } else { + Err(disabled()) + } + }; + result = previous.as_ref().map(|_| target).map_err(Clone::clone); + if result.is_ok() && explicit && view == IslandView::Expanded && current(app, token) { + if let Err(error) = focus(app) { + if current(app, token) { + if let Ok(previous) = previous { + let mut cache = lock(&service.cache); + if cache.current(token.generation) { + cache.view = previous.0; + cache.selected_session = previous.1; + } + } + } + result = Err(error); + } + } + } + if result.is_err() && current(app, token) { + let mode = lock(&app.state::().cache).view; + if resize_owned(app, mode, Some(token)).is_err() { + eprintln!("failed to restore Mini Island native frame"); + } + } + if !current(app, token) { + result = Err(superseded()); + } + } else { + result = Err(superseded()); + } + let Some(entry) = TRANSITIONS.with(|slot| slot.borrow_mut().transitions.release(token)) else { + return; + }; + let success = result.is_ok(); + if let Some(sender) = entry.value.sender { + let _ = sender.send(result.map(|_| ())); + } + if success { + if let Some(app) = app { + let latest = TRANSITIONS.with(|slot| { + let slot = slot.borrow(); + slot.window == Some(token.window) && slot.transitions.serial_matches(token) + }); + let enabled = lock(&app.state::().cache).current(token.generation); + if latest && enabled { + changed(app); + } + } + } + if let Some(app) = app { + drain(app); + } else { + invalidate(true); + } +} + +fn drain(app: &AppHandle) { + let next = TRANSITIONS.with(|slot| slot.borrow_mut().transitions.next()); + if let Some(token) = next { + start_transition(app, token); + } else { + let dirty = TRANSITIONS.with(|slot| { + let mut slot = slot.borrow_mut(); + slot.transitions.active.is_none() && std::mem::take(&mut slot.transitions.dirty) + }); + if dirty { + reconcile(app, false); + } + } +} + +pub(super) fn reconcile(app: &AppHandle, display_changed: bool) { + let target = TRANSITIONS.with(|slot| { + slot.borrow() + .transitions + .active + .as_ref() + .and_then(|entry| entry.value.target.map(|target| (entry.value.view, target))) + }); + let display_changed = display_changed + || target.is_some_and(|(view, target)| { + screen() + .map(|screen| placement(screen, view) != target) + .unwrap_or(false) + }); + let idle = TRANSITIONS.with(|slot| { + let mut slot = slot.borrow_mut(); + slot.window.is_some() && slot.transitions.geometry_event(display_changed) + }); + if !idle { + return; + } + let service = app.state::(); + let (generation, view) = { + let cache = lock(&service.cache); + if !cache.enabled { + return; + } + (cache.generation, cache.view) + }; + let _ = enqueue( + app, + generation, + Request { + view, + explicit: false, + instant: true, + target: None, + sender: None, + }, + ); +} + +fn destroyed(window: WindowId) -> bool { + if TRANSITIONS.with(|slot| slot.borrow().window == Some(window)) { + invalidate(true); + super::hover::remove(); + true + } else { + false + } +} + +pub(super) fn event_is_current(window: &tauri::Window) -> bool { + let Ok(pointer) = window.ns_window() else { + return false; + }; + let native = unsafe { &*pointer.cast::() }; + let window = identity(native); + TRANSITIONS.with(|slot| slot.borrow().window == Some(window)) +} + +fn rect(value: NSRect) -> Rect { + Rect { + x: value.origin.x, + y: value.origin.y, + width: value.size.width, + height: value.size.height, + } +} + +fn screen() -> Result { + let mtm = MainThreadMarker::new().ok_or_else(failure)?; + let screens = NSScreen::screens(mtm); + let selected = screens + .iter() + .find(|screen| { + screen.respondsToSelector(sel!(safeAreaInsets)) && screen.safeAreaInsets().top > 0.0 + }) + .or_else(|| screens.iter().next()) + .ok_or_else(failure)?; + let has_safe_area = selected.respondsToSelector(sel!(safeAreaInsets)) + && selected.respondsToSelector(sel!(auxiliaryTopLeftArea)) + && selected.respondsToSelector(sel!(auxiliaryTopRightArea)); + let (top_inset, notch_left, notch_right) = if has_safe_area { + let left = selected.auxiliaryTopLeftArea(); + let right = selected.auxiliaryTopRightArea(); + ( + selected.safeAreaInsets().top, + left.origin.x + left.size.width, + right.origin.x, + ) + } else { + (0.0, 0.0, 0.0) + }; + Ok(Screen { + frame: rect(selected.frame()), + visible: rect(selected.visibleFrame()), + top_inset, + notch_left, + notch_right, + }) +} + +fn readback( + native: &NSWindow, + selected: Screen, + target: Placement, + mode: IslandView, + phase: &str, +) -> Result<(), AppError> { + let actual = rect(native.frame()); + let actual_top = actual.y + actual.height; + let screen_top = selected.frame.y + selected.frame.height; + let requested_top = target.frame.y + target.frame.height; + let requested_matched = frame_matches(actual, target.frame); + let diagnostic = format!( + "Mini Island native readback: phase={phase}, mode={mode:?}, window={}, level={}, requested={:?}, actual={actual:?}, requested.maxY={requested_top}, NSWindow.frame.maxY={actual_top}, NSScreen.frame.maxY={screen_top}, top_gap(screen-actual)={}, top_delta(actual-requested)={}, horizontal_origin_delta(actual-requested)={}, requested_exact={}, requested_matched={requested_matched}", + native.windowNumber(), + native.level(), + target.frame, + screen_top - actual_top, + actual_top - requested_top, + actual.x - target.frame.x, + actual == target.frame, + ); + eprintln!("{diagnostic}"); + #[cfg(debug_assertions)] + if std::fs::write( + std::env::temp_dir().join("opencode-mom-island-native-frame.log"), + format!("pid={}, {diagnostic}\n", std::process::id()), + ) + .is_err() + { + eprintln!("failed to write Mini Island native frame diagnostic"); + } + verified_frame(actual, target.frame) +} + +fn open_new_window( + registered: bool, + create: impl FnOnce() -> Result, +) -> Result { + // Destroy is queued; a registered label may still belong to the retiring window. + if registered { + return Err(failure()); + } + create() +} + +pub(super) fn open(app: &AppHandle, mode: IslandView) -> Result { + open_new_window(app.get_webview_window("island").is_some(), || { + let target = placement(screen()?, mode); + let window = WebviewWindowBuilder::new(app, "island", WebviewUrl::App("/island".into())) + .title("MOM Mini Island") + .inner_size(target.frame.width, target.frame.height) + .transparent(true) + .decorations(false) + .resizable(false) + .focused(false) + .accept_first_mouse(true) + .skip_taskbar(true) + .always_on_top(true) + .visible_on_all_workspaces(true) + .shadow(false) + .visible(false) + .background_color(tauri::window::Color(0, 0, 0, 0)) + .build() + .map_err(|_| failure())?; + let pointer = window.ns_window().map_err(|_| failure())?; + let target = resize(app, mode)?; + // Tauri show() makes macOS windows key; orderFront keeps automatic startup non-activating. + unsafe { + let native = &*pointer.cast::(); + native.orderFront(None); + readback(native, screen()?, target, mode, "after-orderFront")?; + } + super::hover::install(app)?; + let native = native_window(app)?; + let identity = identity(&native); + TRANSITIONS.with(|slot| slot.borrow_mut().window = Some(identity)); + let service = app.state::(); + let cache = std::sync::Arc::downgrade(&service.cache); + let notifications = service.notifications.clone(); + window.on_window_event(move |event| { + if matches!(event, tauri::WindowEvent::Destroyed) && destroyed(identity) { + let app = notifications.upgrade().and_then(|slot| lock(&slot).clone()); + if let Some(service) = app + .as_ref() + .and_then(|app| app.try_state::()) + { + service.shutdown(); + } else if let Some(cache) = cache.upgrade() { + lock(&cache).stop(); + } + } + }); + Ok(target) + }) +} + +pub(super) fn resize(app: &AppHandle, mode: IslandView) -> Result { + resize_owned(app, mode, None) +} + +fn resize_owned( + app: &AppHandle, + mode: IslandView, + token: Option, +) -> Result { + ensure_current(token.map(|token| (app, token)))?; + let _mtm = MainThreadMarker::new().ok_or_else(failure)?; + let selected = screen()?; + let target = placement(selected, mode); + let native = native_window(app)?; + // Use public AppKit point coordinates directly: Tao mixes display pixels and window DPI. + policy(&native); + place_instant( + &native, + selected, + target, + mode, + token.map(|token| (app, token)), + )?; + Ok(target) +} + +fn policy(native: &NSWindow) { + let level = NSMainMenuWindowLevel + 2; + if native.level() != level { + native.setLevel(level); + } + let collection = NSWindowCollectionBehavior::CanJoinAllSpaces + | NSWindowCollectionBehavior::FullScreenAuxiliary + | NSWindowCollectionBehavior::Stationary + | NSWindowCollectionBehavior::IgnoresCycle; + if native.collectionBehavior() != collection { + native.setCollectionBehavior(collection); + } +} + +fn native_rect(frame: Rect) -> NSRect { + NSRect::new( + NSPoint::new(frame.x, frame.y), + NSSize::new(frame.width, frame.height), + ) +} + +fn ensure_current(owner: Option<(&AppHandle, Token)>) -> Result<(), AppError> { + if owner.is_some_and(|(app, token)| !current(app, token)) { + Err(superseded()) + } else { + Ok(()) + } +} + +fn place_instant( + native: &NSWindow, + selected: Screen, + target: Placement, + mode: IslandView, + owner: Option<(&AppHandle, Token)>, +) -> Result<(), AppError> { + ensure_current(owner)?; + let frame = native_rect(target.frame); + let before = native.frame(); + if !frame_matches(rect(before), target.frame) { + native.setFrame_display(frame, true); + correct_and_readback( + native, + selected, + target, + mode, + "after-setFrameOrigin", + owner, + )?; + } + Ok(()) +} + +fn correct_and_readback( + native: &NSWindow, + selected: Screen, + target: Placement, + mode: IslandView, + phase: &str, + owner: Option<(&AppHandle, Token)>, +) -> Result<(), AppError> { + ensure_current(owner)?; + let frame = native_rect(target.frame); + let actual = native.frame(); + if actual.origin != frame.origin && !frame_matches(rect(actual), target.frame) { + ensure_current(owner)?; + native.setFrameOrigin(frame.origin); + } + ensure_current(owner)?; + readback(native, selected, target, mode, phase) +} + +pub(super) fn focus(app: &AppHandle) -> Result<(), AppError> { + app.get_webview_window("island") + .ok_or_else(failure)? + .set_focus() + .map_err(|_| failure()) +} + +pub(super) fn close(app: &AppHandle) { + invalidate(true); + super::hover::remove(); + if let Some(window) = app.get_webview_window("island") { + if window.destroy().is_err() { + eprintln!("failed to destroy Mini Island window"); + } + } +} + +pub(super) fn changed(app: &AppHandle) { + if app.emit_to("island", "island:changed", ()).is_err() { + eprintln!("failed to notify Mini Island window"); + } +} + +#[cfg(test)] +mod tests { + use super::super::Cache; + use super::*; + + #[test] + fn delayed_destroy_rejects_reenable_until_label_is_removed() { + let mut cache = Cache { + enabled: true, + ..Default::default() + }; + let mut native_setup_calls = 0; + let target = Placement { + frame: Rect::default(), + notch_width: 0.0, + top_inset: 0.0, + }; + let mut registered = true; + cache.stop(); + cache.enabled = true; + cache.connection = Some("connecting"); + let generation = cache.generation; + assert!(open_new_window(registered, || { + native_setup_calls += 1; + Ok(target) + }) + .is_err()); + assert_eq!(native_setup_calls, 0); + assert!(cache.window_failed(generation)); + assert!(!cache.enabled); + assert!(cache.cancel.is_none()); + assert!(cache.api.is_none()); + assert_eq!(cache.connection, Some("error")); + + // Simulate runtime Destroyed removing the label; no automatic retry occurs. + registered = false; + assert!(!cache.enabled); + cache.enabled = true; + cache.connection = Some("connecting"); + assert_eq!( + open_new_window(registered, || { + native_setup_calls += 1; + Ok(target) + }) + .unwrap(), + target + ); + assert_eq!(native_setup_calls, 1); + assert!(cache.enabled); + } +} diff --git a/src-tauri/src/opencode_cli/island.rs b/src-tauri/src/opencode_cli/island.rs new file mode 100644 index 0000000..f0b0ec5 --- /dev/null +++ b/src-tauri/src/opencode_cli/island.rs @@ -0,0 +1,345 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use reqwest::{Method, RequestBuilder, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::error::AppError; + +use super::token_usage::transport::{self, ManagedAsyncConnection}; + +mod events; +mod projection; + +pub(crate) use events::IslandChange; +pub(crate) type IslandEvents = events::IslandEvents; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_BYTES: usize = 2 * 1024 * 1024; +const MAX_SESSIONS: usize = 100; +const MAX_PENDING: usize = 100; +const MAX_FIELDS: usize = 32; +const MAX_OPTIONS: usize = 100; +const MAX_TEXT: usize = 16 * 1024; +const JS_MAX_DATE: u64 = 8_640_000_000_000_000; + +#[derive(Default, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IslandSnapshot { + pub sessions: Vec, + pub permissions: Vec, + pub questions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IslandSession { + pub id: String, + pub title: String, + pub directory: String, + pub updated_at: u64, + pub parent_id: Option, + pub status: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IslandPermission { + pub id: String, + pub session_id: String, + pub action: String, + pub resources: Vec, + pub save: Vec, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IslandQuestion { + pub id: String, + pub session_id: String, + pub title: String, + pub answerable: bool, + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IslandQuestionField { + pub key: String, + #[serde(rename = "type")] + pub kind: String, + pub title: Option, + pub description: Option, + pub options: Vec, + pub custom: bool, + pub required: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IslandQuestionOption { + pub value: String, + pub label: String, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IslandMessage { + pub id: String, + pub role: String, + pub text: String, + pub time: u64, + pub truncated: bool, +} + +#[derive(Clone)] +pub(crate) struct IslandApi { + connection: Arc, +} + +pub(crate) async fn connect() -> Result { + let snapshot = super::target::snapshot()?; + let deadline = Instant::now() + Duration::from_secs(20); + let reservation = snapshot.reserve_side_effect(deadline)?; + let connection = tauri::async_runtime::spawn_blocking(move || { + // A dropped async caller cannot cancel a blocking bootstrap worker. + let _reservation = reservation; + transport::connect_snapshot(snapshot, deadline)?.into_async() + }) + .await + .map_err(|_| failure("connection worker failed"))??; + Ok(IslandApi { + connection: Arc::new(connection), + }) +} + +#[cfg(test)] +pub(crate) fn test_api(url: &str) -> IslandApi { + IslandApi { + connection: Arc::new( + transport::test_connection(url, Instant::now() + REQUEST_TIMEOUT) + .into_async() + .unwrap(), + ), + } +} + +#[derive(Deserialize)] +struct Data { + data: T, +} + +fn failure(kind: &str) -> AppError { + AppError::configuration(format!("opencode island: {kind}.")) +} + +fn invalid(kind: &str) -> AppError { + AppError::validation(format!("opencode island: invalid {kind}.")) +} + +fn identifier(value: &str, prefix: &str) -> Result<(), AppError> { + if value.len() <= 256 + && value.strip_prefix(prefix).is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) + { + Ok(()) + } else { + Err(invalid("identifier")) + } +} + +fn plain(value: &str, max: usize, multiline: bool) -> Result<(), AppError> { + if value.len() > max + || value + .chars() + .any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t'))) + { + Err(invalid("plaintext")) + } else { + Ok(()) + } +} + +fn safe_time(value: u64) -> Result { + if value <= JS_MAX_DATE { + Ok(value) + } else { + Err(invalid("time")) + } +} + +fn request_error(error: reqwest::Error) -> AppError { + failure(if error.is_timeout() { + "API request timed out" + } else if error.is_connect() { + "API connection failed" + } else { + "API request failed" + }) +} + +async fn body(mut response: Response) -> Result, AppError> { + if response + .content_length() + .is_some_and(|size| size > MAX_BYTES as u64) + { + return Err(failure("response byte limit exceeded")); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(request_error)? { + if bytes.len() + chunk.len() > MAX_BYTES { + return Err(failure("response byte limit exceeded")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +impl IslandApi { + fn request(&self, method: Method, path: &[&str]) -> Result { + Ok(self + .connection + .request(method, path)? + .timeout(REQUEST_TIMEOUT)) + } + + async fn read Deserialize<'de>>( + &self, + path: &[&str], + query: &[(&str, &str)], + ) -> Result { + let response = self + .request(Method::GET, path)? + .query(query) + .send() + .await + .map_err(request_error)?; + if response.status() != reqwest::StatusCode::OK { + return Err(failure(&format!("API HTTP {}", response.status().as_u16()))); + } + let value = transport::decode_json::>( + &body(response).await?, + "island response", + ) + .map(|value| value.0)?; + self.connection.ensure_current()?; + Ok(value) + } + + async fn write(&self, request: RequestBuilder) -> Result<(), AppError> { + let deadline = Instant::now() + REQUEST_TIMEOUT; + let _reservation = self.connection.prepare_write(deadline).await?; + let timeout = deadline + .checked_duration_since(Instant::now()) + .filter(|timeout| !timeout.is_zero()) + .ok_or_else(|| failure("write timed out before dispatch"))?; + let request = request.timeout(timeout); + let response = request + .send() + .await + .map_err(|_| failure("write outcome unknown; may have been accepted; do not retry"))?; + let status = response.status(); + if !status.is_success() { + if status.is_client_error() && status != reqwest::StatusCode::REQUEST_TIMEOUT { + return Err(failure(&format!( + "API HTTP {}; write rejected", + status.as_u16() + ))); + } + return Err(failure(&format!( + "API HTTP {}; write may have been accepted; do not retry", + status.as_u16() + ))); + } + body(response) + .await + .map_err(|_| failure("write outcome unknown; may have been accepted; do not retry"))?; + self.connection.ensure_current()?; + Ok(()) + } + + pub(crate) async fn send( + &self, + session_id: &str, + text: &str, + message_id: &str, + ) -> Result<(), AppError> { + identifier(session_id, "ses_")?; + identifier(message_id, "msg_")?; + plain(text, MAX_TEXT, true)?; + if text.trim().is_empty() { + return Err(invalid("message")); + } + self.write( + self.request(Method::POST, &["session", session_id, "prompt"])? + .json(&serde_json::json!({ "id": message_id, "text": text, "delivery": "queue" })), + ) + .await + } + + pub(crate) async fn reply_permission( + &self, + session_id: &str, + request_id: &str, + decision: &str, + ) -> Result<(), AppError> { + identifier(session_id, "ses_")?; + identifier(request_id, "per")?; + if !matches!(decision, "once" | "always" | "reject") { + return Err(invalid("permission decision")); + } + if decision == "always" + && !self + .permissions(session_id) + .await + .map_err(|_| invalid("pending permission"))? + .iter() + .any(|permission| { + permission.session_id == session_id + && permission.id == request_id + && !permission.save.is_empty() + }) + { + return Err(invalid("stale or unsavable permission")); + } + self.write( + self.request( + Method::POST, + &["session", session_id, "permission", request_id, "reply"], + )? + .json(&serde_json::json!({ "decision": decision })), + ) + .await + } + + pub(crate) async fn interrupt(&self, session_id: &str) -> Result<(), AppError> { + identifier(session_id, "ses_")?; + self.write( + self.request(Method::POST, &["session", session_id, "interrupt"])? + .query(&[("resume", "false")]), + ) + .await + } + + pub(crate) async fn cancel_question( + &self, + session_id: &str, + form_id: &str, + ) -> Result<(), AppError> { + identifier(session_id, "ses_")?; + identifier(form_id, "frm_")?; + self.write(self.request(Method::DELETE, &["session", session_id, "form", form_id])?) + .await + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/opencode_cli/island/events.rs b/src-tauri/src/opencode_cli/island/events.rs new file mode 100644 index 0000000..5ec5982 --- /dev/null +++ b/src-tauri/src/opencode_cli/island/events.rs @@ -0,0 +1,190 @@ +use super::*; + +const MAX_FRAME: usize = 64 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum IslandChange { + Snapshot, + Messages { session_id: String }, +} + +pub(crate) struct IslandEvents { + response: Response, + buffer: Vec, + offset: usize, + frame: Frame, +} + +#[derive(Default)] +pub(super) struct Frame { + line: Vec, + data: Vec, + size: usize, + skip_lf: bool, +} + +impl Frame { + pub(super) fn byte(&mut self, byte: u8) -> Result>, AppError> { + if self.skip_lf { + self.skip_lf = false; + if byte == b'\n' { + return Ok(None); + } + } + self.size += 1; + if self.size > MAX_FRAME { + return Err(failure("event frame byte limit exceeded")); + } + if !matches!(byte, b'\r' | b'\n') { + self.line.push(byte); + return Ok(None); + } + self.skip_lf = byte == b'\r'; + if self.line.is_empty() { + self.size = 0; + if self.data.last() == Some(&b'\n') { + self.data.pop(); + } + return Ok(Some(std::mem::take(&mut self.data))); + } + if let Some(data) = self.line.strip_prefix(b"data:") { + self.data + .extend_from_slice(data.strip_prefix(b" ").unwrap_or(data)); + self.data.push(b'\n'); + } + self.line.clear(); + Ok(None) + } +} + +impl IslandApi { + pub(crate) async fn events(&self) -> Result { + // Bound the handshake only; a quiet live stream must not become snapshot polling. + let response = tokio::time::timeout( + REQUEST_TIMEOUT, + self.connection + .request(Method::GET, &["event"])? + .header(reqwest::header::ACCEPT, "text/event-stream") + .send(), + ) + .await + .map_err(|_| failure("event connection timed out"))? + .map_err(request_error)?; + if response.status() != reqwest::StatusCode::OK { + return Err(failure(&format!("API HTTP {}", response.status().as_u16()))); + } + if response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + != Some("text/event-stream") + { + return Err(invalid("event content type")); + } + Ok(IslandEvents { + response, + buffer: Vec::new(), + offset: 0, + frame: Frame::default(), + }) + } +} + +impl IslandEvents { + pub(crate) async fn next(&mut self) -> Result { + loop { + while self.offset < self.buffer.len() { + let byte = self.buffer[self.offset]; + self.offset += 1; + if let Some(data) = self.frame.byte(byte)? { + if !data.is_empty() { + if let Some(change) = classify(&data)? { + return Ok(change); + } + } + } + } + self.buffer.clear(); + self.offset = 0; + let chunk = self + .response + .chunk() + .await + .map_err(request_error)? + .ok_or_else(|| failure("event stream ended"))?; + if chunk.len() > MAX_BYTES { + return Err(failure("event chunk byte limit exceeded")); + } + self.buffer.extend_from_slice(&chunk); + } + } +} + +pub(super) fn classify(bytes: &[u8]) -> Result, AppError> { + let event: Value = transport::decode_json(bytes, "island event")?; + let kind = event + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| invalid("event type"))?; + let snapshot = matches!( + kind, + "server.connected" + | "session.created" + | "session.deleted" + | "session.forked" + | "session.renamed" + | "session.moved" + | "session.status" + | "session.idle" + | "session.execution.started" + | "session.execution.succeeded" + | "session.execution.failed" + | "session.execution.interrupted" + | "permission.asked" + | "permission.replied" + | "form.created" + | "form.replied" + | "form.cancelled" + | "location.shutdown" + ); + let messages = matches!( + kind, + "session.text.started" + | "session.text.delta" + | "session.text.ended" + | "session.message.content.updated" + | "session.inbox.delivered" + ); + if !snapshot && !messages { + return Ok(None); + } + let data = event + .get("data") + .filter(|value| value.is_object()) + .ok_or_else(|| invalid("event data"))?; + // Snapshot invalidation needs no routing ID; native lifecycle payloads differ by event. + if snapshot && !kind.starts_with("form.") { + return Ok(Some(IslandChange::Snapshot)); + } + let data = if kind == "form.created" { + data.get("form").ok_or_else(|| invalid("form event"))? + } else { + data + }; + let session_id = data + .get("sessionID") + .and_then(Value::as_str) + .ok_or_else(|| invalid("event session"))?; + if session_id == "global" && kind.starts_with("form.") { + return Ok(None); + } + identifier(session_id, "ses_")?; + if snapshot { + Ok(Some(IslandChange::Snapshot)) + } else { + Ok(Some(IslandChange::Messages { + session_id: session_id.to_owned(), + })) + } +} diff --git a/src-tauri/src/opencode_cli/island/projection.rs b/src-tauri/src/opencode_cli/island/projection.rs new file mode 100644 index 0000000..c8894c0 --- /dev/null +++ b/src-tauri/src/opencode_cli/island/projection.rs @@ -0,0 +1,654 @@ +use super::*; + +#[derive(Deserialize)] +struct Active { + #[serde(deserialize_with = "super::super::active::running_sessions")] + data: HashSet, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Session { + id: String, + title: Option, + location: Location, + outcome: Option, + #[serde(rename = "parentID")] + parent_id: Option, + time: SessionTime, +} + +#[derive(Deserialize)] +struct Location { + directory: String, +} + +#[derive(Deserialize)] +struct SessionTime { + updated: u64, + archived: Option, +} + +impl Session { + fn public(self, running: bool) -> Result { + identifier(&self.id, "ses_")?; + if let Some(parent) = &self.parent_id { + identifier(parent, "ses_")?; + } + let title = self.title.unwrap_or_default(); + plain(&title, 4096, false)?; + plain(&self.location.directory, 4096, false)?; + if self + .outcome + .as_deref() + .is_some_and(|outcome| !matches!(outcome, "succeeded" | "failed" | "interrupted")) + { + return Err(invalid("session outcome")); + } + if let Some(time) = self.time.archived { + safe_time(time)?; + } + Ok(IslandSession { + id: self.id, + title, + directory: self.location.directory, + updated_at: safe_time(self.time.updated)?, + parent_id: self.parent_id, + status: if running { + "running" + } else if self.outcome.as_deref() == Some("failed") { + "error" + } else { + "idle" + } + .into(), + }) + } +} + +async fn bounded>>( + futures: impl IntoIterator, + mut retain: impl FnMut(T) -> Result<(), AppError>, +) -> Result<(), AppError> { + let mut input = futures.into_iter(); + let mut pending: Vec<_> = input.by_ref().take(4).map(Box::pin).collect(); + while !pending.is_empty() { + let (index, result) = std::future::poll_fn(|context| { + for (index, future) in pending.iter_mut().enumerate() { + if let std::task::Poll::Ready(result) = future.as_mut().poll(context) { + return std::task::Poll::Ready((index, result)); + } + } + std::task::Poll::Pending + }) + .await; + retain(result?)?; + if let Some(future) = input.next() { + pending[index] = Box::pin(future); + } else { + drop(pending.swap_remove(index)); + } + } + Ok(()) +} + +impl IslandApi { + pub(super) async fn permissions( + &self, + session_id: &str, + ) -> Result, AppError> { + let pending: Data> = self + .read(&["session", session_id, "permission"], &[]) + .await?; + if pending.data.len() > MAX_PENDING { + return Err(failure("permission display limit exceeded")); + } + let mut ids = HashSet::new(); + pending + .data + .into_iter() + .map(|request| { + let id = string(&request, "id")?; + identifier(id, "per")?; + if !ids.insert(id.to_owned()) || string(&request, "sessionID")? != session_id { + return Err(invalid("permission identity")); + } + let action = string(&request, "action")?; + plain(action, 1024, false)?; + if action.is_empty() { + return Err(invalid("permission action")); + } + Ok(IslandPermission { + id: id.to_owned(), + session_id: session_id.to_owned(), + action: action.to_owned(), + resources: bounded_strings( + request + .get("resources") + .ok_or_else(|| invalid("permission resources"))?, + MAX_OPTIONS, + )?, + save: request + .get("save") + .map(|value| bounded_strings(value, MAX_OPTIONS)) + .transpose()? + .unwrap_or_default(), + message: optional_string(&request, "message", 4096)?, + }) + }) + .collect() + } + + async fn questions(&self, session_id: &str) -> Result, AppError> { + let pending: Data> = self.read(&["session", session_id, "form"], &[]).await?; + if pending.data.len() > MAX_PENDING { + return Err(failure("form display limit exceeded")); + } + let mut ids = HashSet::new(); + pending + .data + .into_iter() + .map(|form| { + let question = public_question(&form, session_id)?; + if !ids.insert(question.id.clone()) { + return Err(invalid("duplicate form")); + } + Ok(question) + }) + .collect() + } + + pub(crate) async fn reply_question( + &self, + session_id: &str, + form_id: &str, + mut answer: Map, + ) -> Result<(), AppError> { + identifier(session_id, "ses_")?; + identifier(form_id, "frm_")?; + if answer.len() > MAX_FIELDS { + return Err(invalid("question answer limit")); + } + let current: Data = self + .read(&["session", session_id, "form", form_id], &[]) + .await?; + if current + .data + .get("state") + .and_then(|state| state.get("status")) + .and_then(Value::as_str) + != Some("pending") + { + return Err(invalid("stale question")); + } + let question = public_question(¤t.data, session_id)?; + if question.id != form_id { + return Err(invalid("form identity")); + } + answer_valid(&question, &mut answer)?; + self.write( + self.request( + Method::POST, + &["session", session_id, "form", form_id, "reply"], + )? + .json(&serde_json::json!({"answer":answer})), + ) + .await + } + + pub(crate) async fn snapshot(&self) -> Result { + let snapshot = tokio::time::timeout(Duration::from_secs(15), self.snapshot_inner()) + .await + .map_err(|_| failure("snapshot timed out"))??; + self.connection.ensure_current()?; + Ok(snapshot) + } + + async fn snapshot_inner(&self) -> Result { + let recent: Data> = self + .read( + &["session"], + &[("limit", "20"), ("order", "desc"), ("parentID", "null")], + ) + .await?; + if recent.data.len() > 20 { + return Err(failure("recent session limit exceeded")); + } + let active: Active = self.read(&["session", "active"], &[]).await?; + if active.data.len() > MAX_SESSIONS { + return Err(failure("active session display limit exceeded")); + } + for id in &active.data { + identifier(id, "ses_")?; + } + let mut seen = HashSet::new(); + let mut sessions = Vec::new(); + for session in recent.data { + if session.parent_id.is_some() || !seen.insert(session.id.clone()) { + return Err(invalid("recent root session")); + } + let archived = session.time.archived.is_some(); + let running = active.data.contains(&session.id); + let session = session.public(running)?; + if !archived || running { + sessions.push(session); + } + } + let missing: Vec<_> = active + .data + .iter() + .filter(|id| !sessions.iter().any(|session| &session.id == *id)) + .cloned() + .collect(); + if sessions.len() + missing.len() > MAX_SESSIONS { + return Err(failure("session display limit exceeded")); + } + let details: Vec<_> = missing + .into_iter() + .map(|id| async move { + let session: Data = self.read(&["session", &id], &[]).await?; + if session.data.id != id { + return Err(invalid("session identity")); + } + session.data.public(true) + }) + .collect(); + bounded(details, |session| { + sessions.push(session); + Ok(()) + }) + .await?; + let mut snapshot = IslandSnapshot { + sessions, + ..IslandSnapshot::default() + }; + let mut retained_bytes = serde_json::to_vec(&snapshot) + .map_err(|_| failure("snapshot encoding failed"))? + .len(); + if retained_bytes > MAX_BYTES { + return Err(failure("snapshot retained byte limit exceeded")); + } + // Recent roots are checked once too: pending work is not inferred from connection state. + let ids: Vec<_> = snapshot + .sessions + .iter() + .map(|session| session.id.clone()) + .collect(); + let pending: Vec<_> = ids + .into_iter() + .map(|id| async move { + let permissions = self.permissions(&id).await?; + let questions = self.questions(&id).await?; + Ok((permissions, questions)) + }) + .collect(); + bounded(pending, |(permissions, questions)| { + if snapshot.permissions.len() + + snapshot.questions.len() + + permissions.len() + + questions.len() + > MAX_PENDING + { + return Err(failure("pending request display limit exceeded")); + } + retained_bytes += serde_json::to_vec(&(&permissions, &questions)) + .map_err(|_| failure("snapshot encoding failed"))? + .len(); + if retained_bytes > MAX_BYTES { + return Err(failure("snapshot retained byte limit exceeded")); + } + snapshot.permissions.extend(permissions); + snapshot.questions.extend(questions); + Ok(()) + }) + .await?; + let pending_ids: HashSet<_> = snapshot + .permissions + .iter() + .map(|permission| permission.session_id.as_str()) + .chain( + snapshot + .questions + .iter() + .map(|question| question.session_id.as_str()), + ) + .collect(); + snapshot.sessions.sort_by(|left, right| { + let priority = |session: &IslandSession| { + if pending_ids.contains(session.id.as_str()) { + 0 + } else if session.status == "running" { + 1 + } else { + 2 + } + }; + priority(left) + .cmp(&priority(right)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(snapshot) + } + + pub(crate) async fn messages(&self, session_id: &str) -> Result, AppError> { + identifier(session_id, "ses_")?; + let messages: Data> = self + .read( + &["session", session_id, "message"], + &[("limit", "20"), ("order", "desc")], + ) + .await?; + public_messages(messages.data) + } +} + +pub(super) fn public_question(form: &Value, session_id: &str) -> Result { + let id = string(form, "id")?; + identifier(id, "frm_")?; + if string(form, "sessionID")? != session_id { + return Err(invalid("form owner")); + } + let title = string(form, "title")?; + plain(title, 4096, true)?; + let fields = form + .get("fields") + .and_then(Value::as_array) + .ok_or_else(|| invalid("form fields"))?; + if fields.len() > MAX_FIELDS { + return Err(failure("form field display limit exceeded")); + } + let mut question = IslandQuestion { + id: id.to_owned(), + session_id: session_id.to_owned(), + title: title.to_owned(), + answerable: !fields.is_empty() + && form + .get("metadata") + .and_then(|metadata| metadata.get("kind")) + .and_then(Value::as_str) + == Some("question"), + fields: Vec::new(), + }; + let mut keys = HashSet::new(); + for field in fields { + let key = string(field, "key")?; + plain(key, 256, false)?; + if key.is_empty() || !keys.insert(key.to_owned()) { + return Err(invalid("form field key")); + } + let kind = string(field, "type")?; + plain(kind, 64, false)?; + let mut required = public_flag(field, "required")?; + let conditional = match field.get("when") { + None | Some(Value::Null) => false, + Some(Value::Array(conditions)) => !conditions.is_empty(), + _ => return Err(invalid("form conditions")), + }; + let selection_minimum = match field.get("minItems") { + None | Some(Value::Null) => false, + Some(value) if kind == "multiselect" && value.as_u64() == Some(0) => false, + Some(value) if kind == "multiselect" && value.as_u64() == Some(1) => { + required = true; + false + } + Some(_) => true, + }; + // Constraints/conditions and non-question forms require the full OpenCode client. + if !matches!(kind, "string" | "multiselect") + || public_flag(field, "hidden")? + || conditional + || selection_minimum + || ["format", "minLength", "maxLength", "pattern", "maxItems"] + .iter() + .any(|key| field.get(*key).is_some_and(|value| !value.is_null())) + { + question.answerable = false; + } + let mut options = Vec::new(); + let mut values = HashSet::new(); + if let Some(list) = field.get("options").filter(|value| !value.is_null()) { + let list = list.as_array().ok_or_else(|| invalid("form options"))?; + if list.len() > MAX_OPTIONS { + return Err(failure("form option display limit exceeded")); + } + for option in list { + let value = string(option, "value")?; + let label = string(option, "label")?; + plain(value, 4096, true)?; + plain(label, 4096, true)?; + if !values.insert(value.to_owned()) { + return Err(invalid("duplicate form option")); + } + options.push(IslandQuestionOption { + value: value.to_owned(), + label: label.to_owned(), + description: optional_string(option, "description", 4096)?, + }); + } + } else if kind == "multiselect" { + question.answerable = false; + } + let custom = match field.get("custom") { + None | Some(Value::Null) => options.is_empty(), + Some(Value::Bool(custom)) => *custom, + _ => return Err(invalid("form custom flag")), + }; + question.fields.push(IslandQuestionField { + key: key.to_owned(), + kind: kind.to_owned(), + title: optional_string(field, "title", 4096)?, + description: optional_string(field, "description", 4096)?, + options, + custom, + required, + }); + } + if !question.answerable { + question.fields.clear(); + } + Ok(question) +} + +pub(super) fn public_messages(messages: Vec) -> Result, AppError> { + if messages.len() > 20 { + return Err(failure("message display limit exceeded")); + } + let mut result = Vec::new(); + let mut ids = HashSet::new(); + for message in messages { + let id = string(&message, "id")?; + identifier(id, "msg_")?; + if !ids.insert(id.to_owned()) { + return Err(invalid("duplicate message")); + } + let role = string(&message, "type")?; + plain(role, 64, false)?; + if !matches!(role, "user" | "assistant") { + continue; + } + if private_content(&message)? { + continue; + } + let time = safe_time( + message + .get("time") + .and_then(|time| time.get("created")) + .and_then(Value::as_u64) + .ok_or_else(|| invalid("message time"))?, + )?; + let mut text = String::new(); + let mut truncated = false; + let mut append = |value: &str| -> Result<(), AppError> { + plain(value, MAX_BYTES, true)?; + let (piece, cut) = trim_text(value, MAX_TEXT - text.len()); + text.push_str(piece); + truncated |= cut; + Ok(()) + }; + if role == "user" { + append(string(&message, "text")?)?; + } else { + let content = message + .get("content") + .and_then(Value::as_array) + .ok_or_else(|| invalid("assistant content"))?; + if content.len() > 256 { + return Err(failure("message content item limit exceeded")); + } + for part in content { + if part.get("type").and_then(Value::as_str) == Some("text") + && !private_content(part)? + { + append(string(part, "text")?)?; + } + } + } + if !text.is_empty() { + result.push(IslandMessage { + id: id.to_owned(), + role: role.to_owned(), + text, + time, + truncated, + }); + } + } + result.sort_by(|left, right| { + left.time + .cmp(&right.time) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(result) +} + +fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str, AppError> { + value + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| invalid("response field")) +} + +fn optional_string(value: &Value, key: &str, max: usize) -> Result, AppError> { + match value.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) => { + plain(text, max, true)?; + Ok(Some(text.clone())) + } + _ => Err(invalid("response field")), + } +} + +fn bounded_strings(value: &Value, max_items: usize) -> Result, AppError> { + let values = value.as_array().ok_or_else(|| invalid("response list"))?; + if values.len() > max_items { + return Err(failure("response item limit exceeded")); + } + values + .iter() + .map(|value| { + let text = value.as_str().ok_or_else(|| invalid("response field"))?; + plain(text, 4096, true)?; + Ok(text.to_owned()) + }) + .collect() +} + +fn trim_text(text: &str, remaining: usize) -> (&str, bool) { + let mut end = text.len().min(remaining); + while !text.is_char_boundary(end) { + end -= 1; + } + (&text[..end], end < text.len()) +} + +fn public_flag(value: &Value, key: &str) -> Result { + match value.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(flag)) => Ok(*flag), + _ => Err(invalid("public content flag")), + } +} + +fn private_content(value: &Value) -> Result { + for flags in + std::iter::once(value).chain(value.get("metadata").filter(|metadata| !metadata.is_null())) + { + if !flags.is_object() { + return Err(invalid("content metadata")); + } + for key in ["hidden", "synthetic", "internal"] { + if public_flag(flags, key)? { + return Ok(true); + } + } + } + Ok(false) +} + +fn answer_valid( + question: &IslandQuestion, + answer: &mut Map, +) -> Result<(), AppError> { + if !question.answerable || answer.len() > MAX_FIELDS { + return Err(invalid("question answer")); + } + for key in answer.keys() { + if !question.fields.iter().any(|field| field.key == *key) { + return Err(invalid("question answer key")); + } + } + for field in &question.fields { + if !field.required + && answer.get(&field.key).is_some_and(|value| { + (field.kind == "string" && value.as_str() == Some("")) + || (field.kind == "multiselect" && value.as_array().is_some_and(Vec::is_empty)) + }) + { + answer.remove(&field.key); + continue; + } + let Some(value) = answer.get(&field.key) else { + if field.required { + return Err(invalid("required question answer")); + } + continue; + }; + let values = match field.kind.as_str() { + "string" => vec![value + .as_str() + .ok_or_else(|| invalid("question answer type"))?], + "multiselect" => { + let values = value + .as_array() + .ok_or_else(|| invalid("question answer type"))?; + if values.len() > MAX_OPTIONS { + return Err(invalid("question answer limit")); + } + values + .iter() + .map(|value| { + value + .as_str() + .ok_or_else(|| invalid("question answer type")) + }) + .collect::, _>>()? + } + _ => return Err(invalid("question answer type")), + }; + if field.required && (values.is_empty() || values.iter().any(|v| v.trim().is_empty())) { + return Err(invalid("required question answer")); + } + let mut unique = HashSet::new(); + for value in values { + plain(value, 4096, true)?; + if !unique.insert(value) + || (!field.custom && !field.options.iter().any(|option| option.value == value)) + { + return Err(invalid("question answer option")); + } + } + } + Ok(()) +} diff --git a/src-tauri/src/opencode_cli/island/tests.rs b/src-tauri/src/opencode_cli/island/tests.rs new file mode 100644 index 0000000..0f9ac65 --- /dev/null +++ b/src-tauri/src/opencode_cli/island/tests.rs @@ -0,0 +1,1366 @@ +use std::future::Future; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::thread; + +use serde_json::json; + +use super::*; + +struct FakeApi { + address: SocketAddr, + stop: Arc, + thread: Option>, +} + +impl FakeApi { + fn new(handler: impl Fn(&str, &str, Value, &mut TcpStream) + Send + Sync + 'static) -> Self { + Self::with_info(move |method, target, body, stream| { + if target == "/api/info" { + respond(stream, 200, &json!({"pid":42,"version":"2.0.22"})); + } else { + handler(method, target, body, stream); + } + }) + } + + fn with_info( + handler: impl Fn(&str, &str, Value, &mut TcpStream) + Send + Sync + 'static, + ) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let stopping = stop.clone(); + let handler = Arc::new(handler); + let thread = thread::spawn(move || { + let mut workers = Vec::new(); + for incoming in listener.incoming() { + if stopping.load(Ordering::Relaxed) { + break; + } + let mut stream = incoming.unwrap(); + let handler = handler.clone(); + workers.push(thread::spawn(move || { + stream.set_read_timeout(Some(REQUEST_TIMEOUT)).unwrap(); + let mut bytes = Vec::new(); + let end = loop { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + assert!(bytes.len() < 8192); + if bytes.ends_with(b"\r\n\r\n") { + break bytes.len(); + } + }; + let headers = String::from_utf8(bytes.clone()).unwrap(); + assert!(headers + .to_ascii_lowercase() + .contains("authorization: basic ")); + assert!(!headers.to_ascii_lowercase().contains("last-event-id")); + let length = headers + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + assert!(length < MAX_BYTES); + bytes.resize(end + length, 0); + stream.read_exact(&mut bytes[end..]).unwrap(); + let mut request = headers.lines().next().unwrap().split_whitespace(); + let method = request.next().unwrap(); + let target = request.next().unwrap(); + let body = if length == 0 { + Value::Null + } else { + serde_json::from_slice(&bytes[end..]).unwrap() + }; + handler(method, target, body, &mut stream); + })); + } + for worker in workers { + worker.join().unwrap(); + } + }); + Self { + address, + stop, + thread: Some(thread), + } + } + + fn api(&self) -> IslandApi { + let url = format!("http://{}", self.address); + let connection = transport::test_connection(&url, Instant::now() + REQUEST_TIMEOUT) + .into_async() + .unwrap(); + IslandApi { + connection: Arc::new(connection), + } + } +} + +impl Drop for FakeApi { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + let _ = TcpStream::connect(self.address); + self.thread.take().unwrap().join().unwrap(); + } +} + +fn respond(stream: &mut TcpStream, status: u16, body: &Value) { + raw(stream, status, "", &serde_json::to_vec(body).unwrap()); +} + +fn raw(stream: &mut TcpStream, status: u16, headers: &str, body: &[u8]) { + let _ = write!( + stream, + "HTTP/1.1 {status} Fixture\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n", + body.len() + ); + let _ = stream.write_all(body); +} + +fn run(future: impl std::future::Future) -> T { + tauri::async_runtime::block_on(future) +} + +#[test] +fn api_is_clone_send_sync_and_dtos_are_camel_case() { + fn traits() {} + traits::(); + let message = IslandMessage { + id: "msg_a".into(), + role: "user".into(), + text: "public".into(), + time: 1, + truncated: false, + }; + assert_eq!(serde_json::to_value(message).unwrap()["truncated"], false); + assert_eq!( + serde_json::to_value(IslandSnapshot::default()).unwrap(), + json!({"sessions":[],"permissions":[],"questions":[]}) + ); +} + +#[test] +fn public_text_excludes_reasoning_tools_synthetic_and_hidden() { + let result = projection::public_messages(vec![ + json!({"id":"msg_a","type":"assistant","time":{"created":2},"content":[ + {"type":"reasoning","text":"PRIVATE_REASONING"}, + {"type":"tool","text":"PRIVATE_TOOL"}, + {"type":"text","text":"PRIVATE_SYNTHETIC","synthetic":true}, + {"type":"text","text":"PRIVATE_HIDDEN","hidden":true}, + {"type":"text","text":"public reply"} + ]}), + json!({"id":"msg_u","type":"user","time":{"created":1},"text":"public question"}), + json!({"id":"msg_c","type":"compaction","text":"PRIVATE_INTERNAL"}), + ]) + .unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].role, "user"); + assert_eq!(result[1].text, "public reply"); + assert!(!serde_json::to_string(&result).unwrap().contains("PRIVATE")); +} + +#[test] +fn public_text_is_utf8_bounded_and_time_is_safe() { + let text = "界".repeat(MAX_TEXT); + let mut message = json!({"id":"msg_a","type":"user","time":{"created":1},"text":text}); + let result = projection::public_messages(vec![message.clone()]).unwrap(); + assert!(result[0].truncated); + assert!(result[0].text.len() <= MAX_TEXT); + message["time"]["created"] = json!(JS_MAX_DATE + 1); + assert!(projection::public_messages(vec![message]).is_err()); +} + +#[test] +fn write_urls_bodies_and_decisions_are_exact_and_not_retried() { + let requests = Arc::new(Mutex::new(Vec::new())); + let capture = requests.clone(); + let server = FakeApi::new(move |method, target, body, stream| { + capture + .lock() + .unwrap() + .push((method.to_owned(), target.to_owned(), body)); + respond(stream, 200, &Value::Null); + }); + let api = server.api(); + run(async { + api.send("ses_a", "hello", "msg_a").await.unwrap(); + api.reply_permission("ses_a", "per_a", "once") + .await + .unwrap(); + api.reply_permission("ses_a", "per_a", "reject") + .await + .unwrap(); + api.cancel_question("ses_a", "frm_a").await.unwrap(); + api.interrupt("ses_a").await.unwrap(); + assert!(api + .reply_permission("ses_a", "per_a", "allow") + .await + .is_err()); + assert!(api.send("ses_a/secret", "hello", "msg_a").await.is_err()); + assert!(api.send("ses_a", "hello", "bad_a").await.is_err()); + }); + let requests = requests.lock().unwrap(); + assert_eq!( + requests.as_slice(), + [ + ( + "POST".into(), + "/api/session/ses_a/prompt".into(), + json!({"id":"msg_a","text":"hello","delivery":"queue"}) + ), + ( + "POST".into(), + "/api/session/ses_a/permission/per_a/reply".into(), + json!({"decision":"once"}) + ), + ( + "POST".into(), + "/api/session/ses_a/permission/per_a/reply".into(), + json!({"decision":"reject"}) + ), + ( + "DELETE".into(), + "/api/session/ses_a/form/frm_a".into(), + Value::Null + ), + ( + "POST".into(), + "/api/session/ses_a/interrupt?resume=false".into(), + Value::Null + ), + ] + ); +} + +#[test] +fn post_reserves_target_until_completion_or_local_cancellation_without_transition_lock() { + use crate::error::ErrorCode; + use crate::opencode_cli::target::{self, TargetRuntime}; + + for cancel in [false, true] { + let directory = + std::env::temp_dir().join(format!("mom-island-write-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&directory).unwrap(); + let runtime = TargetRuntime::new( + directory.join("target.json"), + directory.join("service.json"), + directory.clone(), + ); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let server = FakeApi::new(move |method, _, _, stream| { + assert_eq!(method, "POST"); + entered_tx.send(()).unwrap(); + release_rx + .lock() + .unwrap() + .recv_timeout(REQUEST_TIMEOUT) + .unwrap(); + respond(stream, 200, &Value::Null); + }); + std::fs::write(&runtime.registration, serde_json::to_vec(&json!({ + "id":uuid::Uuid::new_v4(), "version":"2.0.22", "url":format!("http://{}", server.address), "pid":42, "password":"private" + })).unwrap()).unwrap(); + let connection = transport::connect_target( + runtime.snapshot().unwrap(), + &runtime.registration, + Instant::now() + REQUEST_TIMEOUT, + |_| panic!("no bootstrap"), + ) + .unwrap() + .into_async() + .unwrap(); + let api = IslandApi { + connection: Arc::new(connection), + }; + let task = + tauri::async_runtime::spawn(async move { api.send("ses_a", "hello", "msg_a").await }); + entered_rx.recv_timeout(REQUEST_TIMEOUT).unwrap(); + drop(target::transition().unwrap()); + let error = runtime.set(None, None, || Ok(())).err().unwrap(); + assert_eq!(error.code, ErrorCode::Busy); + if cancel { + task.abort(); + assert!(run(task).is_err()); + runtime.set(None, None, || Ok(())).unwrap(); + // Dropping the client cannot withdraw the POST already received by the server. + release_tx.send(()).unwrap(); + } else { + release_tx.send(()).unwrap(); + run(task).unwrap().unwrap(); + runtime.set(None, None, || Ok(())).unwrap(); + } + drop(server); + std::fs::remove_dir_all(directory).unwrap(); + } +} + +#[test] +fn server_restart_at_same_endpoint_rejects_write_before_post() { + let restarted = Arc::new(AtomicBool::new(false)); + let flag = restarted.clone(); + let posts = Arc::new(Mutex::new(0)); + let count = posts.clone(); + let server = FakeApi::with_info(move |_, target, _, stream| { + if target == "/api/info" { + respond( + stream, + 200, + &json!({"pid":if flag.load(Ordering::SeqCst) {43} else {42}, "version":"2.0.22"}), + ); + } else { + *count.lock().unwrap() += 1; + respond(stream, 200, &Value::Null); + } + }); + let api = server.api(); + restarted.store(true, Ordering::SeqCst); + let error = run(api.send("ses_a", "hello", "msg_a")).unwrap_err(); + assert!(error.message.contains("identity changed")); + assert_eq!(*posts.lock().unwrap(), 0); +} + +#[test] +fn malformed_oversized_redirect_and_write_errors_are_private() { + for (status, bytes) in [ + (200, b"PRIVATE_MALFORMED".to_vec()), + (200, vec![b' '; MAX_BYTES + 1]), + (302, b"PRIVATE_REDIRECT".to_vec()), + (401, b"PRIVATE_AUTH_ERROR".to_vec()), + ] { + let server = FakeApi::new(move |_, _, _, stream| { + raw( + stream, + status, + "Location: http://example.com/PRIVATE\r\n", + &bytes, + ) + }); + let api = server.api(); + let error = run(api.messages("ses_a")).unwrap_err(); + assert!(!error.message.contains("PRIVATE")); + assert!(error.detail.is_none()); + } + let calls = Arc::new(Mutex::new(0)); + let count = calls.clone(); + let server = FakeApi::new(move |_, _, _, stream| { + *count.lock().unwrap() += 1; + raw(stream, 500, "", b"PRIVATE_WRITE_ERROR"); + }); + let api = server.api(); + let error = run(api.send("ses_a", "hello", "msg_a")).unwrap_err(); + assert!(error.message.contains("may have been accepted")); + assert!(!error.message.contains("PRIVATE")); + assert_eq!(*calls.lock().unwrap(), 1); + let server = FakeApi::new(|_, _, _, stream| raw(stream, 403, "", b"PRIVATE_REJECTED")); + let api = server.api(); + let error = run(api.send("ses_a", "hello", "msg_a")).unwrap_err(); + assert!(error.message.contains("write rejected")); + assert!(!error.message.contains("may have been accepted")); +} + +#[test] +fn duplicate_message_id_is_rejected_once_without_retry_or_id_replacement() { + let calls = Arc::new(Mutex::new(0)); + let count = calls.clone(); + let server = FakeApi::new(move |method, target, body, stream| { + assert_eq!(method, "POST"); + assert_eq!(target, "/api/session/ses_a/prompt"); + assert_eq!(body["id"], "msg_stable"); + *count.lock().unwrap() += 1; + raw(stream, 409, "", b"PRIVATE_DUPLICATE_ID"); + }); + let api = server.api(); + let error = run(api.send("ses_a", "hello", "msg_stable")).unwrap_err(); + assert!(error.message.contains("HTTP 409; write rejected")); + assert!(!error.message.contains("PRIVATE")); + assert!(!error.message.contains("may have been accepted")); + assert_eq!(*calls.lock().unwrap(), 1); +} + +#[test] +fn v2_prompt_write_and_read_only_message_routes_follow_protocol_contract() { + // V2 protocol: session.prompt is POST; session.messages is GET only. + let server = FakeApi::new(|method, target, body, stream| match (method, target) { + ("POST", "/api/session/ses_a/prompt") => { + assert_eq!( + body, + json!({"id":"msg_stable","text":"hello","delivery":"queue"}) + ); + respond( + stream, + 200, + &json!({"data":{"id":"msg_stable","type":"user"}}), + ); + } + ("GET", "/api/session/ses_a/message?limit=20&order=desc") => { + respond(stream, 200, &json!({"data":[],"cursor":{}})); + } + ("POST", "/api/session/ses_a/message") => raw(stream, 405, "", b""), + _ => raw(stream, 404, "", b""), + }); + let api = server.api(); + run(async { + api.send("ses_a", "hello", "msg_stable").await.unwrap(); + assert!(api.messages("ses_a").await.unwrap().is_empty()); + }); +} + +#[test] +fn sse_frames_handle_fragmentation_multiline_crlf_comments_and_bounds() { + let mut frame = events::Frame::default(); + let source = b": heartbeat\r\nid: ignored\r\ndata: {\r\ndata: \"type\":\"unknown\"}\r\n\r\n"; + let mut result = Vec::new(); + for piece in source.chunks(3) { + for byte in piece { + if let Some(data) = frame.byte(*byte).unwrap() { + result.push(data); + } + } + } + assert_eq!(result, vec![b"{\n\"type\":\"unknown\"}".to_vec()]); + assert!(events::classify(&result[0]).unwrap().is_none()); + for _ in 0..65536 { + frame.byte(b'x').unwrap(); + } + assert!(frame.byte(b'x').is_err()); +} + +#[test] +fn messages_fetches_only_newest_bounded_page() { + let server = FakeApi::new(|method, target, _, stream| { + assert_eq!(method, "GET"); + assert_eq!(target, "/api/session/ses_a/message?limit=20&order=desc"); + respond( + stream, + 200, + &json!({"data":[ + {"id":"msg_b","type":"assistant","time":{"created":2},"content":[{"type":"text","text":"reply"}]}, + {"id":"msg_a","type":"user","time":{"created":1},"text":"hello"} + ],"cursor":{"next":"DO_NOT_FETCH_HISTORY"}}), + ); + }); + let api = server.api(); + let messages = run(api.messages("ses_a")).unwrap(); + assert_eq!(messages[0].id, "msg_a"); + assert_eq!(messages[1].text, "reply"); +} + +#[test] +fn path_segments_are_encoded_by_shared_transport() { + let server = FakeApi::new(|_, target, _, stream| { + assert_eq!(target, "/api/session/ses_%2F%3F%23%25%20x/message"); + respond(stream, 200, &json!({"data":[]})); + }); + let api = server.api(); + run(async { + let request = api + .connection + .request(Method::GET, &["session", "ses_/?#% x", "message"]) + .unwrap(); + let response = request.timeout(REQUEST_TIMEOUT).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + }); +} + +#[test] +fn snapshot_rejects_disconnected_malformed_or_over_limit_active_map() { + for active in [ + json!({"data":{"ses_a":{"connected":false}}}), + json!({"data":{"ses_a":{"type":"idle","connected":false}}}), + json!({"data":{"PRIVATE_INVALID_ID":{"type":"running"}}}), + json!({"data":(0..101).map(|index| (format!("ses_{index}"), json!({"type":"running"}))).collect::>()}), + ] { + let server = FakeApi::new(move |_, target, _, stream| match target { + "/api/session?limit=20&order=desc&parentID=null" => { + respond(stream, 200, &json!({"data":[]})) + } + "/api/session/active" => respond(stream, 200, &active), + _ => panic!("must fail before pending or detail fetch"), + }); + let api = server.api(); + let error = run(api.snapshot()).unwrap_err(); + assert!(!error.message.contains("PRIVATE")); + assert!(error.detail.is_none()); + } +} + +#[test] +fn active_map_duplicate_ids_are_rejected_by_shared_parser() { + let server = FakeApi::new(|_, target, _, stream| match target { + "/api/session?limit=20&order=desc&parentID=null" => { + respond(stream, 200, &json!({"data":[]})) + } + "/api/session/active" => raw( + stream, + 200, + "", + br#"{"data":{"ses_a":{"type":"running"},"ses_a":{"type":"running"}}}"#, + ), + _ => panic!("must fail before pending or detail fetch"), + }); + let api = server.api(); + assert!(run(api.snapshot()).is_err()); +} + +fn session(id: &str, directory: &str, parent: Option<&str>, time: u64) -> Value { + json!({"id":id,"title":id,"location":{"directory":directory},"parentID":parent,"time":{"updated":time}}) +} + +fn permission(session_id: &str) -> Value { + json!({"id":"per_a","sessionID":session_id,"action":"edit","resources":["src/file"],"save":["src/*"]}) +} + +fn question() -> Value { + json!({"id":"frm_a","sessionID":"ses_child","title":"Choose","metadata":{"kind":"question"},"fields":[ + {"key":"original-key","type":"string","title":"Choice","required":true,"custom":false, + "options":[{"value":"wire-value","label":"Display label"}]}, + {"key":"many","type":"multiselect","required":true,"custom":false, + "options":[{"value":"one","label":"One"},{"value":"two","label":"Two"}]}, + {"key":"custom","type":"string","custom":true,"options":[]} + ]}) +} + +#[test] +fn snapshot_covers_multiple_directories_active_children_and_recent_root_pending() { + let server = FakeApi::new(|method, target, _, stream| { + assert_eq!(method, "GET"); + let result = match target { + "/api/session?limit=20&order=desc&parentID=null" => { + json!({"data":[session("ses_recent","/one",None,30),session("ses_other","/two",None,20)]}) + } + "/api/session/active" => { + json!({"data":{"ses_child":{"type":"running","connected":false},"ses_old":{"type":"running"}}}) + } + "/api/session/ses_child" => { + json!({"data":session("ses_child","/three",Some("ses_old"),10)}) + } + "/api/session/ses_old" => json!({"data":session("ses_old","/three",None,5)}), + "/api/session/ses_child/permission" => { + json!({"data":[{"id":"per_child","sessionID":"ses_child","action":"edit","resources":["src/file"],"save":["src/*"],"message":"approval","metadata":{"private":"PRIVATE_METADATA"}}]}) + } + "/api/session/ses_recent/permission" => { + json!({"data":[{"id":"per_root","sessionID":"ses_recent","action":"bash","resources":["command"]}]}) + } + "/api/session/ses_child/form" => json!({"data":[question()]}), + "/api/session/ses_recent/form" + | "/api/session/ses_other/form" + | "/api/session/ses_old/form" + | "/api/session/ses_other/permission" + | "/api/session/ses_old/permission" => json!({"data":[]}), + _ => panic!("unexpected snapshot request: {target}"), + }; + respond(stream, 200, &result); + }); + let api = server.api(); + let result = run(api.snapshot()).unwrap(); + assert_eq!(result.sessions.len(), 4); + assert_eq!(result.sessions[0].id, "ses_recent"); + assert_eq!(result.sessions[1].id, "ses_child"); + assert_eq!(result.sessions[1].parent_id.as_deref(), Some("ses_old")); + assert_eq!(result.sessions[1].directory, "/three"); + assert_eq!(result.sessions[1].status, "running"); + assert_eq!( + result + .permissions + .iter() + .find(|request| request.id == "per_child") + .unwrap() + .session_id, + "ses_child" + ); + assert_eq!(result.questions[0].fields[0].key, "original-key"); + let public = serde_json::to_value(&result).unwrap(); + for request in public["permissions"].as_array().unwrap() { + assert_eq!( + request["save"], + if request["id"] == "per_child" { + json!(["src/*"]) + } else { + json!([]) + } + ); + assert!(request.get("sessionId").is_some()); + assert!(request.get("sessionID").is_none()); + } + assert!(!serde_json::to_string(&result).unwrap().contains("PRIVATE")); +} + +#[test] +fn permission_save_is_optional_bounded_plaintext_and_errors_are_private() { + for (save, accepted) in [ + (None, true), + (Some(json!([])), true), + (Some(json!(["src/*", "command\nwith\ttabs"])), true), + (Some(json!(vec!["x".repeat(4096); MAX_OPTIONS])), true), + (Some(Value::Null), false), + (Some(json!("PRIVATE_NOT_A_LIST")), false), + (Some(json!({"PRIVATE_RULE":true})), false), + (Some(json!(["src/*", 1])), false), + (Some(json!(["PRIVATE\u{0}RULE"])), false), + (Some(json!(["PRIVATE\u{7f}RULE"])), false), + (Some(json!(["PRIVATE".repeat(586)])), false), + (Some(json!(vec!["PRIVATE_RULE"; MAX_OPTIONS + 1])), false), + ] { + let mut pending = permission("ses_a"); + match &save { + Some(save) => pending["save"] = save.clone(), + None => { + pending.as_object_mut().unwrap().remove("save"); + } + } + let calls = Arc::new(Mutex::new(0)); + let count = calls.clone(); + let server = FakeApi::new(move |method, target, _, stream| { + assert_eq!(method, "GET"); + assert_eq!(target, "/api/session/ses_a/permission"); + *count.lock().unwrap() += 1; + respond(stream, 200, &json!({"data":[pending]})); + }); + let api = server.api(); + let result = run(api.permissions("ses_a")); + assert_eq!(result.is_ok(), accepted); + match result { + Ok(requests) => { + assert_eq!( + serde_json::to_value(&requests).unwrap()[0]["save"], + save.unwrap_or_else(|| json!([])) + ); + } + Err(error) => { + assert!(!error.message.contains("PRIVATE")); + assert!(error.detail.is_none()); + } + } + assert_eq!(*calls.lock().unwrap(), 1); + } +} + +#[test] +fn always_rereads_exact_pending_identity_and_posts_only_the_decision_including_children() { + for session_id in ["ses_a", "ses_child"] { + let requests = Arc::new(Mutex::new(Vec::new())); + let capture = requests.clone(); + let server = FakeApi::new(move |method, target, body, stream| { + capture + .lock() + .unwrap() + .push((method.to_owned(), target.to_owned(), body)); + match method { + "GET" => { + assert_eq!(target, format!("/api/session/{session_id}/permission")); + respond(stream, 200, &json!({"data":[permission(session_id)]})); + } + "POST" => raw(stream, 204, "", b""), + _ => panic!("unexpected Always method"), + } + }); + let api = server.api(); + run(api.reply_permission(session_id, "per_a", "always")).unwrap(); + assert_eq!( + requests.lock().unwrap().as_slice(), + [ + ( + "GET".into(), + format!("/api/session/{session_id}/permission"), + Value::Null + ), + ( + "POST".into(), + format!("/api/session/{session_id}/permission/per_a/reply"), + json!({"decision":"always"}) + ), + ] + ); + } +} + +#[test] +fn stale_missing_unsavable_or_invalid_always_never_posts() { + for cause in [ + "missing", + "no-save", + "empty-save", + "wrong-request", + "wrong-owner", + "null-save", + "control-save", + "oversized-save", + "too-many-save", + "invalid-response", + ] { + let mut pending = permission("ses_a"); + match cause { + "no-save" => { + pending.as_object_mut().unwrap().remove("save"); + } + "empty-save" => pending["save"] = json!([]), + "wrong-request" => pending["id"] = json!("per_other"), + "wrong-owner" => pending["sessionID"] = json!("ses_other"), + "null-save" => pending["save"] = Value::Null, + "control-save" => pending["save"] = json!(["PRIVATE\u{0}RULE"]), + "oversized-save" => pending["save"] = json!(["PRIVATE".repeat(586)]), + "too-many-save" => pending["save"] = json!(vec!["PRIVATE_RULE"; MAX_OPTIONS + 1]), + _ => {} + } + let calls = Arc::new(Mutex::new(0)); + let count = calls.clone(); + let server = FakeApi::new(move |method, target, _, stream| { + assert_eq!(method, "GET"); + assert_eq!(target, "/api/session/ses_a/permission"); + *count.lock().unwrap() += 1; + if cause == "invalid-response" { + raw(stream, 200, "", b"PRIVATE_INVALID"); + } else { + respond( + stream, + 200, + &json!({"data":if cause == "missing" { vec![] } else { vec![pending.clone()] }}), + ); + } + }); + let api = server.api(); + let error = run(api.reply_permission("ses_a", "per_a", "always")).unwrap_err(); + assert_eq!(error.code, crate::error::ErrorCode::ValidationFailed); + assert!(!error.message.contains("PRIVATE")); + assert!(error.detail.is_none()); + assert_eq!(*calls.lock().unwrap(), 1); + } +} + +#[test] +fn always_rejects_an_externally_replied_request_even_after_a_savable_snapshot() { + let reads = Arc::new(Mutex::new(0)); + let count = reads.clone(); + let server = FakeApi::new(move |method, target, _, stream| { + assert_eq!(method, "GET"); + let result = match target { + "/api/session?limit=20&order=desc&parentID=null" => { + json!({"data":[session("ses_a","/project",None,1)]}) + } + "/api/session/active" => json!({"data":{}}), + "/api/session/ses_a/form" => json!({"data":[]}), + "/api/session/ses_a/permission" => { + let mut reads = count.lock().unwrap(); + *reads += 1; + json!({"data":if *reads == 1 { vec![permission("ses_a")] } else { vec![] }}) + } + _ => panic!("unexpected stale Always route"), + }; + respond(stream, 200, &result); + }); + let api = server.api(); + run(async { + let snapshot = api.snapshot().await.unwrap(); + assert!(!snapshot.permissions[0].save.is_empty()); + let error = api + .reply_permission("ses_a", "per_a", "always") + .await + .unwrap_err(); + assert_eq!(error.code, crate::error::ErrorCode::ValidationFailed); + }); + assert_eq!(*reads.lock().unwrap(), 2); +} + +#[test] +fn permission_conflicts_are_never_retried_or_downgraded() { + for decision in ["once", "always"] { + let requests = Arc::new(Mutex::new(Vec::new())); + let capture = requests.clone(); + let server = FakeApi::new(move |method, target, body, stream| { + capture + .lock() + .unwrap() + .push((method.to_owned(), target.to_owned(), body)); + match method { + "GET" => respond(stream, 200, &json!({"data":[permission("ses_child")]})), + "POST" => raw(stream, 409, "", b"PRIVATE_ALREADY_REPLIED"), + _ => panic!("unexpected permission conflict method"), + } + }); + let api = server.api(); + let error = run(api.reply_permission("ses_child", "per_a", decision)).unwrap_err(); + assert!(error.message.contains("HTTP 409; write rejected")); + assert!(!error.message.contains("PRIVATE")); + assert!(!error.message.contains("may have been accepted")); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), if decision == "always" { 2 } else { 1 }); + if decision == "always" { + assert_eq!( + requests[0], + ( + "GET".into(), + "/api/session/ses_child/permission".into(), + Value::Null + ) + ); + } + assert_eq!( + requests.last().unwrap(), + &( + "POST".into(), + "/api/session/ses_child/permission/per_a/reply".into(), + json!({"decision":decision}) + ) + ); + } +} + +#[test] +fn cancelling_the_always_pending_read_leaves_no_background_post() { + let (started, reading) = tokio::sync::oneshot::channel(); + let started = Mutex::new(Some(started)); + let (release, released) = std::sync::mpsc::channel(); + let released = Mutex::new(released); + let calls = Arc::new(Mutex::new(0)); + let count = calls.clone(); + let server = FakeApi::new(move |method, target, _, stream| { + assert_eq!(method, "GET"); + assert_eq!(target, "/api/session/ses_a/permission"); + *count.lock().unwrap() += 1; + started.lock().unwrap().take().unwrap().send(()).unwrap(); + released + .lock() + .unwrap() + .recv_timeout(REQUEST_TIMEOUT) + .unwrap(); + respond(stream, 200, &json!({"data":[permission("ses_a")]})); + }); + let api = server.api(); + run(async { + { + let mut pending = std::pin::pin!(api.reply_permission("ses_a", "per_a", "always")); + let mut reading = std::pin::pin!(reading); + std::future::poll_fn(|context| { + if let std::task::Poll::Ready(result) = reading.as_mut().poll(context) { + result.unwrap(); + return std::task::Poll::Ready(()); + } + assert!(pending.as_mut().poll(context).is_pending()); + std::task::Poll::Pending + }) + .await; + } + release.send(()).unwrap(); + }); + drop(api); + drop(server); + assert_eq!(*calls.lock().unwrap(), 1); +} + +#[test] +fn form_reply_rereads_pending_state_and_posts_values_under_original_keys() { + let requests = Arc::new(Mutex::new(Vec::new())); + let capture = requests.clone(); + let server = FakeApi::new(move |method, target, body, stream| { + capture + .lock() + .unwrap() + .push((method.to_owned(), target.to_owned(), body)); + match (method, target) { + ("GET", "/api/session/ses_child/form/frm_a") => { + let mut form = question(); + form["state"] = json!({"status":"pending"}); + respond(stream, 200, &json!({"data":form})); + } + ("POST", "/api/session/ses_child/form/frm_a/reply") => raw(stream, 204, "", b""), + _ => panic!("unexpected form request"), + } + }); + let api = server.api(); + let answer = json!({"original-key":"wire-value","many":["one","two"],"custom":"free text"}) + .as_object() + .unwrap() + .clone(); + run(api.reply_question("ses_child", "frm_a", answer.clone())).unwrap(); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].2, json!({"answer":answer})); +} + +#[test] +fn v2_form_detail_uses_state_status_and_top_level_metadata_only() { + let detail = json!({ + "id":"frm_a","sessionID":"ses_child","title":"Choose", + "metadata":{"kind":"question"}, + "fields":[{"key":"choice","type":"string","required":true,"custom":false, + "options":[{"value":"wire-value","label":"Display label"}]}], + "state":{"status":"pending"} + }); + for variant in [ + "valid", + "type-discriminator", + "missing-state", + "state-metadata", + "envelope-metadata", + "missing-metadata", + "cancelled", + "hidden", + "when", + "pattern", + "minItems", + ] { + let mut form = detail.clone(); + let mut envelope = json!({}); + match variant { + "type-discriminator" => form["state"] = json!({"type":"pending"}), + "missing-state" => { + form.as_object_mut().unwrap().remove("state"); + } + "state-metadata" => { + form.as_object_mut().unwrap().remove("metadata"); + form["state"]["metadata"] = json!({"kind":"question"}); + } + "envelope-metadata" => { + form.as_object_mut().unwrap().remove("metadata"); + envelope["metadata"] = json!({"kind":"question"}); + } + "missing-metadata" => { + form.as_object_mut().unwrap().remove("metadata"); + } + "cancelled" => form["state"] = json!({"status":"cancelled"}), + "hidden" => form["fields"][0]["hidden"] = json!(true), + "when" => form["fields"][0]["when"] = json!([{"key":"earlier","op":"eq","value":"x"}]), + "pattern" => form["fields"][0]["pattern"] = json!(".*"), + "minItems" => form["fields"][0]["minItems"] = json!(1), + _ => {} + } + envelope["data"] = form; + let writes = Arc::new(Mutex::new(0)); + let count = writes.clone(); + let server = FakeApi::new(move |method, target, body, stream| match (method, target) { + ("GET", "/api/session/ses_child/form/frm_a") => respond(stream, 200, &envelope), + ("POST", "/api/session/ses_child/form/frm_a/reply") => { + assert_eq!(body, json!({"answer":{"choice":"wire-value"}})); + *count.lock().unwrap() += 1; + raw(stream, 204, "", b""); + } + _ => panic!("unexpected V2 form route"), + }); + let api = server.api(); + let answer = json!({"choice":"wire-value"}).as_object().unwrap().clone(); + assert_eq!( + run(api.reply_question("ses_child", "frm_a", answer)).is_ok(), + variant == "valid" + ); + assert_eq!(*writes.lock().unwrap(), usize::from(variant == "valid")); + } +} + +#[test] +fn inert_form_defaults_and_optional_empty_answers_preserve_required_option_guards() { + let detail = json!({ + "id":"frm_a","sessionID":"ses_child","title":"Choose","metadata":{"kind":"question"}, + "fields":[ + {"key":"required","type":"string","required":true,"hidden":false,"when":[],"custom":false,"options":[{"value":"value","label":"Label"}]}, + {"key":"optional","type":"string","hidden":false,"when":[],"custom":false,"options":[{"value":"value","label":"Label"}]}, + {"key":"optional-many","type":"multiselect","minItems":0,"hidden":false,"when":[],"custom":false,"options":[{"value":"value","label":"Label"}]}, + {"key":"at-least-one","type":"multiselect","minItems":1,"custom":false,"options":[{"value":"value","label":"Label"}]} + ],"state":{"status":"pending"} + }); + let question = projection::public_question(&detail, "ses_child").unwrap(); + assert!(question.answerable); + assert!(question.fields[3].required); + for (answer, accepted) in [ + ( + json!({"required":"value","optional":"","optional-many":[],"at-least-one":["value"]}), + true, + ), + ( + json!({"required":"","optional":"","at-least-one":["value"]}), + false, + ), + (json!({"required":"Label","at-least-one":["value"]}), false), + ( + json!({"required":"value","optional":"Label","at-least-one":["value"]}), + false, + ), + (json!({"required":"value","at-least-one":[]}), false), + (json!({"required":"value"}), false), + ] { + let detail = detail.clone(); + let writes = Arc::new(Mutex::new(0)); + let count = writes.clone(); + let server = FakeApi::new(move |method, target, body, stream| match (method, target) { + ("GET", "/api/session/ses_child/form/frm_a") => { + respond(stream, 200, &json!({"data":detail})) + } + ("POST", "/api/session/ses_child/form/frm_a/reply") => { + assert_eq!( + body, + json!({"answer":{"required":"value","at-least-one":["value"]}}) + ); + *count.lock().unwrap() += 1; + raw(stream, 204, "", b""); + } + _ => panic!("unexpected optional-answer route"), + }); + let api = server.api(); + assert_eq!( + run(api.reply_question("ses_child", "frm_a", answer.as_object().unwrap().clone())) + .is_ok(), + accepted + ); + assert_eq!(*writes.lock().unwrap(), usize::from(accepted)); + } + let mut constrained = detail; + constrained["fields"][3]["minItems"] = json!(2); + assert!( + !projection::public_question(&constrained, "ses_child") + .unwrap() + .answerable + ); +} + +#[test] +fn fieldless_non_question_form_does_not_fail_the_snapshot() { + let server = FakeApi::new(|_, target, _, stream| { + let value = match target { + "/api/session?limit=20&order=desc&parentID=null" => { + json!({"data":[session("ses_a","/project",None,1)]}) + } + "/api/session/active" => json!({"data":{}}), + "/api/session/ses_a/permission" => json!({"data":[]}), + "/api/session/ses_a/form" => { + json!({"data":[{"id":"frm_a","sessionID":"ses_a","title":"External form","metadata":{"kind":"authentication"},"fields":[]}]}) + } + _ => panic!("unexpected fieldless-form route"), + }; + respond(stream, 200, &value); + }); + let api = server.api(); + let snapshot = run(api.snapshot()).unwrap(); + assert_eq!(snapshot.questions.len(), 1); + assert!(!snapshot.questions[0].answerable); + assert!(snapshot.questions[0].fields.is_empty()); +} + +#[test] +fn metadata_private_flags_never_enter_public_message_projection() { + let mut messages = Vec::new(); + for (index, key) in ["hidden", "synthetic", "internal"].into_iter().enumerate() { + let mut metadata = Map::new(); + metadata.insert(key.into(), json!(true)); + messages.push(json!({"id":format!("msg_{index}"),"type":"user","time":{"created":1},"metadata":metadata,"text":"PRIVATE_MESSAGE"})); + } + messages.push( + json!({"id":"msg_public","type":"assistant","time":{"created":2},"content":[ + {"type":"text","metadata":{"hidden":true},"text":"PRIVATE_PART"}, + {"type":"text","metadata":{"hidden":false},"text":"public"}, + {"type":"reasoning","text":"PRIVATE_REASONING"}, + {"type":"tool","state":{"content":[{"type":"text","text":"PRIVATE_TOOL"}]}} + ]}), + ); + let result = projection::public_messages(messages).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].text, "public"); + assert!(!serde_json::to_string(&result).unwrap().contains("PRIVATE")); +} + +#[test] +fn stale_unknown_unsupported_and_invalid_answers_never_write() { + for (status, answer, unsupported) in [ + ( + "answered", + json!({"original-key":"wire-value","many":["one"]}), + false, + ), + ( + "pending", + json!({"original-key":"Display label","many":["one"]}), + false, + ), + ( + "pending", + json!({"original-key":"wire-value","many":["one","one"]}), + false, + ), + ("pending", json!({"original-key":"wire-value"}), false), + ( + "pending", + json!({"original-key":"wire-value","many":["one"],"unknown":"private"}), + false, + ), + ( + "pending", + json!({"original-key":"wire-value","many":["one"]}), + true, + ), + ] { + let server = FakeApi::new(move |method, _, _, stream| { + assert_eq!(method, "GET"); + let mut form = question(); + form["state"] = json!({"status":status}); + if unsupported { + form["metadata"]["kind"] = json!("authentication"); + } + respond(stream, 200, &json!({"data":form})); + }); + let api = server.api(); + assert!( + run(api.reply_question("ses_child", "frm_a", answer.as_object().unwrap().clone())) + .is_err() + ); + } + for change in [ + json!({"type":"number"}), + json!({"hidden":true}), + json!({"when":[{"key":"first","op":"eq","value":"x"}]}), + json!({"pattern":".*"}), + json!({"minItems":1}), + json!({"maxItems":2}), + ] { + let mut form = question(); + for (key, value) in change.as_object().unwrap() { + form["fields"][0][key] = value.clone(); + } + let public = projection::public_question(&form, "ses_child").unwrap(); + assert!(!public.answerable); + assert!(public.fields.is_empty()); + } +} + +#[test] +fn sse_classification_ignores_private_and_unrelated_events_without_token_snapshots() { + for kind in [ + "session.status", + "session.idle", + "session.execution.started", + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + "session.deleted", + "permission.asked", + "permission.replied", + "form.replied", + "form.cancelled", + ] { + assert_eq!( + events::classify( + &serde_json::to_vec(&json!({"type":kind,"data":{"sessionID":"ses_a"}})).unwrap() + ) + .unwrap(), + Some(IslandChange::Snapshot) + ); + } + assert_eq!( + events::classify( + &serde_json::to_vec(&json!({"id":"evt_shutdown","type":"location.shutdown","created":1,"location":{"directory":"/project"},"data":{}})).unwrap() + ).unwrap(), + Some(IslandChange::Snapshot) + ); + assert_eq!( + events::classify(br#"{"id":"evt_connected","type":"server.connected","data":{}}"#).unwrap(), + Some(IslandChange::Snapshot) + ); + assert_eq!( + events::classify( + &serde_json::to_vec(&json!({"type":"form.created","data":{"form":question()}})) + .unwrap() + ) + .unwrap(), + Some(IslandChange::Snapshot) + ); + for kind in [ + "session.text.delta", + "session.text.started", + "session.text.ended", + "session.message.content.updated", + "session.inbox.delivered", + ] { + assert_eq!( + events::classify( + &serde_json::to_vec( + &json!({"type":kind,"data":{"sessionID":"ses_a","delta":"public"}}) + ) + .unwrap() + ) + .unwrap(), + Some(IslandChange::Messages { + session_id: "ses_a".into() + }) + ); + } + for kind in [ + "server.heartbeat", + "provider.updated", + "config.updated", + "rpc.private", + "session.reasoning.delta", + "session.tool.progress", + "session.synthetic", + "session.compaction.delta", + "session.step.ended", + "session.usage.updated", + ] { + assert!(events::classify( + &serde_json::to_vec( + &json!({"type":kind,"data":{"sessionID":"PRIVATE_ID","text":"PRIVATE_CONTENT"}}) + ) + .unwrap() + ) + .unwrap() + .is_none()); + } + assert!(events::classify( + br#"{"type":"session.text.delta","properties":{"sessionID":"ses_a"}}"# + ) + .is_err()); + assert!(events::classify( + br#"{"type":"session.text.delta","data":"{\"sessionID\":\"ses_a\"}"}"# + ) + .is_err()); +} + +#[test] +fn session_snapshot_lifecycles_do_not_require_a_shared_session_id_shape() { + for (kind, data) in [ + ( + "session.created", + json!({"session":{"id":"ses_child","parentID":"ses_root","location":{"directory":"/project"}}}), + ), + ( + "session.forked", + json!({"sessionID":"ses_child","parentID":"ses_root","boundary":{"type":"through","messageID":"msg_a"}}), + ), + ( + "session.moved", + json!({"sessionID":"ses_child","location":{"directory":"/other"}}), + ), + ( + "session.renamed", + json!({"sessionID":"ses_child","title":"new title"}), + ), + ("session.deleted", json!({"sessionID":"ses_child"})), + ] { + let event = json!({"id":"evt_a","type":kind,"created":1,"location":{"directory":"/project"},"data":data}); + assert_eq!( + events::classify(&serde_json::to_vec(&event).unwrap()).unwrap(), + Some(IslandChange::Snapshot) + ); + } +} + +#[test] +fn nested_session_created_does_not_end_the_live_stream() { + let server = FakeApi::new(|_, target, _, stream| { + assert_eq!(target, "/api/event"); + let created = json!({"id":"evt_created","type":"session.created","created":1,"location":{"directory":"/project"},"data":{"session":{"id":"ses_a"}}}); + let text = json!({"id":"evt_text","type":"session.text.delta","created":2,"location":{"directory":"/project"},"data":{"sessionID":"ses_a","delta":"public"}}); + let frames = format!("data: {created}\n\ndata: {text}\n\n"); + raw( + stream, + 200, + "Content-Type: text/event-stream\r\n", + frames.as_bytes(), + ); + }); + let api = server.api(); + run(async { + let mut events = api.events().await.unwrap(); + assert_eq!(events.next().await.unwrap(), IslandChange::Snapshot); + assert_eq!( + events.next().await.unwrap(), + IslandChange::Messages { + session_id: "ses_a".into() + } + ); + }); +} + +#[test] +fn sse_http_fragmentation_and_eof_are_bounded_without_replay() { + let server = FakeApi::new(|method, target, _, stream| { + assert_eq!(method, "GET"); + assert_eq!(target, "/api/event"); + let _ = write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"); + for piece in b": heartbeat\n\ndata: {\ndata: \"type\":\"session.text.delta\",\"data\":{\"sessionID\":\"ses_a\",\"delta\":\"public\"}}\n\ndata: {\"type\":\"session.execution.succeeded\",\"data\":{\"sessionID\":\"ses_a\"}}\n\n".chunks(7) { + let _ = write!(stream,"{:x}\r\n",piece.len()); + let _ = stream.write_all(piece); + let _ = stream.write_all(b"\r\n"); + } + let _ = stream.write_all(b"0\r\n\r\n"); + }); + let api = server.api(); + run(async { + let mut events = api.events().await.unwrap(); + assert_eq!( + events.next().await.unwrap(), + IslandChange::Messages { + session_id: "ses_a".into() + } + ); + assert_eq!(events.next().await.unwrap(), IslandChange::Snapshot); + assert!(events.next().await.is_err()); + }); +} + +#[test] +fn pending_limits_owner_mismatch_and_unsafe_plaintext_fail_without_partial_snapshot() { + for cause in ["too-many", "wrong-owner", "control-text"] { + let server = FakeApi::new(move |_, target, _, stream| { + let result = match target { + "/api/session?limit=20&order=desc&parentID=null" => { + json!({"data":[session("ses_a","/one",None,1)]}) + } + "/api/session/active" => json!({"data":{}}), + "/api/session/ses_a/permission" => { + let request = json!({"id":"per_a","sessionID":if cause == "wrong-owner" { "ses_wrong" } else { "ses_a" },"action":if cause == "control-text" { "PRIVATE\u{0}ACTION" } else { "edit" },"resources":[]}); + json!({"data":if cause == "too-many" { vec![request;101] } else { vec![request] }}) + } + _ => panic!("invalid pending request must fail before any form reads"), + }; + respond(stream, 200, &result); + }); + let api = server.api(); + let error = run(api.snapshot()).unwrap_err(); + assert!(!error.message.contains("PRIVATE")); + } +} + +#[test] +fn optional_form_custom_and_required_flags_are_validated_without_guessing_labels() { + let form = json!({"id":"frm_a","sessionID":"ses_a","title":"Question","metadata":{"kind":"question"},"fields":[ + {"key":"free","type":"string"}, + {"key":"selection","type":"string","options":[{"value":"v","label":"Label"}]} + ]}); + let question = projection::public_question(&form, "ses_a").unwrap(); + assert!(question.answerable); + assert!(question.fields[0].custom); + assert!(!question.fields[0].required); + assert!(!question.fields[1].custom); + let mut malformed = form; + malformed["fields"][0]["required"] = json!("PRIVATE_FLAG"); + let error = projection::public_question(&malformed, "ses_a").unwrap_err(); + assert!(!error.message.contains("PRIVATE")); +} + +#[test] +fn quiet_sse_reads_are_async_and_cancel_safe() { + let server = FakeApi::new(|_, _, _, stream| { + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").unwrap(); + thread::sleep(Duration::from_millis(80)); + let event = b"data: {\"type\":\"session.execution.started\",\"data\":{\"sessionID\":\"ses_a\"}}\n\n"; + let _ = write!(stream, "{:x}\r\n", event.len()); + let _ = stream.write_all(event); + let _ = stream.write_all(b"\r\n0\r\n\r\n"); + }); + let api = server.api(); + run(async { + let mut events = api.events().await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(10), events.next()) + .await + .is_err() + ); + assert_eq!(events.next().await.unwrap(), IslandChange::Snapshot); + }); +} From 7707f94992cadbeef6734238f4372e01a9783556 Mon Sep 17 00:00:00 2001 From: seho Date: Tue, 20 Oct 2026 23:13:20 +0800 Subject: [PATCH 57/97] feat(backend/commands): wire IPC commands, dialog plugin, and integration tests --- src-tauri/src/commands/AGENTS.md | 34 +- src-tauri/src/commands/agent.rs | 84 ++-- src-tauri/src/commands/app.rs | 69 ++- src-tauri/src/commands/app/tests.rs | 116 +++++ src-tauri/src/commands/cli.rs | 108 +++- src-tauri/src/commands/group.rs | 12 +- src-tauri/src/commands/mod.rs | 16 + src-tauri/src/commands/model.rs | 14 +- src-tauri/src/commands/provider.rs | 24 +- src-tauri/src/commands/resources.rs | 74 ++- src-tauri/src/commands/updates.rs | 14 +- src-tauri/src/lib.rs | 54 +- src-tauri/tests/AGENTS.md | 10 +- src-tauri/tests/app.rs | 743 +++++++++++++++++++++++++++- src-tauri/tests/resources.rs | 682 ++++++++++++++++++++++++- 15 files changed, 1881 insertions(+), 173 deletions(-) diff --git a/src-tauri/src/commands/AGENTS.md b/src-tauri/src/commands/AGENTS.md index df1a893..b39b972 100644 --- a/src-tauri/src/commands/AGENTS.md +++ b/src-tauri/src/commands/AGENTS.md @@ -11,15 +11,16 @@ commands/ # IPC handler boundary ├── AGENTS.md # Directory-specific instructions ├── mod.rs # Domain exports and tray handler re-exports ├── agent.rs # Agent DTO conversion, CRUD, and model validation -├── app.rs # Aggregate app state and preference persistence +├── app.rs # App state, preferences, project folders, and native chooser ├── app/ # App command tests -│ └── tests.rs # AppStateResponse serialization regression test +│ └── tests.rs # AppStateResponse serialization and mock project-add boundary tests ├── autostart.rs # Read and toggle platform autostart -├── cli.rs # OpenCode CLI discovery, reload, and usage queries +├── cli.rs # OpenCode CLI discovery, installation/update/upgrade, reload, and usage ├── group.rs # Save, copy, delete, and switch groups +├── island.rs # Mini Island fixed IPC contract and managed service requests ├── model.rs # Provider-scoped model CRUD and reference checks ├── power.rs # Lid protection through managed PowerService -├── provider.rs # Provider CRUD and custom-provider filtering +├── provider.rs # Provider CRUD and deletion reference checks ├── resources.rs # MCP and skill list, get, and mutation handlers └── updates.rs # Actual package metadata, manual update check, and fixed project links ``` @@ -30,14 +31,15 @@ commands/ # IPC handler boundary | :--- | :--- | :--- | | Command exports | `mod.rs` | Declares domain modules and re-exports their handlers plus `crate::tray::*`. | | Agent boundary | `agent.rs` | Converts source/effective data to DTOs; selects storage and validates model references. | -| App state | `app.rs` | Returns `AppStateResponse`; saves preferences and notifies the tray. | -| Serialization test | `app/tests.rs` | Checks `selectedGroupId` naming and nullable serialization. | +| App state | `app.rs` | Returns `AppStateResponse`; saves preferences and project metadata; runs the native folder chooser in spawn_blocking. | +| App command tests | `app/tests.rs` | Checks snapshot serialization and rejects Global-overlapping project adds before persistence through mock IPC, without opening the chooser. | | Autostart | `autostart.rs` | Uses the autostart plugin on macOS, Windows, and Linux; rejects unsupported platforms. | -| CLI bridge | `cli.rs` | Delegates catalog, binary, reload, active-session, and token-usage operations to `opencode_cli`. | +| CLI bridge | `cli.rs` | Delegates catalog, binary, installation, explicit update/upgrade, reload, active-session, and token-usage operations to `opencode_cli`. | | Group mutations | `group.rs` | Delegates persistence/projection to `groups`; tray switches notify the main window. | +| Mini Island | `island.rs` | Cached state, enable/disable, explicit compact/peek/expanded view, refresh, messages and guarded session actions through `IslandService`. | | Model mutations | `model.rs` | Parses `ModelRef` and blocks deletion when references remain. | | Power boundary | `power.rs` | Delegates reads and toggles to managed `PowerService`. | -| Provider mutations | `provider.rs` | Lists all/custom providers and checks references before deletion. | +| Provider mutations | `provider.rs` | Lists providers and checks references before deletion. | | Resource boundary | `resources.rs` | Delegates MCP operations directly and skill operations through blocking tasks. | | Update boundary | `updates.rs` | Reads `AppHandle.package_info()` for the actual version; delegates manual release checks and fixed-destination browser opens. | @@ -46,15 +48,21 @@ commands/ # IPC handler boundary - **Domains**: Keep handlers in their resource module; delegate persistence and system operations to the existing backend modules. - **Exposure**: Use snake_case `#[tauri::command]` functions, re-export through `mod.rs`, and add `commands::name` to `../lib.rs`'s `invoke_handler` list. - **Managed state**: Config-backed handlers accept `State<'_, ConfigPaths>`; power handlers accept `State<'_, PowerService>` instead. +- **Resource scope**: Agent/MCP/skill reads accept optional projectId; omitted/null remains Global regardless of persisted selection. Reuse ConfigPaths::for_project and existing domain functions. All resource mutations reject a provided projectId before reading or writing; no project edit API. +- **Project folders**: pick_project_folder uses only the Rust dialog plugin and returns null on cancellation. add_project validates the canonical root against captured Global config/resource boundaries before models.rs persistence; scoped reads recheck fixed sources. Filesystem roots/overlaps return `validation_failed`, missing roots `not_found`, without Global fallback; no home/depth blacklist. add/remove/select share existing AppConfig persistence/locking. projects and selectedProjectId join AppStateResponse; stale selections normalize null on config load. No frontend dialog/fs capability is needed. - **Reads and mutations**: `load_app_state`, `list_*`, and `get_*` expose reads; `create_*`, `update_*`, `delete_*`, `save_*`, `copy_group`, `switch_group`, and `set_*` perform mutations. `list_models` returns provider-grouped `Vec`. -- **Command results**: Return `Result` directly or via local `CommandResult`. Serialization yields frontend `CommandError` fields `{ code, message, detail? }`; codes are `not_found`, `references_blocked`, `validation_failed`, and `configuration_failed`. -- **Agent inputs**: Creation/update require explicit storage and consume `mutation`; validate nonempty model references against existing provider models. Deletion may infer storage only for an unambiguous source. +- **Command results**: Return `Result` directly or via local `CommandResult`. Serialization yields frontend `CommandError` fields `{ code, message, detail? }`; codes are `not_found`, `references_blocked`, `validation_failed`, `configuration_failed`, and `busy` for guarded upgrades or service-target operations. +- **Agent inputs**: Creation/update require explicit storage and consume `mutation`; validate nonempty model references against existing provider models. Updates require `expectedPath`/`expectedContent` copied from `sources[].resolvedPath`/`content`; content is backend-generated inline agent JSON text or full Markdown, not JavaScript-stringified `raw`. Missing snapshots return `validation_failed`; stale snapshots are rejected. Create payloads remain unchanged. Deletion may infer storage only for an unambiguous source. - **Deletion guards**: Collect agent references before agent deletion; reject referenced models/providers with `AppError::references` and location details before deleting them. - **Wire DTOs**: `AgentDefinitionDto`, `AgentSourcePreviewDto`, and `AppStateResponse` serialize fields as camelCase; `AgentStorageDto` serializes variants as snake_case. -- **Notifications**: Emit `app:preferences-changed` to `tray` after saving preferences; tray-origin group switches emit `tray:config-changed` to `main`. Log notification failures without failing a completed mutation. -- **Blocking work**: Async CLI reload/session/usage, skill, update-check, and project-opener handlers use `tauri::async_runtime::spawn_blocking`; map join failures to configuration errors. +- **Notifications**: Emit `app:preferences-changed` to `tray` and `island` after saving preferences; tray-origin group switches emit `tray:config-changed` to `main`. Log notification failures without failing a completed mutation. +- **Island boundary**: Never load the aggregate app catalog for the island. State/preferences reads are cache-only; successful preference saves update the cache before notifying. Failed window setup returns an error with disabled readback. Explicit Refresh restarts a missing/error connection while enabled without writes. Messages/send/permission/question/cancel/interrupt require the caller's `expectedConnectionGeneration`; reject mismatches under the same cache lock before selecting a session or cloning the API/action token. Missing arguments never default to the current generation. Keep enabled/API/action-generation guards through mutation-lock waits and completion; cancellation never claims an accepted POST was withdrawn. Never retry writes automatically. Message SSE notifies only the expanded selected session. Session arguments use Tauri's default camelCase mapping. +- **Island view**: Async `set_island_view { view: 'compact' | 'peek' | 'expanded' }` keeps the enum-to-void wire contract; capture enabled generation before `on_main`, return the native operation's oneshot receiver from that dispatch and await it outside the main thread/cache/TLS borrow. Resolve only after still-current final native animation readback; latest pending replacement returns a safe superseded error, never a stale success/focus/rollback. Snapshot notifications are content-only, peek is nonactivating, only explicit expanded focuses, and native compact/blur/close immediately cache compact, clear selection and emit empty `island:collapsed` before shrink without session actions. State includes the cache-only numeric `connectionGeneration` for target-local UI lifetimes. +- **Blocking work**: Async CLI catalog/service-status/target-save/installation/update/upgrade/reload/session/usage, skill, update-check, and project-opener handlers use `tauri::async_runtime::spawn_blocking`; map join failures to configuration errors. +- **Service target IPC**: `opencode_get_service_status` probes locally without bootstrap. `opencode_set_service_target(url?, password?)` prepares pure power/Island guards under the shared transition mutex, persists the target, then invalidates old observations/connections before releasing the guards. Persistence failure leaves monitors unchanged. Wake/reconnect happens outside the transition, before the status probe; post-commit refresh errors never turn a saved target into a save failure. Preserve enabled preferences and current authorization; disabled features stay disabled. Active bootstrap/writes or initial power authorization can return `busy`, without requiring disabled monitors. Null URL returns Shared and clears password; omitted/null password retains only on unchanged normalized URL, empty clears. Save offline succeeds with connected=false status. DTOs always serialize nullable url/pid/version/error, expose only hasPassword, and never include password. +- **OpenCode installation**: `opencode_get_installation` is local-only; `opencode_check_update` is an explicit stable V2 check. `opencode_upgrade` accepts `expectedBinaryPath`, `expectedVersion`, and `targetVersion` through default Tauri camelCase mapping and delegates all upgrade guards to the installation module. No service lifecycle actions or automatic checks/upgrades. - **Manual updates**: `get_app_info` performs no network work; `check_for_updates` has no caller-controlled endpoint or credentials; `open_project_page` accepts only the domain-validated `repository`/`releases` destination names. No automatic checks or installation. -- **Tests**: `app.rs` includes `app/tests.rs` via `#[cfg(test)] mod tests` for response serialization. Filesystem workflow coverage in `../../tests/app.rs` uses temporary homes with `ConfigPaths::for_home`. +- **Tests**: `app.rs` includes `app/tests.rs` via `#[cfg(test)] mod tests` for serialization and mock add-project IPC guards. Filesystem and agent snapshot/JavaScript numeric-roundtrip coverage in `../../tests/app.rs` uses temporary homes with `ConfigPaths::for_home`, never the native chooser. ## Sync diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index 8343b53..df951d1 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -1,22 +1,19 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use tauri::State; +use uuid::Uuid; use crate::agents::{ self, AgentCreate, AgentDefinition, AgentFieldOverride, AgentMutation, AgentReferenceIndex, AgentSource, AgentStorage, InlineAgentSource, MarkdownAgentSource, }; use crate::error::AppError; -use crate::paths::ConfigPaths; +use crate::paths::{reject_project_write, ConfigPaths}; use crate::providers; use crate::refs; type CommandResult = Result; -fn paths(state: &State<'_, ConfigPaths>) -> ConfigPaths { - state.inner().clone() -} - /// One V2 permission rule: ordered `{action, resource, effect}` entry. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -96,6 +93,10 @@ pub struct AgentSourcePreviewDto { pub storage: AgentStorageDto, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, + #[serde(default)] + pub resolved_path: String, + #[serde(default)] + pub content: String, pub raw: Value, pub fields: Map, pub prompt: Option, @@ -108,8 +109,7 @@ impl From for AgentDefinitionDto { AgentSource::Markdown => unique_markdown_storage(&agent), AgentSource::Both => None, }; - let inline_source = agent.inline.clone(); - let inline = inline_source.clone().and_then(|source| object(source.raw)); + let inline = agent.inline.and_then(|source| object(source.raw)); let markdown = markdown_fields(&agent.markdown); let effective = object(agent.effective); let fields = effective.as_ref(); @@ -117,7 +117,7 @@ impl From for AgentDefinitionDto { id: agent.id, source: agent.source, storage, - sources: source_previews(inline_source, &agent.markdown), + sources: source_previews(&agent.inline_sources, &agent.markdown), overrides: agent.overrides, mutation: None, inline, @@ -143,16 +143,19 @@ fn permission_rules(fields: Option<&Map>) -> Option, + inline: &[InlineAgentSource], markdown: &[MarkdownAgentSource], ) -> Vec { let mut sources = Vec::new(); - if let Some(inline) = inline { + for inline in inline { sources.push(AgentSourcePreviewDto { storage: AgentStorageDto::Inline, - path: None, + path: Some(inline.path.display().to_string()), + resolved_path: inline.resolved_path.display().to_string(), + // Generate before JavaScript can normalize floats or round large integers. + content: inline.raw.to_string(), fields: inline.raw.as_object().cloned().unwrap_or_default(), - raw: inline.raw, + raw: inline.raw.clone(), prompt: None, }); } @@ -162,6 +165,8 @@ fn source_previews( AgentSourcePreviewDto { storage: source.storage.into(), path: Some(source.path.display().to_string()), + resolved_path: source.resolved_path.display().to_string(), + content: source.raw.clone(), raw: Value::String(source.raw.clone()), fields, prompt: Some(source.prompt.clone()), @@ -208,29 +213,38 @@ fn unsigned_field(fields: Option<&Map>, key: &str) -> Option } #[tauri::command] -pub fn list_agents(state: State<'_, ConfigPaths>) -> CommandResult> { - let agents = agents::list(&paths(&state))?; +pub fn list_agents( + state: State<'_, ConfigPaths>, + project_id: Option, +) -> CommandResult> { + let agents = agents::list(&state.inner().for_project(project_id)?)?; Ok(agents.into_iter().map(Into::into).collect()) } #[tauri::command] -pub fn get_agent(state: State<'_, ConfigPaths>, id: String) -> CommandResult { - agents::get(&paths(&state), &id).map(Into::into) +pub fn get_agent( + state: State<'_, ConfigPaths>, + id: String, + project_id: Option, +) -> CommandResult { + agents::get(&state.inner().for_project(project_id)?, &id).map(Into::into) } #[tauri::command] pub fn create_agent( state: State<'_, ConfigPaths>, agent: AgentDefinitionDto, + project_id: Option, ) -> CommandResult { - let paths = paths(&state); + reject_project_write(project_id)?; + let paths = state.inner(); let storage = agent.storage.map(Into::into).ok_or_else(|| { AppError::validation("agent creation requires an explicit storage target") })?; - let mutation = agent.mutation.clone().unwrap_or_default(); - validate_agent_model_reference(&paths, &mutation.fields)?; + let mutation = agent.mutation.unwrap_or_default(); + validate_agent_model_reference(paths, &mutation.fields)?; agents::create( - &paths, + paths, AgentCreate { id: agent.id, storage, @@ -245,25 +259,17 @@ pub fn create_agent( pub fn update_agent( state: State<'_, ConfigPaths>, agent: AgentDefinitionDto, + project_id: Option, ) -> CommandResult { - let paths = paths(&state); + reject_project_write(project_id)?; + let paths = state.inner(); let storage = agent .storage .map(Into::into) .ok_or_else(|| AppError::validation("agent update requires an explicit storage target"))?; - let mutation = agent.mutation.clone().unwrap_or_default(); - validate_agent_model_reference(&paths, &mutation.fields)?; - agents::update( - &paths, - &agent.id, - storage, - AgentMutation { - fields: mutation.fields, - prompt: mutation.prompt, - clear_fields: mutation.clear_fields, - }, - ) - .map(Into::into) + let mutation = agent.mutation.unwrap_or_default(); + validate_agent_model_reference(paths, &mutation.fields)?; + agents::update(paths, &agent.id, storage, mutation).map(Into::into) } #[tauri::command] @@ -271,12 +277,14 @@ pub fn delete_agent( state: State<'_, ConfigPaths>, id: String, storage: Option, + project_id: Option, ) -> CommandResult<()> { - let paths = paths(&state); + reject_project_write(project_id)?; + let paths = state.inner(); let storage: AgentStorage = match storage { Some(storage) => storage.into(), None => { - let current: AgentDefinition = agents::get(&paths, &id)?; + let current: AgentDefinition = agents::get(paths, &id)?; match current.source { AgentSource::Inline => AgentStorage::Inline, AgentSource::Both => { @@ -302,8 +310,8 @@ pub fn delete_agent( } } }; - let references: AgentReferenceIndex = refs::collect_agent_references(&paths)?; - agents::delete(&paths, &id, storage, &references).map(|_| ()) + let references: AgentReferenceIndex = refs::collect_agent_references(paths)?; + agents::delete(paths, &id, storage, &references).map(|_| ()) } fn validate_agent_model_reference( diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index ac627e4..6d6cb06 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -1,5 +1,6 @@ use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; +use tauri_plugin_dialog::DialogExt; use uuid::Uuid; use crate::agents; @@ -11,13 +12,9 @@ use crate::providers; type CommandResult = Result; -fn paths(state: &State<'_, ConfigPaths>) -> ConfigPaths { - state.inner().clone() -} - #[tauri::command] pub fn load_app_state(state: State<'_, ConfigPaths>) -> CommandResult { - let paths = paths(&state); + let paths = state.inner(); let config = models::load_config(&paths.config_file())?; config.validate()?; // Read selection and preferences before `groups` is moved out of `config`. @@ -25,29 +22,83 @@ pub fn load_app_state(state: State<'_, ConfigPaths>) -> CommandResult>(), groups: config.groups, selected_group_id, + projects: config.projects, + selected_project_id: config.state.selected_project_id, preferences, }) } +#[tauri::command] +pub async fn pick_project_folder(app: AppHandle) -> CommandResult> { + tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .file() + .blocking_pick_folder() + .map(|folder| { + let path = folder.into_path().map_err(|error| { + AppError::configuration(format!("pick project folder: {error}")) + })?; + path.into_os_string() + .into_string() + .map_err(|_| AppError::validation("project path must be valid UTF-8")) + }) + .transpose() + }) + .await + .map_err(|error| AppError::configuration(format!("pick project folder: {error}")))? +} + +#[tauri::command] +pub fn add_project( + state: State<'_, ConfigPaths>, + path: String, +) -> CommandResult { + let root = state + .inner() + .validate_project_directory(std::path::Path::new(&path))?; + let path = root + .to_str() + .ok_or_else(|| AppError::validation("project path must be valid UTF-8"))?; + models::add_project(&state.inner().config_file(), path) +} + +#[tauri::command] +pub fn remove_project(state: State<'_, ConfigPaths>, id: Uuid) -> CommandResult<()> { + models::remove_project(&state.inner().config_file(), id) +} + +#[tauri::command] +pub fn select_project(state: State<'_, ConfigPaths>, id: Option) -> CommandResult<()> { + models::select_project(&state.inner().config_file(), id) +} + #[tauri::command] pub fn save_preferences( app: AppHandle, state: State<'_, ConfigPaths>, preferences: crate::models::AppPreferences, ) -> CommandResult<()> { - let paths = paths(&state); + let paths = state.inner(); + let write = models::config_write_lock(); let mut config = models::load_config(&paths.config_file())?; config.state.preferences = preferences; models::save_config(&paths.config_file(), &config)?; + if let Some(service) = app.try_state::() { + service.update_preferences(preferences); + } + drop(write); if let Err(error) = app.emit_to("tray", "app:preferences-changed", ()) { eprintln!("failed to notify tray window of preference change: {error}"); } + if let Err(error) = app.emit_to("island", "app:preferences-changed", ()) { + eprintln!("failed to notify island window of preference change: {error}"); + } Ok(()) } @@ -59,6 +110,8 @@ pub struct AppStateResponse { pub agents: Vec, pub groups: Vec, pub selected_group_id: Option, + pub projects: Vec, + pub selected_project_id: Option, pub preferences: crate::models::AppPreferences, } diff --git a/src-tauri/src/commands/app/tests.rs b/src-tauri/src/commands/app/tests.rs index e17c8c7..f5b878e 100644 --- a/src-tauri/src/commands/app/tests.rs +++ b/src-tauri/src/commands/app/tests.rs @@ -8,6 +8,8 @@ fn app_state_serializes_selected_group_id_as_camel_case_or_null() { agents: Vec::new(), groups: Vec::new(), selected_group_id, + projects: Vec::new(), + selected_project_id: None, preferences: crate::models::AppPreferences::default(), }; let value = serde_json::to_value(response).unwrap(); @@ -17,5 +19,119 @@ fn app_state_serializes_selected_group_id_as_camel_case_or_null() { ); assert!(value.get("selectedGroupID").is_none()); assert!(value.get("selected_group_id").is_none()); + assert_eq!(value["projects"], serde_json::json!([])); + assert_eq!(value["selectedProjectId"], serde_json::Value::Null); + } +} + +#[test] +fn add_project_ipc_rejects_global_overlaps_before_persistence_without_opening_picker() { + use std::fs; + use std::path::{Path, PathBuf}; + + struct Home(PathBuf); + impl Drop for Home { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let home = + Home(std::env::temp_dir().join(format!("opencode-mom-project-ipc-{}", Uuid::new_v4()))); + let global = home.0.join(".config/opencode"); + let override_root = home.0.join("override"); + let pinned_root = home.0.join("pinned"); + let valid = home.0.join("project"); + let other = home.0.join("other-project"); + for root in [&global, &override_root, &pinned_root, &valid, &other] { + fs::create_dir_all(root).unwrap(); + } + for root in [&global, &override_root, &pinned_root] { + fs::write(root.join("opencode.jsonc"), "{}").unwrap(); + } + let paths = ConfigPaths::with_resource_parts( + home.0.clone(), + Some(pinned_root.join("opencode.jsonc")), + Some(override_root.clone()), + home.0.join(".config"), + home.0.join("cwd"), + ); + // No dialog plugin or picker handler: only add_project is dispatched through mock IPC. + let app = tauri::test::mock_builder() + .manage(paths.clone()) + .invoke_handler(tauri::generate_handler![super::add_project]) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + let invoke = |path: &Path| { + tauri::test::get_ipc_response( + &webview, + tauri::webview::InvokeRequest { + cmd: "add_project".into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: tauri::ipc::InvokeBody::Json(serde_json::json!({"path": path})), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.into(), + }, + ) + }; + let invalid = vec![global, override_root, pinned_root]; + #[cfg(unix)] + let invalid = { + let alias = home.0.join("global-alias"); + std::os::unix::fs::symlink(&invalid[0], &alias).unwrap(); + let mut invalid = invalid; + invalid.push(alias); + invalid + }; + for root in &invalid { + let error = invoke(root).unwrap_err(); + assert_eq!(error["code"], "validation_failed", "{}", root.display()); + assert!(!paths.app_config_dir().exists()); + } + let project = invoke(&valid) + .unwrap() + .deserialize::() + .unwrap(); + assert_eq!( + project.path, + fs::canonicalize(&valid).unwrap().to_str().unwrap() + ); + let mut config = models::load_config(&paths.config_file()).unwrap(); + assert_eq!(config.projects, [project.clone()]); + assert_eq!(config.state.selected_project_id, Some(project.id)); + config.state.island_enabled = true; + config.state.preferences.locale = models::LocalePreference::Zh; + models::save_config(&paths.config_file(), &config).unwrap(); + let before = fs::read(paths.config_file()).unwrap(); + for root in &invalid { + assert_eq!(invoke(root).unwrap_err()["code"], "validation_failed"); + assert_eq!(fs::read(paths.config_file()).unwrap(), before); + assert_eq!(models::load_config(&paths.config_file()).unwrap(), config); + } + let duplicate = invoke(&valid.join(".")) + .unwrap() + .deserialize::() + .unwrap(); + assert_eq!(duplicate, project); + assert_eq!(fs::read(paths.config_file()).unwrap(), before); + let added = invoke(&other) + .unwrap() + .deserialize::() + .unwrap(); + let after = models::load_config(&paths.config_file()).unwrap(); + assert_eq!(after.projects, [project, added.clone()]); + assert_eq!(after.state.selected_project_id, Some(added.id)); + assert!(after.state.island_enabled); + assert_eq!(after.state.preferences, config.state.preferences); + for root in invalid.iter().take(3) { + assert_eq!( + fs::read_to_string(root.join("opencode.jsonc")).unwrap(), + "{}" + ); } } diff --git a/src-tauri/src/commands/cli.rs b/src-tauri/src/commands/cli.rs index 1cc2b24..8a224e6 100644 --- a/src-tauri/src/commands/cli.rs +++ b/src-tauri/src/commands/cli.rs @@ -1,21 +1,63 @@ -use tauri::State; +use tauri::{AppHandle, Manager, State}; +use super::spawn_blocking_task; use crate::error::AppError; -use crate::opencode_cli::{self, ModelCatalogEntry, TokenUsageRecord}; +use crate::opencode_cli::{ + self, installation, ModelCatalogEntry, OpenCodeInstallation, OpenCodeServiceStatus, + OpenCodeUpdateCheck, TokenUsageRecord, +}; use crate::paths::ConfigPaths; type CommandResult = Result; -fn paths(state: &State<'_, ConfigPaths>) -> ConfigPaths { - state.inner().clone() -} - #[tauri::command] -pub fn opencode_list_models( +pub async fn opencode_list_models( state: State<'_, ConfigPaths>, provider: Option, ) -> CommandResult> { - opencode_cli::list_models_via_cli(&paths(&state), provider.as_deref()) + let paths = state.inner().clone(); + spawn_blocking_task("OpenCode model catalog", move || { + opencode_cli::list_models_via_cli(&paths, provider.as_deref()) + }) + .await +} + +#[tauri::command] +pub async fn opencode_get_service_status() -> CommandResult { + spawn_blocking_task("OpenCode service status", opencode_cli::target::get_status).await +} + +#[tauri::command] +pub async fn opencode_set_service_target( + app: AppHandle, + url: Option, + password: Option, +) -> CommandResult { + spawn_blocking_task("OpenCode service target", move || { + let power = app.state::(); + let island = app.state::(); + opencode_cli::target::set_target( + url, + password, + || { + Ok(( + power.prepare_target_refresh()?, + island.prepare_target_refresh(), + )) + }, + |(power, island)| { + power.invalidate(); + island.invalidate() + }, + |generation| { + power.finish_target_refresh(); + if island.finish_target_refresh(&app, generation).is_err() { + eprintln!("OpenCode target saved; Mini Island refresh unavailable"); + } + }, + ) + }) + .await } #[tauri::command] @@ -25,31 +67,47 @@ pub fn opencode_resolve_binary() -> CommandResult { .to_string()) } +#[tauri::command] +pub async fn opencode_get_installation() -> CommandResult { + spawn_blocking_task("OpenCode installation", installation::get_installation).await +} + +#[tauri::command] +pub async fn opencode_check_update() -> CommandResult { + spawn_blocking_task("OpenCode update check", installation::check_for_update).await +} + +#[tauri::command] +pub async fn opencode_upgrade( + expected_binary_path: String, + expected_version: String, + target_version: String, +) -> CommandResult { + spawn_blocking_task("OpenCode upgrade", move || { + installation::upgrade(&expected_binary_path, &expected_version, &target_version) + }) + .await +} + #[tauri::command] pub async fn opencode_reload() -> CommandResult<()> { - tauri::async_runtime::spawn_blocking(opencode_cli::reload_via_cli) - .await - .map_err(|error| { - AppError::configuration(format!("failed to join opencode reload task: {error}")) - })? + spawn_blocking_task("OpenCode reload", opencode_cli::reload_via_cli).await } #[tauri::command] pub async fn opencode_active_sessions() -> CommandResult { - tauri::async_runtime::spawn_blocking(opencode_cli::active_sessions_via_cli) - .await - .map_err(|error| { - AppError::configuration(format!( - "failed to join opencode active sessions task: {error}" - )) - })? + spawn_blocking_task( + "OpenCode active sessions", + opencode_cli::active_sessions_via_cli, + ) + .await } #[tauri::command] pub async fn opencode_token_usage_records() -> CommandResult> { - tauri::async_runtime::spawn_blocking(opencode_cli::token_usage_records_via_cli) - .await - .map_err(|error| { - AppError::configuration(format!("failed to join opencode token usage task: {error}")) - })? + spawn_blocking_task( + "OpenCode token usage", + opencode_cli::token_usage_records_via_cli, + ) + .await } diff --git a/src-tauri/src/commands/group.rs b/src-tauri/src/commands/group.rs index 8fa13a6..7b41916 100644 --- a/src-tauri/src/commands/group.rs +++ b/src-tauri/src/commands/group.rs @@ -8,13 +8,9 @@ use crate::paths::ConfigPaths; type CommandResult = Result; -fn paths(state: &State<'_, ConfigPaths>) -> ConfigPaths { - state.inner().clone() -} - #[tauri::command] pub fn save_group(state: State<'_, ConfigPaths>, group: ModelGroup) -> CommandResult { - let (group, _) = groups::save_group(&paths(&state), group)?; + let (group, _) = groups::save_group(state.inner(), group)?; Ok(group) } @@ -24,13 +20,13 @@ pub fn copy_group( id: Uuid, name: Option, ) -> CommandResult { - let (group, _) = groups::copy_group(&paths(&state), id, name)?; + let (group, _) = groups::copy_group(state.inner(), id, name)?; Ok(group) } #[tauri::command] pub fn delete_group(state: State<'_, ConfigPaths>, id: Uuid) -> CommandResult<()> { - groups::delete_group(&paths(&state), id).map(|_| ()) + groups::delete_group(state.inner(), id).map(|_| ()) } #[tauri::command] @@ -39,7 +35,7 @@ pub fn switch_group( state: State<'_, ConfigPaths>, id: Uuid, ) -> CommandResult<()> { - groups::switch_group(&paths(&state), id)?; + groups::switch_group(state.inner(), id)?; if window.label() == "tray" { if let Err(error) = window .app_handle() diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 64cc8fc..4bdf286 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,20 +3,36 @@ pub mod app; pub mod autostart; pub mod cli; pub mod group; +pub(crate) mod island; pub mod model; pub mod power; pub mod provider; pub mod resources; pub mod updates; +use crate::error::AppError; pub use crate::tray::*; pub use agent::*; pub use app::*; pub use autostart::*; pub use cli::*; pub use group::*; +pub(crate) use island::*; pub use model::*; pub use power::*; pub use provider::*; pub use resources::*; pub use updates::*; + +pub(crate) async fn spawn_blocking_task( + operation: &'static str, + task: F, +) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tauri::async_runtime::spawn_blocking(task) + .await + .map_err(|_| AppError::configuration(format!("{operation} worker unavailable")))? +} diff --git a/src-tauri/src/commands/model.rs b/src-tauri/src/commands/model.rs index 7080e51..ee97b20 100644 --- a/src-tauri/src/commands/model.rs +++ b/src-tauri/src/commands/model.rs @@ -8,13 +8,9 @@ use crate::refs; type CommandResult = Result; -fn paths(state: &State<'_, ConfigPaths>) -> ConfigPaths { - state.inner().clone() -} - #[tauri::command] pub fn list_models(state: State<'_, ConfigPaths>) -> CommandResult> { - providers::list_providers(&paths(&state).opencode_file()) + providers::list_providers(&state.inner().opencode_file()) } #[tauri::command] @@ -23,7 +19,7 @@ pub fn create_model( provider_id: String, model: ModelDef, ) -> CommandResult { - providers::create_model(&paths(&state).opencode_file(), &provider_id, model) + providers::create_model(&state.inner().opencode_file(), &provider_id, model) } #[tauri::command] @@ -33,14 +29,14 @@ pub fn update_model( model: ModelDef, ) -> CommandResult { let model_ref = ModelRef::new(&provider_id, &model.id)?; - providers::update_model(&paths(&state).opencode_file(), &model_ref, model) + providers::update_model(&state.inner().opencode_file(), &model_ref, model) } #[tauri::command] pub fn delete_model(state: State<'_, ConfigPaths>, model_ref: String) -> CommandResult<()> { - let paths = paths(&state); + let paths = state.inner(); let model_ref = ModelRef::parse(&model_ref)?; - let references = refs::model_is_referenced(&paths, &model_ref)?; + let references = refs::collect_model_references(paths)?.for_model(&model_ref); if !references.is_empty() { return Err(AppError::references( format!( diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index dd01854..f7225b2 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -8,23 +8,9 @@ use crate::refs; type CommandResult = Result; -fn paths(state: &State<'_, ConfigPaths>) -> ConfigPaths { - state.inner().clone() -} - #[tauri::command] pub fn list_providers(state: State<'_, ConfigPaths>) -> CommandResult> { - providers::list_providers(&paths(&state).opencode_file()) -} - -#[tauri::command] -pub fn list_custom_providers(state: State<'_, ConfigPaths>) -> CommandResult> { - let all = providers::list_providers(&paths(&state).opencode_file())?; - let custom_ids = providers::custom_provider_ids(&paths(&state).opencode_file())?; - Ok(all - .into_iter() - .filter(|p| custom_ids.contains(&p.name)) - .collect()) + providers::list_providers(&state.inner().opencode_file()) } #[tauri::command] @@ -32,7 +18,7 @@ pub fn create_provider( state: State<'_, ConfigPaths>, provider: ProviderDef, ) -> CommandResult { - providers::create_provider(&paths(&state).opencode_file(), provider) + providers::create_provider(&state.inner().opencode_file(), provider) } #[tauri::command] @@ -40,13 +26,13 @@ pub fn update_provider( state: State<'_, ConfigPaths>, provider: ProviderDef, ) -> CommandResult { - providers::update_provider(&paths(&state).opencode_file(), provider) + providers::update_provider(&state.inner().opencode_file(), provider) } #[tauri::command] pub fn delete_provider(state: State<'_, ConfigPaths>, id: String) -> CommandResult<()> { - let paths = paths(&state); - let references = refs::provider_is_referenced(&paths, &id)?; + let paths = state.inner(); + let references = refs::collect_model_references(paths)?.for_provider(&id); if !references.is_empty() { return Err(AppError::references( format!("{id} is referenced by {} location(s)", references.len()), diff --git a/src-tauri/src/commands/resources.rs b/src-tauri/src/commands/resources.rs index 82b8afc..89445cf 100644 --- a/src-tauri/src/commands/resources.rs +++ b/src-tauri/src/commands/resources.rs @@ -1,69 +1,95 @@ use tauri::State; +use uuid::Uuid; use crate::error::AppError; use crate::mcp::{self, McpDraft, McpList, McpServer, McpUpdate}; -use crate::paths::ConfigPaths; +use crate::paths::{reject_project_write, ConfigPaths}; use crate::skills::{self, SkillDraft, SkillEntry, SkillList, SkillUpdate}; #[tauri::command] -pub fn list_mcps(state: State<'_, ConfigPaths>) -> Result { - mcp::list(state.inner()) +pub fn list_mcps( + state: State<'_, ConfigPaths>, + project_id: Option, +) -> Result { + mcp::list(&state.inner().for_project(project_id)?) } #[tauri::command] -pub fn get_mcp(state: State<'_, ConfigPaths>, name: String) -> Result { - mcp::get(state.inner(), &name) +pub fn get_mcp( + state: State<'_, ConfigPaths>, + name: String, + project_id: Option, +) -> Result { + mcp::get(&state.inner().for_project(project_id)?, &name) } #[tauri::command] -pub fn create_mcp(state: State<'_, ConfigPaths>, draft: McpDraft) -> Result { +pub fn create_mcp( + state: State<'_, ConfigPaths>, + draft: McpDraft, + project_id: Option, +) -> Result { + reject_project_write(project_id)?; mcp::create(state.inner(), draft) } #[tauri::command] -pub fn update_mcp(state: State<'_, ConfigPaths>, draft: McpUpdate) -> Result { +pub fn update_mcp( + state: State<'_, ConfigPaths>, + draft: McpUpdate, + project_id: Option, +) -> Result { + reject_project_write(project_id)?; mcp::update(state.inner(), draft) } #[tauri::command] -pub fn delete_mcp(state: State<'_, ConfigPaths>, name: String) -> Result<(), AppError> { +pub fn delete_mcp( + state: State<'_, ConfigPaths>, + name: String, + project_id: Option, +) -> Result<(), AppError> { + reject_project_write(project_id)?; mcp::delete(state.inner(), &name) } #[tauri::command] -pub async fn list_skills(state: State<'_, ConfigPaths>) -> Result { - let paths = state.inner().clone(); - tauri::async_runtime::spawn_blocking(move || skills::list(&paths)) - .await - .map_err(|error| AppError::configuration(format!("discover skills: {error}")))? +pub async fn list_skills( + state: State<'_, ConfigPaths>, + project_id: Option, +) -> Result { + let paths = state.inner().for_project(project_id)?; + super::spawn_blocking_task("Skill discovery", move || skills::list(&paths)).await } #[tauri::command] -pub async fn get_skill(state: State<'_, ConfigPaths>, id: String) -> Result { - let paths = state.inner().clone(); - tauri::async_runtime::spawn_blocking(move || skills::get(&paths, &id)) - .await - .map_err(|error| AppError::configuration(format!("read skill: {error}")))? +pub async fn get_skill( + state: State<'_, ConfigPaths>, + id: String, + project_id: Option, +) -> Result { + let paths = state.inner().for_project(project_id)?; + super::spawn_blocking_task("Read skill", move || skills::get(&paths, &id)).await } #[tauri::command] pub async fn create_skill( state: State<'_, ConfigPaths>, draft: SkillDraft, + project_id: Option, ) -> Result { + reject_project_write(project_id)?; let paths = state.inner().clone(); - tauri::async_runtime::spawn_blocking(move || skills::create(&paths, draft)) - .await - .map_err(|_| AppError::configuration("Create skill operation failed"))? + super::spawn_blocking_task("Create skill", move || skills::create(&paths, draft)).await } #[tauri::command] pub async fn update_skill( state: State<'_, ConfigPaths>, draft: SkillUpdate, + project_id: Option, ) -> Result { + reject_project_write(project_id)?; let paths = state.inner().clone(); - tauri::async_runtime::spawn_blocking(move || skills::update(&paths, draft)) - .await - .map_err(|_| AppError::configuration("Update skill operation failed"))? + super::spawn_blocking_task("Update skill", move || skills::update(&paths, draft)).await } diff --git a/src-tauri/src/commands/updates.rs b/src-tauri/src/commands/updates.rs index d8c96e0..14a8a77 100644 --- a/src-tauri/src/commands/updates.rs +++ b/src-tauri/src/commands/updates.rs @@ -11,14 +11,16 @@ pub fn get_app_info(app: AppHandle) -> AppInfo { #[tauri::command] pub async fn check_for_updates(app: AppHandle) -> Result { let version = app.package_info().version.to_string(); - tauri::async_runtime::spawn_blocking(move || updates::check_for_updates(&version)) - .await - .map_err(|error| AppError::configuration(format!("Check for updates task: {error}")))? + super::spawn_blocking_task("Check for updates", move || { + updates::check_for_updates(&version) + }) + .await } #[tauri::command] pub async fn open_project_page(page: String) -> Result<(), AppError> { - tauri::async_runtime::spawn_blocking(move || updates::open_project_page(&page)) - .await - .map_err(|error| AppError::configuration(format!("Open project page task: {error}")))? + super::spawn_blocking_task("Open project page", move || { + updates::open_project_page(&page) + }) + .await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0bb6654..0fda993 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod commands; pub mod document; pub mod error; pub mod groups; +mod island; pub mod jsonc; pub mod mcp; pub mod models; @@ -13,7 +14,6 @@ pub mod power; pub mod projection; pub mod providers; pub mod refs; -pub mod replacement; mod resource_file; pub mod skills; mod tray; @@ -22,34 +22,67 @@ pub mod updates; use tauri::Manager; pub fn run() { + let island_notifications = std::sync::Arc::new(std::sync::Mutex::new(None)); + let setup_notifications = island_notifications.clone(); let builder = tauri::Builder::default(); - #[cfg(any(target_os = "macos", target_os = "windows"))] - let builder = builder.on_window_event(tray::on_window_event); + let builder = builder.on_window_event(|window, event| { + #[cfg(any(target_os = "macos", target_os = "windows"))] + tray::on_window_event(window, event); + island::on_window_event(window, event); + }); #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] let builder = builder.plugin(tauri_plugin_autostart::Builder::new().build()); builder + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Err(error) = tray::open_main(app, None) { eprintln!("failed to restore main window: {error}"); } })) - .setup(|app| { + .setup(move |app| { let paths = paths::ConfigPaths::from_environment()?; + opencode_cli::target::initialize(&paths)?; app.manage(power::PowerService::new(paths.app_config_dir())?); - app.manage(paths); + app.manage(paths.clone()); + *setup_notifications + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(app.handle().clone()); + island::setup( + app.handle(), + &paths, + std::sync::Arc::downgrade(&setup_notifications), + ); #[cfg(any(target_os = "macos", target_os = "windows"))] tray::setup(app)?; Ok(()) }) .invoke_handler(tauri::generate_handler![ commands::load_app_state, + commands::get_island_state, + commands::set_island_enabled, + commands::refresh_island, + commands::set_island_view, + commands::island_messages, + commands::island_send, + commands::island_reply_permission, + commands::island_reply_question, + commands::island_cancel_question, + commands::island_interrupt, + commands::pick_project_folder, + commands::add_project, + commands::remove_project, + commands::select_project, commands::get_app_info, commands::check_for_updates, commands::open_project_page, commands::list_providers, - commands::list_custom_providers, commands::opencode_list_models, commands::opencode_resolve_binary, + commands::opencode_get_installation, + commands::opencode_get_service_status, + commands::opencode_set_service_target, + commands::opencode_check_update, + commands::opencode_upgrade, commands::opencode_reload, commands::opencode_token_usage_records, commands::opencode_active_sessions, @@ -89,8 +122,15 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("failed to run omo-switch Tauri shell") - .run(|app, event| { + .run(move |app, event| { if matches!(event, tauri::RunEvent::Exit) { + if let Some(service) = app.try_state::() { + service.shutdown(); + } + island_notifications + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); if let Some(service) = app.try_state::() { service.shutdown(); } diff --git a/src-tauri/tests/AGENTS.md b/src-tauri/tests/AGENTS.md index 9e1f7ec..1b1d5bc 100644 --- a/src-tauri/tests/AGENTS.md +++ b/src-tauri/tests/AGENTS.md @@ -18,20 +18,22 @@ tests/ # Public-API integration test targets | Responsibility | File | Description | | --- | --- | --- | | App harness | `app.rs` | `temp_home` returns `TestHome` with explicit `ConfigPaths`; `Drop` removes its root. | -| App fixtures | `app.rs` | `provider`, `model_def`, `binding`, `group`, and `write_opencode` seed lifecycle tests; `load_config` rereads persisted state. | -| Resource harness | `resources.rs` | `Home::new`, `paths`, and `write` isolate filesystem fixtures with `Drop` cleanup. | +| App fixtures | `app.rs` | Existing helpers seed project/group lifecycles and agent stale/missing snapshots, alias retargets, checked publication and JavaScript numeric round-trips. | +| Resource harness | `resources.rs` | Existing Home helpers isolate exact-root resource scopes and Global alias/relative-source behavior; mock IPC covers project read-only guards and explicit Global writes without native UI. | | HTTP fixtures | `resources.rs` | `serve`, `configure_catalog`, and `assert_catalog_requests` provide loopback catalog responses and request assertions. | | Mutation guards | `resources.rs` | `mcp_update` and `skill_update` carry expected source/content; `mcp_directory_snapshot` captures files for no-write assertions. | | Live smoke | `app.rs` | `readonly_smoke_against_real_v2_config` reads providers, agents, and references only when the real config exists. | ## Conventions -- **API boundary**: Import public modules from `opencode_mom_tauri`; assert observable results and persisted files rather than invoking Tauri handlers or launching the app. +- **API boundary**: Import public modules from `opencode_mom_tauri` for filesystem cases; guarded IPC cases may use a mock Tauri builder without launching the shell or native chooser. Assert observable results and persisted files. - **Temporary homes**: Keep `TestHome` or `Home` alive for each mutating test so `Drop` cleans up its unique temporary root, including on panic. - **Explicit paths**: App fixtures use `ConfigPaths::for_home`; resource fixtures use `ConfigPaths::with_resource_parts` to supply home, overrides, config directory, and working directory without changing process environment variables. - **Fixture reuse**: Reuse the helpers in the relevant test target; `write_opencode` and `Home::write` seed files through `document::write_file`. -- **Round-trips**: Reread persisted config or Markdown after successful mutations. For rejected mutations, compare original bytes or directory snapshots and check that no unwanted files appear. +- **Round-trips**: Reread persisted config or Markdown after successful mutations. For rejected mutations, compare original bytes or directory snapshots and check that no unwanted files appear. Selection coverage preserves the independent `island_enabled` flag across group switch/save/deletion. - **Resource assertions**: Cover provenance, shadow precedence, diagnostics, unrelated JSONC content, and stale source/content rejection alongside CRUD results. +- **Agent assertions**: Echo backend preview `resolvedPath`/`content` as expected snapshots; reject missing/stale snapshots and detected alias retargets while allowing static symlinks. The real Node JSON round-trip checks float/large-integer content strings and unchanged numeric disk fields, not a Rust-only object clone. Create stays compatible; existing-file inline CRUD/Markdown updates share resource recovery checks. +- **Project assertions**: Exact-root fixtures exclude ancestors/siblings/children and custom skill arrays, preserve managed Global paths/files/cwd, reject filesystem roots/fixed-source canonical Global overlaps and return `not_found` for missing saved roots. Explicit null/omitted scope remains writable Global even with a selected or missing project; explicit project writes fail. Folder removal never deletes filesystem content. No home/depth blacklist or symlink-race guarantee; native chooser and power behavior are not automated. - **HTTP isolation**: Use `serve` on `127.0.0.1:0` with its unique catalog prefix; join the server worker and assert requested paths. `assert_catalog_requests` requires the index request first and sorts the remaining paths. - **Unix coverage**: Gate symlink and permission-mode tests with `#[cfg(unix)]` and keep all fixtures inside the temporary home. - **Real-home exception**: Keep `readonly_smoke_against_real_v2_config` read-only; it returns early when `HOME` or the resolved config is absent. Do not reuse its real paths for mutation fixtures. diff --git a/src-tauri/tests/app.rs b/src-tauri/tests/app.rs index 0b88774..4219897 100644 --- a/src-tauri/tests/app.rs +++ b/src-tauri/tests/app.rs @@ -221,6 +221,8 @@ fn agent_inline_and_markdown_lifecycle() { fields: serde_json::Map::new(), prompt: Some("updated prompt".to_owned()), clear_fields: vec!["model".to_owned()], + expected_path: Some(created.inline.as_ref().unwrap().resolved_path.clone()), + expected_content: Some(created.inline.as_ref().unwrap().raw.to_string()), }, ) .unwrap(); @@ -534,6 +536,9 @@ fn selection_state_survives_save_of_unselected_group() { let home = temp_home("state"); let selected = group(GroupType::Native, "selected"); let (selected_saved, _) = groups::save_group(&home.paths, selected).unwrap(); + let mut enabled_config = load_config(&home); + enabled_config.state.island_enabled = true; + opencode_mom_tauri::models::save_config(&home.paths.config_file(), &enabled_config).unwrap(); groups::switch_group(&home.paths, selected_saved.id).unwrap(); let other = group(GroupType::Slim, "other"); @@ -543,7 +548,743 @@ fn selection_state_survives_save_of_unselected_group() { AppSelectionState { selected_group_id: Some(selected_saved.id), selected_group_name: Some("selected".to_owned()), - preferences: Default::default(), + selected_project_id: None, + island_enabled: true, + ..Default::default() } ); + let config = groups::delete_group(&home.paths, selected_saved.id).unwrap(); + assert!(config.state.island_enabled); + assert!(config.state.selected_group_id.is_none()); +} + +#[test] +fn project_folder_lifecycle_preserves_files_and_normalizes_stale_selection() { + use opencode_mom_tauri::models; + let home = temp_home("projects"); + let root = home.root.join("project"); + fs::create_dir(&root).unwrap(); + let file = root.join("opencode.json"); + fs::write(&file, "{}").unwrap(); + let config_file = home.paths.config_file(); + let added = models::add_project(&config_file, root.to_str().unwrap()).unwrap(); + assert_eq!( + added.path, + fs::canonicalize(&root).unwrap().to_str().unwrap() + ); + assert_eq!( + models::add_project(&config_file, root.join(".").to_str().unwrap()).unwrap(), + added + ); + let config = load_config(&home); + assert_eq!(config.projects, [added.clone()]); + assert_eq!(config.state.selected_project_id, Some(added.id)); + models::select_project(&config_file, None).unwrap(); + assert_eq!(load_config(&home).state.selected_project_id, None); + assert_eq!( + models::add_project(&config_file, root.to_str().unwrap()).unwrap(), + added + ); + models::remove_project(&config_file, added.id).unwrap(); + assert!(root.is_dir()); + assert_eq!(fs::read_to_string(&file).unwrap(), "{}"); + assert!(load_config(&home).projects.is_empty()); + assert_eq!(load_config(&home).state.selected_project_id, None); + let unknown = Uuid::new_v4(); + assert_eq!( + models::select_project(&config_file, Some(unknown)) + .unwrap_err() + .code, + ErrorCode::NotFound + ); + assert_eq!( + models::remove_project(&config_file, unknown) + .unwrap_err() + .code, + ErrorCode::NotFound + ); + assert_eq!( + home.paths.for_project(Some(unknown)).unwrap_err().code, + ErrorCode::NotFound + ); + for invalid in [PathBuf::from("relative"), home.root.join("missing"), file] { + assert!(models::add_project(&config_file, invalid.to_str().unwrap()).is_err()); + } + let mut config = load_config(&home); + config.state.selected_project_id = Some(unknown); + models::save_config(&config_file, &config).unwrap(); + assert_eq!( + models::load_config(&config_file) + .unwrap() + .state + .selected_project_id, + None + ); +} + +#[cfg(unix)] +#[test] +fn project_folder_symlink_dedup_unreadable_and_non_utf8_validation() { + use opencode_mom_tauri::models; + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::{symlink, PermissionsExt}; + let home = temp_home("project-alias"); + let root = home.root.join("project"); + fs::create_dir(&root).unwrap(); + let alias = home.root.join("alias"); + symlink(&root, &alias).unwrap(); + let added = models::add_project(&home.paths.config_file(), root.to_str().unwrap()).unwrap(); + assert_eq!( + models::add_project(&home.paths.config_file(), alias.to_str().unwrap()).unwrap(), + added + ); + let scoped = home.paths.for_project(Some(added.id)).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o0)).unwrap(); + let privileged = unsafe { libc::geteuid() } == 0; + let unreadable_add = models::add_project(&home.paths.config_file(), root.to_str().unwrap()); + let unreadable_read = opencode_mom_tauri::agents::list(&scoped); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + if !privileged { + assert!(unreadable_add.is_err()); + assert!(unreadable_read.is_err()); + } + let invalid = home.root.join(OsString::from_vec(vec![0xff])); + if fs::create_dir(&invalid).is_ok() { + let invalid_alias = home.root.join("invalid-alias"); + symlink(&invalid, &invalid_alias).unwrap(); + assert_eq!( + models::add_project(&home.paths.config_file(), invalid_alias.to_str().unwrap()) + .unwrap_err() + .code, + ErrorCode::ValidationFailed + ); + } + assert_eq!(load_config(&home).projects, [added]); +} + +#[test] +fn group_lifecycle_preserves_selected_project_and_preferences() { + use opencode_mom_tauri::models; + let home = temp_home("group-project-selection"); + write_opencode(&home); + let root = home.root.join("project"); + fs::create_dir(&root).unwrap(); + let project = models::add_project(&home.paths.config_file(), root.to_str().unwrap()).unwrap(); + let mut config = load_config(&home); + config.state.preferences.locale = models::LocalePreference::Zh; + models::save_config(&home.paths.config_file(), &config).unwrap(); + let (saved, _) = groups::save_group(&home.paths, group(GroupType::Native, "selected")).unwrap(); + groups::switch_group(&home.paths, saved.id).unwrap(); + groups::copy_group(&home.paths, saved.id, None).unwrap(); + groups::delete_group(&home.paths, saved.id).unwrap(); + let config = load_config(&home); + assert_eq!(config.projects, [project.clone()]); + assert_eq!(config.state.selected_project_id, Some(project.id)); + assert_eq!( + config.state.preferences.locale, + models::LocalePreference::Zh + ); + assert_eq!(config.state.selected_group_id, None); +} + +#[test] +fn global_agent_aliases_retain_paths_and_refuse_ambiguous_mutations() { + use opencode_mom_tauri::agents; + let home = temp_home("agent-aliases"); + let singular = home.root.join(".config/opencode/agent/nested/helper.md"); + write_file(&singular, b"---\nmodel: acme/old\n---\nsingular").unwrap(); + write_file( + &home.root.join(".config/opencode/mode/ignored.md"), + b"ignored", + ) + .unwrap(); + write_file( + &home.root.join(".config/opencode/modes/ignored.md"), + b"ignored", + ) + .unwrap(); + assert_eq!(agents::list(&home.paths).unwrap().len(), 1); + assert_eq!( + agents::collect_markdown_files_in_roots(&home.paths).unwrap(), + [singular.clone()] + ); + assert_eq!(agents::model_references(&home.paths).unwrap().len(), 1); + agents::update( + &home.paths, + "nested/helper", + AgentStorage::GlobalMarkdown, + AgentMutation { + prompt: Some("edited".to_owned()), + expected_path: Some(fs::canonicalize(&singular).unwrap()), + expected_content: Some(fs::read_to_string(&singular).unwrap()), + ..Default::default() + }, + ) + .unwrap(); + let plural = home.paths.global_agents_dir().join("nested/helper.md"); + assert!(!plural.exists()); + assert!(fs::read_to_string(&singular).unwrap().ends_with("edited")); + write_file(&plural, b"plural").unwrap(); + let agent = agents::get(&home.paths, "nested/helper").unwrap(); + assert_eq!(agent.markdown.len(), 2); + assert_eq!(agent.effective["system"], "plural"); + let before = fs::read(&singular).unwrap(); + let error = agents::update( + &home.paths, + "nested/helper", + AgentStorage::GlobalMarkdown, + AgentMutation { + expected_path: Some(fs::canonicalize(&singular).unwrap()), + expected_content: Some(fs::read_to_string(&singular).unwrap()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::ValidationFailed); + assert!(error.message.contains("multiple Markdown paths")); + assert!(agents::delete( + &home.paths, + "nested/helper", + AgentStorage::GlobalMarkdown, + &Default::default() + ) + .is_err()); + assert!(agents::create( + &home.paths, + AgentCreate { + id: "nested/helper".to_owned(), + storage: AgentStorage::GlobalMarkdown, + fields: Default::default(), + prompt: None + } + ) + .is_err()); + assert_eq!(fs::read(&singular).unwrap(), before); + assert_eq!(fs::read_to_string(&plural).unwrap(), "plural"); + fs::remove_file(&plural).unwrap(); + agents::delete( + &home.paths, + "nested/helper", + AgentStorage::GlobalMarkdown, + &Default::default(), + ) + .unwrap(); + assert!(!singular.exists()); + assert!(!plural.exists()); +} + +#[cfg(unix)] +#[test] +fn agent_alias_symlinks_to_one_file_are_not_ambiguous() { + use opencode_mom_tauri::agents; + use std::os::unix::fs::symlink; + let home = temp_home("agent-symlink-aliases"); + let singular = home.root.join(".config/opencode/agent/helper.md"); + write_file(&singular, b"body").unwrap(); + let plural = home.paths.global_agents_dir().join("helper.md"); + fs::create_dir_all(plural.parent().unwrap()).unwrap(); + symlink(&singular, &plural).unwrap(); + agents::update( + &home.paths, + "helper", + AgentStorage::GlobalMarkdown, + AgentMutation { + prompt: Some("edited".to_owned()), + expected_path: Some(fs::canonicalize(&singular).unwrap()), + expected_content: Some(fs::read_to_string(&singular).unwrap()), + ..Default::default() + }, + ) + .unwrap(); + assert!(fs::read_to_string(&singular).unwrap().ends_with("edited")); + assert!(fs::symlink_metadata(plural) + .unwrap() + .file_type() + .is_symlink()); +} + +#[cfg(unix)] +#[test] +fn project_scope_rejects_saved_root_retarget_but_ignores_added_alias_retarget() { + use opencode_mom_tauri::{agents, mcp, models, skills}; + use std::os::unix::fs::symlink; + let home = temp_home("project-root-retarget"); + let root = home.root.join("project"); + let target = home.root.join("target"); + for (directory, marker) in [(&root, "original"), (&target, "retargeted")] { + write_file( + &directory.join("opencode.json"), + serde_json::json!({ + "agents": {"helper": {"description": marker}}, + "mcp": {"servers": {"server": {"type": "local", "command": [marker]}}} + }) + .to_string() + .as_bytes(), + ) + .unwrap(); + write_file( + &directory.join(".opencode/skills/Shared.md"), + marker.as_bytes(), + ) + .unwrap(); + } + let alias = home.root.join("alias"); + symlink(&root, &alias).unwrap(); + let project = models::add_project(&home.paths.config_file(), alias.to_str().unwrap()).unwrap(); + assert_eq!( + PathBuf::from(&project.path), + fs::canonicalize(&root).unwrap() + ); + let scoped = home.paths.for_project(Some(project.id)).unwrap(); + let config_before = fs::read(home.paths.config_file()).unwrap(); + fs::remove_file(&alias).unwrap(); + symlink(&target, &alias).unwrap(); + for paths in [&scoped, &home.paths.for_project(Some(project.id)).unwrap()] { + assert_eq!(agents::list(paths).unwrap().len(), 1); + assert_eq!( + agents::get(paths, "helper").unwrap().effective["description"], + "original" + ); + assert_eq!(mcp::list(paths).unwrap().data.len(), 1); + assert_eq!(mcp::get(paths, "server").unwrap().target, "original"); + assert_eq!(skills::list(paths).unwrap().data.len(), 1); + assert_eq!(skills::get(paths, "Shared").unwrap().content, "original"); + } + fs::rename(&root, home.root.join("moved-project")).unwrap(); + symlink(&target, &root).unwrap(); + assert_eq!( + home.paths.for_project(Some(project.id)).unwrap_err().code, + ErrorCode::ConfigurationFailed + ); + for error in [ + agents::list(&scoped).unwrap_err(), + agents::get(&scoped, "helper").unwrap_err(), + mcp::list(&scoped).unwrap_err(), + mcp::get(&scoped, "server").unwrap_err(), + skills::list(&scoped).unwrap_err(), + skills::get(&scoped, "Shared").unwrap_err(), + ] { + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + } + assert_eq!(fs::read(home.paths.config_file()).unwrap(), config_before); +} + +#[cfg(unix)] +#[test] +fn agent_delete_refuses_same_target_logical_aliases_in_both_directions() { + use opencode_mom_tauri::agents; + use std::os::unix::fs::symlink; + for singular_is_link in [false, true] { + let home = temp_home("agent-delete-aliases"); + let singular = home.root.join(".config/opencode/agent/helper.md"); + let plural = home.paths.global_agents_dir().join("helper.md"); + let (real, alias) = if singular_is_link { + (&plural, &singular) + } else { + (&singular, &plural) + }; + write_file(real, b"---\ndescription: keep\n---\noriginal body").unwrap(); + fs::create_dir_all(alias.parent().unwrap()).unwrap(); + symlink(real, alias).unwrap(); + let original = fs::read(real).unwrap(); + let original_link = fs::read_link(alias).unwrap(); + assert_eq!( + agents::get(&home.paths, "helper").unwrap().markdown.len(), + 2 + ); + let error = agents::delete( + &home.paths, + "helper", + AgentStorage::GlobalMarkdown, + &Default::default(), + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::ValidationFailed); + assert!(error.message.contains("multiple Markdown paths")); + assert_eq!(fs::read(real).unwrap(), original); + assert_eq!(fs::read(alias).unwrap(), original); + assert_eq!(fs::read_link(alias).unwrap(), original_link); + assert!(fs::symlink_metadata(alias) + .unwrap() + .file_type() + .is_symlink()); + assert!(!fs::symlink_metadata(real).unwrap().file_type().is_symlink()); + assert_eq!( + agents::get(&home.paths, "helper").unwrap().markdown.len(), + 2 + ); + } +} + +#[test] +fn agent_update_rejects_stale_inline_and_markdown_drafts_without_writes() { + use opencode_mom_tauri::agents; + use serde_json::json; + for storage in [AgentStorage::Inline, AgentStorage::GlobalMarkdown] { + let home = temp_home("agent-stale-draft"); + let (path, original, changed, expected_content) = match storage { + AgentStorage::Inline => ( + home.paths.opencode_file(), + r#"{"agents":{"helper":{"description":"original"}},"unrelated":"keep"}"#, + r#"{"agents":{"helper":{"description":"external edit"}},"unrelated":"keep"}"#, + json!({"description": "original"}).to_string(), + ), + AgentStorage::GlobalMarkdown => ( + home.paths.global_agents_dir().join("helper.md"), + "---\ndescription: original\n---\noriginal body", + "---\ndescription: external edit\n---\nexternal body", + "---\ndescription: original\n---\noriginal body".to_owned(), + ), + }; + write_file(&path, original.as_bytes()).unwrap(); + let mutation: AgentMutation = serde_json::from_value(json!({ + "fields": {"description": "draft"}, + "expectedPath": fs::canonicalize(&path).unwrap(), + "expectedContent": expected_content, + })) + .unwrap(); + fs::write(&path, changed).unwrap(); + let error = agents::update(&home.paths, "helper", storage, mutation).unwrap_err(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + assert_eq!(fs::read_to_string(&path).unwrap(), changed); + assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); + } +} + +#[test] +fn agent_update_requires_expected_source_but_creation_stays_compatible() { + use opencode_mom_tauri::agents; + use serde_json::json; + for storage in [AgentStorage::Inline, AgentStorage::GlobalMarkdown] { + let home = temp_home("agent-required-snapshot"); + agents::create( + &home.paths, + AgentCreate { + id: "helper".to_owned(), + storage, + fields: Default::default(), + prompt: Some("keep".to_owned()), + }, + ) + .unwrap(); + let path = match storage { + AgentStorage::Inline => home.paths.opencode_file(), + AgentStorage::GlobalMarkdown => home.paths.global_agents_dir().join("helper.md"), + }; + let before = fs::read(&path).unwrap(); + for payload in [ + json!({}), + json!({"expectedPath": fs::canonicalize(&path).unwrap()}), + json!({"expectedContent": "keep"}), + json!({"expectedPath": fs::canonicalize(&path).unwrap(), "expectedRaw": {"system": "keep"}}), + ] { + let mutation = serde_json::from_value(payload).unwrap(); + let error = agents::update(&home.paths, "helper", storage, mutation).unwrap_err(); + assert_eq!(error.code, ErrorCode::ValidationFailed); + assert_eq!(fs::read(&path).unwrap(), before); + } + assert_eq!( + agents::get(&home.paths, "helper").unwrap().effective["system"], + "keep" + ); + } +} + +#[cfg(unix)] +#[test] +fn agent_update_rejects_identical_content_symlink_retarget_for_both_storages() { + use opencode_mom_tauri::agents; + use serde_json::json; + use std::os::unix::fs::symlink; + for storage in [AgentStorage::Inline, AgentStorage::GlobalMarkdown] { + let home = temp_home("agent-draft-retarget"); + let (logical, content, expected_content) = match storage { + AgentStorage::Inline => ( + home.paths.opencode_file(), + r#"{"agents":{"helper":{"description":"original"}}}"#, + json!({"description": "original"}).to_string(), + ), + AgentStorage::GlobalMarkdown => ( + home.paths.global_agents_dir().join("helper.md"), + "---\ndescription: original\n---\nbody", + "---\ndescription: original\n---\nbody".to_owned(), + ), + }; + let original = home.root.join("original"); + let outside = home.root.join("outside"); + fs::write(&original, content).unwrap(); + fs::write(&outside, content).unwrap(); + fs::create_dir_all(logical.parent().unwrap()).unwrap(); + symlink(&original, &logical).unwrap(); + let mutation: AgentMutation = serde_json::from_value(json!({ + "fields": {"description": "draft"}, + "expectedPath": fs::canonicalize(&original).unwrap(), + "expectedContent": expected_content, + })) + .unwrap(); + fs::remove_file(&logical).unwrap(); + symlink(&outside, &logical).unwrap(); + let error = agents::update(&home.paths, "helper", storage, mutation).unwrap_err(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + assert_eq!(fs::read_to_string(&original).unwrap(), content); + assert_eq!(fs::read_to_string(&outside).unwrap(), content); + assert_eq!(fs::read_link(&logical).unwrap(), outside); + } +} + +#[cfg(unix)] +#[test] +fn agent_markdown_symlink_update_replaces_target_without_truncating_other_hardlinks() { + use opencode_mom_tauri::agents; + use serde_json::json; + use std::os::unix::fs::{symlink, PermissionsExt}; + let home = temp_home("agent-atomic-symlink"); + let logical = home.paths.global_agents_dir().join("helper.md"); + let target = home.root.join("linked/helper.md"); + let held = home.root.join("held.md"); + let content = "---\ndescription: original\n---\nbody"; + write_file(&target, content.as_bytes()).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o640)).unwrap(); + fs::hard_link(&target, &held).unwrap(); + fs::create_dir_all(logical.parent().unwrap()).unwrap(); + symlink(&target, &logical).unwrap(); + let mutation: AgentMutation = serde_json::from_value(json!({ + "fields": {"description": "edited"}, + "expectedPath": fs::canonicalize(&target).unwrap(), + "expectedContent": content, + })) + .unwrap(); + let saved = agents::update( + &home.paths, + "helper", + AgentStorage::GlobalMarkdown, + mutation, + ) + .unwrap(); + assert_eq!(saved.effective["description"], "edited"); + assert_eq!(fs::read_to_string(&held).unwrap(), content); + assert_eq!(fs::read_link(&logical).unwrap(), target); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o640 + ); + assert_eq!(fs::read_dir(target.parent().unwrap()).unwrap().count(), 1); +} + +#[test] +fn agent_inline_expected_content_allows_unrelated_config_edits_and_preserves_jsonc() { + use opencode_mom_tauri::agents; + use serde_json::json; + let home = temp_home("agent-inline-unrelated"); + let path = home.paths.opencode_file(); + let original = "{\n// keep comment\n\"agents\":{\"helper\":{\"description\":\"original\",\"custom\":true}},\n\"other\":\"original\",\n}\n"; + write_file(&path, original.as_bytes()).unwrap(); + let loaded = agents::get(&home.paths, "helper").unwrap().inline.unwrap(); + fs::write( + &path, + original.replace("\"other\":\"original\"", "\"other\":\"external\""), + ) + .unwrap(); + let mutation: AgentMutation = serde_json::from_value(json!({ + "fields": {"description": "draft"}, + "expectedPath": loaded.resolved_path, + "expectedContent": loaded.raw.to_string(), + })) + .unwrap(); + let saved = agents::update(&home.paths, "helper", AgentStorage::Inline, mutation).unwrap(); + assert_eq!(saved.effective["description"], "draft"); + assert_eq!(saved.effective["custom"], true); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("// keep comment")); + assert!(content.contains("\"other\":\"external\"")); + assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); +} + +#[test] +fn agent_ipc_forwards_expected_source_and_rejects_stale_payloads() { + use opencode_mom_tauri::commands::{self, AgentDefinitionDto}; + use serde_json::json; + use tauri::Manager; + for storage in ["inline", "global_markdown"] { + let home = temp_home("agent-ipc-snapshot"); + let path = if storage == "inline" { + home.paths.opencode_file() + } else { + home.paths.global_agents_dir().join("helper.md") + }; + let original = if storage == "inline" { + r#"{"agents":{"helper":{"description":"original"}}}"# + } else { + "---\ndescription: original\n---\nbody" + }; + write_file(&path, original.as_bytes()).unwrap(); + let app = tauri::test::mock_app(); + app.manage(home.paths.clone()); + let loaded = + commands::get_agent(app.state::(), "helper".to_owned(), None).unwrap(); + let preview = &loaded.sources[0]; + assert_eq!(preview.path.as_deref(), path.to_str()); + let resolved = fs::canonicalize(&path).unwrap(); + assert_eq!(preview.resolved_path, resolved.to_str().unwrap()); + assert_eq!( + preview.content, + if storage == "inline" { + preview.raw.to_string() + } else { + preview.raw.as_str().unwrap().to_owned() + } + ); + let draft: AgentDefinitionDto = serde_json::from_value(json!({ + "id": "helper", "source": loaded.source, "storage": storage, + "mutation": {"fields": {"description": "draft"}, + "expectedPath": preview.resolved_path, "expectedContent": preview.content}, + })) + .unwrap(); + let saved = + commands::update_agent(app.state::(), draft.clone(), None).unwrap(); + assert_eq!(saved.description.as_deref(), Some("draft")); + let before = fs::read(&path).unwrap(); + let error = commands::update_agent(app.state::(), draft, None).unwrap_err(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + assert_eq!(fs::read(&path).unwrap(), before); + } +} + +#[cfg(unix)] +#[test] +fn agent_markdown_delete_removes_only_the_leaf_symlink() { + use opencode_mom_tauri::agents; + use std::os::unix::fs::symlink; + let home = temp_home("agent-delete-leaf-symlink"); + let logical = home.paths.global_agents_dir().join("helper.md"); + let target = home.root.join("outside.md"); + fs::write(&target, "keep external body").unwrap(); + fs::create_dir_all(logical.parent().unwrap()).unwrap(); + symlink(&target, &logical).unwrap(); + agents::delete( + &home.paths, + "helper", + AgentStorage::GlobalMarkdown, + &Default::default(), + ) + .unwrap(); + assert!(fs::symlink_metadata(logical).is_err()); + assert_eq!(fs::read_to_string(target).unwrap(), "keep external body"); +} + +#[test] +fn agent_inline_create_rejects_invalid_config_filename_without_creating_directories() { + let home = temp_home("agent-invalid-config-name"); + let missing = home.root.join("missing"); + let paths = ConfigPaths::with_parts(home.root.clone(), Some(missing.join("..")), None); + let error = opencode_mom_tauri::agents::create( + &paths, + AgentCreate { + id: "helper".to_owned(), + storage: AgentStorage::Inline, + fields: Default::default(), + prompt: None, + }, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::ValidationFailed); + assert!(!missing.exists()); +} + +#[test] +fn agent_inline_javascript_roundtrip_preserves_numeric_snapshots_and_disk_fields() { + use opencode_mom_tauri::commands::{self, AgentDefinitionDto}; + use std::io::Write; + use std::process::{Command, Stdio}; + use tauri::Manager; + + let home = temp_home("agent-js-numeric-roundtrip"); + let path = home.paths.opencode_file(); + let original = r#"{ + // Numeric spelling and precision must survive an unrelated description edit. + "agents": {"helper": {"description":"original","temperature":1.0, + "nested":{"float":1e0,"large":9007199254740993},"array":[1.0,9007199254740995]}}, + "other":"keep" +}"#; + write_file(&path, original.as_bytes()).unwrap(); + let app = tauri::test::mock_app(); + app.manage(home.paths.clone()); + let draft = |description: &str| { + let loaded = + commands::get_agent(app.state::(), "helper".to_owned(), None).unwrap(); + let mut node = Command::new("node") + .args([ + "--input-type=module", + "-e", + r#"import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +const agent = JSON.parse(readFileSync(0, 'utf8')); +const source = agent.sources[0]; +assert.equal(JSON.stringify(source.raw.temperature), '1'); +assert.equal(source.raw.nested.large, 9007199254740992); +assert.equal(typeof source.content, 'string'); +assert.ok(source.content.includes('"temperature":1.0')); +assert.ok(source.content.includes('"large":9007199254740993')); +process.stdout.write(JSON.stringify({id:agent.id,source:agent.source,storage:'inline', + mutation:{fields:{description:process.argv[1]},expectedPath:source.resolvedPath,expectedContent:source.content}}));"#, + description, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("agent numeric roundtrip regression requires Node.js"); + node.stdin + .take() + .unwrap() + .write_all(&serde_json::to_vec(&loaded).unwrap()) + .unwrap(); + let output = node.wait_with_output().unwrap(); + assert!( + output.status.success(), + "JavaScript roundtrip failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice::(&output.stdout).unwrap() + }; + + let saved = commands::update_agent(app.state::(), draft("draft"), None).unwrap(); + assert_eq!(saved.description.as_deref(), Some("draft")); + let saved_content = fs::read_to_string(&path).unwrap(); + assert!(saved_content.contains("\"temperature\":1.0")); + assert!(saved_content.contains("\"float\":1e0")); + assert!(saved_content.contains("\"large\":9007199254740993")); + assert!(saved_content.contains("\"array\":[1.0,9007199254740995]")); + + for external in [ + saved_content.replace("9007199254740993", "9007199254740992"), + saved_content.replace("\"temperature\":1.0", "\"temperature\":1"), + saved_content.replace("\"description\":\"draft\"", "\"description\":\"external\""), + ] { + fs::write(&path, &saved_content).unwrap(); + let stale = draft("stale draft"); + fs::write(&path, &external).unwrap(); + let error = commands::update_agent(app.state::(), stale, None).unwrap_err(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + assert_eq!(fs::read_to_string(&path).unwrap(), external); + assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); + } + + fs::write(&path, &saved_content).unwrap(); + let unrelated = draft("second draft"); + fs::write( + &path, + saved_content.replace("\"other\":\"keep\"", "\"other\":\"external\""), + ) + .unwrap(); + let saved = commands::update_agent(app.state::(), unrelated, None).unwrap(); + assert_eq!(saved.description.as_deref(), Some("second draft")); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("\"other\":\"external\"")); + assert!(content.contains("\"temperature\":1.0")); + assert!(content.contains("\"float\":1e0")); + assert!(content.contains("\"large\":9007199254740993")); + assert!(content.contains("\"array\":[1.0,9007199254740995]")); + assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); } diff --git a/src-tauri/tests/resources.rs b/src-tauri/tests/resources.rs index b9b6c6c..02c1a5b 100644 --- a/src-tauri/tests/resources.rs +++ b/src-tauri/tests/resources.rs @@ -9,7 +9,7 @@ use std::time::Duration; use opencode_mom_tauri::document::{write_file, JsoncDoc}; use opencode_mom_tauri::error::ErrorCode; use opencode_mom_tauri::paths::ConfigPaths; -use opencode_mom_tauri::{mcp, skills}; +use opencode_mom_tauri::{agents, mcp, models, skills}; use serde_json::json; struct Home(PathBuf); @@ -46,7 +46,7 @@ impl Drop for Home { } #[test] -fn global_paths_include_all_sources_in_override_order_and_anchor_relative_skills() { +fn global_paths_include_aliases_in_override_order_and_skip_relative_skills() { let home = Home::new(); let paths = ConfigPaths::with_resource_parts( home.0.clone(), @@ -66,26 +66,686 @@ fn global_paths_include_all_sources_in_override_order_and_anchor_relative_skills ] ); assert_eq!(paths.opencode_file(), home.0.join("cwd/override.jsonc")); - assert_eq!(paths.resolve_skill_path("~/custom"), home.0.join("custom")); assert_eq!( - paths.resolve_skill_path("relative"), - home.0.join("cwd/relative") + paths.resolve_skill_path("~/custom"), + Some(home.0.join("custom")) ); + assert_eq!(paths.resolve_skill_path("relative"), None); assert_eq!( paths.resolve_skill_path(&home.0.display().to_string()), - home.0 + Some(home.0.clone()) ); assert_eq!( paths.global_skill_dirs(), &[ home.0.join(".claude/skills"), home.0.join(".agents/skills"), + home.0.join("xdg/opencode/skill"), home.0.join("xdg/opencode/skills"), + home.0.join("cwd/extra/skill"), home.0.join("cwd/extra/skills"), ] ); } +#[test] +fn project_scope_reads_only_exact_root_sources_and_preserves_global_paths() { + let home = Home::new(); + let paths = home.paths(); + let original_paths = paths.clone(); + let cwd = std::env::current_dir().unwrap(); + let root = home.0.join("workspace/project"); + let mut source_files = Vec::new(); + for (index, relative) in [ + "opencode.json", + "opencode.jsonc", + ".opencode/opencode.json", + ".opencode/opencode.jsonc", + ] + .into_iter() + .enumerate() + { + source_files.push(home.write(&format!("workspace/project/{relative}"), &json!({ + "agents": {"shared": {"description": format!("source-{index}"), "steps": index}}, + "mcp": {"servers": {"shared": {"type":"local", "command":[format!("source-{index}")]}}}, + "skills": ["./team-skills", home.0.join("custom"), "~/custom", "http://127.0.0.1:1/ignored"] + }).to_string())); + } + for outside in [ + ".config/opencode", + "workspace", + "workspace/sibling", + "workspace/project/child", + ] { + home.write(&format!("{outside}/opencode.json"), r#"{"agents":{"outside":{}},"mcp":{"servers":{"outside":{"type":"local","command":["outside"]}}}}"#); + home.write(&format!("{outside}/.opencode/agents/outside.md"), "outside"); + home.write(&format!("{outside}/.opencode/skills/Outside.md"), "outside"); + } + home.write(".config/opencode/agents/global.md", "global"); + home.write(".config/opencode/skills/Global.md", "global"); + for ignored in [ + "workspace/project/team-skills", + "workspace/project/.claude/skills", + "workspace/project/.agents/skills", + "custom", + ] { + home.write(&format!("{ignored}/Ignored.md"), "ignored custom skill"); + } + home.write("workspace/project/.opencode/mode/ignored.md", "ignored"); + home.write("workspace/project/.opencode/modes/ignored.md", "ignored"); + home.write( + "workspace/project/.opencode/agent/nested/reviewer.md", + "singular", + ); + let markdown = home.write( + "workspace/project/.opencode/agents/nested/reviewer.md", + "plural", + ); + home.write("workspace/project/.opencode/skill/Shared.md", "singular"); + let skill_file = home.write("workspace/project/.opencode/skills/Shared.md", "plural"); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + let scoped = paths.for_project(Some(project.id)).unwrap(); + let canonical = fs::canonicalize(&root).unwrap(); + assert_eq!( + scoped.global_config_files(), + [ + canonical.join("opencode.json"), + canonical.join("opencode.jsonc"), + canonical.join(".opencode/opencode.json"), + canonical.join(".opencode/opencode.jsonc") + ] + ); + assert_eq!(scoped.opencode_file(), paths.opencode_file()); + assert_eq!(scoped.config_file(), paths.config_file()); + let app_config_before = fs::read(paths.config_file()).unwrap(); + let global_before = fs::read(paths.opencode_file()).unwrap(); + let inline = agents::get(&scoped, "shared").unwrap(); + assert_eq!(inline.inline_sources.len(), 4); + assert_eq!(inline.effective["description"], "source-3"); + let dto: opencode_mom_tauri::commands::AgentDefinitionDto = inline.into(); + assert_eq!( + dto.sources + .iter() + .map(|source| source.path.clone().unwrap()) + .collect::>(), + scoped + .global_config_files() + .iter() + .map(|path| path.display().to_string()) + .collect::>() + ); + let reviewer = agents::get(&scoped, "nested/reviewer").unwrap(); + assert_eq!(reviewer.markdown.len(), 2); + assert_eq!(reviewer.effective["system"], "plural"); + assert_eq!( + reviewer.markdown.last().unwrap().path, + canonical.join(".opencode/agents/nested/reviewer.md") + ); + assert_eq!(agents::list(&scoped).unwrap().len(), 2); + let server = mcp::get(&scoped, "shared").unwrap(); + assert_eq!(server.target, "source-3"); + assert_eq!(server.source_paths.len(), 4); + assert_eq!( + server.source_path, + canonical + .join(".opencode/opencode.jsonc") + .display() + .to_string() + ); + assert_eq!(mcp::list(&scoped).unwrap().data.len(), 1); + let skill = skills::get(&scoped, "Shared").unwrap(); + assert_eq!(skill.content, "plural"); + assert_eq!( + skill.path, + canonical + .join(".opencode/skills/Shared.md") + .display() + .to_string() + ); + let listed = skills::list(&scoped).unwrap(); + assert_eq!(listed.data.len(), 1); + assert!(listed.diagnostics.is_empty()); + assert!(agents::get(&scoped, "outside").is_err()); + assert!(agents::get(&scoped, "../shared").is_err()); + assert!(skills::get(&scoped, "Ignored").is_err()); + assert_eq!(paths.for_project(None).unwrap(), original_paths); + assert_eq!(paths, original_paths); + assert_eq!(std::env::current_dir().unwrap(), cwd); + assert_eq!(fs::read(paths.config_file()).unwrap(), app_config_before); + assert_eq!(fs::read(paths.opencode_file()).unwrap(), global_before); + assert_eq!(fs::read_to_string(markdown).unwrap(), "plural"); + assert_eq!(fs::read_to_string(skill_file).unwrap(), "plural"); + assert!(skills::get(&paths, "Global").is_ok()); + assert!(agents::get(&paths, "global").is_ok()); + assert!(agents::get(&paths, "shared").is_err()); + assert!(mcp::get(&paths, "shared").is_err()); + fs::remove_dir_all(&root).unwrap(); + assert_eq!( + paths.for_project(Some(project.id)).unwrap_err().code, + ErrorCode::NotFound + ); + for error in [ + agents::list(&scoped).unwrap_err(), + mcp::list(&scoped).unwrap_err(), + skills::list(&scoped).unwrap_err(), + ] { + assert_eq!(error.code, ErrorCode::NotFound); + } + assert_eq!( + models::load_config(&paths.config_file()).unwrap().projects, + [project] + ); + assert!(source_files.iter().all(|path| !path.exists())); +} + +#[test] +fn global_aliases_and_absolute_home_remote_skill_sources_skip_relative_paths() { + let home = Home::new(); + let paths = ConfigPaths::with_resource_parts( + home.0.clone(), + None, + Some(home.0.join("override")), + home.0.join(".config"), + home.0.join("cwd"), + ); + home.write(".claude/skills/Claude.md", "claude"); + home.write(".agents/skills/Agents.md", "agents"); + home.write(".config/opencode/skill/Shared.md", "native singular"); + home.write(".config/opencode/skills/Shared.md", "native plural"); + home.write("override/skill/Shared.md", "override singular"); + let shared_path = home.write("override/skills/Shared.md", "override plural"); + home.write(".config/opencode/agent/native.md", "native singular"); + home.write(".config/opencode/agents/native-plural.md", "native plural"); + home.write("override/agent/override.md", "override singular"); + home.write("override/agents/override-plural.md", "override plural"); + home.write("override/mode/ignored.md", "ignored"); + home.write("override/modes/ignored.md", "ignored"); + home.write("absolute/Absolute.md", "absolute"); + home.write("home-source/Home.md", "home"); + home.write("cwd/team-skills/Relative.md", "relative ignored"); + home.write("cwd/not-a-directory", "would diagnose if read"); + let (url, server) = serve(vec![ + ( + "/catalog/index.json", + 200, + json!({"skills":[{"name":"Remote","files":["Remote.md"]}]}).to_string(), + None, + ), + ("/catalog/Remote/Remote.md", 200, "remote".to_owned(), None), + ]); + home.write("override/opencode.json", &json!({"skills": ["./team-skills", "not-a-directory", home.0.join("absolute"), "~/home-source", url]}).to_string()); + let listed = skills::list(&paths).unwrap(); + assert!(listed.diagnostics.is_empty(), "{:?}", listed.diagnostics); + assert_eq!( + listed + .data + .iter() + .map(|skill| skill.id.as_str()) + .collect::>(), + ["Absolute", "Agents", "Claude", "Home", "Remote", "Shared"] + ); + assert_eq!( + listed + .data + .iter() + .find(|skill| skill.id == "Remote") + .unwrap() + .kind, + skills::SkillKind::Remote + ); + let shared = listed + .data + .iter() + .find(|skill| skill.id == "Shared") + .unwrap(); + assert_eq!(shared.content, "override plural"); + assert_eq!(shared.path, shared_path.display().to_string()); + assert_catalog_requests( + server.join().unwrap(), + &["/catalog/index.json", "/catalog/Remote/Remote.md"], + ); + assert_eq!( + agents::list(&paths) + .unwrap() + .iter() + .map(|agent| agent.id.as_str()) + .collect::>(), + ["native", "native-plural", "override", "override-plural"] + ); +} + +#[test] +fn project_resource_ipc_mutations_are_refused_before_any_write() { + use opencode_mom_tauri::commands; + use tauri::Manager; + let home = Home::new(); + let paths = home.paths(); + let global = home.write(".config/opencode/opencode.json", r#"{"agents":{"helper":{}},"mcp":{"servers":{"server":{"type":"local","command":["run"]}}}}"#); + let root = home.0.join("project"); + fs::create_dir(&root).unwrap(); + let project_file = home.write("project/opencode.json", "{}"); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + let scoped = paths.for_project(Some(project.id)).unwrap(); + let app = tauri::test::mock_app(); + app.manage(paths.clone()); + let global_before = fs::read(&global).unwrap(); + let config_before = fs::read(paths.config_file()).unwrap(); + let agent: commands::AgentDefinitionDto = + serde_json::from_value(json!({"id":"new", "source":"inline", "storage":"inline"})).unwrap(); + for id in [project.id, uuid::Uuid::new_v4()] { + let state = || app.state::(); + let errors = vec![ + commands::create_agent(state(), agent.clone(), Some(id)).unwrap_err(), + commands::update_agent(state(), agent.clone(), Some(id)).unwrap_err(), + commands::delete_agent(state(), "helper".to_owned(), None, Some(id)).unwrap_err(), + commands::create_mcp( + state(), + mcp_draft("new", json!({"type":"local","command":["run"]})), + Some(id), + ) + .unwrap_err(), + commands::update_mcp( + state(), + mcp_update( + &mcp::get(&paths, "server").unwrap(), + json!({"type":"local","command":["changed"]}), + ), + Some(id), + ) + .unwrap_err(), + commands::delete_mcp(state(), "server".to_owned(), Some(id)).unwrap_err(), + tauri::async_runtime::block_on(commands::create_skill( + state(), + skills::SkillDraft { + id: "new".to_owned(), + content: "body".to_owned(), + }, + Some(id), + )) + .unwrap_err(), + tauri::async_runtime::block_on(commands::update_skill( + state(), + skills::SkillUpdate { + id: "new".to_owned(), + content: "body".to_owned(), + expected_content: "".to_owned(), + expected_path: "".to_owned(), + }, + Some(id), + )) + .unwrap_err(), + ]; + assert!(errors + .iter() + .all(|error| error.code == ErrorCode::ValidationFailed + && error.message.contains("read-only"))); + } + assert!(agents::create( + &scoped, + agents::AgentCreate { + id: "new".to_owned(), + storage: agents::AgentStorage::Inline, + fields: Default::default(), + prompt: None + } + ) + .is_err()); + assert!(mcp::create( + &scoped, + mcp_draft("new", json!({"type":"local","command":["run"]})) + ) + .is_err()); + assert!(skills::create( + &scoped, + skills::SkillDraft { + id: "new".to_owned(), + content: "body".to_owned() + } + ) + .is_err()); + assert_eq!(fs::read(&global).unwrap(), global_before); + assert_eq!(fs::read(paths.config_file()).unwrap(), config_before); + assert_eq!(fs::read_to_string(project_file).unwrap(), "{}"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(!paths.native_global_skills_dir().exists()); + let response = commands::load_app_state(app.state()).unwrap(); + assert_eq!(response.projects, [project.clone()]); + assert_eq!(response.selected_project_id, Some(project.id)); + assert_eq!(response.agents.len(), 1); + assert_eq!(commands::list_agents(app.state(), None).unwrap().len(), 1); + assert!(commands::list_agents(app.state(), Some(project.id)) + .unwrap() + .is_empty()); + assert!(commands::get_agent(app.state(), "helper".to_owned(), Some(project.id)).is_err()); + assert!(commands::get_mcp(app.state(), "server".to_owned(), Some(project.id)).is_err()); + assert!(tauri::async_runtime::block_on(commands::get_skill( + app.state(), + "Global".to_owned(), + Some(project.id) + )) + .is_err()); + assert!(commands::list_mcps(app.state(), Some(project.id)) + .unwrap() + .data + .is_empty()); + assert!( + tauri::async_runtime::block_on(commands::list_skills(app.state(), Some(project.id))) + .unwrap() + .data + .is_empty() + ); + commands::select_project(app.state(), None).unwrap(); + assert_eq!( + commands::load_app_state(app.state()) + .unwrap() + .selected_project_id, + None + ); + fs::remove_dir_all(&root).unwrap(); + commands::select_project(app.state(), Some(project.id)).unwrap(); + assert_eq!( + commands::load_app_state(app.state()) + .unwrap() + .selected_project_id, + Some(project.id) + ); + for error in [ + commands::list_agents(app.state(), Some(project.id)).unwrap_err(), + commands::get_agent(app.state(), "helper".to_owned(), Some(project.id)).unwrap_err(), + commands::list_mcps(app.state(), Some(project.id)).unwrap_err(), + commands::get_mcp(app.state(), "server".to_owned(), Some(project.id)).unwrap_err(), + tauri::async_runtime::block_on(commands::list_skills(app.state(), Some(project.id))) + .unwrap_err(), + tauri::async_runtime::block_on(commands::get_skill( + app.state(), + "Global".to_owned(), + Some(project.id), + )) + .unwrap_err(), + ] { + assert_eq!(error.code, ErrorCode::NotFound); + } + commands::remove_project(app.state(), project.id).unwrap(); + assert!(commands::load_app_state(app.state()) + .unwrap() + .projects + .is_empty()); + let mut config = models::load_config(&paths.config_file()).unwrap(); + config.state.selected_project_id = Some(uuid::Uuid::new_v4()); + models::save_config(&paths.config_file(), &config).unwrap(); + assert_eq!( + commands::load_app_state(app.state()) + .unwrap() + .selected_project_id, + None + ); +} + +#[test] +fn explicit_global_resource_ipc_remains_writable_while_a_project_is_selected() { + use opencode_mom_tauri::commands; + use tauri::Manager; + let home = Home::new(); + let paths = home.paths(); + let root = home.0.join("project"); + fs::create_dir(&root).unwrap(); + let project_file = home.write("project/opencode.json", "{\"project\":true}"); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + let config_before = fs::read(paths.config_file()).unwrap(); + let app = tauri::test::mock_app(); + app.manage(paths.clone()); + let state = || app.state::(); + let server = commands::create_mcp( + state(), + mcp_draft("global", json!({"type":"local","command":["created"]})), + None, + ) + .unwrap(); + let updated = commands::update_mcp( + state(), + mcp_update(&server, json!({"type":"local","command":["updated"]})), + None, + ) + .unwrap(); + assert_eq!( + commands::get_mcp(state(), "global".to_owned(), None).unwrap(), + updated + ); + commands::delete_mcp(state(), "global".to_owned(), None).unwrap(); + assert!(commands::list_mcps(state(), None).unwrap().data.is_empty()); + let skill = tauri::async_runtime::block_on(commands::create_skill( + state(), + skills::SkillDraft { + id: "Global".to_owned(), + content: "created".to_owned(), + }, + None, + )) + .unwrap(); + let updated = tauri::async_runtime::block_on(commands::update_skill( + state(), + skill_update(&skill, "updated"), + None, + )) + .unwrap(); + assert_eq!(updated.content, "updated"); + assert_eq!( + tauri::async_runtime::block_on(commands::get_skill(state(), "Global".to_owned(), None,)) + .unwrap(), + updated + ); + assert!(commands::list_mcps(state(), Some(project.id)) + .unwrap() + .data + .is_empty()); + assert!( + tauri::async_runtime::block_on(commands::list_skills(state(), Some(project.id))) + .unwrap() + .data + .is_empty() + ); + assert_eq!( + fs::read_to_string(&project_file).unwrap(), + "{\"project\":true}" + ); + fs::remove_dir_all(&root).unwrap(); + commands::create_mcp( + state(), + mcp_draft( + "missing-project-global", + json!({"type":"local","command":["run"]}), + ), + None, + ) + .unwrap(); + assert_eq!(fs::read(paths.config_file()).unwrap(), config_before); + assert_eq!( + models::load_config(&paths.config_file()) + .unwrap() + .state + .selected_project_id, + Some(project.id) + ); +} + +#[test] +fn project_scope_rejects_managed_global_roots_but_not_ancestors_or_unrelated_projects() { + let home = Home::new(); + let paths = ConfigPaths::with_resource_parts( + home.0.clone(), + Some(home.0.join("selected/custom.jsonc")), + Some(home.0.join("override")), + home.0.join(".config"), + home.0.join("cwd"), + ); + for relative in [ + ".config/opencode", + ".config/opencode/agents", + ".config/opencode/skill", + ".claude/skills", + ".agents/skills", + "override", + "override/agents", + ] { + let root = home.0.join(relative); + fs::create_dir_all(&root).unwrap(); + assert_eq!( + paths.validate_project_directory(&root).unwrap_err().code, + ErrorCode::ValidationFailed, + "{relative}", + ); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + assert_eq!( + paths.for_project(Some(project.id)).unwrap_err().code, + ErrorCode::ValidationFailed, + "{relative}", + ); + } + for relative in [ + "", + ".config", + "project", + "override-sibling", + "selected", + "override/unrelated-project", + ".config/opencode/unrelated-project", + ] { + let root = home.0.join(relative); + fs::create_dir_all(&root).unwrap(); + assert!( + paths.validate_project_directory(&root).is_ok(), + "{relative}" + ); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + assert!(paths.for_project(Some(project.id)).is_ok(), "{relative}"); + } +} + +#[cfg(unix)] +#[test] +fn project_scope_rejects_global_directory_aliases_and_fixed_source_aliases() { + use std::os::unix::fs::symlink; + for global_is_alias in [false, true] { + let home = Home::new(); + let global = home.0.join(".config/opencode"); + fs::create_dir_all(global.parent().unwrap()).unwrap(); + let root = home.0.join("project"); + let path = if global_is_alias { + fs::create_dir(&root).unwrap(); + symlink(&root, &global).unwrap(); + root + } else { + fs::create_dir(&global).unwrap(); + symlink(&global, &root).unwrap(); + root + }; + let paths = home.paths(); + assert_eq!( + paths.validate_project_directory(&path).unwrap_err().code, + ErrorCode::ValidationFailed + ); + let project = models::add_project(&paths.config_file(), path.to_str().unwrap()).unwrap(); + assert_eq!( + paths.for_project(Some(project.id)).unwrap_err().code, + ErrorCode::ValidationFailed + ); + } + let home = Home::new(); + let paths = home.paths(); + let global_file = home.write(".config/opencode/opencode.json", "{}"); + let root = home.0.join("project"); + fs::create_dir(&root).unwrap(); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + let scoped = paths.for_project(Some(project.id)).unwrap(); + let config_before = fs::read(paths.config_file()).unwrap(); + symlink(&global_file, root.join("opencode.json")).unwrap(); + assert_eq!( + paths.for_project(Some(project.id)).unwrap_err().code, + ErrorCode::ValidationFailed + ); + for error in [ + agents::list(&scoped).unwrap_err(), + mcp::list(&scoped).unwrap_err(), + skills::list(&scoped).unwrap_err(), + ] { + assert_eq!(error.code, ErrorCode::ValidationFailed); + } + assert_eq!(fs::read(paths.config_file()).unwrap(), config_before); + assert_eq!(fs::read_to_string(&global_file).unwrap(), "{}"); + fs::remove_file(root.join("opencode.json")).unwrap(); + symlink(global_file.parent().unwrap(), root.join(".opencode")).unwrap(); + assert_eq!( + paths.for_project(Some(project.id)).unwrap_err().code, + ErrorCode::ValidationFailed + ); +} + +#[test] +fn project_scope_rejects_pinned_fixed_config_but_not_an_unrelated_explicit_filename() { + for (filename, overlaps) in [("opencode.jsonc", true), ("custom.jsonc", false)] { + let home = Home::new(); + let root = home.0.join("project"); + fs::create_dir(&root).unwrap(); + let explicit = root.join(filename); + let paths = ConfigPaths::with_resource_parts( + home.0.clone(), + Some(explicit), + None, + home.0.join(".config"), + home.0.join("cwd"), + ); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + let result = paths.for_project(Some(project.id)); + if overlaps { + assert_eq!(result.unwrap_err().code, ErrorCode::ValidationFailed); + } else { + assert!(result.is_ok()); + } + } +} + +#[cfg(unix)] +#[test] +fn unrelated_unreadable_global_resources_do_not_block_project_browsing() { + use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; + } + let home = Home::new(); + let global = home.0.join(".config/opencode"); + let root = home.0.join("project"); + fs::create_dir_all(&global).unwrap(); + fs::create_dir(&root).unwrap(); + let paths = home.paths(); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + fs::set_permissions(&global, fs::Permissions::from_mode(0o0)).unwrap(); + let scoped = paths.for_project(Some(project.id)); + fs::set_permissions(&global, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(scoped.is_ok()); +} + +#[test] +fn malformed_global_path_does_not_block_an_unrelated_project() { + let home = Home::new(); + home.write(".config/opencode", "not a directory"); + let root = home.0.join("project"); + fs::create_dir(&root).unwrap(); + let paths = home.paths(); + let project = models::add_project(&paths.config_file(), root.to_str().unwrap()).unwrap(); + assert!(paths.for_project(Some(project.id)).is_ok()); +} + +#[test] +fn global_skill_alias_update_keeps_existing_singular_path() { + let home = Home::new(); + let path = home.write(".config/opencode/skill/Existing.md", "original"); + let current = skills::get(&home.paths(), "Existing").unwrap(); + skills::update(&home.paths(), skill_update(¤t, "edited")).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "edited"); + assert!(!home.0.join(".config/opencode/skills/Existing.md").exists()); + assert!(!home.0.join(".config/opencode/skills/Existing").exists()); +} + #[test] fn explicit_selected_config_is_managed_but_unselected_projects_are_not_discovered() { let home = Home::new(); @@ -103,12 +763,12 @@ fn explicit_selected_config_is_managed_but_unselected_projects_are_not_discovere ); home.write( ".config/opencode/opencode.json", - r#"{"mcp":{"servers":{"shared":{"type":"local","command":["global"]}}},"skills":["relative"]}"#, + r#"{"mcp":{"servers":{"shared":{"type":"local","command":["global"]}}},"skills":["~/cwd/relative"]}"#, ); home.write("cwd/relative/Global.md", "global configured skill"); let project = home.write( "cwd/opencode.jsonc", - r#"{"mcp":{"servers":{"shared":{"type":"local","command":["project"]},"project-only":{"type":"local","command":["project"]}}},"skills":["project-skills"]}"#, + r#"{"mcp":{"servers":{"shared":{"type":"local","command":["project"]},"project-only":{"type":"local","command":["project"]}}},"skills":["~/cwd/project-skills"]}"#, ); home.write("cwd/project-skills/Project.md", "project configured skill"); let before = fs::read_to_string(&project).unwrap(); @@ -132,7 +792,7 @@ fn explicit_selected_config_is_managed_but_unselected_projects_are_not_discovere ErrorCode::NotFound ); assert_eq!(fs::read_to_string(&project).unwrap(), before); - home.write(".config/opencode/opencode.json", r#"{"mcp":{"servers":{"shared":{"type":"local","command":["global"]}}},"skills":["relative"]}"#); + home.write(".config/opencode/opencode.json", r#"{"mcp":{"servers":{"shared":{"type":"local","command":["global"]}}},"skills":["~/cwd/relative"]}"#); let selected = ConfigPaths::with_resource_parts( home.0.clone(), Some(PathBuf::from("opencode.jsonc")), @@ -332,7 +992,7 @@ fn skill_discovery_has_case_sensitive_file_ids_precedence_frontmatter_and_diagno ); home.write( ".config/opencode/opencode.jsonc", - r#"{"skills":["second","~/lowercase-source","missing"]}"#, + r#"{"skills":["~/cwd/second","~/lowercase-source","missing"]}"#, ); home.write("cwd/.opencode/skills/Project.md", "project must not appear"); let paths = home.paths(); @@ -1643,7 +2303,7 @@ fn absent_explicit_config_is_a_create_target_and_arbitrary_source_supports_round assert!(!home.0.join(".config/opencode/opencode.jsonc").exists()); let mut document = JsoncDoc::parse(&fs::read_to_string(&explicit).unwrap()).unwrap(); document - .patch(&["skills"], Some(json!(["relative"]))) + .patch(&["skills"], Some(json!(["~/cwd/relative"]))) .unwrap(); fs::write(&explicit, document.content()).unwrap(); let skill = home.write("cwd/relative/Selected.md", "original selected skill"); From f2aeeedb01bb158f7516dde86600117ebce942a0 Mon Sep 17 00:00:00 2001 From: seho Date: Thu, 22 Oct 2026 23:20:06 +0800 Subject: [PATCH 58/97] doc: update skills and agents --- .opencode/agents/doc.md | 100 ++++ .opencode/agents/test.md | 212 ++++++++ .opencode/skills/rust-skills/AGENTS.md | 1 + .opencode/skills/rust-skills/SKILL.md | 499 ++++++++++++++++++ .../rust-skills/rules/anti-clone-excessive.md | 124 +++++ .../rules/anti-collect-intermediate.md | 131 +++++ .../rust-skills/rules/anti-empty-catch.md | 132 +++++ .../rust-skills/rules/anti-expect-lazy.md | 95 ++++ .../rust-skills/rules/anti-format-hot-path.md | 144 +++++ .../rust-skills/rules/anti-index-over-iter.md | 125 +++++ .../rules/anti-lock-across-await.md | 127 +++++ .../rules/anti-over-abstraction.md | 120 +++++ .../rust-skills/rules/anti-panic-expected.md | 131 +++++ .../rules/anti-premature-optimize.md | 158 ++++++ .../rust-skills/rules/anti-string-for-str.md | 122 +++++ .../rust-skills/rules/anti-stringly-typed.md | 167 ++++++ .../rust-skills/rules/anti-type-erasure.md | 140 +++++ .../rust-skills/rules/anti-unwrap-abuse.md | 143 +++++ .../rust-skills/rules/anti-vec-for-slice.md | 121 +++++ .../rust-skills/rules/api-builder-must-use.md | 143 +++++ .../rust-skills/rules/api-builder-pattern.md | 187 +++++++ .../rust-skills/rules/api-common-traits.md | 166 ++++++ .../rust-skills/rules/api-default-impl.md | 190 +++++++ .../rust-skills/rules/api-extension-trait.md | 164 ++++++ .../rust-skills/rules/api-from-not-into.md | 148 ++++++ .../rust-skills/rules/api-impl-asref.md | 142 +++++ .../rules/api-impl-fromiterator.md | 118 +++++ .../skills/rust-skills/rules/api-impl-into.md | 160 ++++++ .../skills/rust-skills/rules/api-must-use.md | 125 +++++ .../rust-skills/rules/api-newtype-safety.md | 162 ++++++ .../rust-skills/rules/api-non-exhaustive.md | 177 +++++++ .../rules/api-operator-overload.md | 109 ++++ .../rules/api-parse-dont-validate.md | 186 +++++++ .../rust-skills/rules/api-sealed-trait.md | 168 ++++++ .../rust-skills/rules/api-serde-optional.md | 184 +++++++ .../skills/rust-skills/rules/api-typestate.md | 199 +++++++ .../rules/async-async-fn-bounds.md | 101 ++++ .../rules/async-bounded-channel.md | 175 ++++++ .../rules/async-broadcast-pubsub.md | 185 +++++++ .../rust-skills/rules/async-cancel-safety.md | 112 ++++ .../rules/async-cancellation-token.md | 203 +++++++ .../rules/async-clone-before-await.md | 171 ++++++ .../rust-skills/rules/async-fn-in-trait.md | 105 ++++ .../rust-skills/rules/async-join-parallel.md | 158 ++++++ .../rules/async-joinset-structured.md | 195 +++++++ .../rust-skills/rules/async-mpsc-queue.md | 171 ++++++ .../rust-skills/rules/async-no-lock-await.md | 153 ++++++ .../rules/async-oneshot-response.md | 191 +++++++ .../rust-skills/rules/async-select-racing.md | 198 +++++++ .../rust-skills/rules/async-spawn-blocking.md | 154 ++++++ .../rust-skills/rules/async-tokio-fs.md | 167 ++++++ .../rust-skills/rules/async-tokio-runtime.md | 175 ++++++ .../rust-skills/rules/async-try-join.md | 172 ++++++ .../rust-skills/rules/async-watch-latest.md | 189 +++++++ .../rules/closure-disjoint-capture.md | 85 +++ .../rules/closure-fn-trait-bounds.md | 89 ++++ .../rules/closure-impl-fn-return.md | 115 ++++ .../rust-skills/rules/closure-move-capture.md | 70 +++ .../rules/closure-static-vs-dyn.md | 96 ++++ .../rust-skills/rules/coll-binaryheap.md | 108 ++++ .../rust-skills/rules/coll-map-choice.md | 109 ++++ .../rust-skills/rules/coll-seq-choice.md | 85 +++ .../rust-skills/rules/coll-set-membership.md | 87 +++ .../rust-skills/rules/conc-atomic-ordering.md | 106 ++++ .../rust-skills/rules/conc-rayon-par-iter.md | 67 +++ .../rust-skills/rules/conc-scoped-threads.md | 73 +++ .../rust-skills/rules/conc-thread-local.md | 81 +++ .../skills/rust-skills/rules/const-block.md | 77 +++ .../skills/rust-skills/rules/const-fn.md | 63 +++ .../rust-skills/rules/const-generics.md | 90 ++++ .../rust-skills/rules/const-vs-static.md | 78 +++ .../rust-skills/rules/conv-asmut-mutable.md | 67 +++ .../rust-skills/rules/conv-fromstr-parsing.md | 82 +++ .../rules/conv-tryfrom-fallible.md | 80 +++ .../rust-skills/rules/doc-all-public.md | 113 ++++ .../rust-skills/rules/doc-cargo-metadata.md | 147 ++++++ .../rust-skills/rules/doc-crate-readme.md | 75 +++ .../rust-skills/rules/doc-errors-section.md | 122 +++++ .../rust-skills/rules/doc-examples-section.md | 161 ++++++ .../rust-skills/rules/doc-hidden-setup.md | 149 ++++++ .../rust-skills/rules/doc-intra-links.md | 138 +++++ .../rust-skills/rules/doc-link-types.md | 169 ++++++ .../rust-skills/rules/doc-module-inner.md | 116 ++++ .../rust-skills/rules/doc-panics-section.md | 128 +++++ .../rust-skills/rules/doc-question-mark.md | 136 +++++ .../rust-skills/rules/doc-safety-section.md | 131 +++++ .../rust-skills/rules/err-anyhow-app.md | 179 +++++++ .../rust-skills/rules/err-context-chain.md | 144 +++++ .../rust-skills/rules/err-custom-type.md | 152 ++++++ .../rust-skills/rules/err-doc-errors.md | 145 +++++ .../rust-skills/rules/err-expect-bugs-only.md | 133 +++++ .../skills/rust-skills/rules/err-from-impl.md | 153 ++++++ .../rust-skills/rules/err-lowercase-msg.md | 124 +++++ .../rust-skills/rules/err-no-unwrap-prod.md | 115 ++++ .../rust-skills/rules/err-question-mark.md | 151 ++++++ .../rules/err-result-over-panic.md | 130 +++++ .../rust-skills/rules/err-source-chain.md | 155 ++++++ .../rust-skills/rules/err-thiserror-lib.md | 171 ++++++ .../rust-skills/rules/lint-cargo-metadata.md | 138 +++++ .../rust-skills/rules/lint-cfg-check.md | 69 +++ .../rules/lint-clippy-nursery-selected.md | 75 +++ .../rules/lint-deny-correctness.md | 107 ++++ .../rust-skills/rules/lint-missing-docs.md | 154 ++++++ .../rules/lint-pedantic-selective.md | 118 +++++ .../rust-skills/rules/lint-rustfmt-check.md | 157 ++++++ .../rust-skills/rules/lint-unsafe-doc.md | 133 +++++ .../rust-skills/rules/lint-warn-complexity.md | 131 +++++ .../rust-skills/rules/lint-warn-perf.md | 136 +++++ .../rust-skills/rules/lint-warn-style.md | 135 +++++ .../rust-skills/rules/lint-warn-suspicious.md | 122 +++++ .../rust-skills/rules/lint-workspace-lints.md | 172 ++++++ .../rules/macro-export-crate-path.md | 87 +++ .../rules/macro-fragment-specifiers.md | 81 +++ .../rules/macro-prefer-functions.md | 61 +++ .../rules/macro-private-helpers.md | 98 ++++ .../rules/macro-proc-error-spans.md | 118 +++++ .../rust-skills/rules/macro-proc-syn-quote.md | 110 ++++ .../rust-skills/rules/macro-proc-two-crate.md | 143 +++++ .../rust-skills/rules/macro-rules-hygiene.md | 98 ++++ .../rust-skills/rules/mem-arena-allocator.md | 171 ++++++ .../skills/rust-skills/rules/mem-arrayvec.md | 143 +++++ .../rust-skills/rules/mem-assert-type-size.md | 168 ++++++ .../rust-skills/rules/mem-avoid-format.md | 147 ++++++ .../rules/mem-box-large-variant.md | 158 ++++++ .../rust-skills/rules/mem-boxed-slice.md | 139 +++++ .../rust-skills/rules/mem-clone-from.md | 147 ++++++ .../rust-skills/rules/mem-compact-string.md | 151 ++++++ .../rust-skills/rules/mem-drop-order.md | 128 +++++ .../rules/mem-reuse-collections.md | 174 ++++++ .../rust-skills/rules/mem-smaller-integers.md | 161 ++++++ .../skills/rust-skills/rules/mem-smallvec.md | 138 +++++ .../rust-skills/rules/mem-take-replace.md | 121 +++++ .../skills/rust-skills/rules/mem-thinvec.md | 142 +++++ .../rust-skills/rules/mem-with-capacity.md | 157 ++++++ .../rules/mem-write-over-format.md | 179 +++++++ .../skills/rust-skills/rules/mem-zero-copy.md | 166 ++++++ .../rust-skills/rules/name-acronym-word.md | 99 ++++ .../skills/rust-skills/rules/name-as-free.md | 104 ++++ .../rules/name-consts-screaming.md | 94 ++++ .../rust-skills/rules/name-crate-no-rs.md | 78 +++ .../rust-skills/rules/name-funcs-snake.md | 76 +++ .../rust-skills/rules/name-into-ownership.md | 123 +++++ .../rust-skills/rules/name-is-has-bool.md | 127 +++++ .../rust-skills/rules/name-iter-convention.md | 129 +++++ .../rust-skills/rules/name-iter-method.md | 131 +++++ .../rust-skills/rules/name-iter-type-match.md | 142 +++++ .../rust-skills/rules/name-lifetime-short.md | 86 +++ .../rust-skills/rules/name-no-get-prefix.md | 154 ++++++ .../rust-skills/rules/name-to-expensive.md | 118 +++++ .../rules/name-type-param-single.md | 92 ++++ .../rust-skills/rules/name-types-camel.md | 65 +++ .../rust-skills/rules/name-variants-camel.md | 101 ++++ .../rust-skills/rules/num-cast-try-from.md | 95 ++++ .../rust-skills/rules/num-float-compare.md | 91 ++++ .../skills/rust-skills/rules/num-nonzero.md | 94 ++++ .../rules/num-overflow-explicit.md | 87 +++ .../rust-skills/rules/num-saturating-clamp.md | 91 ++++ .../rust-skills/rules/obs-error-chain.md | 92 ++++ .../rust-skills/rules/obs-instrument-spans.md | 81 +++ .../rust-skills/rules/obs-levels-filter.md | 83 +++ .../rust-skills/rules/obs-library-facade.md | 73 +++ .../rules/obs-no-sensitive-data.md | 87 +++ .../rules/obs-structured-fields.md | 71 +++ .../rust-skills/rules/obs-tracing-over-log.md | 55 ++ .../rust-skills/rules/opt-bounds-check.md | 163 ++++++ .../rust-skills/rules/opt-cache-friendly.md | 187 +++++++ .../rust-skills/rules/opt-codegen-units.md | 142 +++++ .../rust-skills/rules/opt-cold-unlikely.md | 152 ++++++ .../rules/opt-inline-always-rare.md | 141 +++++ .../rules/opt-inline-never-cold.md | 182 +++++++ .../rust-skills/rules/opt-inline-small.md | 164 ++++++ .../rust-skills/rules/opt-likely-hint.md | 187 +++++++ .../rust-skills/rules/opt-lto-release.md | 135 +++++ .../rust-skills/rules/opt-pgo-profile.md | 167 ++++++ .../rust-skills/rules/opt-simd-portable.md | 143 +++++ .../rust-skills/rules/opt-target-cpu.md | 159 ++++++ .../rust-skills/rules/own-arc-shared.md | 143 +++++ .../rules/own-borrow-over-clone.md | 96 ++++ .../rust-skills/rules/own-clone-explicit.md | 135 +++++ .../rust-skills/rules/own-copy-small.md | 124 +++++ .../rust-skills/rules/own-cow-conditional.md | 135 +++++ .../rust-skills/rules/own-lifetime-elision.md | 134 +++++ .../rust-skills/rules/own-move-large.md | 134 +++++ .../rust-skills/rules/own-mutex-interior.md | 106 ++++ .../rust-skills/rules/own-rc-single-thread.md | 97 ++++ .../rust-skills/rules/own-refcell-interior.md | 116 ++++ .../rust-skills/rules/own-rwlock-readers.md | 122 +++++ .../rust-skills/rules/own-slice-over-vec.md | 119 +++++ .../rust-skills/rules/pat-at-bindings.md | 99 ++++ .../rust-skills/rules/pat-exhaustive-enum.md | 89 ++++ .../rust-skills/rules/pat-if-let-chains.md | 81 +++ .../skills/rust-skills/rules/pat-let-else.md | 69 +++ .../rust-skills/rules/pat-matches-macro.md | 107 ++++ .../skills/rust-skills/rules/perf-ahash.md | 79 +++ .../rust-skills/rules/perf-black-box-bench.md | 153 ++++++ .../rust-skills/rules/perf-chain-avoid.md | 136 +++++ .../rust-skills/rules/perf-collect-into.md | 142 +++++ .../rust-skills/rules/perf-collect-once.md | 120 +++++ .../rust-skills/rules/perf-drain-reuse.md | 137 +++++ .../rust-skills/rules/perf-entry-api.md | 135 +++++ .../rust-skills/rules/perf-extend-batch.md | 150 ++++++ .../rust-skills/rules/perf-io-buffering.md | 98 ++++ .../rust-skills/rules/perf-iter-lazy.md | 123 +++++ .../rust-skills/rules/perf-iter-over-index.md | 114 ++++ .../rust-skills/rules/perf-profile-first.md | 175 ++++++ .../rust-skills/rules/perf-release-profile.md | 149 ++++++ .../skills/rust-skills/rules/proj-bin-dir.md | 142 +++++ .../rules/proj-build-rs-minimal.md | 70 +++ .../rules/proj-feature-additive.md | 70 +++ .../rust-skills/rules/proj-flat-small.md | 133 +++++ .../rust-skills/rules/proj-lib-main-split.md | 148 ++++++ .../rust-skills/rules/proj-mod-by-feature.md | 130 +++++ .../rust-skills/rules/proj-mod-rs-dir.md | 120 +++++ .../rust-skills/rules/proj-msrv-declare.md | 57 ++ .../rust-skills/rules/proj-prelude-module.md | 155 ++++++ .../rules/proj-pub-crate-internal.md | 139 +++++ .../rules/proj-pub-super-parent.md | 135 +++++ .../rules/proj-pub-use-reexport.md | 162 ++++++ .../rust-skills/rules/proj-workspace-deps.md | 186 +++++++ .../rust-skills/rules/proj-workspace-large.md | 162 ++++++ .../rust-skills/rules/serde-custom-with.md | 80 +++ .../rust-skills/rules/serde-default-compat.md | 71 +++ .../rules/serde-deny-unknown-fields.md | 74 +++ .../rules/serde-enum-representation.md | 85 +++ .../skills/rust-skills/rules/serde-flatten.md | 86 +++ .../rust-skills/rules/serde-rename-all.md | 72 +++ .../rust-skills/rules/serde-skip-empty.md | 76 +++ .../rules/serde-try-from-validate.md | 114 ++++ .../rules/test-arrange-act-assert.md | 160 ++++++ .../rust-skills/rules/test-cfg-test-module.md | 151 ++++++ .../rust-skills/rules/test-criterion-bench.md | 171 ++++++ .../rules/test-descriptive-names.md | 142 +++++ .../rules/test-doctest-examples.md | 168 ++++++ .../rust-skills/rules/test-fixture-raii.md | 154 ++++++ .../rust-skills/rules/test-integration-dir.md | 144 +++++ .../rules/test-loom-concurrency.md | 102 ++++ .../rust-skills/rules/test-mock-traits.md | 189 +++++++ .../rust-skills/rules/test-mockall-mocking.md | 226 ++++++++ .../rules/test-proptest-properties.md | 161 ++++++ .../rust-skills/rules/test-should-panic.md | 130 +++++ .../rules/test-snapshot-testing.md | 91 ++++ .../rust-skills/rules/test-tokio-async.md | 154 ++++++ .../rust-skills/rules/test-use-super.md | 127 +++++ .../rules/trait-associated-type-vs-generic.md | 111 ++++ .../rust-skills/rules/trait-blanket-impl.md | 80 +++ .../rules/trait-coherence-newtype.md | 85 +++ .../rules/trait-default-methods.md | 117 ++++ .../rust-skills/rules/trait-dyn-vs-generic.md | 99 ++++ .../rust-skills/rules/trait-object-safety.md | 109 ++++ .../rust-skills/rules/type-deref-coercion.md | 91 ++++ .../rules/type-display-vs-debug.md | 79 +++ .../rust-skills/rules/type-enum-states.md | 156 ++++++ .../rust-skills/rules/type-generic-bounds.md | 144 +++++ .../rust-skills/rules/type-never-diverge.md | 153 ++++++ .../rust-skills/rules/type-newtype-ids.md | 161 ++++++ .../rules/type-newtype-validated.md | 161 ++++++ .../rust-skills/rules/type-no-stringly.md | 144 +++++ .../rust-skills/rules/type-numeric-fmt.md | 87 +++ .../rust-skills/rules/type-option-nullable.md | 137 +++++ .../rust-skills/rules/type-phantom-marker.md | 188 +++++++ .../rules/type-repr-transparent.md | 143 +++++ .../rust-skills/rules/type-result-fallible.md | 131 +++++ .../rust-skills/rules/unsafe-extern-block.md | 72 +++ .../rust-skills/rules/unsafe-maybeuninit.md | 71 +++ .../rules/unsafe-minimize-scope.md | 78 +++ .../rust-skills/rules/unsafe-miri-ci.md | 65 +++ .../rules/unsafe-no-mangle-unsafe.md | 65 +++ .../rules/unsafe-safety-comment.md | 72 +++ .../rules/unsafe-send-sync-manual.md | 99 ++++ 269 files changed, 34466 insertions(+) create mode 100644 .opencode/agents/doc.md create mode 100644 .opencode/agents/test.md create mode 120000 .opencode/skills/rust-skills/AGENTS.md create mode 100644 .opencode/skills/rust-skills/SKILL.md create mode 100644 .opencode/skills/rust-skills/rules/anti-clone-excessive.md create mode 100644 .opencode/skills/rust-skills/rules/anti-collect-intermediate.md create mode 100644 .opencode/skills/rust-skills/rules/anti-empty-catch.md create mode 100644 .opencode/skills/rust-skills/rules/anti-expect-lazy.md create mode 100644 .opencode/skills/rust-skills/rules/anti-format-hot-path.md create mode 100644 .opencode/skills/rust-skills/rules/anti-index-over-iter.md create mode 100644 .opencode/skills/rust-skills/rules/anti-lock-across-await.md create mode 100644 .opencode/skills/rust-skills/rules/anti-over-abstraction.md create mode 100644 .opencode/skills/rust-skills/rules/anti-panic-expected.md create mode 100644 .opencode/skills/rust-skills/rules/anti-premature-optimize.md create mode 100644 .opencode/skills/rust-skills/rules/anti-string-for-str.md create mode 100644 .opencode/skills/rust-skills/rules/anti-stringly-typed.md create mode 100644 .opencode/skills/rust-skills/rules/anti-type-erasure.md create mode 100644 .opencode/skills/rust-skills/rules/anti-unwrap-abuse.md create mode 100644 .opencode/skills/rust-skills/rules/anti-vec-for-slice.md create mode 100644 .opencode/skills/rust-skills/rules/api-builder-must-use.md create mode 100644 .opencode/skills/rust-skills/rules/api-builder-pattern.md create mode 100644 .opencode/skills/rust-skills/rules/api-common-traits.md create mode 100644 .opencode/skills/rust-skills/rules/api-default-impl.md create mode 100644 .opencode/skills/rust-skills/rules/api-extension-trait.md create mode 100644 .opencode/skills/rust-skills/rules/api-from-not-into.md create mode 100644 .opencode/skills/rust-skills/rules/api-impl-asref.md create mode 100644 .opencode/skills/rust-skills/rules/api-impl-fromiterator.md create mode 100644 .opencode/skills/rust-skills/rules/api-impl-into.md create mode 100644 .opencode/skills/rust-skills/rules/api-must-use.md create mode 100644 .opencode/skills/rust-skills/rules/api-newtype-safety.md create mode 100644 .opencode/skills/rust-skills/rules/api-non-exhaustive.md create mode 100644 .opencode/skills/rust-skills/rules/api-operator-overload.md create mode 100644 .opencode/skills/rust-skills/rules/api-parse-dont-validate.md create mode 100644 .opencode/skills/rust-skills/rules/api-sealed-trait.md create mode 100644 .opencode/skills/rust-skills/rules/api-serde-optional.md create mode 100644 .opencode/skills/rust-skills/rules/api-typestate.md create mode 100644 .opencode/skills/rust-skills/rules/async-async-fn-bounds.md create mode 100644 .opencode/skills/rust-skills/rules/async-bounded-channel.md create mode 100644 .opencode/skills/rust-skills/rules/async-broadcast-pubsub.md create mode 100644 .opencode/skills/rust-skills/rules/async-cancel-safety.md create mode 100644 .opencode/skills/rust-skills/rules/async-cancellation-token.md create mode 100644 .opencode/skills/rust-skills/rules/async-clone-before-await.md create mode 100644 .opencode/skills/rust-skills/rules/async-fn-in-trait.md create mode 100644 .opencode/skills/rust-skills/rules/async-join-parallel.md create mode 100644 .opencode/skills/rust-skills/rules/async-joinset-structured.md create mode 100644 .opencode/skills/rust-skills/rules/async-mpsc-queue.md create mode 100644 .opencode/skills/rust-skills/rules/async-no-lock-await.md create mode 100644 .opencode/skills/rust-skills/rules/async-oneshot-response.md create mode 100644 .opencode/skills/rust-skills/rules/async-select-racing.md create mode 100644 .opencode/skills/rust-skills/rules/async-spawn-blocking.md create mode 100644 .opencode/skills/rust-skills/rules/async-tokio-fs.md create mode 100644 .opencode/skills/rust-skills/rules/async-tokio-runtime.md create mode 100644 .opencode/skills/rust-skills/rules/async-try-join.md create mode 100644 .opencode/skills/rust-skills/rules/async-watch-latest.md create mode 100644 .opencode/skills/rust-skills/rules/closure-disjoint-capture.md create mode 100644 .opencode/skills/rust-skills/rules/closure-fn-trait-bounds.md create mode 100644 .opencode/skills/rust-skills/rules/closure-impl-fn-return.md create mode 100644 .opencode/skills/rust-skills/rules/closure-move-capture.md create mode 100644 .opencode/skills/rust-skills/rules/closure-static-vs-dyn.md create mode 100644 .opencode/skills/rust-skills/rules/coll-binaryheap.md create mode 100644 .opencode/skills/rust-skills/rules/coll-map-choice.md create mode 100644 .opencode/skills/rust-skills/rules/coll-seq-choice.md create mode 100644 .opencode/skills/rust-skills/rules/coll-set-membership.md create mode 100644 .opencode/skills/rust-skills/rules/conc-atomic-ordering.md create mode 100644 .opencode/skills/rust-skills/rules/conc-rayon-par-iter.md create mode 100644 .opencode/skills/rust-skills/rules/conc-scoped-threads.md create mode 100644 .opencode/skills/rust-skills/rules/conc-thread-local.md create mode 100644 .opencode/skills/rust-skills/rules/const-block.md create mode 100644 .opencode/skills/rust-skills/rules/const-fn.md create mode 100644 .opencode/skills/rust-skills/rules/const-generics.md create mode 100644 .opencode/skills/rust-skills/rules/const-vs-static.md create mode 100644 .opencode/skills/rust-skills/rules/conv-asmut-mutable.md create mode 100644 .opencode/skills/rust-skills/rules/conv-fromstr-parsing.md create mode 100644 .opencode/skills/rust-skills/rules/conv-tryfrom-fallible.md create mode 100644 .opencode/skills/rust-skills/rules/doc-all-public.md create mode 100644 .opencode/skills/rust-skills/rules/doc-cargo-metadata.md create mode 100644 .opencode/skills/rust-skills/rules/doc-crate-readme.md create mode 100644 .opencode/skills/rust-skills/rules/doc-errors-section.md create mode 100644 .opencode/skills/rust-skills/rules/doc-examples-section.md create mode 100644 .opencode/skills/rust-skills/rules/doc-hidden-setup.md create mode 100644 .opencode/skills/rust-skills/rules/doc-intra-links.md create mode 100644 .opencode/skills/rust-skills/rules/doc-link-types.md create mode 100644 .opencode/skills/rust-skills/rules/doc-module-inner.md create mode 100644 .opencode/skills/rust-skills/rules/doc-panics-section.md create mode 100644 .opencode/skills/rust-skills/rules/doc-question-mark.md create mode 100644 .opencode/skills/rust-skills/rules/doc-safety-section.md create mode 100644 .opencode/skills/rust-skills/rules/err-anyhow-app.md create mode 100644 .opencode/skills/rust-skills/rules/err-context-chain.md create mode 100644 .opencode/skills/rust-skills/rules/err-custom-type.md create mode 100644 .opencode/skills/rust-skills/rules/err-doc-errors.md create mode 100644 .opencode/skills/rust-skills/rules/err-expect-bugs-only.md create mode 100644 .opencode/skills/rust-skills/rules/err-from-impl.md create mode 100644 .opencode/skills/rust-skills/rules/err-lowercase-msg.md create mode 100644 .opencode/skills/rust-skills/rules/err-no-unwrap-prod.md create mode 100644 .opencode/skills/rust-skills/rules/err-question-mark.md create mode 100644 .opencode/skills/rust-skills/rules/err-result-over-panic.md create mode 100644 .opencode/skills/rust-skills/rules/err-source-chain.md create mode 100644 .opencode/skills/rust-skills/rules/err-thiserror-lib.md create mode 100644 .opencode/skills/rust-skills/rules/lint-cargo-metadata.md create mode 100644 .opencode/skills/rust-skills/rules/lint-cfg-check.md create mode 100644 .opencode/skills/rust-skills/rules/lint-clippy-nursery-selected.md create mode 100644 .opencode/skills/rust-skills/rules/lint-deny-correctness.md create mode 100644 .opencode/skills/rust-skills/rules/lint-missing-docs.md create mode 100644 .opencode/skills/rust-skills/rules/lint-pedantic-selective.md create mode 100644 .opencode/skills/rust-skills/rules/lint-rustfmt-check.md create mode 100644 .opencode/skills/rust-skills/rules/lint-unsafe-doc.md create mode 100644 .opencode/skills/rust-skills/rules/lint-warn-complexity.md create mode 100644 .opencode/skills/rust-skills/rules/lint-warn-perf.md create mode 100644 .opencode/skills/rust-skills/rules/lint-warn-style.md create mode 100644 .opencode/skills/rust-skills/rules/lint-warn-suspicious.md create mode 100644 .opencode/skills/rust-skills/rules/lint-workspace-lints.md create mode 100644 .opencode/skills/rust-skills/rules/macro-export-crate-path.md create mode 100644 .opencode/skills/rust-skills/rules/macro-fragment-specifiers.md create mode 100644 .opencode/skills/rust-skills/rules/macro-prefer-functions.md create mode 100644 .opencode/skills/rust-skills/rules/macro-private-helpers.md create mode 100644 .opencode/skills/rust-skills/rules/macro-proc-error-spans.md create mode 100644 .opencode/skills/rust-skills/rules/macro-proc-syn-quote.md create mode 100644 .opencode/skills/rust-skills/rules/macro-proc-two-crate.md create mode 100644 .opencode/skills/rust-skills/rules/macro-rules-hygiene.md create mode 100644 .opencode/skills/rust-skills/rules/mem-arena-allocator.md create mode 100644 .opencode/skills/rust-skills/rules/mem-arrayvec.md create mode 100644 .opencode/skills/rust-skills/rules/mem-assert-type-size.md create mode 100644 .opencode/skills/rust-skills/rules/mem-avoid-format.md create mode 100644 .opencode/skills/rust-skills/rules/mem-box-large-variant.md create mode 100644 .opencode/skills/rust-skills/rules/mem-boxed-slice.md create mode 100644 .opencode/skills/rust-skills/rules/mem-clone-from.md create mode 100644 .opencode/skills/rust-skills/rules/mem-compact-string.md create mode 100644 .opencode/skills/rust-skills/rules/mem-drop-order.md create mode 100644 .opencode/skills/rust-skills/rules/mem-reuse-collections.md create mode 100644 .opencode/skills/rust-skills/rules/mem-smaller-integers.md create mode 100644 .opencode/skills/rust-skills/rules/mem-smallvec.md create mode 100644 .opencode/skills/rust-skills/rules/mem-take-replace.md create mode 100644 .opencode/skills/rust-skills/rules/mem-thinvec.md create mode 100644 .opencode/skills/rust-skills/rules/mem-with-capacity.md create mode 100644 .opencode/skills/rust-skills/rules/mem-write-over-format.md create mode 100644 .opencode/skills/rust-skills/rules/mem-zero-copy.md create mode 100644 .opencode/skills/rust-skills/rules/name-acronym-word.md create mode 100644 .opencode/skills/rust-skills/rules/name-as-free.md create mode 100644 .opencode/skills/rust-skills/rules/name-consts-screaming.md create mode 100644 .opencode/skills/rust-skills/rules/name-crate-no-rs.md create mode 100644 .opencode/skills/rust-skills/rules/name-funcs-snake.md create mode 100644 .opencode/skills/rust-skills/rules/name-into-ownership.md create mode 100644 .opencode/skills/rust-skills/rules/name-is-has-bool.md create mode 100644 .opencode/skills/rust-skills/rules/name-iter-convention.md create mode 100644 .opencode/skills/rust-skills/rules/name-iter-method.md create mode 100644 .opencode/skills/rust-skills/rules/name-iter-type-match.md create mode 100644 .opencode/skills/rust-skills/rules/name-lifetime-short.md create mode 100644 .opencode/skills/rust-skills/rules/name-no-get-prefix.md create mode 100644 .opencode/skills/rust-skills/rules/name-to-expensive.md create mode 100644 .opencode/skills/rust-skills/rules/name-type-param-single.md create mode 100644 .opencode/skills/rust-skills/rules/name-types-camel.md create mode 100644 .opencode/skills/rust-skills/rules/name-variants-camel.md create mode 100644 .opencode/skills/rust-skills/rules/num-cast-try-from.md create mode 100644 .opencode/skills/rust-skills/rules/num-float-compare.md create mode 100644 .opencode/skills/rust-skills/rules/num-nonzero.md create mode 100644 .opencode/skills/rust-skills/rules/num-overflow-explicit.md create mode 100644 .opencode/skills/rust-skills/rules/num-saturating-clamp.md create mode 100644 .opencode/skills/rust-skills/rules/obs-error-chain.md create mode 100644 .opencode/skills/rust-skills/rules/obs-instrument-spans.md create mode 100644 .opencode/skills/rust-skills/rules/obs-levels-filter.md create mode 100644 .opencode/skills/rust-skills/rules/obs-library-facade.md create mode 100644 .opencode/skills/rust-skills/rules/obs-no-sensitive-data.md create mode 100644 .opencode/skills/rust-skills/rules/obs-structured-fields.md create mode 100644 .opencode/skills/rust-skills/rules/obs-tracing-over-log.md create mode 100644 .opencode/skills/rust-skills/rules/opt-bounds-check.md create mode 100644 .opencode/skills/rust-skills/rules/opt-cache-friendly.md create mode 100644 .opencode/skills/rust-skills/rules/opt-codegen-units.md create mode 100644 .opencode/skills/rust-skills/rules/opt-cold-unlikely.md create mode 100644 .opencode/skills/rust-skills/rules/opt-inline-always-rare.md create mode 100644 .opencode/skills/rust-skills/rules/opt-inline-never-cold.md create mode 100644 .opencode/skills/rust-skills/rules/opt-inline-small.md create mode 100644 .opencode/skills/rust-skills/rules/opt-likely-hint.md create mode 100644 .opencode/skills/rust-skills/rules/opt-lto-release.md create mode 100644 .opencode/skills/rust-skills/rules/opt-pgo-profile.md create mode 100644 .opencode/skills/rust-skills/rules/opt-simd-portable.md create mode 100644 .opencode/skills/rust-skills/rules/opt-target-cpu.md create mode 100644 .opencode/skills/rust-skills/rules/own-arc-shared.md create mode 100644 .opencode/skills/rust-skills/rules/own-borrow-over-clone.md create mode 100644 .opencode/skills/rust-skills/rules/own-clone-explicit.md create mode 100644 .opencode/skills/rust-skills/rules/own-copy-small.md create mode 100644 .opencode/skills/rust-skills/rules/own-cow-conditional.md create mode 100644 .opencode/skills/rust-skills/rules/own-lifetime-elision.md create mode 100644 .opencode/skills/rust-skills/rules/own-move-large.md create mode 100644 .opencode/skills/rust-skills/rules/own-mutex-interior.md create mode 100644 .opencode/skills/rust-skills/rules/own-rc-single-thread.md create mode 100644 .opencode/skills/rust-skills/rules/own-refcell-interior.md create mode 100644 .opencode/skills/rust-skills/rules/own-rwlock-readers.md create mode 100644 .opencode/skills/rust-skills/rules/own-slice-over-vec.md create mode 100644 .opencode/skills/rust-skills/rules/pat-at-bindings.md create mode 100644 .opencode/skills/rust-skills/rules/pat-exhaustive-enum.md create mode 100644 .opencode/skills/rust-skills/rules/pat-if-let-chains.md create mode 100644 .opencode/skills/rust-skills/rules/pat-let-else.md create mode 100644 .opencode/skills/rust-skills/rules/pat-matches-macro.md create mode 100644 .opencode/skills/rust-skills/rules/perf-ahash.md create mode 100644 .opencode/skills/rust-skills/rules/perf-black-box-bench.md create mode 100644 .opencode/skills/rust-skills/rules/perf-chain-avoid.md create mode 100644 .opencode/skills/rust-skills/rules/perf-collect-into.md create mode 100644 .opencode/skills/rust-skills/rules/perf-collect-once.md create mode 100644 .opencode/skills/rust-skills/rules/perf-drain-reuse.md create mode 100644 .opencode/skills/rust-skills/rules/perf-entry-api.md create mode 100644 .opencode/skills/rust-skills/rules/perf-extend-batch.md create mode 100644 .opencode/skills/rust-skills/rules/perf-io-buffering.md create mode 100644 .opencode/skills/rust-skills/rules/perf-iter-lazy.md create mode 100644 .opencode/skills/rust-skills/rules/perf-iter-over-index.md create mode 100644 .opencode/skills/rust-skills/rules/perf-profile-first.md create mode 100644 .opencode/skills/rust-skills/rules/perf-release-profile.md create mode 100644 .opencode/skills/rust-skills/rules/proj-bin-dir.md create mode 100644 .opencode/skills/rust-skills/rules/proj-build-rs-minimal.md create mode 100644 .opencode/skills/rust-skills/rules/proj-feature-additive.md create mode 100644 .opencode/skills/rust-skills/rules/proj-flat-small.md create mode 100644 .opencode/skills/rust-skills/rules/proj-lib-main-split.md create mode 100644 .opencode/skills/rust-skills/rules/proj-mod-by-feature.md create mode 100644 .opencode/skills/rust-skills/rules/proj-mod-rs-dir.md create mode 100644 .opencode/skills/rust-skills/rules/proj-msrv-declare.md create mode 100644 .opencode/skills/rust-skills/rules/proj-prelude-module.md create mode 100644 .opencode/skills/rust-skills/rules/proj-pub-crate-internal.md create mode 100644 .opencode/skills/rust-skills/rules/proj-pub-super-parent.md create mode 100644 .opencode/skills/rust-skills/rules/proj-pub-use-reexport.md create mode 100644 .opencode/skills/rust-skills/rules/proj-workspace-deps.md create mode 100644 .opencode/skills/rust-skills/rules/proj-workspace-large.md create mode 100644 .opencode/skills/rust-skills/rules/serde-custom-with.md create mode 100644 .opencode/skills/rust-skills/rules/serde-default-compat.md create mode 100644 .opencode/skills/rust-skills/rules/serde-deny-unknown-fields.md create mode 100644 .opencode/skills/rust-skills/rules/serde-enum-representation.md create mode 100644 .opencode/skills/rust-skills/rules/serde-flatten.md create mode 100644 .opencode/skills/rust-skills/rules/serde-rename-all.md create mode 100644 .opencode/skills/rust-skills/rules/serde-skip-empty.md create mode 100644 .opencode/skills/rust-skills/rules/serde-try-from-validate.md create mode 100644 .opencode/skills/rust-skills/rules/test-arrange-act-assert.md create mode 100644 .opencode/skills/rust-skills/rules/test-cfg-test-module.md create mode 100644 .opencode/skills/rust-skills/rules/test-criterion-bench.md create mode 100644 .opencode/skills/rust-skills/rules/test-descriptive-names.md create mode 100644 .opencode/skills/rust-skills/rules/test-doctest-examples.md create mode 100644 .opencode/skills/rust-skills/rules/test-fixture-raii.md create mode 100644 .opencode/skills/rust-skills/rules/test-integration-dir.md create mode 100644 .opencode/skills/rust-skills/rules/test-loom-concurrency.md create mode 100644 .opencode/skills/rust-skills/rules/test-mock-traits.md create mode 100644 .opencode/skills/rust-skills/rules/test-mockall-mocking.md create mode 100644 .opencode/skills/rust-skills/rules/test-proptest-properties.md create mode 100644 .opencode/skills/rust-skills/rules/test-should-panic.md create mode 100644 .opencode/skills/rust-skills/rules/test-snapshot-testing.md create mode 100644 .opencode/skills/rust-skills/rules/test-tokio-async.md create mode 100644 .opencode/skills/rust-skills/rules/test-use-super.md create mode 100644 .opencode/skills/rust-skills/rules/trait-associated-type-vs-generic.md create mode 100644 .opencode/skills/rust-skills/rules/trait-blanket-impl.md create mode 100644 .opencode/skills/rust-skills/rules/trait-coherence-newtype.md create mode 100644 .opencode/skills/rust-skills/rules/trait-default-methods.md create mode 100644 .opencode/skills/rust-skills/rules/trait-dyn-vs-generic.md create mode 100644 .opencode/skills/rust-skills/rules/trait-object-safety.md create mode 100644 .opencode/skills/rust-skills/rules/type-deref-coercion.md create mode 100644 .opencode/skills/rust-skills/rules/type-display-vs-debug.md create mode 100644 .opencode/skills/rust-skills/rules/type-enum-states.md create mode 100644 .opencode/skills/rust-skills/rules/type-generic-bounds.md create mode 100644 .opencode/skills/rust-skills/rules/type-never-diverge.md create mode 100644 .opencode/skills/rust-skills/rules/type-newtype-ids.md create mode 100644 .opencode/skills/rust-skills/rules/type-newtype-validated.md create mode 100644 .opencode/skills/rust-skills/rules/type-no-stringly.md create mode 100644 .opencode/skills/rust-skills/rules/type-numeric-fmt.md create mode 100644 .opencode/skills/rust-skills/rules/type-option-nullable.md create mode 100644 .opencode/skills/rust-skills/rules/type-phantom-marker.md create mode 100644 .opencode/skills/rust-skills/rules/type-repr-transparent.md create mode 100644 .opencode/skills/rust-skills/rules/type-result-fallible.md create mode 100644 .opencode/skills/rust-skills/rules/unsafe-extern-block.md create mode 100644 .opencode/skills/rust-skills/rules/unsafe-maybeuninit.md create mode 100644 .opencode/skills/rust-skills/rules/unsafe-minimize-scope.md create mode 100644 .opencode/skills/rust-skills/rules/unsafe-miri-ci.md create mode 100644 .opencode/skills/rust-skills/rules/unsafe-no-mangle-unsafe.md create mode 100644 .opencode/skills/rust-skills/rules/unsafe-safety-comment.md create mode 100644 .opencode/skills/rust-skills/rules/unsafe-send-sync-manual.md diff --git a/.opencode/agents/doc.md b/.opencode/agents/doc.md new file mode 100644 index 0000000..6d6deaf --- /dev/null +++ b/.opencode/agents/doc.md @@ -0,0 +1,100 @@ +--- +description: Documentation specialist for technical writing. Handles user feedback reports, review feedback reports, and general technical docs. Use when writing documentation, authoring markdown, generating reports, or updating module knowledge bases. +mode: subagent +model: main/mini +temperature: 0.2 +tools: + write: true + edit: true + read: true +--- + +## Scope + +**In scope:** + +- User feedback reports +- Review feedback reports +- Technical documentation +- Markdown files with structured content + +**Out of scope:** + +- AGENTS.md (managed by gen-agents-md command) +- Code comments (inline documentation) +- API documentation (auto-generated) + +--- + +## General Documentation Rules + +All document types must follow: [General Docs Rules](../prompts/agents/doc/references/general-docs-rules.md) + +Key points: + +- English language +- Max 3 heading levels +- Code blocks must specify language +- Tables use `:---` alignment +- No fluff writing + +--- + +## Document Types + +### 1. Feedback Reports + +User feedback reports documenting issues, suggestions, and improvements. Used for post-release feedback summaries and requirement change records. + +**Triggers:** + +- Collecting user feedback +- Post-release feedback summary +- Requirement change documentation + +**Rules:** [Feedback Report Rules](../prompts/agents/doc/references/feedback-report-rules.md) +**Template:** [Feedback Report Template](../prompts/agents/doc/templates/feedback-report-template.md) + +--- + +### 2. Review Reports + +Code/design review feedback reports. Used for Code Review output, design review documentation, and technical proposal review records. + +**Triggers:** + +- Post-Code Review output needed +- Design review documentation +- Technical proposal review records + +**Rules:** [Review Report Rules](../prompts/agents/doc/references/review-report-rules.md) +**Template:** [Review Report Template](../prompts/agents/doc/templates/review-report-template.md) + +--- + +### 3. General Documentation + +Other technical docs: module usage guides, architecture design docs, deployment/configuration guides. + +**Triggers:** + +- Module usage documentation +- Architecture design docs +- Deployment/configuration guides + +**Rules:** [General Docs Rules](../prompts/agents/doc/references/general-docs-rules.md) + +--- + +## Detailed Resources + +### References + +- [General Docs Rules](../prompts/agents/doc/references/general-docs-rules.md) - Rules for all document types +- [Feedback Report Rules](../prompts/agents/doc/references/feedback-report-rules.md) - Feedback report rules +- [Review Report Rules](../prompts/agents/doc/references/review-report-rules.md) - Review report rules + +### Templates + +- [Feedback Report Template](../prompts/agents/doc/templates/feedback-report-template.md) +- [Review Report Template](../prompts/agents/doc/templates/review-report-template.md) diff --git a/.opencode/agents/test.md b/.opencode/agents/test.md new file mode 100644 index 0000000..a68de6e --- /dev/null +++ b/.opencode/agents/test.md @@ -0,0 +1,212 @@ +--- +description: Define project-wide frontend testing methodology for planning, writing, refactoring, and reviewing component, logic, util, and integration tests. Use when adding new frontend tests, reducing brittle assertions, splitting oversized specs, deciding mock boundaries, designing reusable test helpers, or reviewing test quality in product code. +mode: subagent +model: main/mini +temperature: 0.1 +tools: + write: true + edit: true + bash: true +--- + +## Scope + +Use this agent for frontend test design and refactoring across the project. + +**In scope:** + +- component tests +- logic tests +- util tests +- integration-style UI/container tests +- test review and refactor planning + +**Out of scope:** + +- E2E strategy and browser flows +- test runner installation or project bootstrap +- business-module knowledge unrelated to testing decisions + +## Shared Utilities Discovery (MANDATORY) + +Before writing any test helper, mock, or render utility locally, check the shared test utility directory for existing equivalents. + +### Discovery order + +1. **Check the shared test directory first (commonly `src/test/`)** — always search here before implementing anything new +2. **Reuse if available** — if the utility exists there, import and use it +3. **If not found** — evaluate whether it is truly cross-module reusable or domain-private: + - **Cross-module** (used by 2+ modules or far-apart subdirectories) → add to the shared test directory + - **Domain-private** (used only within a single `pages/{domain}/` subtree) → co-locate locally in `__tests__/` + +### Shared test utilities are cross-module only + +The shared test directory (commonly `src/test/`) is reserved for utilities that span multiple business domains. Do not place domain-private helpers here. + +Helper names and layouts vary per project. Inspect the actual shared test directory before importing anything; never assume a helper exists. + +## Core Principles + +1. **Keep tests minimal and expressive** - Write the smallest test that still protects the real business contract. +2. **Test behavior, not implementation** - Assert what the code does, not how it does it. +3. **Use the lowest-level test that catches real bugs** - Prefer util/logic tests over component tests when logic is isolated. +4. **Mock boundaries, not collaborators** - Mock network/time/random; do not mock the code under test. +5. **Every assertion must earn its place** - If an assertion can be removed without losing meaningful coverage, it is probably redundant. +6. **Reuse shared test utilities** - Do not create local duplicates of shared helpers. + +## Default Decision Flow + +1. **Classify the target** - Is it a util, logic unit, component, or integration? +2. **Identify the contract** - What behavior should be protected? +3. **Choose test level** - Pick the smallest scope that catches real regressions. +4. **Decide mock boundaries** - Mock external dependencies only. +5. **Use templates** - Start from the closest matching template in `prompts/agents/test/templates/`. +6. **Reuse shared utilities first** - Prefer existing shared helpers (render wrappers, logic unit setup, async helpers) before writing local test shells. +7. **Validate** - Check against `prompts/agents/test/references/review-checklist.md`. + +## Test Levels + +### Util Test + +- Pure business functions: filters, mappers, parsers, formatters, validators +- Edge cases that change outcomes: empty input, malformed data, boundary values + +### Logic Test + +- State machines and derived state +- Async coordination (API calls, timeouts) +- Callback contracts that are independent of a specific component tree +- Prefer the project's async wait helpers; use one waiting style per async spec and do not mix manual promise draining with wait helpers. + +### Component Test + +- User interactions that drive business behavior +- Conditional rendering when it affects behavior or downstream interaction +- Props that materially change the public contract +- Prefer existing public behavior over adding test-only wrappers or shells + +### Integration Test + +- Multi-component cooperation +- Shared state wiring +- Config-driven behavior across module boundaries + +## Mock Boundary Rules + +**Mock these:** + +- Network requests (fetch or the project's HTTP client) +- Time (setTimeout, setInterval, Date) +- Random values (Math.random) +- Browser APIs (localStorage, IntersectionObserver) +- Heavy third-party SDKs (e.g. maps, rich-text editors, media engines) + +**Do NOT mock:** + +- Internal logic units or selectors under test +- Component state or shared state layers being verified +- Business logic being tested + +**Keep mocks narrow and composable:** + +- Prefer the smallest UI-library mock fragment that supports the spec +- Reuse the project's existing shared mocks instead of rewriting the library +- Build only the pieces the test needs, not a full UI facade + +## Anti-Patterns + +### Assertion Anti-Patterns + +**Do NOT assert on non-business details:** + +- CSS classes, inline styles, or computed style values +- Static text content or copy (button labels, headings, placeholder text) +- DOM structure depth, tag names, or element ordering +- Icon names or icon library imports +- Layout or visual positioning + +**Only assert on these when they are part of the business contract** - for example, a conditional class, i18n-driven text, or a computed label that changes behavior. + +**Prefer stable, user-facing queries and interactions over implementation selectors:** + +- Prefer `role`, `label`, and stable `data-testid` +- Avoid `querySelector('.class')`, `input[type=...]`, button indexes, and DOM depth traversal +- Do not add test-only markers unless they are already part of the product contract + +**Other assertion anti-patterns:** + +- using snapshots instead of behavior-focused assertions +- asserting CSS classes or styles that are purely cosmetic +- asserting static copy/text content that has no business logic dependency +- snapshot testing of DOM structure or styling + +### Test Design Anti-Patterns + +- **Over-mocking** - Mocking everything to make a test pass defeats the purpose. +- **Empty tests** - A test that only checks `renders without crash` provides no value. +- **Orphaned assertions** - Deleting assertions without ensuring coverage elsewhere. +- **Hidden setup** - Extracting helpers that obscure what the test is actually verifying. +- **Wrapper boilerplate** - Adding shells, adapters, or fixture layers that do not help express the business behavior under test. +- **Test rewrite drift** - When improving a spec, delete only what is redundant; do not rewrite the whole test unless the contract changed. + +### Code Pollution Anti-Patterns + +**Never add to business code for test convenience:** + +- Extra state, return values, imperative handles, or flags +- Derived data that only exists for assertions + +**If coverage is difficult:** + +1. Assert through existing public behavior +2. Redesign the test boundary +3. Do NOT expand business state to make testing easier + +### Code Splitting Anti-Patterns + +**Never split code files solely for test convenience:** + +- Extracting components just to test smaller pieces +- Creating artificial module boundaries for testability +- Fragmenting business logic across multiple files + +**Rule**: Code structure serves business needs, not test convenience. + +## Detailed Resources + +For comprehensive guidance, refer to the following resources: + +### Methodology + +- [Testing Methodology](../prompts/agents/test/references/testing-methodology.md) - Core testing strategy and goals + +### Boundaries + +- [Testing Boundaries](../prompts/agents/test/references/testing-boundaries.md) - Test level decision criteria + +### Mock Rules + +- [Mock Boundary Rules](../prompts/agents/test/references/mock-boundary-rules.md) - Detailed mocking guidelines + +### Adapter Boundaries + +- [Adapter Notes](../prompts/agents/test/references/adapter-notes.md) - Testing patterns for host/adapter boundaries + +### Quality + +- [Review Checklist](../prompts/agents/test/references/review-checklist.md) - Test quality validation + +### Templates + +- [Component Test Template](../prompts/agents/test/templates/component-test-template.ts) +- [Logic Test Template](../prompts/agents/test/templates/logic-test-template.ts) +- [Util Test Template](../prompts/agents/test/templates/util-test-template.ts) +- [Integration Test Template](../prompts/agents/test/templates/integration-test-template.ts) +- Templates and examples import from ``; replace it with the project's test runner. + +### Examples + +- [Component Test Example](../prompts/agents/test/examples/component-test-example.ts) +- [Logic Test Example](../prompts/agents/test/examples/logic-test-example.ts) +- [Util Test Example](../prompts/agents/test/examples/util-test-example.ts) +- [Integration Test Example](../prompts/agents/test/examples/integration-test-example.ts) diff --git a/.opencode/skills/rust-skills/AGENTS.md b/.opencode/skills/rust-skills/AGENTS.md new file mode 120000 index 0000000..d69512c --- /dev/null +++ b/.opencode/skills/rust-skills/AGENTS.md @@ -0,0 +1 @@ +SKILL.md \ No newline at end of file diff --git a/.opencode/skills/rust-skills/SKILL.md b/.opencode/skills/rust-skills/SKILL.md new file mode 100644 index 0000000..4233f5f --- /dev/null +++ b/.opencode/skills/rust-skills/SKILL.md @@ -0,0 +1,499 @@ +--- +name: rust-skills +description: > + Comprehensive Rust coding guidelines with 265 rules across 26 categories. + Use when writing, reviewing, or refactoring Rust code. Covers ownership, + error handling, async patterns, concurrency, unsafe code, API design, memory + optimization, performance, numeric safety, conversions, serde, pattern + matching, macros, closures, observability, testing, and common anti-patterns. + Invoke with /rust-skills. +license: MIT +metadata: + author: leonardomso + version: "1.5.1" + sources: + - Rust API Guidelines + - Rust Performance Book + - Rust 2024 Edition Guide + - The Rustonomicon + - ripgrep, tokio, serde, polars, axum, cargo codebases +--- + +# Rust Best Practices + +Comprehensive guide for writing high-quality, idiomatic, and highly optimized Rust code. Contains 265 rules across 26 categories, prioritized by impact to guide LLMs in code generation and refactoring. Current for Rust 1.96 (2024 edition). + +## When to Apply + +Reference these guidelines when: +- Writing new Rust functions, structs, or modules +- Implementing error handling or async code +- Writing concurrent, parallel, or `unsafe` code +- Designing public APIs for libraries +- Reviewing code for ownership/borrowing issues +- Optimizing memory usage or reducing allocations +- Tuning performance for hot paths +- Refactoring existing Rust code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Ownership & Borrowing | CRITICAL | `own-` | 12 | +| 2 | Error Handling | CRITICAL | `err-` | 12 | +| 3 | Memory Optimization | CRITICAL | `mem-` | 17 | +| 4 | Unsafe Code | CRITICAL | `unsafe-` | 7 | +| 5 | API Design | HIGH | `api-` | 17 | +| 6 | Async/Await | HIGH | `async-` | 18 | +| 7 | Concurrency | HIGH | `conc-` | 4 | +| 8 | Compiler Optimization | HIGH | `opt-` | 12 | +| 9 | Numeric & Arithmetic Safety | HIGH | `num-` | 5 | +| 10 | Type Safety | MEDIUM | `type-` | 13 | +| 11 | Trait & Generics Design | MEDIUM | `trait-` | 6 | +| 12 | Conversions | MEDIUM | `conv-` | 3 | +| 13 | Const & Compile-Time | MEDIUM | `const-` | 4 | +| 14 | Serde | MEDIUM | `serde-` | 8 | +| 15 | Pattern Matching | MEDIUM | `pat-` | 5 | +| 16 | Macros | MEDIUM | `macro-` | 8 | +| 17 | Closures | MEDIUM | `closure-` | 5 | +| 18 | Collections | MEDIUM | `coll-` | 4 | +| 19 | Naming Conventions | MEDIUM | `name-` | 16 | +| 20 | Testing | MEDIUM | `test-` | 15 | +| 21 | Documentation | MEDIUM | `doc-` | 12 | +| 22 | Observability | MEDIUM | `obs-` | 7 | +| 23 | Performance Patterns | MEDIUM | `perf-` | 13 | +| 24 | Project Structure | LOW | `proj-` | 14 | +| 25 | Clippy & Linting | LOW | `lint-` | 13 | +| 26 | Anti-patterns | REFERENCE | `anti-` | 15 | + +--- + +## Quick Reference + +### 1. Ownership & Borrowing (CRITICAL) + +- [`own-borrow-over-clone`](rules/own-borrow-over-clone.md) - Prefer `&T` borrowing over `.clone()` +- [`own-slice-over-vec`](rules/own-slice-over-vec.md) - Accept `&[T]` not `&Vec`, `&str` not `&String` +- [`own-cow-conditional`](rules/own-cow-conditional.md) - Use `Cow<'a, T>` for conditional ownership +- [`own-arc-shared`](rules/own-arc-shared.md) - Use `Arc` for thread-safe shared ownership +- [`own-rc-single-thread`](rules/own-rc-single-thread.md) - Use `Rc` for shared ownership in single-threaded contexts +- [`own-refcell-interior`](rules/own-refcell-interior.md) - Use `RefCell` for interior mutability in single-threaded code +- [`own-mutex-interior`](rules/own-mutex-interior.md) - Use `Mutex` for interior mutability across threads +- [`own-rwlock-readers`](rules/own-rwlock-readers.md) - Use `RwLock` when reads significantly outnumber writes +- [`own-copy-small`](rules/own-copy-small.md) - Implement `Copy` for small, simple types +- [`own-clone-explicit`](rules/own-clone-explicit.md) - Use explicit `Clone` for types where copying has meaningful cost +- [`own-move-large`](rules/own-move-large.md) - Move large types instead of copying; use `Box` if moves are expensive +- [`own-lifetime-elision`](rules/own-lifetime-elision.md) - Rely on lifetime elision rules; add explicit lifetimes only when required + +### 2. Error Handling (CRITICAL) + +- [`err-thiserror-lib`](rules/err-thiserror-lib.md) - Use `thiserror` for library error types +- [`err-anyhow-app`](rules/err-anyhow-app.md) - Use `anyhow` for application error handling +- [`err-result-over-panic`](rules/err-result-over-panic.md) - Return `Result` instead of panicking for recoverable errors +- [`err-context-chain`](rules/err-context-chain.md) - Add context with `.context()` or `.with_context()` +- [`err-no-unwrap-prod`](rules/err-no-unwrap-prod.md) - Avoid `unwrap()` in production code; use `?`, `expect()`, or handle errors +- [`err-expect-bugs-only`](rules/err-expect-bugs-only.md) - Use `expect()` only for invariants that indicate bugs, not user errors +- [`err-question-mark`](rules/err-question-mark.md) - Use `?` operator for clean propagation +- [`err-from-impl`](rules/err-from-impl.md) - Implement `From` for error conversions to enable `?` operator +- [`err-source-chain`](rules/err-source-chain.md) - Preserve error chains with `#[source]` or `source()` method +- [`err-lowercase-msg`](rules/err-lowercase-msg.md) - Start error messages lowercase, no trailing punctuation +- [`err-doc-errors`](rules/err-doc-errors.md) - Document error conditions with `# Errors` section in doc comments +- [`err-custom-type`](rules/err-custom-type.md) - Define custom error types for domain-specific failures + +### 3. Memory Optimization (CRITICAL) + +- [`mem-with-capacity`](rules/mem-with-capacity.md) - Use `with_capacity()` when size is known +- [`mem-smallvec`](rules/mem-smallvec.md) - Use `SmallVec` for usually-small collections +- [`mem-arrayvec`](rules/mem-arrayvec.md) - Use `ArrayVec` for fixed-capacity collections that never heap-allocate +- [`mem-box-large-variant`](rules/mem-box-large-variant.md) - Box large enum variants to reduce overall enum size +- [`mem-boxed-slice`](rules/mem-boxed-slice.md) - Use `Box<[T]>` instead of `Vec` for fixed-size heap data +- [`mem-thinvec`](rules/mem-thinvec.md) - Use `ThinVec` for nullable collections with minimal overhead +- [`mem-clone-from`](rules/mem-clone-from.md) - Use `clone_from()` to reuse allocations when repeatedly cloning +- [`mem-reuse-collections`](rules/mem-reuse-collections.md) - Clear and reuse collections instead of creating new ones in loops +- [`mem-avoid-format`](rules/mem-avoid-format.md) - Avoid `format!()` when string literals work +- [`mem-write-over-format`](rules/mem-write-over-format.md) - Use `write!()` into existing buffers instead of `format!()` allocations +- [`mem-arena-allocator`](rules/mem-arena-allocator.md) - Use arena allocators for batch allocations +- [`mem-zero-copy`](rules/mem-zero-copy.md) - Use zero-copy patterns with slices and `Bytes` +- [`mem-compact-string`](rules/mem-compact-string.md) - Use compact string types for memory-constrained string storage +- [`mem-smaller-integers`](rules/mem-smaller-integers.md) - Use appropriately-sized integers to reduce memory footprint +- [`mem-assert-type-size`](rules/mem-assert-type-size.md) - Use static assertions to guard against accidental type size growth +- [`mem-take-replace`](rules/mem-take-replace.md) - Use `mem::take` / `mem::replace` to move a value out of a `&mut` without cloning +- [`mem-drop-order`](rules/mem-drop-order.md) - Know and control drop order: struct fields drop top-to-bottom, locals in reverse + +### 4. Unsafe Code (CRITICAL) + +- [`unsafe-safety-comment`](rules/unsafe-safety-comment.md) - Write a `// SAFETY:` comment above every `unsafe` block and a `# Safety` section in every `unsafe fn`. +- [`unsafe-minimize-scope`](rules/unsafe-minimize-scope.md) - Keep `unsafe` blocks as small as possible — mark only the operation that requires unsafety, not the surrounding safe code. +- [`unsafe-miri-ci`](rules/unsafe-miri-ci.md) - Run `cargo miri test` in CI for every crate that contains `unsafe` code. +- [`unsafe-maybeuninit`](rules/unsafe-maybeuninit.md) - Use `MaybeUninit` for uninitialized memory; never use `mem::uninitialized()` or `mem::zeroed()` for types with validity invariants. +- [`unsafe-extern-block`](rules/unsafe-extern-block.md) - In Rust 2024, wrap `extern` blocks in `unsafe extern { }` and annotate each item as `safe` or `unsafe`. +- [`unsafe-send-sync-manual`](rules/unsafe-send-sync-manual.md) - Document the invariants when manually implementing `Send` or `Sync`; prefer letting the compiler derive them automatically. +- [`unsafe-no-mangle-unsafe`](rules/unsafe-no-mangle-unsafe.md) - In Rust 2024, write `#[unsafe(no_mangle)]`, `#[unsafe(export_name = "...")]`, and `#[unsafe(link_section = "...")]` — not the bare attribute forms. + +### 5. API Design (HIGH) + +- [`api-builder-pattern`](rules/api-builder-pattern.md) - Use Builder pattern for complex construction +- [`api-builder-must-use`](rules/api-builder-must-use.md) - Mark builder methods with `#[must_use]` to prevent silent drops +- [`api-newtype-safety`](rules/api-newtype-safety.md) - Use newtypes to prevent mixing semantically different values +- [`api-typestate`](rules/api-typestate.md) - Use typestate pattern to encode state machine invariants in the type system +- [`api-sealed-trait`](rules/api-sealed-trait.md) - Use sealed traits to prevent external implementations while allowing use +- [`api-extension-trait`](rules/api-extension-trait.md) - Use extension traits to add methods to external types +- [`api-parse-dont-validate`](rules/api-parse-dont-validate.md) - Parse into validated types at boundaries +- [`api-impl-into`](rules/api-impl-into.md) - Accept `impl Into` for flexible APIs, implement `From` for conversions +- [`api-impl-asref`](rules/api-impl-asref.md) - Use `AsRef` when you only need to borrow the inner data +- [`api-must-use`](rules/api-must-use.md) - Mark types and functions with `#[must_use]` when ignoring results is likely a bug +- [`api-non-exhaustive`](rules/api-non-exhaustive.md) - Use `#[non_exhaustive]` on public enums and structs for forward compatibility +- [`api-from-not-into`](rules/api-from-not-into.md) - Implement `From`, not `Into` - From gives you Into for free +- [`api-default-impl`](rules/api-default-impl.md) - Implement `Default` for types with sensible default values +- [`api-common-traits`](rules/api-common-traits.md) - Implement standard traits (Debug, Clone, PartialEq, etc.) for public types +- [`api-serde-optional`](rules/api-serde-optional.md) - Make serde a feature flag, not a hard dependency for library crates +- [`api-impl-fromiterator`](rules/api-impl-fromiterator.md) - Implement `FromIterator` and `Extend` for collection types, and `IntoIterator` for all three reference forms +- [`api-operator-overload`](rules/api-operator-overload.md) - Overload operators only when the semantics are natural and unsurprising + +### 6. Async/Await (HIGH) + +- [`async-tokio-runtime`](rules/async-tokio-runtime.md) - Configure Tokio runtime appropriately for your workload +- [`async-no-lock-await`](rules/async-no-lock-await.md) - Never hold `Mutex`/`RwLock` across `.await` +- [`async-spawn-blocking`](rules/async-spawn-blocking.md) - Use `spawn_blocking` for CPU-intensive work +- [`async-tokio-fs`](rules/async-tokio-fs.md) - Use `tokio::fs` instead of `std::fs` in async code +- [`async-cancellation-token`](rules/async-cancellation-token.md) - Use `CancellationToken` for graceful shutdown and task cancellation +- [`async-join-parallel`](rules/async-join-parallel.md) - Use `join!` or `try_join!` for concurrent independent futures +- [`async-try-join`](rules/async-try-join.md) - Use `try_join!` for concurrent fallible operations with early return on error +- [`async-select-racing`](rules/async-select-racing.md) - Use `select!` to race futures and handle the first to complete +- [`async-bounded-channel`](rules/async-bounded-channel.md) - Use bounded channels to apply backpressure and prevent unbounded memory growth +- [`async-mpsc-queue`](rules/async-mpsc-queue.md) - Use `mpsc` channels for async message queues between tasks +- [`async-broadcast-pubsub`](rules/async-broadcast-pubsub.md) - Use `broadcast` channel for pub/sub where all subscribers receive all messages +- [`async-watch-latest`](rules/async-watch-latest.md) - Use `watch` channel for sharing the latest value with multiple observers +- [`async-oneshot-response`](rules/async-oneshot-response.md) - Use `oneshot` channel for request-response patterns +- [`async-joinset-structured`](rules/async-joinset-structured.md) - Use `JoinSet` for managing dynamic collections of spawned tasks +- [`async-clone-before-await`](rules/async-clone-before-await.md) - Clone Arc/Rc data before await points to avoid holding references across suspension +- [`async-fn-in-trait`](rules/async-fn-in-trait.md) - Use native `async fn` in traits (stable 1.75) instead of the `async_trait` macro +- [`async-async-fn-bounds`](rules/async-async-fn-bounds.md) - Use `AsyncFn`/`AsyncFnMut`/`AsyncFnOnce` bounds instead of `F: Fn() -> Fut, Fut: Future` +- [`async-cancel-safety`](rules/async-cancel-safety.md) - Ensure futures used in `tokio::select!` branches are cancellation-safe + +### 7. Concurrency (HIGH) + +- [`conc-rayon-par-iter`](rules/conc-rayon-par-iter.md) - Use rayon's `par_iter()` for CPU-bound data parallelism +- [`conc-scoped-threads`](rules/conc-scoped-threads.md) - Use `std::thread::scope` to borrow stack data across threads +- [`conc-atomic-ordering`](rules/conc-atomic-ordering.md) - Use the weakest correct memory `Ordering` for every atomic operation +- [`conc-thread-local`](rules/conc-thread-local.md) - Prefer `thread_local!` with `Cell`/`RefCell` over `static mut` + +### 8. Compiler Optimization (HIGH) + +- [`opt-inline-small`](rules/opt-inline-small.md) - Use `#[inline]` for small hot functions +- [`opt-inline-always-rare`](rules/opt-inline-always-rare.md) - Use `#[inline(always)]` sparingly—only for critical hot paths proven by profiling +- [`opt-inline-never-cold`](rules/opt-inline-never-cold.md) - Use `#[inline(never)]` and `#[cold]` for error paths and rarely-executed code +- [`opt-cold-unlikely`](rules/opt-cold-unlikely.md) - Mark unlikely code paths with `#[cold]` to help compiler optimization +- [`opt-likely-hint`](rules/opt-likely-hint.md) - Use code structure to hint at likely branches; use intrinsics on nightly +- [`opt-lto-release`](rules/opt-lto-release.md) - Enable LTO in release builds +- [`opt-codegen-units`](rules/opt-codegen-units.md) - Set `codegen-units = 1` for maximum optimization in release builds +- [`opt-pgo-profile`](rules/opt-pgo-profile.md) - Use Profile-Guided Optimization (PGO) for maximum performance +- [`opt-target-cpu`](rules/opt-target-cpu.md) - Use `target-cpu=native` for maximum performance on known deployment targets +- [`opt-bounds-check`](rules/opt-bounds-check.md) - Use iterators and patterns that eliminate bounds checks in hot paths +- [`opt-simd-portable`](rules/opt-simd-portable.md) - Use portable SIMD for vectorized operations across architectures +- [`opt-cache-friendly`](rules/opt-cache-friendly.md) - Organize data for cache-efficient access patterns + +### 9. Numeric & Arithmetic Safety (HIGH) + +- [`num-overflow-explicit`](rules/num-overflow-explicit.md) - Handle integer overflow explicitly: `checked_`/`saturating_`/`wrapping_`/`overflowing_` +- [`num-cast-try-from`](rules/num-cast-try-from.md) - Avoid `as` for narrowing casts; use `From` for widening and `TryFrom` for narrowing +- [`num-float-compare`](rules/num-float-compare.md) - Don't compare floats with `==`; use a tolerance, and `total_cmp` for ordering +- [`num-saturating-clamp`](rules/num-saturating-clamp.md) - Bound values with `clamp` and saturating arithmetic +- [`num-nonzero`](rules/num-nonzero.md) - Use `NonZero*` types to forbid zero and unlock the niche optimization + +### 10. Type Safety (MEDIUM) + +- [`type-newtype-ids`](rules/type-newtype-ids.md) - Wrap IDs in newtypes: `UserId(u64)` +- [`type-newtype-validated`](rules/type-newtype-validated.md) - Use newtypes to enforce validation at construction time +- [`type-enum-states`](rules/type-enum-states.md) - Use enums for mutually exclusive states +- [`type-option-nullable`](rules/type-option-nullable.md) - Use `Option` for values that might not exist +- [`type-result-fallible`](rules/type-result-fallible.md) - Use `Result` for operations that can fail +- [`type-phantom-marker`](rules/type-phantom-marker.md) - Use `PhantomData` to express type relationships without runtime cost +- [`type-never-diverge`](rules/type-never-diverge.md) - Use `!` (never type) for functions that never return +- [`type-generic-bounds`](rules/type-generic-bounds.md) - Add trait bounds only where needed, prefer where clauses for readability +- [`type-no-stringly`](rules/type-no-stringly.md) - Avoid stringly-typed APIs; use enums, newtypes, or validated types +- [`type-repr-transparent`](rules/type-repr-transparent.md) - Use `#[repr(transparent)]` for newtypes in FFI contexts +- [`type-deref-coercion`](rules/type-deref-coercion.md) - Implement `Deref`/`DerefMut` only for smart-pointer and transparent wrapper types +- [`type-display-vs-debug`](rules/type-display-vs-debug.md) - Use `Display` for user-facing output and `Debug` for diagnostics; never swap them +- [`type-numeric-fmt`](rules/type-numeric-fmt.md) - Implement `LowerHex`, `UpperHex`, `Octal`, and `Binary` for numeric newtypes + +### 11. Trait & Generics Design (MEDIUM) + +- [`trait-associated-type-vs-generic`](rules/trait-associated-type-vs-generic.md) - Use an associated type when each impl has exactly one output type; use a generic parameter when a type can implement the trait for many input types +- [`trait-blanket-impl`](rules/trait-blanket-impl.md) - Use a blanket impl `impl Trait for T` to give behaviour to every type that satisfies a bound +- [`trait-coherence-newtype`](rules/trait-coherence-newtype.md) - Respect the orphan rule; wrap a foreign type in a newtype to implement a foreign trait on it +- [`trait-default-methods`](rules/trait-default-methods.md) - Define a trait in terms of a few required methods plus defaulted ones built on top of them +- [`trait-dyn-vs-generic`](rules/trait-dyn-vs-generic.md) - Choose static dispatch (generics / `impl Trait`) vs dynamic dispatch (`dyn Trait`) deliberately +- [`trait-object-safety`](rules/trait-object-safety.md) - Keep a trait dyn-compatible (object-safe) when you need `dyn Trait` + +### 12. Conversions (MEDIUM) + +- [`conv-tryfrom-fallible`](rules/conv-tryfrom-fallible.md) - Implement `TryFrom` for fallible conversions instead of ad-hoc conversion functions +- [`conv-fromstr-parsing`](rules/conv-fromstr-parsing.md) - Implement `FromStr` to enable `str::parse` for string-to-type conversions +- [`conv-asmut-mutable`](rules/conv-asmut-mutable.md) - Accept `impl AsMut` for flexible mutable borrowed inputs instead of concrete mutable references + +### 13. Const & Compile-Time (MEDIUM) + +- [`const-block`](rules/const-block.md) - Use inline `const { }` blocks for compile-time evaluation and assertions +- [`const-fn`](rules/const-fn.md) - Make functions `const fn` when they can run at compile time +- [`const-generics`](rules/const-generics.md) - Parameterize over values with const generics `` +- [`const-vs-static`](rules/const-vs-static.md) - Use `const` for an inlined value and `static` for a single addressed instance + +### 14. Serde (MEDIUM) + +- [`serde-rename-all`](rules/serde-rename-all.md) - Match the external naming convention with `#[serde(rename_all = ...)]` +- [`serde-default-compat`](rules/serde-default-compat.md) - Use `#[serde(default)]` for optional and backward-compatible fields +- [`serde-skip-empty`](rules/serde-skip-empty.md) - Omit empty fields with `skip_serializing_if` +- [`serde-flatten`](rules/serde-flatten.md) - Inline nested structs or capture extra keys with `#[serde(flatten)]` +- [`serde-enum-representation`](rules/serde-enum-representation.md) - Choose enum tagging deliberately: externally, internally, adjacently tagged, or untagged +- [`serde-deny-unknown-fields`](rules/serde-deny-unknown-fields.md) - Reject unexpected keys with `#[serde(deny_unknown_fields)]` +- [`serde-custom-with`](rules/serde-custom-with.md) - Customize a field's (de)serialization with `with` / `serialize_with` / `deserialize_with` +- [`serde-try-from-validate`](rules/serde-try-from-validate.md) - Validate while deserializing with `#[serde(try_from = "Raw")]` + +### 15. Pattern Matching (MEDIUM) + +- [`pat-let-else`](rules/pat-let-else.md) - Use `let ... else` for early-return pattern extraction +- [`pat-matches-macro`](rules/pat-matches-macro.md) - Use `matches!()` for boolean pattern tests +- [`pat-if-let-chains`](rules/pat-if-let-chains.md) - Use `if let` chains to combine pattern bindings and conditions +- [`pat-exhaustive-enum`](rules/pat-exhaustive-enum.md) - Match owned enums exhaustively; avoid catch-all `_` that hides new variants +- [`pat-at-bindings`](rules/pat-at-bindings.md) - Use `@` bindings to capture a value while matching it against a pattern + +### 16. Macros (MEDIUM) + +- [`macro-prefer-functions`](rules/macro-prefer-functions.md) - Reach for a macro only when a function or generic cannot express it +- [`macro-rules-hygiene`](rules/macro-rules-hygiene.md) - Rely on `macro_rules!` hygiene and use `$crate` for paths to your crate's items +- [`macro-fragment-specifiers`](rules/macro-fragment-specifiers.md) - Capture with precise fragment specifiers, not raw `:tt`, where you can +- [`macro-export-crate-path`](rules/macro-export-crate-path.md) - Export declarative macros with `#[macro_export]` and a clean import path +- [`macro-private-helpers`](rules/macro-private-helpers.md) - Hide macro-generated helper items behind a `#[doc(hidden)] pub mod __private` +- [`macro-proc-two-crate`](rules/macro-proc-two-crate.md) - Put procedural macros in a dedicated `proc-macro = true` crate and re-export from the facade +- [`macro-proc-syn-quote`](rules/macro-proc-syn-quote.md) - Build procedural macros with `syn`, `quote`, and `proc-macro2` +- [`macro-proc-error-spans`](rules/macro-proc-error-spans.md) - Report proc-macro errors as spanned compile errors, never by panicking + +### 17. Closures (MEDIUM) + +- [`closure-fn-trait-bounds`](rules/closure-fn-trait-bounds.md) - Require the least restrictive `Fn` trait a callback needs (`FnOnce` ⊇ `FnMut` ⊇ `Fn`) +- [`closure-impl-fn-return`](rules/closure-impl-fn-return.md) - Return closures as `impl Fn`/`FnMut`/`FnOnce`, not `Box` +- [`closure-move-capture`](rules/closure-move-capture.md) - Use `move` for closures that outlive the current scope; clone before `move` to keep the original +- [`closure-static-vs-dyn`](rules/closure-static-vs-dyn.md) - Accept `impl Fn` (generic) for hot callbacks; use `&dyn Fn`/`Box` to cut code size or to store them +- [`closure-disjoint-capture`](rules/closure-disjoint-capture.md) - Capture only what you use; lean on edition-2021 disjoint closure captures + +### 18. Collections (MEDIUM) + +- [`coll-binaryheap`](rules/coll-binaryheap.md) - Use `BinaryHeap` for a priority queue or repeated max-extraction +- [`coll-map-choice`](rules/coll-map-choice.md) - Pick the map by access pattern: `HashMap` (fast, unordered), `BTreeMap` (sorted / range queries), `IndexMap` (insertion order) +- [`coll-seq-choice`](rules/coll-seq-choice.md) - Default to `Vec`; use `VecDeque` for queue/deque behaviour; avoid `LinkedList` +- [`coll-set-membership`](rules/coll-set-membership.md) - Use `HashSet`/`BTreeSet` for membership tests and dedup, not linear `Vec::contains` + +### 19. Naming Conventions (MEDIUM) + +- [`name-types-camel`](rules/name-types-camel.md) - Use `UpperCamelCase` for types, traits, and enum names +- [`name-variants-camel`](rules/name-variants-camel.md) - Use `UpperCamelCase` for enum variants +- [`name-funcs-snake`](rules/name-funcs-snake.md) - Use `snake_case` for functions, methods, variables, and modules +- [`name-consts-screaming`](rules/name-consts-screaming.md) - Use `SCREAMING_SNAKE_CASE` for constants and statics +- [`name-lifetime-short`](rules/name-lifetime-short.md) - Use short, conventional lifetime names: `'a`, `'b`, `'de`, `'src` +- [`name-type-param-single`](rules/name-type-param-single.md) - Use single uppercase letters for type parameters: `T`, `E`, `K`, `V` +- [`name-as-free`](rules/name-as-free.md) - `as_` prefix: free reference conversion +- [`name-to-expensive`](rules/name-to-expensive.md) - Use `to_` prefix for expensive conversions that allocate or compute +- [`name-into-ownership`](rules/name-into-ownership.md) - Use `into_` prefix for ownership-consuming conversions +- [`name-no-get-prefix`](rules/name-no-get-prefix.md) - Omit get_ prefix for simple getters +- [`name-is-has-bool`](rules/name-is-has-bool.md) - Use `is_`, `has_`, `can_`, `should_` prefixes for boolean-returning methods +- [`name-iter-convention`](rules/name-iter-convention.md) - Use iter/iter_mut/into_iter for iterator methods +- [`name-iter-method`](rules/name-iter-method.md) - Name iterator methods `iter()`, `iter_mut()`, and `into_iter()` consistently +- [`name-iter-type-match`](rules/name-iter-type-match.md) - Name iterator types after their source method +- [`name-acronym-word`](rules/name-acronym-word.md) - Treat acronyms as words in identifiers: `HttpServer`, not `HTTPServer` +- [`name-crate-no-rs`](rules/name-crate-no-rs.md) - Don't suffix crate names with `-rs` or `-rust` + +### 20. Testing (MEDIUM) + +- [`test-cfg-test-module`](rules/test-cfg-test-module.md) - Put unit tests in `#[cfg(test)] mod tests { }` within each module +- [`test-use-super`](rules/test-use-super.md) - Use `use super::*;` in test modules to access parent module items +- [`test-integration-dir`](rules/test-integration-dir.md) - Put integration tests in the `tests/` directory +- [`test-descriptive-names`](rules/test-descriptive-names.md) - Use descriptive test names that explain what is being tested +- [`test-arrange-act-assert`](rules/test-arrange-act-assert.md) - Structure tests with clear Arrange, Act, Assert sections +- [`test-proptest-properties`](rules/test-proptest-properties.md) - Use proptest for property-based testing +- [`test-mockall-mocking`](rules/test-mockall-mocking.md) - Use mockall for trait mocking +- [`test-mock-traits`](rules/test-mock-traits.md) - Use traits for dependencies to enable mocking in tests +- [`test-fixture-raii`](rules/test-fixture-raii.md) - Use RAII pattern (Drop trait) for automatic test cleanup +- [`test-tokio-async`](rules/test-tokio-async.md) - Use `#[tokio::test]` for async tests +- [`test-should-panic`](rules/test-should-panic.md) - Use `#[should_panic]` to test that code panics as expected +- [`test-criterion-bench`](rules/test-criterion-bench.md) - Use `criterion` for benchmarking +- [`test-doctest-examples`](rules/test-doctest-examples.md) - Keep documentation examples as executable doctests +- [`test-loom-concurrency`](rules/test-loom-concurrency.md) - Use `loom` to exhaustively test lock-free and concurrent code +- [`test-snapshot-testing`](rules/test-snapshot-testing.md) - Use snapshot testing (insta) for complex or serialized output + +### 21. Documentation (MEDIUM) + +- [`doc-all-public`](rules/doc-all-public.md) - Document all public items with `///` doc comments +- [`doc-module-inner`](rules/doc-module-inner.md) - Use `//!` for module-level documentation +- [`doc-examples-section`](rules/doc-examples-section.md) - Include `# Examples` with runnable code +- [`doc-errors-section`](rules/doc-errors-section.md) - Include `# Errors` section for fallible functions +- [`doc-panics-section`](rules/doc-panics-section.md) - Include `# Panics` section for functions that can panic +- [`doc-safety-section`](rules/doc-safety-section.md) - Include `# Safety` section for unsafe functions +- [`doc-question-mark`](rules/doc-question-mark.md) - Use `?` in examples, not `.unwrap()` +- [`doc-hidden-setup`](rules/doc-hidden-setup.md) - Use `# ` prefix to hide example setup code +- [`doc-intra-links`](rules/doc-intra-links.md) - Use intra-doc links to reference types and items +- [`doc-link-types`](rules/doc-link-types.md) - Use intra-doc links to connect related types and functions +- [`doc-cargo-metadata`](rules/doc-cargo-metadata.md) - Fill `Cargo.toml` metadata for published crates +- [`doc-crate-readme`](rules/doc-crate-readme.md) - Unify the README and crate root docs with `#![doc = include_str!("../README.md")]` + +### 22. Observability (MEDIUM) + +- [`obs-tracing-over-log`](rules/obs-tracing-over-log.md) - Use `tracing` for structured, span-aware diagnostics instead of `println!` or bare `log` +- [`obs-library-facade`](rules/obs-library-facade.md) - Libraries emit through the tracing/log facade and never install a subscriber +- [`obs-structured-fields`](rules/obs-structured-fields.md) - Record structured key-value fields, not values interpolated into the message string +- [`obs-instrument-spans`](rules/obs-instrument-spans.md) - Use `#[tracing::instrument]` and spans to attach context to async tasks and requests +- [`obs-levels-filter`](rules/obs-levels-filter.md) - Use log levels meaningfully and filter with `EnvFilter` / `RUST_LOG` +- [`obs-error-chain`](rules/obs-error-chain.md) - Log errors with their full source chain, and log each error exactly once +- [`obs-no-sensitive-data`](rules/obs-no-sensitive-data.md) - Never log secrets or PII; redact or skip them + +### 23. Performance Patterns (MEDIUM) + +- [`perf-iter-over-index`](rules/perf-iter-over-index.md) - Prefer iterators over manual indexing +- [`perf-iter-lazy`](rules/perf-iter-lazy.md) - Keep iterators lazy, collect only when needed +- [`perf-collect-once`](rules/perf-collect-once.md) - Don't collect intermediate iterators +- [`perf-entry-api`](rules/perf-entry-api.md) - Use entry API for map insert-or-update +- [`perf-drain-reuse`](rules/perf-drain-reuse.md) - Use drain to reuse allocations +- [`perf-extend-batch`](rules/perf-extend-batch.md) - Use extend for batch insertions +- [`perf-chain-avoid`](rules/perf-chain-avoid.md) - Avoid chain in hot loops +- [`perf-collect-into`](rules/perf-collect-into.md) - Use collect_into for reusing containers +- [`perf-black-box-bench`](rules/perf-black-box-bench.md) - Use black_box in benchmarks +- [`perf-release-profile`](rules/perf-release-profile.md) - Optimize release profile settings +- [`perf-profile-first`](rules/perf-profile-first.md) - Profile before optimizing +- [`perf-ahash`](rules/perf-ahash.md) - Use a faster hasher (`ahash` / `FxHashMap`) when DoS resistance is not needed +- [`perf-io-buffering`](rules/perf-io-buffering.md) - Wrap `Read`/`Write` in `BufReader`/`BufWriter` for many small operations + +### 24. Project Structure (LOW) + +- [`proj-lib-main-split`](rules/proj-lib-main-split.md) - Keep `main.rs` minimal, logic in `lib.rs` +- [`proj-mod-by-feature`](rules/proj-mod-by-feature.md) - Organize modules by feature, not type +- [`proj-flat-small`](rules/proj-flat-small.md) - Keep small projects flat +- [`proj-mod-rs-dir`](rules/proj-mod-rs-dir.md) - Use mod.rs for multi-file modules +- [`proj-pub-crate-internal`](rules/proj-pub-crate-internal.md) - Use pub(crate) for internal APIs +- [`proj-pub-super-parent`](rules/proj-pub-super-parent.md) - Use pub(super) for parent-only visibility +- [`proj-pub-use-reexport`](rules/proj-pub-use-reexport.md) - Use pub use for clean public API +- [`proj-prelude-module`](rules/proj-prelude-module.md) - Create prelude module for common imports +- [`proj-bin-dir`](rules/proj-bin-dir.md) - Put multiple binaries in src/bin/ +- [`proj-workspace-large`](rules/proj-workspace-large.md) - Use workspaces for large projects +- [`proj-workspace-deps`](rules/proj-workspace-deps.md) - Use workspace dependency inheritance for consistent versions across crates +- [`proj-feature-additive`](rules/proj-feature-additive.md) - Design Cargo features to be strictly additive +- [`proj-msrv-declare`](rules/proj-msrv-declare.md) - Declare `rust-version` (MSRV) in Cargo.toml and test it in CI +- [`proj-build-rs-minimal`](rules/proj-build-rs-minimal.md) - Keep `build.rs` minimal, deterministic, and idempotent + +### 25. Clippy & Linting (LOW) + +- [`lint-deny-correctness`](rules/lint-deny-correctness.md) - `#![deny(clippy::correctness)]` +- [`lint-warn-suspicious`](rules/lint-warn-suspicious.md) - Enable clippy::suspicious for likely bugs +- [`lint-warn-style`](rules/lint-warn-style.md) - Enable clippy::style for idiomatic code +- [`lint-warn-complexity`](rules/lint-warn-complexity.md) - Enable clippy::complexity for simpler code +- [`lint-warn-perf`](rules/lint-warn-perf.md) - Enable clippy::perf for performance improvements +- [`lint-pedantic-selective`](rules/lint-pedantic-selective.md) - Enable clippy::pedantic selectively +- [`lint-missing-docs`](rules/lint-missing-docs.md) - Warn on missing documentation for public items +- [`lint-unsafe-doc`](rules/lint-unsafe-doc.md) - Require documentation for unsafe blocks +- [`lint-cargo-metadata`](rules/lint-cargo-metadata.md) - Enable clippy::cargo for published crates +- [`lint-rustfmt-check`](rules/lint-rustfmt-check.md) - Run cargo fmt --check in CI +- [`lint-workspace-lints`](rules/lint-workspace-lints.md) - Configure lints at workspace level for consistent enforcement +- [`lint-cfg-check`](rules/lint-cfg-check.md) - Enable `unexpected_cfgs` and declare known cfgs to catch feature-gate typos +- [`lint-clippy-nursery-selected`](rules/lint-clippy-nursery-selected.md) - Enable high-value `clippy::nursery` lints selectively, not the whole group + +### 26. Anti-patterns (REFERENCE) + +- [`anti-unwrap-abuse`](rules/anti-unwrap-abuse.md) - Don't use `.unwrap()` in production code +- [`anti-expect-lazy`](rules/anti-expect-lazy.md) - Don't use expect for recoverable errors +- [`anti-clone-excessive`](rules/anti-clone-excessive.md) - Don't clone when borrowing works +- [`anti-lock-across-await`](rules/anti-lock-across-await.md) - Don't hold locks across await points +- [`anti-string-for-str`](rules/anti-string-for-str.md) - Don't accept &String when &str works +- [`anti-vec-for-slice`](rules/anti-vec-for-slice.md) - Don't accept &Vec when &[T] works +- [`anti-index-over-iter`](rules/anti-index-over-iter.md) - Don't use indexing when iterators work +- [`anti-panic-expected`](rules/anti-panic-expected.md) - Don't panic on expected or recoverable errors +- [`anti-empty-catch`](rules/anti-empty-catch.md) - Don't silently ignore errors +- [`anti-over-abstraction`](rules/anti-over-abstraction.md) - Don't over-abstract with excessive generics +- [`anti-premature-optimize`](rules/anti-premature-optimize.md) - Don't optimize before profiling +- [`anti-type-erasure`](rules/anti-type-erasure.md) - Don't use Box when impl Trait works +- [`anti-format-hot-path`](rules/anti-format-hot-path.md) - Don't use format! in hot paths +- [`anti-collect-intermediate`](rules/anti-collect-intermediate.md) - Don't collect intermediate iterators +- [`anti-stringly-typed`](rules/anti-stringly-typed.md) - Don't use strings where enums or newtypes would provide type safety + +--- + +## Recommended Cargo.toml Settings + +```toml +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.bench] +inherits = "release" +debug = true +strip = false + +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies in dev +``` + +--- + +## How to Use + +This skill provides rule identifiers for quick reference. When generating or reviewing Rust code: + +1. **Check relevant category** based on task type +2. **Apply rules** with matching prefix +3. **Prioritize** CRITICAL > HIGH > MEDIUM > LOW +4. **Read rule files** in `rules/` for detailed examples + +### Rule Application by Task + +| Task | Primary Categories | +|------|-------------------| +| New function | `own-`, `err-`, `name-`, `pat-` | +| New struct/API | `api-`, `type-`, `conv-`, `doc-` | +| Async code | `async-`, `own-` | +| Concurrency / parallelism | `conc-`, `async-`, `own-` | +| Unsafe code | `unsafe-`, `type-`, `test-` | +| Error handling | `err-`, `api-`, `pat-` | +| Type conversions | `conv-`, `api-` | +| Serialization (serde) | `serde-`, `type-`, `api-` | +| Numeric / arithmetic | `num-`, `type-` | +| Macros / code generation | `macro-`, `anti-` | +| Closures / callbacks | `closure-`, `type-` | +| Logging / observability | `obs-`, `err-` | +| Memory optimization | `mem-`, `own-`, `perf-` | +| Performance tuning | `opt-`, `mem-`, `perf-` | +| Code review | `anti-`, `lint-` | + +--- + +## Sources & Attribution + +This skill is an independent synthesis of official Rust guidance, well-known books, and patterns from widely-used crates. It is not affiliated with or endorsed by the Rust project or any crate author; the text and code examples are original. + +**Official Rust documentation** +- [The Rust Reference](https://doc.rust-lang.org/reference/) +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [The Rustonomicon](https://doc.rust-lang.org/nomicon/) (unsafe code) +- [Rust 2024 Edition Guide](https://doc.rust-lang.org/edition-guide/rust-2024/) +- [The Cargo Book](https://doc.rust-lang.org/cargo/) +- [Standard library docs](https://doc.rust-lang.org/std/) and [release notes](https://doc.rust-lang.org/releases.html) + +**Books & guides** +- [The Rust Performance Book](https://nnethercote.github.io/perf-book/) — Nicholas Nethercote +- [Rust Design Patterns](https://rust-unofficial.github.io/patterns/) — rust-unofficial +- [Rust Atomics and Locks](https://marabos.nl/atomics/) — Mara Bos +- [Effective Rust](https://effective-rust.com/) — David Drysdale + +**Tooling** +- [Clippy lint documentation](https://rust-lang.github.io/rust-clippy/) +- [Miri](https://github.com/rust-lang/miri) + +**Real-world codebases studied for idioms** +- ripgrep, tokio, serde, clap, polars, axum, cargo, hyper, bevy, rayon, and dtolnay's crates (thiserror, anyhow, syn) + +This project is MIT-licensed. Referenced upstream materials remain under their own licenses (the official Rust docs and API Guidelines are dual MIT / Apache-2.0). diff --git a/.opencode/skills/rust-skills/rules/anti-clone-excessive.md b/.opencode/skills/rust-skills/rules/anti-clone-excessive.md new file mode 100644 index 0000000..539dac2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-clone-excessive.md @@ -0,0 +1,124 @@ +# anti-clone-excessive + +> Don't clone when borrowing works + +## Why It Matters + +`.clone()` allocates memory and copies data. When you only need to read data, borrowing (`&T`) is free. Excessive cloning wastes memory, CPU cycles, and often indicates misunderstanding of ownership. + +## Bad + +```rust +// Cloning to pass to a function that only reads +fn print_name(name: String) { // Takes ownership + println!("{}", name); +} +let name = "Alice".to_string(); +print_name(name.clone()); // Unnecessary clone +print_name(name); // Could have just done this + +// Cloning in a loop +for item in items.clone() { // Clones entire Vec + process(&item); +} + +// Cloning for comparison +if input.clone() == expected { // Pointless clone + // ... +} + +// Cloning struct fields +fn get_name(&self) -> String { + self.name.clone() // Caller might not need ownership +} +``` + +## Good + +```rust +// Accept reference if only reading +fn print_name(name: &str) { + println!("{}", name); +} +let name = "Alice".to_string(); +print_name(&name); // Borrow, no clone + +// Iterate by reference +for item in &items { + process(item); +} + +// Compare by reference +if input == expected { + // ... +} + +// Return reference when possible +fn get_name(&self) -> &str { + &self.name +} +``` + +## When to Clone + +```rust +// Need owned data for async move +let name = name.clone(); +tokio::spawn(async move { + process(name).await; +}); + +// Storing in a new struct +struct Cache { + data: String, +} +impl Cache { + fn store(&mut self, data: &str) { + self.data = data.to_string(); // Must own + } +} + +// Multiple owners (use Arc instead if frequent) +let shared = data.clone(); +thread::spawn(move || use_data(shared)); +``` + +## Alternatives to Clone + +| Instead of | Use | +|------------|-----| +| `s.clone()` for reading | `&s` | +| `vec.clone()` for iteration | `&vec` or `vec.iter()` | +| `Clone` for shared ownership | `Arc` | +| Clone in hot loop | Move outside loop | +| `s.to_string()` from `&str` | Accept `&str` if possible | + +## Pattern: Clone on Write + +```rust +use std::borrow::Cow; + +fn process(input: Cow) -> Cow { + if needs_modification(&input) { + Cow::Owned(modify(&input)) // Clone only if needed + } else { + input // No clone + } +} +``` + +## Detecting Excessive Clones + +```toml +# Cargo.toml +[lints.clippy] +clone_on_copy = "warn" +clone_on_ref_ptr = "warn" +redundant_clone = "warn" +``` + +## See Also + +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Borrowing patterns +- [own-cow-conditional](./own-cow-conditional.md) - Clone on write +- [own-arc-shared](./own-arc-shared.md) - Shared ownership diff --git a/.opencode/skills/rust-skills/rules/anti-collect-intermediate.md b/.opencode/skills/rust-skills/rules/anti-collect-intermediate.md new file mode 100644 index 0000000..b83a2fb --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-collect-intermediate.md @@ -0,0 +1,131 @@ +# anti-collect-intermediate + +> Don't collect intermediate iterators + +## Why It Matters + +Each `.collect()` allocates a new collection. Collecting intermediate results in a chain creates unnecessary allocations and prevents iterator fusion. Keep the chain lazy; collect only at the end. + +## Bad + +```rust +// Three allocations, three passes +fn process(data: Vec) -> Vec { + let step1: Vec<_> = data.into_iter() + .filter(|x| *x > 0) + .collect(); + + let step2: Vec<_> = step1.into_iter() + .map(|x| x * 2) + .collect(); + + step2.into_iter() + .filter(|x| *x < 100) + .collect() +} + +// Collecting just to check length +fn has_valid_items(items: &[Item]) -> bool { + let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + !valid.is_empty() +} + +// Collecting to iterate again +fn sum_valid(items: &[Item]) -> i64 { + let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + valid.iter().map(|i| i.value).sum() +} +``` + +## Good + +```rust +// Single allocation, single pass +fn process(data: Vec) -> Vec { + data.into_iter() + .filter(|x| *x > 0) + .map(|x| x * 2) + .filter(|x| *x < 100) + .collect() +} + +// No allocation - iterator short-circuits +fn has_valid_items(items: &[Item]) -> bool { + items.iter().any(|i| i.is_valid()) +} + +// No intermediate allocation +fn sum_valid(items: &[Item]) -> i64 { + items.iter() + .filter(|i| i.is_valid()) + .map(|i| i.value) + .sum() +} +``` + +## When Collection Is Needed + +```rust +// Need to iterate twice +let valid: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); +let count = valid.len(); +for item in &valid { + process(item); +} + +// Need to sort (requires concrete collection) +let mut sorted: Vec<_> = items.iter() + .filter(|i| i.is_active()) + .collect(); +sorted.sort_by_key(|i| i.priority); + +// Need random access +let indexed: Vec<_> = items.iter().collect(); +let middle = indexed.get(indexed.len() / 2); +``` + +## Iterator Methods That Avoid Collection + +| Instead of Collecting to... | Use | +|-----------------------------|-----| +| Check if empty | `.any(|_| true)` or `.next().is_some()` | +| Check if any match | `.any(predicate)` | +| Check if all match | `.all(predicate)` | +| Count elements | `.count()` | +| Sum elements | `.sum()` | +| Find first | `.find(predicate)` | +| Get first | `.next()` | +| Get last | `.last()` | + +## Pattern: Deferred Collection + +```rust +// Return iterator, let caller collect if needed +fn valid_items(items: &[Item]) -> impl Iterator { + items.iter().filter(|i| i.is_valid()) +} + +// Caller decides +let count = valid_items(&items).count(); // No collection +let vec: Vec<_> = valid_items(&items).collect(); // Collection when needed +``` + +## Comparison + +| Pattern | Allocations | Passes | +|---------|-------------|--------| +| `.collect()` each step | N | N | +| Single chain, one `.collect()` | 1 | 1 | +| No collection (streaming) | 0 | 1 | + +## See Also + +- [perf-collect-once](./perf-collect-once.md) - Single collect +- [perf-iter-lazy](./perf-iter-lazy.md) - Lazy evaluation +- [perf-iter-over-index](./perf-iter-over-index.md) - Iterator patterns diff --git a/.opencode/skills/rust-skills/rules/anti-empty-catch.md b/.opencode/skills/rust-skills/rules/anti-empty-catch.md new file mode 100644 index 0000000..b2b63f2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-empty-catch.md @@ -0,0 +1,132 @@ +# anti-empty-catch + +> Don't silently ignore errors + +## Why It Matters + +Empty error handling (`if let Err(_) = ...`, `let _ = result`, `.ok()`) silently discards errors. Failures go unnoticed, bugs hide, and debugging becomes impossible. Every error deserves acknowledgment—even if just logging. + +## Bad + +```rust +// Silently ignores errors +let _ = write_to_file(data); + +// Discards error completely +if let Err(_) = send_notification() { + // Nothing - error vanishes +} + +// Converts Result to Option, losing error info +let value = risky_operation().ok(); + +// Match with empty arm +match database.save(record) { + Ok(_) => println!("saved"), + Err(_) => {} // Silent failure +} + +// Ignored in loop +for item in items { + let _ = process(item); // Failures unnoticed +} +``` + +## Good + +```rust +// Log the error +if let Err(e) = write_to_file(data) { + error!("failed to write file: {}", e); +} + +// Propagate if possible +send_notification()?; + +// Or handle explicitly +match send_notification() { + Ok(_) => info!("notification sent"), + Err(e) => warn!("notification failed: {}", e), +} + +// Collect errors in batch operations +let (successes, failures): (Vec<_>, Vec<_>) = items + .into_iter() + .map(process) + .partition(Result::is_ok); + +if !failures.is_empty() { + warn!("{} items failed to process", failures.len()); +} + +// Explicit documentation when ignoring +// Intentionally ignored: cleanup failure is not critical +let _ = cleanup_temp_file(); // Add comment explaining why +``` + +## Acceptable Ignoring (Documented) + +```rust +// Close errors often ignored, but document it +// INTENTIONAL: TCP close errors are not actionable +let _ = stream.shutdown(Shutdown::Both); + +// Mutex poisoning recovery +// INTENTIONAL: We'll reset the state anyway +let guard = mutex.lock().unwrap_or_else(|e| e.into_inner()); +``` + +## Pattern: Collect and Report + +```rust +fn process_batch(items: Vec) -> BatchResult { + let mut errors = Vec::new(); + + for item in items { + if let Err(e) = process_item(&item) { + errors.push((item.id, e)); + } + } + + if errors.is_empty() { + BatchResult::AllSucceeded + } else { + BatchResult::PartialFailure(errors) + } +} +``` + +## Pattern: Best-Effort Operations + +```rust +// Metrics/telemetry can fail without affecting main flow +fn report_metric(name: &str, value: f64) { + if let Err(e) = metrics_client.record(name, value) { + // Log but don't propagate - metrics are not critical + debug!("failed to record metric {}: {}", name, e); + } +} +``` + +## Clippy Lint + +```toml +[lints.clippy] +let_underscore_drop = "warn" +ignored_unit_patterns = "warn" +``` + +## Decision Guide + +| Situation | Action | +|-----------|--------| +| Critical operation | `?` or handle explicitly | +| Non-critical, debugging needed | Log the error | +| Truly ignorable (rare) | `let _ =` with comment | +| Batch operation | Collect errors, report | + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Proper error handling +- [err-context-chain](./err-context-chain.md) - Adding context +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap issues diff --git a/.opencode/skills/rust-skills/rules/anti-expect-lazy.md b/.opencode/skills/rust-skills/rules/anti-expect-lazy.md new file mode 100644 index 0000000..24e2b3d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-expect-lazy.md @@ -0,0 +1,95 @@ +# anti-expect-lazy + +> Don't use expect for recoverable errors + +## Why It Matters + +`.expect()` panics with a custom message, but it's still a panic. Using it for errors that could reasonably occur in production (network failures, file not found, invalid input) crashes the program instead of handling the error gracefully. + +Reserve `.expect()` for programming errors where panic is appropriate. + +## Bad + +```rust +// Network failures are expected - don't panic +let response = client.get(url).await.expect("failed to fetch"); + +// Files might not exist +let config = fs::read_to_string("config.toml").expect("config not found"); + +// User input can be invalid +let age: u32 = input.parse().expect("invalid age"); + +// Database queries can fail +let user = db.find_user(id).await.expect("user not found"); +``` + +## Good + +```rust +// Handle recoverable errors properly +let response = client.get(url).await + .context("failed to fetch URL")?; + +// Return error if file doesn't exist +let config = fs::read_to_string("config.toml") + .context("failed to read config file")?; + +// Validate and return error +let age: u32 = input.parse() + .map_err(|_| Error::InvalidInput("age must be a number"))?; + +// Handle missing data +let user = db.find_user(id).await? + .ok_or(Error::NotFound("user"))?; +``` + +## When expect() Is Appropriate + +Use `.expect()` for invariants that indicate bugs: + +```rust +// Mutex poisoning indicates a bug elsewhere +let guard = mutex.lock().expect("mutex poisoned"); + +// Regex is known valid at compile time +let re = Regex::new(r"^\d{4}$").expect("invalid regex"); + +// Thread spawn failure is unrecoverable +let handle = thread::spawn(|| work()).expect("failed to spawn thread"); + +// Static data that must be valid +let config: Config = toml::from_str(EMBEDDED_CONFIG) + .expect("embedded config is invalid"); +``` + +## Pattern: expect() vs unwrap() + +```rust +// unwrap: no context, hard to debug +let x = option.unwrap(); + +// expect: gives context, still panics +let x = option.expect("value should exist after validation"); + +// ?: proper error handling +let x = option.ok_or(Error::MissingValue)?; +``` + +## Decision Guide + +| Situation | Use | +|-----------|-----| +| User input | `?` with error | +| File/network I/O | `?` with error | +| Database operations | `?` with error | +| Parsed constants | `.expect()` | +| Thread/mutex operations | `.expect()` | +| After validation check | `.expect()` with explanation | +| Never expected to fail | `.expect()` documenting invariant | + +## See Also + +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to use expect +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoiding unwrap +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap anti-pattern diff --git a/.opencode/skills/rust-skills/rules/anti-format-hot-path.md b/.opencode/skills/rust-skills/rules/anti-format-hot-path.md new file mode 100644 index 0000000..bc6f2d8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-format-hot-path.md @@ -0,0 +1,144 @@ +# anti-format-hot-path + +> Don't use format! in hot paths + +## Why It Matters + +`format!()` allocates a new `String` every call. In hot paths (loops, frequently called functions), this creates allocation churn that impacts performance. Pre-allocate, reuse buffers, or use `write!()` to an existing buffer. + +## Bad + +```rust +// format! in loop - allocates every iteration +fn log_events(events: &[Event]) { + for event in events { + let message = format!("[{}] {}: {}", event.level, event.source, event.message); + logger.log(&message); + } +} + +// format! for building parts +fn build_url(base: &str, path: &str, params: &[(&str, &str)]) -> String { + let mut url = format!("{}{}", base, path); + for (key, value) in params { + url = format!("{}{}={}&", url, key, value); // New allocation each time + } + url +} + +// format! for simple concatenation +fn greet(name: &str) -> String { + format!("Hello, {}!", name) // Fine for one-off, bad if called 1M times +} +``` + +## Good + +```rust +use std::fmt::Write; + +// Reuse buffer across iterations +fn log_events(events: &[Event]) { + let mut buffer = String::with_capacity(256); + for event in events { + buffer.clear(); + write!(buffer, "[{}] {}: {}", event.level, event.source, event.message).unwrap(); + logger.log(&buffer); + } +} + +// Build incrementally in single buffer +fn build_url(base: &str, path: &str, params: &[(&str, &str)]) -> String { + let mut url = String::with_capacity(base.len() + path.len() + params.len() * 20); + url.push_str(base); + url.push_str(path); + for (key, value) in params { + write!(url, "{}={}&", key, value).unwrap(); + } + url +} + +// For truly hot paths, avoid allocation entirely +fn greet_to_buf(name: &str, buffer: &mut String) { + buffer.clear(); + buffer.push_str("Hello, "); + buffer.push_str(name); + buffer.push('!'); +} +``` + +## Comparison + +| Approach | Allocations | Performance | +|----------|-------------|-------------| +| `format!()` in loop | N | Slow | +| `write!()` to reused buffer | 1 | Fast | +| `push_str()` + `push()` | 1 | Fastest | +| Pre-sized `String::with_capacity()` | 1 (no realloc) | Fast | + +## When format! Is Fine + +```rust +// One-time initialization +let config_path = format!("{}/config.toml", home_dir); + +// Error messages (not hot path) +return Err(format!("invalid input: {}", input)); + +// Debug output +println!("Debug: {:?}", value); +``` + +## Pattern: Formatter Buffer Pool + +```rust +use std::cell::RefCell; +use std::fmt::Write; // for write! into the String buffer + +thread_local! { + static BUFFER: RefCell = RefCell::new(String::with_capacity(256)); +} + +fn format_event(event: &Event) -> String { + BUFFER.with(|buf| { + let mut buf = buf.borrow_mut(); + buf.clear(); + write!(buf, "[{}] {}", event.level, event.message).unwrap(); + buf.clone() // Still one allocation per call, but no parsing + }) +} +``` + +## Pattern: Display Implementation + +```rust +use std::fmt::Write; // for the caller's write! into a String + +struct Event { + level: Level, + message: String, +} + +impl std::fmt::Display for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.level, self.message) + } +} + +// Caller controls allocation +let mut buf = String::new(); +write!(buf, "{}", event)?; +``` + +## Clippy Lint + +```toml +[lints.clippy] +format_in_format_args = "warn" +``` + +## See Also + +- [mem-avoid-format](./mem-avoid-format.md) - Avoiding format +- [mem-write-over-format](./mem-write-over-format.md) - Using write! +- [mem-reuse-collections](./mem-reuse-collections.md) - Buffer reuse diff --git a/.opencode/skills/rust-skills/rules/anti-index-over-iter.md b/.opencode/skills/rust-skills/rules/anti-index-over-iter.md new file mode 100644 index 0000000..c22d85f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-index-over-iter.md @@ -0,0 +1,125 @@ +# anti-index-over-iter + +> Don't use indexing when iterators work + +## Why It Matters + +Manual indexing (`for i in 0..len`) requires bounds checks on every access, prevents SIMD optimization, and introduces off-by-one error risks. Iterators eliminate these issues and are more idiomatic Rust. + +## Bad + +```rust +// Manual indexing - bounds checked every access +fn sum_squares(data: &[i32]) -> i64 { + let mut result = 0i64; + for i in 0..data.len() { + result += (data[i] as i64) * (data[i] as i64); + } + result +} + +// Index-based with multiple arrays +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + let mut sum = 0.0; + for i in 0..a.len().min(b.len()) { + sum += a[i] * b[i]; + } + sum +} + +// Mutation with indices +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(0.0, f64::max); + for i in 0..data.len() { + data[i] /= max; + } +} +``` + +## Good + +```rust +// Iterator - no bounds checks, SIMD-friendly +fn sum_squares(data: &[i32]) -> i64 { + data.iter() + .map(|&x| (x as i64) * (x as i64)) + .sum() +} + +// Zip - handles length mismatch automatically +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| x * y) + .sum() +} + +// Mutable iteration +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(0.0, f64::max); + for x in data.iter_mut() { + *x /= max; + } +} +``` + +## When Indices Are Needed + +Sometimes you genuinely need indices: + +```rust +// Need index in output +for (i, item) in items.iter().enumerate() { + println!("{}: {}", i, item); +} + +// Non-sequential access +for i in (0..len).step_by(2) { + swap(&mut data[i], &mut data[i + 1]); +} + +// Multi-dimensional iteration +for i in 0..rows { + for j in 0..cols { + matrix[i][j] = i * cols + j; + } +} +``` + +## Comparison + +| Pattern | Bounds Checks | SIMD | Safety | +|---------|---------------|------|--------| +| `for i in 0..len { data[i] }` | Every access | Limited | Off-by-one risk | +| `for x in &data` | None | Good | Safe | +| `for x in data.iter()` | None | Good | Safe | +| `data.iter().enumerate()` | None | Good | Safe | + +## Common Conversions + +| Index Pattern | Iterator Pattern | +|---------------|------------------| +| `for i in 0..v.len()` | `for x in &v` | +| `v[0]` | `v.first()` | +| `v[v.len()-1]` | `v.last()` | +| `for i in 0..a.len() { a[i] + b[i] }` | `a.iter().zip(&b)` | +| `for i in 0..v.len() { v[i] *= 2 }` | `for x in &mut v { *x *= 2 }` | + +## Performance Note + +```rust +// Iterator version can auto-vectorize +let sum: i32 = data.iter().sum(); + +// Manual indexing prevents vectorization +let mut sum = 0; +for i in 0..data.len() { + sum += data[i]; +} +``` + +## See Also + +- [perf-iter-over-index](./perf-iter-over-index.md) - Performance details +- [opt-bounds-check](./opt-bounds-check.md) - Bounds check elimination +- [perf-iter-lazy](./perf-iter-lazy.md) - Lazy iterators diff --git a/.opencode/skills/rust-skills/rules/anti-lock-across-await.md b/.opencode/skills/rust-skills/rules/anti-lock-across-await.md new file mode 100644 index 0000000..20742d8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-lock-across-await.md @@ -0,0 +1,127 @@ +# anti-lock-across-await + +> Don't hold locks across await points + +## Why It Matters + +Holding a `Mutex` or `RwLock` guard across an `.await` causes the lock to be held while the task is suspended. Other tasks waiting for the lock block indefinitely. With `std::sync::Mutex`, this is even worse—it can deadlock the entire runtime. + +## Bad + +```rust +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +// DEADLOCK RISK: std::sync::Mutex held across await +async fn bad_std_mutex(data: &Mutex>) { + let mut guard = data.lock().unwrap(); + do_async_work().await; // Lock held during await! + guard.push(42); +} + +// BLOCKS OTHER TASKS: tokio Mutex held across await +async fn bad_async_mutex(data: &AsyncMutex>) { + let mut guard = data.lock().await; + slow_network_call().await; // Lock held for entire call! + guard.push(42); +} +``` + +## Good + +```rust +use std::sync::Mutex; +use tokio::sync::Mutex as AsyncMutex; + +// Release lock before await +async fn good_approach(data: &Mutex>) { + let value = { + let guard = data.lock().unwrap(); + guard.last().copied() // Extract what you need + }; // Lock released here + + let result = do_async_work(value).await; + + { + let mut guard = data.lock().unwrap(); + guard.push(result); + } +} + +// Minimize lock scope with async mutex +async fn good_async_mutex(data: &AsyncMutex>, item: i32) { + // Quick lock, quick release + data.lock().await.push(item); + + // Async work without lock + let result = slow_network_call().await; + + // Quick lock again + data.lock().await.push(result); +} +``` + +## Pattern: Clone Before Await + +```rust +async fn process(data: &AsyncMutex) -> Result<()> { + // Clone inside lock scope + let config = data.lock().await.clone(); + + // Now use config freely across awaits + let result = fetch_data(&config.url).await?; + process_result(&config, result).await?; + + Ok(()) +} +``` + +## Pattern: Restructure to Avoid Lock + +```rust +// Instead of locking a shared map +struct Service { + data: AsyncMutex>, +} + +// Use channels or owned data +struct BetterService { + // Each task owns its data via channels + sender: mpsc::Sender, +} + +impl BetterService { + async fn request(&self, key: String) -> Data { + let (tx, rx) = oneshot::channel(); + self.sender.send(Request { key, respond: tx }).await?; + rx.await? + } +} +``` + +## What Can Cross Await + +| Type | Safe Across Await? | +|------|--------------------| +| `std::sync::Mutex` guard | **NO** - can deadlock | +| `std::sync::RwLock` guard | **NO** - can deadlock | +| `tokio::sync::Mutex` guard | Allowed but blocks tasks | +| `tokio::sync::RwLock` guard | Allowed but blocks tasks | +| Owned values | Yes | +| `Arc` | Yes | +| References | Depends on lifetime | + +## Detection + +```toml +# Cargo.toml +[lints.clippy] +await_holding_lock = "deny" +await_holding_refcell_ref = "deny" +``` + +## See Also + +- [async-no-lock-await](./async-no-lock-await.md) - Async lock patterns +- [async-clone-before-await](./async-clone-before-await.md) - Clone pattern +- [own-mutex-interior](./own-mutex-interior.md) - Mutex usage diff --git a/.opencode/skills/rust-skills/rules/anti-over-abstraction.md b/.opencode/skills/rust-skills/rules/anti-over-abstraction.md new file mode 100644 index 0000000..44a12f0 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-over-abstraction.md @@ -0,0 +1,120 @@ +# anti-over-abstraction + +> Don't over-abstract with excessive generics + +## Why It Matters + +Generics and traits are powerful but come at a cost: compile times, binary size, and cognitive load. Over-abstraction—making everything generic "for flexibility"—often adds complexity without benefit. Start concrete; generalize when you have real use cases. + +## Bad + +```rust +// Overly generic for a simple function +fn add(a: T, b: U) -> R +where + T: Into, + U: Into, + R: std::ops::Add, +{ + a.into() + b.into() +} + +// Just call add(1, 2) - why make it this complex? + +// Trait explosion +trait Readable {} +trait Writable {} +trait ReadWritable: Readable + Writable {} +trait AsyncReadable {} +trait AsyncWritable {} +trait AsyncReadWritable: AsyncReadable + AsyncWritable {} + +// Abstract factory pattern (Java flashback) +trait Factory { + fn create(&self) -> T; +} +trait FactoryFactory, T> { + fn create_factory(&self) -> F; +} +``` + +## Good + +```rust +// Concrete implementation - clear and simple +fn add_i32(a: i32, b: i32) -> i32 { + a + b +} + +// Generic when actually needed (e.g., library code) +fn add>(a: T, b: T) -> T { + a + b +} + +// Simple traits for actual polymorphism needs +trait Storage { + fn save(&self, key: &str, value: &[u8]) -> Result<(), Error>; + fn load(&self, key: &str) -> Result, Error>; +} + +// Concrete types first +struct FileStorage { path: PathBuf } +struct MemoryStorage { data: HashMap> } +``` + +## Signs of Over-Abstraction + +| Sign | Symptom | +|------|---------| +| Single implementation | Generic trait with only one impl | +| Type parameter soup | `T, U, V, W` everywhere | +| Marker traits | Traits with no methods | +| Deep trait bounds | `where T: A + B + C + D + E` | +| Phantom generics | Type parameters not used meaningfully | + +## When to Generalize + +Generalize when: +- You have 2+ concrete types that share behavior +- You're writing library code for public consumption +- Performance requires static dispatch +- The abstraction simplifies the API + +Don't generalize when: +- You "might need it later" (YAGNI) +- Only one type will ever implement it +- It makes code harder to understand + +## Rule of Three + +Wait until you have three similar concrete implementations before abstracting: + +```rust +// Version 1: Just FileStorage +struct FileStorage { /* ... */ } + +// Version 2: Added MemoryStorage, similar interface +struct MemoryStorage { /* ... */ } + +// Version 3: Now Redis too - time to abstract +trait Storage { + fn save(&self, key: &str, value: &[u8]) -> Result<()>; + fn load(&self, key: &str) -> Result>; +} +``` + +## Prefer Concrete Types in Private Code + +```rust +// Internal function - concrete type is fine +fn process_orders(db: &PostgresDb, orders: Vec) { } + +// Public API - might benefit from abstraction +pub fn process_orders(storage: &S, orders: Vec) { } +``` + +## See Also + +- [type-generic-bounds](./type-generic-bounds.md) - Minimal bounds +- [api-sealed-trait](./api-sealed-trait.md) - Controlled extension +- [anti-type-erasure](./anti-type-erasure.md) - When Box is wrong diff --git a/.opencode/skills/rust-skills/rules/anti-panic-expected.md b/.opencode/skills/rust-skills/rules/anti-panic-expected.md new file mode 100644 index 0000000..cecb34c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-panic-expected.md @@ -0,0 +1,131 @@ +# anti-panic-expected + +> Don't panic on expected or recoverable errors + +## Why It Matters + +Panics crash the program. They're for unrecoverable situations—bugs, corrupted state, invariant violations. Using panic for expected conditions (network failures, file not found, invalid input) makes programs fragile and forces callers to catch panics or die. + +Use `Result` for recoverable errors. + +## Bad + +```rust +// Network failures are expected +fn fetch_data(url: &str) -> Data { + let response = reqwest::blocking::get(url) + .expect("network error"); // Crashes on timeout + response.json().expect("invalid json") // Crashes on bad response +} + +// User input is often invalid +fn parse_config(input: &str) -> Config { + toml::from_str(input).expect("invalid config") // Crashes on typo +} + +// Files may not exist +fn load_settings() -> Settings { + let content = fs::read_to_string("settings.json") + .expect("settings not found"); // Crashes if missing + serde_json::from_str(&content).expect("invalid settings") +} + +// Custom panic for validation +fn process_age(age: i32) { + if age < 0 { + panic!("age cannot be negative"); // Should return error + } +} +``` + +## Good + +```rust +// Return errors for expected failures +fn fetch_data(url: &str) -> Result { + let response = reqwest::blocking::get(url) + .context("failed to connect")?; + let data = response.json() + .context("failed to parse response")?; + Ok(data) +} + +// Validate and return Result +fn parse_config(input: &str) -> Result { + toml::from_str(input).map_err(ConfigError::Parse) +} + +// Handle missing files gracefully +fn load_settings() -> Result { + let content = fs::read_to_string("settings.json")?; + let settings = serde_json::from_str(&content)?; + Ok(settings) +} + +// Return error for validation failure +fn process_age(age: i32) -> Result<(), ValidationError> { + if age < 0 { + return Err(ValidationError::NegativeAge); + } + Ok(()) +} +``` + +## When to Panic + +Panic IS appropriate for: + +```rust +// Bug detection - invariant violated +fn get_unchecked(&self, index: usize) -> &T { + assert!(index < self.len(), "index out of bounds - this is a bug"); + unsafe { self.data.get_unchecked(index) } +} + +// Unrecoverable state +fn init() { + if !CAN_PROCEED { + panic!("system requirements not met"); + } +} + +// Tests +#[test] +fn test_fails() { + panic!("expected panic in test"); +} +``` + +## Decision Guide + +| Condition | Action | +|-----------|--------| +| Invalid user input | Return `Err` | +| Network failure | Return `Err` | +| File not found | Return `Err` | +| Malformed data | Return `Err` | +| Bug/impossible state | `panic!` or `unreachable!` | +| Failed assertion in test | `panic!` | +| Unrecoverable init failure | `panic!` | + +## Anti-pattern: panic! for Control Flow + +```rust +// BAD: Using panic for control flow +fn find_or_die(items: &[Item], id: u64) -> &Item { + items.iter() + .find(|i| i.id == id) + .unwrap_or_else(|| panic!("item {} not found", id)) +} + +// GOOD: Return Option or Result +fn find(items: &[Item], id: u64) -> Option<&Item> { + items.iter().find(|i| i.id == id) +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Use Result +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Unwrap anti-pattern +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to expect diff --git a/.opencode/skills/rust-skills/rules/anti-premature-optimize.md b/.opencode/skills/rust-skills/rules/anti-premature-optimize.md new file mode 100644 index 0000000..8288b53 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-premature-optimize.md @@ -0,0 +1,158 @@ +# anti-premature-optimize + +> Don't optimize before profiling + +## Why It Matters + +Premature optimization wastes time, complicates code, and often targets the wrong bottlenecks. Most code isn't performance-critical; the hot 10% matters. Profile first, then optimize the actual bottlenecks with data-driven decisions. + +## Bad + +```rust +// "Optimizing" without measurement +fn sum(data: &[i32]) -> i32 { + // Using unsafe "for performance" without profiling + unsafe { + let mut sum = 0; + for i in 0..data.len() { + sum += *data.get_unchecked(i); + } + sum + } +} + +// Complex caching with no evidence it's needed +lazy_static! { + static ref CACHE: RwLock>> = + RwLock::new(HashMap::new()); +} + +// Hand-rolled data structures "for speed" +struct MyVec { + ptr: *mut T, + len: usize, + cap: usize, +} +``` + +## Good + +```rust +// Simple, idiomatic - let compiler optimize +fn sum(data: &[i32]) -> i32 { + data.iter().sum() +} + +// Profile, then optimize if needed +fn sum_optimized(data: &[i32]) -> i32 { + // After profiling showed this is a bottleneck, + // we measured that manual SIMD gives 3x speedup + #[cfg(target_arch = "x86_64")] + { + // a hand-written SIMD path would go here (measured ~3x faster); + // fall back to the iterator version as a placeholder + data.iter().sum() + } + #[cfg(not(target_arch = "x86_64"))] + { + data.iter().sum() + } +} + +// Use standard library - it's well-optimized +let cache: HashMap = HashMap::new(); +``` + +## Profiling Workflow + +```bash +# 1. Write correct code first +cargo build --release + +# 2. Profile with real workloads +cargo flamegraph --bin my_app -- --real-args +# or +cargo bench + +# 3. Identify hotspots (top 10% of time) + +# 4. Measure before optimizing +# 5. Optimize ONE thing +# 6. Measure after - verify improvement +# 7. Repeat if still slow +``` + +## Optimization Principles + +| Do | Don't | +|----|-------| +| Profile first | Guess at bottlenecks | +| Optimize hotspots | Optimize everything | +| Measure improvement | Assume it's faster | +| Keep it simple | Add complexity speculatively | +| Trust the compiler | Outsmart the compiler | + +## When to Optimize + +```rust +// AFTER profiling shows this is 40% of runtime +#[inline] +fn hot_function(data: &[u8]) -> u64 { + // Optimized implementation justified by benchmarks +} + +// Clear, measurable benefit documented +/// Pre-allocated buffer for repeated formatting. +/// Benchmarks show 3x speedup for >1000 calls/sec workloads. +struct FormatterPool { + buffers: Vec, +} +``` + +## Common Premature Optimizations + +| Premature | Reality | +|-----------|---------| +| `#[inline(always)]` everywhere | Compiler usually knows better | +| `unsafe` for bounds check removal | Iterator does this safely | +| Custom allocator | Default is usually fine | +| Object pooling | Allocator is fast enough | +| Manual SIMD | Auto-vectorization works | + +## Profile Tools + +```bash +# Sampling profiler +perf record ./target/release/app && perf report + +# Flamegraph +cargo install flamegraph +cargo flamegraph + +# Criterion benchmarks +cargo bench + +# Memory profiling +valgrind --tool=massif ./target/release/app +``` + +## Document Optimizations + +```rust +/// Lookup table for fast character classification. +/// +/// # Performance +/// +/// Benchmarked with criterion (benchmarks/char_class.rs): +/// - Table lookup: 2.3ns/op +/// - Match statement: 8.7ns/op +/// +/// Justified for hot path in parser (called 10M+ times). +static CHAR_CLASS: [CharClass; 256] = [/* ... */]; +``` + +## See Also + +- [perf-profile-first](./perf-profile-first.md) - Profile before optimize +- [test-criterion-bench](./test-criterion-bench.md) - Benchmarking +- [opt-inline-small](./opt-inline-small.md) - Inline guidelines diff --git a/.opencode/skills/rust-skills/rules/anti-string-for-str.md b/.opencode/skills/rust-skills/rules/anti-string-for-str.md new file mode 100644 index 0000000..16de141 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-string-for-str.md @@ -0,0 +1,122 @@ +# anti-string-for-str + +> Don't accept &String when &str works + +## Why It Matters + +`&String` is strictly less flexible than `&str`. A `&str` can be created from `String`, `&str`, literals, and slices. A `&String` requires exactly a `String`. This forces callers to allocate when they might not need to. + +## Bad + +```rust +// Forces callers to have a String +fn greet(name: &String) { + println!("Hello, {}", name); +} + +// Caller must allocate +greet(&"Alice".to_string()); // Unnecessary allocation +greet(&name); // Only works if name is String + +// In struct +struct Config { + name: String, +} + +impl Config { + fn set_name(&mut self, name: &String) { // Too restrictive + self.name = name.clone(); + } +} +``` + +## Good + +```rust +// Accept &str - works with String, &str, literals +fn greet(name: &str) { + println!("Hello, {}", name); +} + +// All these work +greet("Alice"); // String literal +greet(&name); // &String coerces to &str +greet(name.as_str()); // Explicit &str + +// In struct +impl Config { + fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } + + // Or accept owned String if caller usually has one + fn set_name_owned(&mut self, name: String) { + self.name = name; + } + + // Or be generic + fn set_name_into(&mut self, name: impl Into) { + self.name = name.into(); + } +} +``` + +## Deref Coercion + +`String` implements `Deref`, so `&String` automatically coerces to `&str`: + +```rust +fn takes_str(s: &str) { } + +let owned = String::from("hello"); +takes_str(&owned); // &String -> &str via Deref +``` + +## When to Accept &String + +Rarely. Maybe if you need `String`-specific methods: + +```rust +fn needs_capacity(s: &String) -> usize { + s.capacity() // Only String has capacity() +} +``` + +But usually you'd take `&str` and let the caller manage the `String`. + +## Pattern: Flexible APIs + +```rust +// Most flexible: accept anything that can become &str +fn process(input: impl AsRef) { + let s: &str = input.as_ref(); + // ... +} + +process("literal"); +process(String::from("owned")); +process(&some_string); +``` + +## Similar Anti-patterns + +| Anti-pattern | Better | +|--------------|--------| +| `&String` | `&str` | +| `&Vec` | `&[T]` | +| `&Box` | `&T` | +| `&PathBuf` | `&Path` | +| `&OsString` | `&OsStr` | + +## Clippy Detection + +```toml +[lints.clippy] +ptr_arg = "warn" # Catches &String, &Vec, &PathBuf +``` + +## See Also + +- [anti-vec-for-slice](./anti-vec-for-slice.md) - Similar pattern for Vec +- [own-slice-over-vec](./own-slice-over-vec.md) - Slice patterns +- [api-impl-asref](./api-impl-asref.md) - AsRef pattern diff --git a/.opencode/skills/rust-skills/rules/anti-stringly-typed.md b/.opencode/skills/rust-skills/rules/anti-stringly-typed.md new file mode 100644 index 0000000..fac9579 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-stringly-typed.md @@ -0,0 +1,167 @@ +# anti-stringly-typed + +> Don't use strings where enums or newtypes would provide type safety + +## Why It Matters + +Strings are the most primitive way to represent data—they accept any value, provide no validation, and offer no IDE support. When you have a fixed set of valid values or a semantic type, use enums or newtypes. The compiler catches mistakes at compile time instead of runtime. + +## Bad + +```rust +fn process_order(status: &str, priority: &str) { + // What are valid statuses? "pending"? "Pending"? "PENDING"? + // What are valid priorities? "high"? "1"? "urgent"? + match status { + "pending" => { ... } + "completed" => { ... } + _ => panic!("unknown status"), // Runtime error + } +} + +struct User { + email: String, // Any string, even "not an email" + phone: String, // Any string, even "hello" + user_id: String, // Could be confused with other string IDs +} + +// Easy to make mistakes +process_order("complete", "high"); // Typo: "complete" vs "completed" +process_order("high", "pending"); // Swapped arguments - compiles! +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OrderStatus { + Pending, + Processing, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Priority { + Low, + Medium, + High, + Critical, +} + +fn process_order(status: OrderStatus, priority: Priority) { + match status { + OrderStatus::Pending => { ... } + OrderStatus::Processing => { ... } + OrderStatus::Completed => { ... } + OrderStatus::Cancelled => { ... } + } // Exhaustive - compiler checks all cases +} + +// Validated newtypes +struct Email(String); +struct PhoneNumber(String); +struct UserId(u64); + +impl Email { + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(ValidationError::InvalidEmail) + } + } +} + +struct User { + email: Email, // Must be valid email + phone: PhoneNumber, // Must be valid phone + user_id: UserId, // Can't confuse with other IDs +} + +// Compile errors catch mistakes +process_order(OrderStatus::Completed, Priority::High); // Clear and correct +process_order(Priority::High, OrderStatus::Pending); // Compile error! +``` + +## Parsing Strings to Types + +```rust +use std::str::FromStr; + +#[derive(Debug, Clone, Copy)] +enum OrderStatus { + Pending, + Processing, + Completed, + Cancelled, +} + +impl FromStr for OrderStatus { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "pending" => Ok(OrderStatus::Pending), + "processing" => Ok(OrderStatus::Processing), + "completed" => Ok(OrderStatus::Completed), + "cancelled" | "canceled" => Ok(OrderStatus::Cancelled), + _ => Err(ParseError::UnknownStatus(s.to_string())), + } + } +} + +// Parse at boundary, use types internally +fn handle_request(status_str: &str) -> Result<(), Error> { + let status: OrderStatus = status_str.parse()?; // Validate once + process_order(status); // Type-safe from here + Ok(()) +} +``` + +## With Serde + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Status { + Pending, + InProgress, + Completed, +} + +// JSON: {"status": "in_progress"} +// Deserialization validates automatically +``` + +## Error Messages + +```rust +#[derive(Debug, Clone, Copy)] +enum Color { + Red, + Green, + Blue, +} + +impl std::fmt::Display for Color { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Color::Red => write!(f, "red"), + Color::Green => write!(f, "green"), + Color::Blue => write!(f, "blue"), + } + } +} + +// Type-safe and displayable +println!("Selected color: {}", Color::Red); +``` + +## See Also + +- [api-newtype-safety](./api-newtype-safety.md) - Newtype pattern +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Parse at boundaries +- [type-newtype-ids](./type-newtype-ids.md) - Type-safe IDs diff --git a/.opencode/skills/rust-skills/rules/anti-type-erasure.md b/.opencode/skills/rust-skills/rules/anti-type-erasure.md new file mode 100644 index 0000000..b8158c3 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-type-erasure.md @@ -0,0 +1,140 @@ +# anti-type-erasure + +> Don't use Box when impl Trait works + +## Why It Matters + +`Box` (type erasure) introduces heap allocation and dynamic dispatch overhead. When you have a single concrete type or can use generics, `impl Trait` provides the same flexibility with zero overhead through monomorphization. + +## Bad + +```rust +// Unnecessary type erasure +fn get_iterator() -> Box> { + Box::new((0..10).map(|x| x * 2)) +} + +// Boxing for no reason +fn make_handler() -> Box i32> { + Box::new(|x| x + 1) +} + +// Vec of boxed trait objects when one type would do +fn get_validators() -> Vec> { + vec![ + Box::new(LengthValidator), + Box::new(RegexValidator), + ] +} +``` + +## Good + +```rust +// impl Trait - zero overhead, inlined +fn get_iterator() -> impl Iterator { + (0..10).map(|x| x * 2) +} + +// impl Fn - no boxing +fn make_handler() -> impl Fn(i32) -> i32 { + |x| x + 1 +} + +// When mixed types are genuinely needed, Box is OK +fn get_validators() -> Vec> { + // Actually different types at runtime - Box is appropriate + config.validators.iter() + .map(|v| v.create_validator()) + .collect() +} +``` + +## When to Use Box + +Type erasure IS appropriate when: + +```rust +// Heterogeneous collection of different types +let handlers: Vec> = vec![ + Box::new(LogHandler), + Box::new(MetricsHandler), + Box::new(AuthHandler), +]; + +// Type cannot be known at compile time +fn create_from_config(config: &Config) -> Box { + match config.db_type { + DbType::Postgres => Box::new(PostgresDb::new()), + DbType::Sqlite => Box::new(SqliteDb::new()), + } +} + +// Recursive types +struct Node { + value: i32, + children: Vec>, +} + +// Breaking cycles in complex ownership +struct EventLoop { + handlers: Vec>, +} +``` + +## Comparison + +| Approach | Allocation | Dispatch | Binary Size | +|----------|------------|----------|-------------| +| `impl Trait` | Stack/inline | Static | Larger (monomorphization) | +| `Box` | Heap | Dynamic | Smaller | +| Generics `` | Stack/inline | Static | Larger | + +## impl Trait Positions + +```rust +// Return position - caller doesn't need to know concrete type +fn process() -> impl Future { } + +// Argument position - like generics but simpler +fn handle(handler: impl Handler) { } + +// Return-position impl Trait in traits (RPITIT) is stable since Rust 1.75 +trait Processor { + // Use impl Trait when callers don't need to name the return type: + fn process(&self) -> impl Display; // stable, idiomatic (Rust 1.75+) + + // Use an associated type when callers need to name or constrain the type: + type Output: Display; + fn process_named(&self) -> Self::Output; +} +``` + +## Pattern: Enum Instead of dyn + +```rust +// Instead of Box +enum Shape { + Circle { radius: f64 }, + Rectangle { width: f64, height: f64 }, + Triangle { base: f64, height: f64 }, +} + +impl Shape { + fn area(&self) -> f64 { + match self { + Shape::Circle { radius } => PI * radius * radius, + Shape::Rectangle { width, height } => width * height, + Shape::Triangle { base, height } => 0.5 * base * height, + } + } +} +``` + +## See Also + +- [anti-over-abstraction](./anti-over-abstraction.md) - Excessive generics +- [type-generic-bounds](./type-generic-bounds.md) - Generic constraints +- [mem-box-large-variant](./mem-box-large-variant.md) - Boxing enum variants +- [trait-dyn-vs-generic](./trait-dyn-vs-generic.md) - Choose dispatch deliberately +- [closure-static-vs-dyn](./closure-static-vs-dyn.md) - Same tradeoff for closures diff --git a/.opencode/skills/rust-skills/rules/anti-unwrap-abuse.md b/.opencode/skills/rust-skills/rules/anti-unwrap-abuse.md new file mode 100644 index 0000000..ca33261 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-unwrap-abuse.md @@ -0,0 +1,143 @@ +# anti-unwrap-abuse + +> Don't use `.unwrap()` in production code + +## Why It Matters + +`.unwrap()` panics on `None` or `Err`, crashing your program. In production, this means lost data, failed requests, and unhappy users. It also makes debugging harder since panic messages often lack context. + +## Bad + +```rust +// Crashes if file doesn't exist +let content = std::fs::read_to_string("config.toml").unwrap(); + +// Crashes on invalid input +let num: i32 = user_input.parse().unwrap(); + +// Crashes if key missing +let value = map.get("key").unwrap(); + +// Crashes if channel closed +let msg = receiver.recv().unwrap(); +``` + +## Good + +```rust +// Propagate with ? +fn load_config() -> Result { + let content = std::fs::read_to_string("config.toml")?; + Ok(toml::from_str(&content)?) +} + +// Provide default +let num: i32 = user_input.parse().unwrap_or(0); + +// Handle missing key +let value = map.get("key").ok_or(Error::MissingKey)?; + +// Or use if-let +if let Some(value) = map.get("key") { + process(value); +} + +// Channel with proper handling +match receiver.recv() { + Ok(msg) => handle(msg), + Err(_) => break, // Channel closed +} +``` + +## When unwrap() Is Acceptable + +```rust +// 1. Tests - panics are expected failures +#[test] +fn test_parse() { + let result = parse("valid").unwrap(); // OK in tests + assert_eq!(result, expected); +} + +// 2. Const/static initialization (compile-time guaranteed) +static REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^\d+$").unwrap() // Known-valid pattern +}); + +// 3. After a check that guarantees success +if map.contains_key("key") { + let value = map.get("key").unwrap(); // Just checked +} +// Better: use if-let or entry API instead + +// 4. Truly impossible cases with proof comment +let last = vec.pop().unwrap(); +// OK only if you just checked !vec.is_empty() +// Better: use last() or pattern match +``` + +## Alternatives to unwrap() + +```rust +// unwrap_or - provide default +let x = opt.unwrap_or(default); + +// unwrap_or_default - use Default trait +let x = opt.unwrap_or_default(); + +// unwrap_or_else - compute default lazily +let x = opt.unwrap_or_else(|| expensive_default()); + +// ? operator - propagate errors +let x = opt.ok_or(Error::Missing)?; + +// if let - handle Some/Ok case +if let Some(x) = opt { + use_x(x); +} + +// match - handle all cases +match opt { + Some(x) => use_x(x), + None => handle_none(), +} + +// map - transform if present +let y = opt.map(|x| x + 1); + +// and_then - chain fallible operations +let z = opt.and_then(|x| x.checked_add(1)); +``` + +## expect() Is Slightly Better + +```rust +// unwrap() - no context +let file = File::open(path).unwrap(); +// Panics with: "called `Result::unwrap()` on an `Err` value: Os { code: 2, ... }" + +// expect() - adds context +let file = File::open(path) + .expect("config file should exist at startup"); +// Panics with: "config file should exist at startup: Os { code: 2, ... }" + +// But still use only for invariants, not error handling +``` + +## Clippy Lint + +```rust +// Enable these lints to catch unwrap usage: +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] // Stricter + +// Or per-function: +#[allow(clippy::unwrap_used)] +fn tests_only() { } +``` + +## See Also + +- [err-question-mark](err-question-mark.md) - Use ? for propagation +- [err-result-over-panic](err-result-over-panic.md) - Return Result instead of panicking +- [anti-expect-lazy](anti-expect-lazy.md) - Don't use expect for recoverable errors diff --git a/.opencode/skills/rust-skills/rules/anti-vec-for-slice.md b/.opencode/skills/rust-skills/rules/anti-vec-for-slice.md new file mode 100644 index 0000000..ed50a03 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/anti-vec-for-slice.md @@ -0,0 +1,121 @@ +# anti-vec-for-slice + +> Don't accept &Vec when &[T] works + +## Why It Matters + +`&Vec` is strictly less flexible than `&[T]`. A slice can be created from `Vec`, arrays, and other slice-like types. Accepting `&Vec` forces callers to have exactly a `Vec`, preventing them from using arrays, slices, or other collections. + +## Bad + +```rust +// Forces callers to have a Vec +fn sum(numbers: &Vec) -> i32 { + numbers.iter().sum() +} + +// Caller must allocate +let arr = [1, 2, 3, 4, 5]; +sum(&arr.to_vec()); // Unnecessary allocation + +// Slice won't work +let slice: &[i32] = &[1, 2, 3]; +// sum(slice); // Error: expected &Vec +``` + +## Good + +```rust +// Accept slice - works with Vec, arrays, slices +fn sum(numbers: &[i32]) -> i32 { + numbers.iter().sum() +} + +// All these work +sum(&[1, 2, 3, 4, 5]); // Array +sum(&vec![1, 2, 3]); // Vec +sum(&numbers[1..3]); // Slice of slice +sum(numbers.as_slice()); // Explicit slice +``` + +## Deref Coercion + +`Vec` implements `Deref`, so `&Vec` automatically coerces to `&[T]`: + +```rust +fn takes_slice(s: &[i32]) { } + +let vec = vec![1, 2, 3]; +takes_slice(&vec); // &Vec -> &[i32] via Deref +``` + +## Mutable Slices + +Same applies to `&mut`: + +```rust +// Bad +fn double(numbers: &mut Vec) { + for n in numbers.iter_mut() { + *n *= 2; + } +} + +// Good +fn double(numbers: &mut [i32]) { + for n in numbers.iter_mut() { + *n *= 2; + } +} +``` + +## When to Accept &Vec + +Rarely. Only when you need Vec-specific operations: + +```rust +fn needs_capacity(v: &Vec) -> usize { + v.capacity() // Only Vec has capacity +} + +fn might_grow(v: &mut Vec) { + v.push(42); // Slice can't push +} +``` + +## Pattern: Accepting Multiple Types + +```rust +// Accept anything that can be viewed as a slice +fn process>(data: T) { + let bytes: &[u8] = data.as_ref(); + // ... +} + +process(&[1u8, 2, 3]); // Array +process(vec![1u8, 2, 3]); // Vec +process(&some_vec); // &Vec +process(b"bytes"); // Byte string +``` + +## Similar Anti-patterns + +| Anti-pattern | Better | +|--------------|--------| +| `&Vec` | `&[T]` | +| `&String` | `&str` | +| `&PathBuf` | `&Path` | +| `&Box` | `&T` | + +## Clippy Detection + +```toml +[lints.clippy] +ptr_arg = "warn" # Catches &Vec, &String, &PathBuf +``` + +## See Also + +- [anti-string-for-str](./anti-string-for-str.md) - Similar for String +- [own-slice-over-vec](./own-slice-over-vec.md) - Slice patterns +- [api-impl-asref](./api-impl-asref.md) - AsRef pattern diff --git a/.opencode/skills/rust-skills/rules/api-builder-must-use.md b/.opencode/skills/rust-skills/rules/api-builder-must-use.md new file mode 100644 index 0000000..988849d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-builder-must-use.md @@ -0,0 +1,143 @@ +# api-builder-must-use + +> Mark builder methods with `#[must_use]` to prevent silent drops + +## Why It Matters + +Builder pattern methods return a modified builder. Without `#[must_use]`, calling a builder method and ignoring the return value silently does nothing—the builder is dropped, and the configuration is lost. This creates confusing bugs where code appears correct but has no effect. + +## Bad + +```rust +struct RequestBuilder { + url: String, + timeout: Option, + headers: Vec<(String, String)>, +} + +impl RequestBuilder { + fn timeout(mut self, duration: Duration) -> Self { + self.timeout = Some(duration); + self + } + + fn header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } +} + +// Bug: builder methods are ignored - no warning! +let request = RequestBuilder::new("https://api.example.com"); +request.timeout(Duration::from_secs(30)); // Dropped silently! +request.header("Authorization", "Bearer token"); // Dropped silently! +let response = request.send(); // Sends with no timeout or headers +``` + +## Good + +```rust +struct RequestBuilder { + url: String, + timeout: Option, + headers: Vec<(String, String)>, +} + +impl RequestBuilder { + #[must_use = "builder methods return modified builder - chain or assign"] + fn timeout(mut self, duration: Duration) -> Self { + self.timeout = Some(duration); + self + } + + #[must_use = "builder methods return modified builder - chain or assign"] + fn header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } +} + +// Now warns: unused return value that must be used +let request = RequestBuilder::new("https://api.example.com"); +request.timeout(Duration::from_secs(30)); // Warning! + +// Correct: chain methods +let response = RequestBuilder::new("https://api.example.com") + .timeout(Duration::from_secs(30)) + .header("Authorization", "Bearer token") + .send(); +``` + +## Apply to Entire Type + +```rust +#[must_use = "builders do nothing unless consumed"] +struct ConfigBuilder { + log_level: Level, + max_connections: usize, +} + +// Now all methods returning Self warn if ignored +impl ConfigBuilder { + fn log_level(mut self, level: Level) -> Self { + self.log_level = level; + self + } + + fn max_connections(mut self, n: usize) -> Self { + self.max_connections = n; + self + } + + fn build(self) -> Config { + Config { + log_level: self.log_level, + max_connections: self.max_connections, + } + } +} +``` + +## Message Guidelines + +```rust +// Descriptive message helps users understand +#[must_use = "builder methods return modified builder"] +fn with_foo(self, foo: Foo) -> Self { ... } + +#[must_use = "this creates a new String and does not modify the original"] +fn to_uppercase(&self) -> String { ... } + +#[must_use = "iterator adaptors are lazy - use .collect() to consume"] +fn map(self, f: F) -> Map { ... } +``` + +## Clippy Lint + +```toml +[lints.clippy] +must_use_candidate = "warn" # Suggests where #[must_use] would help +return_self_not_must_use = "warn" # Specifically for -> Self methods +``` + +## Standard Library Examples + +```rust +// std::Option - must_use on map, and, or +let x: Option = Some(5); +x.map(|v| v * 2); // Warning: unused return value + +// std::Result - must_use on the type itself +#[must_use = "this `Result` may be an `Err` variant, which should be handled"] +pub enum Result { ... } + +// Iterator adaptors +let v = vec![1, 2, 3]; +v.iter().map(|x| x * 2); // Warning: iterators are lazy +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Builder pattern best practices +- [api-must-use](./api-must-use.md) - General must_use guidelines +- [err-result-over-panic](./err-result-over-panic.md) - Result types are must_use diff --git a/.opencode/skills/rust-skills/rules/api-builder-pattern.md b/.opencode/skills/rust-skills/rules/api-builder-pattern.md new file mode 100644 index 0000000..f825d1d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-builder-pattern.md @@ -0,0 +1,187 @@ +# api-builder-pattern + +> Use Builder pattern for complex construction + +## Why It Matters + +When a type has many optional parameters or complex initialization, the Builder pattern provides a clear, flexible API. It avoids constructors with many parameters (which are error-prone) and makes the code self-documenting. + +## Bad + +```rust +// Constructor with many parameters - hard to read, easy to get wrong +let client = Client::new( + "https://api.example.com", // Which is which? + 30, // Timeout? Retries? + true, // What does this mean? + None, + Some("auth_token"), + false, +); + +// Or many Option fields +struct Client { + url: String, + timeout: Option, + retries: Option, + // ... 10 more optional fields +} +``` + +## Good + +```rust +#[derive(Default)] +#[must_use = "builders do nothing unless you call build()"] +pub struct ClientBuilder { + base_url: Option, + timeout: Option, + max_retries: u32, + auth_token: Option, +} + +impl ClientBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Sets the base URL for all requests. + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = Some(url.into()); + self + } + + /// Sets the request timeout. Default is 30 seconds. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Sets the maximum number of retries. Default is 3. + pub fn max_retries(mut self, n: u32) -> Self { + self.max_retries = n; + self + } + + /// Sets the authentication token. + pub fn auth_token(mut self, token: impl Into) -> Self { + self.auth_token = Some(token.into()); + self + } + + /// Builds the client with the configured options. + pub fn build(self) -> Result { + let base_url = self.base_url + .ok_or(BuilderError::MissingBaseUrl)?; + + Ok(Client { + base_url, + timeout: self.timeout.unwrap_or(Duration::from_secs(30)), + max_retries: self.max_retries, + auth_token: self.auth_token, + }) + } +} + +// Usage - clear and self-documenting +let client = ClientBuilder::new() + .base_url("https://api.example.com") + .timeout(Duration::from_secs(10)) + .max_retries(5) + .auth_token("secret") + .build()?; +``` + +## Builder Variations + +```rust +// 1. Infallible builder (build() returns T, not Result) +impl WidgetBuilder { + pub fn build(self) -> Widget { + Widget { + color: self.color.unwrap_or(Color::Black), + size: self.size.unwrap_or(Size::Medium), + } + } +} + +// 2. Typestate builder (compile-time required field checking) +pub struct ClientBuilder { + url: Url, + timeout: Option, +} + +pub struct NoUrl; +pub struct HasUrl(String); + +impl ClientBuilder { + pub fn new() -> Self { + Self { url: NoUrl, timeout: None } + } + + pub fn url(self, url: String) -> ClientBuilder { + ClientBuilder { url: HasUrl(url), timeout: self.timeout } + } +} + +impl ClientBuilder { + pub fn build(self) -> Client { + // url is guaranteed to be set + Client { url: self.url.0, timeout: self.timeout } + } +} + +// 3. Consuming vs borrowing (consuming is more common) +// Consuming (takes self) +pub fn timeout(mut self, t: Duration) -> Self { ... } + +// Borrowing (takes &mut self, allows reuse) +pub fn timeout(&mut self, t: Duration) -> &mut Self { ... } +``` + +## Evidence from reqwest + +```rust +// https://github.com/seanmonstar/reqwest/blob/master/src/async_impl/client.rs + +#[must_use] +pub struct ClientBuilder { + config: Config, +} + +impl ClientBuilder { + pub fn new() -> ClientBuilder { + ClientBuilder { + config: Config::default(), + } + } + + pub fn timeout(mut self, timeout: Duration) -> ClientBuilder { + self.config.timeout = Some(timeout); + self + } + + pub fn build(self) -> Result { + // Validation and construction + } +} +``` + +## Key Attributes + +```rust +#[derive(Default)] // Enables MyBuilder::default() +#[must_use = "builders do nothing unless you call build()"] +pub struct MyBuilder { ... } + +impl MyBuilder { + #[must_use] // Each method should have this + pub fn option(mut self, value: T) -> Self { ... } +} +``` + +## See Also + +- [api-builder-must-use](api-builder-must-use.md) - Add #[must_use] to builders +- [api-typestate](api-typestate.md) - Compile-time state machines +- [api-impl-into](api-impl-into.md) - Accept impl Into for flexibility diff --git a/.opencode/skills/rust-skills/rules/api-common-traits.md b/.opencode/skills/rust-skills/rules/api-common-traits.md new file mode 100644 index 0000000..985986d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-common-traits.md @@ -0,0 +1,166 @@ +# api-common-traits + +> Implement standard traits (Debug, Clone, PartialEq, etc.) for public types + +## Why It Matters + +Standard traits make your types interoperable with the Rust ecosystem. `Debug` enables `println!("{:?}")` and error messages. `Clone` allows explicit duplication. `PartialEq` enables `==`. Without these, users can't use your types in common patterns like testing, collections, or debugging. + +## Bad + +```rust +// Bare struct - severely limited usability +pub struct Point { + pub x: f64, + pub y: f64, +} + +// Can't debug +println!("{:?}", point); // Error: Debug not implemented + +// Can't compare +if point1 == point2 { } // Error: PartialEq not implemented + +// Can't use in HashMap +let mut map: HashMap = HashMap::new(); // Error: Hash not implemented + +// Can't clone +let copy = point.clone(); // Error: Clone not implemented +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +// Now everything works +println!("{:?}", point); +assert_eq!(point1, point2); +let copy = point; // Copy, not just Clone + +// For hashable types +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct UserId(u64); + +let mut map: HashMap = HashMap::new(); +``` + +## Trait Derivation Guide + +| Trait | Derive When | Requirements | +|-------|-------------|--------------| +| `Debug` | Always for public types | All fields implement Debug | +| `Clone` | Type can be duplicated | All fields implement Clone | +| `Copy` | Small, simple types | All fields implement Copy, no Drop | +| `PartialEq` | Comparison makes sense | All fields implement PartialEq | +| `Eq` | Total equality | PartialEq, no floating-point fields | +| `Hash` | Used as HashMap/HashSet key | Eq, consistent with PartialEq | +| `Default` | Sensible default exists | All fields implement Default | +| `PartialOrd` | Ordering makes sense | PartialEq, all fields implement PartialOrd | +| `Ord` | Total ordering | Eq + PartialOrd, no floating-point | + +## Common Trait Bundles + +```rust +// ID types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EntityId(u64); + +// Value types +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Vector2 { x: f32, y: f32 } + +// Configuration +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Config { + name: String, + options: HashMap, +} + +// Error types +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + InvalidSyntax(String), + UnexpectedToken(Token), +} +``` + +## Manual Implementations + +```rust +// When derive doesn't do what you want +struct CaseInsensitiveString(String); + +impl PartialEq for CaseInsensitiveString { + fn eq(&self, other: &Self) -> bool { + self.0.to_lowercase() == other.0.to_lowercase() + } +} + +impl Eq for CaseInsensitiveString {} + +impl Hash for CaseInsensitiveString { + fn hash(&self, state: &mut H) { + // Must be consistent with PartialEq + self.0.to_lowercase().hash(state); + } +} + +// Custom Debug for sensitive data +struct Password(String); + +impl Debug for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Password([REDACTED])") + } +} +``` + +## Serde Traits + +```rust +use serde::{Serialize, Deserialize}; + +// For serializable types +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiResponse { + pub status: String, + pub data: Vec, +} + +// With custom serialization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub verbose: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} +``` + +## Minimum Recommended + +```rust +// At minimum, public types should have: +#[derive(Debug, Clone, PartialEq)] +pub struct MyType { ... } + +// Add based on use case: +// + Eq, Hash → for HashMap keys +// + Ord, PartialOrd → for BTreeMap, sorting +// + Default → for Option::unwrap_or_default() +// + Copy → for small value types +// + Serialize → for serialization +``` + +## See Also + +- [own-copy-small](./own-copy-small.md) - When to implement Copy +- [api-default-impl](./api-default-impl.md) - Implementing Default +- [doc-examples-section](./doc-examples-section.md) - Documenting trait implementations +- [type-display-vs-debug](./type-display-vs-debug.md) - Display vs Debug responsibilities diff --git a/.opencode/skills/rust-skills/rules/api-default-impl.md b/.opencode/skills/rust-skills/rules/api-default-impl.md new file mode 100644 index 0000000..55bcaa7 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-default-impl.md @@ -0,0 +1,190 @@ +# api-default-impl + +> Implement `Default` for types with sensible default values + +## Why It Matters + +`Default` is a standard trait that provides a canonical way to create a default instance. It integrates with many ecosystem patterns: `Option::unwrap_or_default()`, `#[derive(Default)]`, struct update syntax `..Default::default()`, and generic code that requires `T: Default`. Implementing it makes your types more ergonomic. + +## Bad + +```rust +struct Config { + timeout: Duration, + retries: u32, + verbose: bool, +} + +impl Config { + // Custom constructor - works but non-standard + fn new() -> Self { + Config { + timeout: Duration::from_secs(30), + retries: 3, + verbose: false, + } + } +} + +// Can't use with standard patterns +let config: Config = Default::default(); // Error: Default not implemented +let timeout = settings.get("timeout").unwrap_or_default(); // Won't work +``` + +## Good + +```rust +use std::time::Duration; + +// Simple case: derive uses each field type's Default (Duration::ZERO, 0, false) +#[derive(Default)] +struct Config { + timeout: Duration, + retries: u32, + verbose: bool, +} +``` + +For a non-zero default, implement `Default` by hand instead of deriving. (Per-field defaults like `timeout: Duration = Duration::from_secs(30)` require the nightly `default_field_values` feature.) + +```rust +use std::time::Duration; + +struct Config { + timeout: Duration, + retries: u32, + verbose: bool, +} + +impl Default for Config { + fn default() -> Self { + Config { + timeout: Duration::from_secs(30), + retries: 3, + verbose: false, + } + } +} + +// Now works with all standard patterns +let config = Config::default(); +let config = Config { retries: 5, ..Default::default() }; +let value = map.get("key").cloned().unwrap_or_default(); +``` + +## Derive vs Manual + +```rust +// Derive: all fields use their own Default +#[derive(Default)] +struct Simple { + count: u32, // 0 + name: String, // "" + items: Vec, // [] +} + +// Manual: when you need custom defaults +struct Connection { + host: String, + port: u16, + timeout: Duration, +} + +impl Default for Connection { + fn default() -> Self { + Connection { + host: "localhost".to_string(), + port: 8080, + timeout: Duration::from_secs(30), + } + } +} +``` + +## Builder with Default + +```rust +#[derive(Default)] +struct ServerBuilder { + host: String, + port: u16, + workers: usize, +} + +impl ServerBuilder { + fn host(mut self, host: impl Into) -> Self { + self.host = host.into(); + self + } + + fn port(mut self, port: u16) -> Self { + self.port = port; + self + } +} + +// Clean initialization +let server = ServerBuilder::default() + .host("0.0.0.0") + .port(3000) + .build(); +``` + +## Default with Required Fields + +```rust +// When some fields have no sensible default, don't implement Default +struct User { + id: UserId, // No sensible default + name: String, // Could default to "" +} + +// Instead, provide a constructor +impl User { + fn new(id: UserId, name: impl Into) -> Self { + User { id, name: name.into() } + } +} + +// Or use builder with required fields +struct UserBuilder { + id: Option, + name: String, +} + +impl Default for UserBuilder { + fn default() -> Self { + UserBuilder { + id: None, + name: String::new(), + } + } +} +``` + +## Generic Default + +```rust +// Require Default in generic bounds when needed +fn create_or_default(opt: Option) -> T { + opt.unwrap_or_default() +} + +// PhantomData is Default regardless of T +use std::marker::PhantomData; +struct Wrapper { + _marker: PhantomData, +} + +impl Default for Wrapper { + fn default() -> Self { + Wrapper { _marker: PhantomData } + } +} +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Building complex types +- [api-common-traits](./api-common-traits.md) - Other common traits to implement +- [api-from-not-into](./api-from-not-into.md) - Conversion traits diff --git a/.opencode/skills/rust-skills/rules/api-extension-trait.md b/.opencode/skills/rust-skills/rules/api-extension-trait.md new file mode 100644 index 0000000..8153662 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-extension-trait.md @@ -0,0 +1,164 @@ +# api-extension-trait + +> Use extension traits to add methods to external types + +## Why It Matters + +Rust's orphan rules prevent implementing external traits on external types. Extension traits provide a workaround: define a new trait with your methods, then implement it for the external type. This pattern is used extensively in the ecosystem (e.g., `itertools::Itertools`, `tokio::AsyncReadExt`). + +## Bad + +```rust +// Can't add methods directly to external types +impl Vec { + fn as_hex(&self) -> String { + // Error: cannot define inherent impl for a type outside this crate + } +} + +// Can't implement external trait for external type +impl SomeExternalTrait for Vec { + // Error: orphan rules violation +} +``` + +## Good + +```rust +// Define an extension trait +pub trait ByteSliceExt { + fn as_hex(&self) -> String; + fn is_ascii_printable(&self) -> bool; +} + +// Implement for the external type +impl ByteSliceExt for [u8] { + fn as_hex(&self) -> String { + self.iter() + .map(|b| format!("{:02x}", b)) + .collect() + } + + fn is_ascii_printable(&self) -> bool { + self.iter().all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace()) + } +} + +// Usage: import the trait to use the methods +use my_crate::ByteSliceExt; + +let data: &[u8] = b"hello"; +println!("{}", data.as_hex()); // "68656c6c6f" +``` + +## Convention: Ext Suffix + +```rust +// Standard naming: TypeExt for extending Type +pub trait OptionExt { + fn unwrap_or_log(self, msg: &str) -> Option; +} + +impl OptionExt for Option { + fn unwrap_or_log(self, msg: &str) -> Option { + if self.is_none() { + log::warn!("{}", msg); + } + self + } +} + +// For generic extensions +pub trait ResultExt { + fn log_err(self) -> Self; +} + +impl ResultExt for Result { + fn log_err(self) -> Self { + if let Err(ref e) = self { + log::error!("{}", e); + } + self + } +} +``` + +## Ecosystem Examples + +```rust +// itertools::Itertools +use itertools::Itertools; +let groups = vec![1, 1, 2, 2, 3].into_iter().group_by(|x| *x); + +// futures::StreamExt +use futures::StreamExt; +let next = stream.next().await; + +// tokio::io::AsyncReadExt +use tokio::io::AsyncReadExt; +let mut buf = [0u8; 1024]; +reader.read(&mut buf).await?; + +// anyhow::Context +use anyhow::Context; +let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path))?; +``` + +## Scoped Extensions + +```rust +// Extension only visible where imported +mod string_utils { + pub trait StringExt { + fn truncate_ellipsis(&self, max_len: usize) -> String; + } + + impl StringExt for str { + fn truncate_ellipsis(&self, max_len: usize) -> String { + if self.len() <= max_len { + self.to_string() + } else { + format!("{}...", &self[..max_len.saturating_sub(3)]) + } + } + } +} + +// Only available when explicitly imported +use string_utils::StringExt; +let short = "very long string".truncate_ellipsis(10); +``` + +## Generic Extensions with Bounds + +```rust +pub trait VecExt { + fn push_if_unique(&mut self, item: T) + where + T: PartialEq; +} + +impl VecExt for Vec { + fn push_if_unique(&mut self, item: T) + where + T: PartialEq, + { + if !self.contains(&item) { + self.push(item); + } + } +} + +// Works with any T: PartialEq +let mut v = vec![1, 2, 3]; +v.push_if_unique(2); // No-op +v.push_if_unique(4); // Adds 4 +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Controlling trait implementations +- [api-impl-into](./api-impl-into.md) - Using standard conversion traits +- [name-as-free](./name-as-free.md) - Naming conventions for conversions +- [trait-blanket-impl](./trait-blanket-impl.md) - Blanket impls for extension traits diff --git a/.opencode/skills/rust-skills/rules/api-from-not-into.md b/.opencode/skills/rust-skills/rules/api-from-not-into.md new file mode 100644 index 0000000..e97f346 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-from-not-into.md @@ -0,0 +1,148 @@ +# api-from-not-into + +> Implement `From`, not `Into` - From gives you Into for free + +## Why It Matters + +The standard library has a blanket implementation: `impl Into for T where U: From`. This means implementing `From for U` automatically gives you `Into for T`. Implementing `Into` directly bypasses this and is considered non-idiomatic. Always implement `From`. + +## Bad + +```rust +struct UserId(u64); + +// Non-idiomatic: implementing Into directly +impl Into for u64 { + fn into(self) -> UserId { + UserId(self) + } +} + +// Works, but now you can't use From syntax +let id = UserId::from(42); // Error: From not implemented +let id: UserId = 42.into(); // Works, but limited +``` + +## Good + +```rust +struct UserId(u64); + +// Idiomatic: implement From +impl From for UserId { + fn from(id: u64) -> Self { + UserId(id) + } +} + +// Now both work automatically +let id = UserId::from(42); // From syntax +let id: UserId = 42.into(); // Into syntax (via blanket impl) + +// And Into bound works in generics +fn process(id: impl Into) { + let id: UserId = id.into(); +} +process(42u64); // Works! +``` + +## Blanket Implementation + +```rust +// This is in std, you don't write it +impl Into for T +where + U: From, +{ + fn into(self) -> U { + U::from(self) + } +} + +// So when you implement From: +impl From for MyType { ... } + +// You automatically get: +// impl Into for String { ... } +``` + +## Multiple From Implementations + +```rust +struct Email(String); + +impl From for Email { + fn from(s: String) -> Self { + Email(s) + } +} + +impl From<&str> for Email { + fn from(s: &str) -> Self { + Email(s.to_string()) + } +} + +// All of these work +let e1 = Email::from("test@example.com"); +let e2 = Email::from(String::from("test@example.com")); +let e3: Email = "test@example.com".into(); +let e4: Email = String::from("test@example.com").into(); +``` + +## TryFrom for Fallible Conversions + +```rust +use std::convert::TryFrom; + +struct PositiveInt(u32); + +// Fallible conversion +impl TryFrom for PositiveInt { + type Error = &'static str; + + fn try_from(value: i32) -> Result { + if value > 0 { + Ok(PositiveInt(value as u32)) + } else { + Err("value must be positive") + } + } +} + +// Usage +let pos = PositiveInt::try_from(42)?; // From-style +let pos: PositiveInt = 42.try_into()?; // Into-style (via blanket) +``` + +## Clippy Lint + +```toml +[lints.clippy] +from_over_into = "warn" # Warns when implementing Into instead of From +``` + +```rust +// Clippy will warn: +impl Into for Foo { // Warning: prefer From + fn into(self) -> Bar { ... } +} +``` + +## When Into IS Needed (Rare) + +```rust +// Only when implementing for external types in specific trait bounds +// This is very rare and usually indicates a design issue + +// Example: you can't implement From for ExternalB +// because of orphan rules. But you usually shouldn't need to. +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - Using Into in function parameters +- [err-from-impl](./err-from-impl.md) - From for error types +- [api-newtype-safety](./api-newtype-safety.md) - Newtype conversions +- [conv-tryfrom-fallible](./conv-tryfrom-fallible.md) - TryFrom for fallible conversions +- [conv-fromstr-parsing](./conv-fromstr-parsing.md) - FromStr for string parsing diff --git a/.opencode/skills/rust-skills/rules/api-impl-asref.md b/.opencode/skills/rust-skills/rules/api-impl-asref.md new file mode 100644 index 0000000..688f8cc --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-impl-asref.md @@ -0,0 +1,142 @@ +# api-impl-asref + +> Use `AsRef` when you only need to borrow the inner data + +## Why It Matters + +`AsRef` provides a cheap borrowed view of data without taking ownership or copying. Functions accepting `impl AsRef` can work with multiple types that contain or represent `T`, making APIs flexible while avoiding unnecessary allocations. Use `AsRef` when you only need to read, `Into` when you need to own. + +## Bad + +```rust +// Forces callers to provide exact types +fn process_text(text: &str) { ... } +fn read_file(path: &Path) { ... } + +// Can't call directly with owned types +let s = String::from("hello"); +process_text(&s); // Works but verbose + +let p = PathBuf::from("/file"); +read_file(&p); // Works but verbose +read_file("/file"); // Error! &str != &Path +``` + +## Good + +```rust +// Accept anything that can be viewed as the target type +fn process_text(text: impl AsRef) { + let s: &str = text.as_ref(); + println!("{}", s); +} + +fn read_file(path: impl AsRef) -> io::Result> { + std::fs::read(path.as_ref()) +} + +// All of these work: +process_text("literal"); // &str +process_text(String::from("owned")); // String +process_text(Cow::from("cow")); // Cow + +read_file("/path/to/file"); // &str +read_file(Path::new("/path")); // &Path +read_file(PathBuf::from("/path")); // PathBuf +read_file(OsStr::new("/path")); // &OsStr +``` + +## AsRef vs Into vs Borrow + +```rust +// AsRef: cheap borrow, no ownership transfer +fn read(p: impl AsRef) { + let path: &Path = p.as_ref(); +} + +// Into: ownership transfer, may allocate +fn store(p: impl Into) { + let owned: PathBuf = p.into(); +} + +// Borrow: like AsRef but with Eq/Hash consistency guarantee +use std::borrow::Borrow; +fn lookup(map: &HashMap, key: &Q) -> Option<&V> +where + String: Borrow, + Q: Hash + Eq, +{ + map.get(key) +} +``` + +## Implement AsRef for Custom Types + +```rust +struct Name(String); + +impl AsRef for Name { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef<[u8]> for Name { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +// Now Name works with functions expecting AsRef +fn greet(name: impl AsRef) { + println!("Hello, {}!", name.as_ref()); +} + +greet(Name("Alice".into())); +``` + +## Common AsRef Implementations + +```rust +// Standard library provides many +impl AsRef for String { ... } +impl AsRef for str { ... } +impl AsRef<[u8]> for str { ... } +impl AsRef<[u8]> for String { ... } +impl AsRef<[u8]> for Vec { ... } +impl AsRef for str { ... } +impl AsRef for String { ... } +impl AsRef for PathBuf { ... } +impl AsRef for OsStr { ... } +impl AsRef for str { ... } +``` + +## When to Use Which + +| Trait | Use When | +|-------|----------| +| `&T` | Single type, simple API | +| `AsRef` | Read-only access, multiple input types | +| `Into` | Need to store/own the value | +| `Borrow` | HashMap/HashSet keys, Eq/Hash needed | +| `Deref` | Smart pointer semantics | + +## Pattern: Optional AsRef Bound + +```rust +// When T itself might be passed +fn process, U>(value: T) { + let inner: &U = value.as_ref(); +} + +// More flexible: accept T or &T +fn process + ?Sized, U: ?Sized>(value: &T) { + let inner: &U = value.as_ref(); +} +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - When to use Into instead +- [own-slice-over-vec](./own-slice-over-vec.md) - Using slices for flexibility +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Preferring borrows diff --git a/.opencode/skills/rust-skills/rules/api-impl-fromiterator.md b/.opencode/skills/rust-skills/rules/api-impl-fromiterator.md new file mode 100644 index 0000000..3c99392 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-impl-fromiterator.md @@ -0,0 +1,118 @@ +# api-impl-fromiterator + +> Implement `FromIterator` and `Extend` for collection types, and `IntoIterator` for all three reference forms + +## Why It Matters + +The Rust API Guidelines (C-COLLECT) require that collection types implement `FromIterator` so that `iter.collect::>()` works. Pairing it with `Extend` enables efficient batch insertion — the standard library uses `Extend` internally in `collect` when extending an existing collection. Implementing `IntoIterator` for the type itself, for `&Type`, and for `&mut Type` rounds out the contract and lets the collection participate in `for` loops and iterator adapter chains. Skipping these traits forces callers into awkward manual loops and breaks generic code. + +## Bad + +```rust +struct Bag(Vec); + +impl Bag { + fn new() -> Self { Bag(Vec::new()) } + + fn push(&mut self, item: T) { self.0.push(item); } +} + +fn main() { + // Callers must loop manually — no collect(), no extend(), no for loop + let mut b = Bag::new(); + for x in [1, 2, 3] { + b.push(x); + } +} +``` + +## Good + +```rust +struct Bag(Vec); + +impl Bag { + fn new() -> Self { Bag(Vec::new()) } + + fn push(&mut self, item: T) { self.0.push(item); } + + fn len(&self) -> usize { self.0.len() } + + fn is_empty(&self) -> bool { self.0.is_empty() } +} + +// 1. FromIterator — enables .collect::>() +impl FromIterator for Bag { + fn from_iter>(iter: I) -> Self { + Bag(iter.into_iter().collect()) + } +} + +// 2. Extend — enables .extend(iter) and is used internally by collect +impl Extend for Bag { + fn extend>(&mut self, iter: I) { + self.0.extend(iter); + } +} + +// 3a. IntoIterator for owned Bag (consuming) +impl IntoIterator for Bag { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +// 3b. IntoIterator for &Bag (borrowing) +impl<'a, T> IntoIterator for &'a Bag { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +// 3c. IntoIterator for &mut Bag (mutable borrowing) +impl<'a, T> IntoIterator for &'a mut Bag { + type Item = &'a mut T; + type IntoIter = std::slice::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter_mut() + } +} + +fn main() { + // collect works + let b: Bag = [1, 2, 3].into_iter().collect(); + assert_eq!(b.len(), 3); + + // extend works + let mut b2 = Bag::new(); + b2.extend([4, 5, 6]); + assert_eq!(b2.len(), 3); + + // for loop works on &Bag + for x in &b { + let _ = x; + } + + // map/filter chains work via IntoIterator + let doubled: Bag = b.into_iter().map(|x| x * 2).collect(); + assert_eq!(doubled.len(), 3); +} +``` + +## Notes + +- If your collection wraps an existing standard container, delegate `from_iter` and `extend` to the inner container's own implementations for maximum efficiency. +- `FromIterator` + `Extend` enable `collect` to call `extend` on a pre-allocated collection when possible, avoiding extra allocations. + +## See Also + +- [name-iter-convention](name-iter-convention.md) - `iter`/`iter_mut`/`into_iter` method naming +- [perf-collect-once](perf-collect-once.md) - avoid collecting intermediate iterators +- [api-common-traits](api-common-traits.md) - implement `Debug`, `Clone`, `PartialEq` eagerly diff --git a/.opencode/skills/rust-skills/rules/api-impl-into.md b/.opencode/skills/rust-skills/rules/api-impl-into.md new file mode 100644 index 0000000..9d67075 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-impl-into.md @@ -0,0 +1,160 @@ +# api-impl-into + +> Accept `impl Into` for flexible APIs, implement `From` for conversions + +## Why It Matters + +APIs that accept `impl Into` are ergonomic—callers can pass the target type directly or any type that converts to it. This reduces boilerplate `.into()` calls at call sites. Implement `From` rather than `Into` because `From` implies `Into` through a blanket implementation. + +## Bad + +```rust +// Requires exact type - forces callers to convert +fn process_path(path: PathBuf) { ... } +fn set_name(name: String) { ... } + +// Caller must convert explicitly +process_path(PathBuf::from("/path/to/file")); +process_path("/path/to/file".to_path_buf()); // Verbose +process_path("/path/to/file".into()); // Explicit + +set_name(String::from("Alice")); +set_name("Alice".to_string()); // Verbose +``` + +## Good + +```rust +// Accept anything that converts to the target type +fn process_path(path: impl Into) { + let path = path.into(); // Convert once inside + // ... +} + +fn set_name(name: impl Into) { + let name = name.into(); + // ... +} + +// Callers are ergonomic +process_path("/path/to/file"); // &str converts automatically +process_path(PathBuf::from(".")); // PathBuf works too + +set_name("Alice"); // &str +set_name(String::from("Alice")); // String +set_name(format!("User-{}", id)); // String from format! +``` + +## Implement From, Not Into + +```rust +struct UserId(u64); + +// ✅ Implement From +impl From for UserId { + fn from(id: u64) -> Self { + UserId(id) + } +} + +// Into is automatically provided by blanket impl +let id: UserId = 42u64.into(); // Works! + +// ❌ Don't implement Into directly +impl Into for u64 { + fn into(self) -> UserId { + UserId(self) // This works but is non-idiomatic + } +} +``` + +## Common Conversions + +```rust +// String-like types +fn log_message(msg: impl Into) { ... } +log_message("literal"); // &str +log_message(String::from("own")); // String +log_message(Cow::from("cow")); // Cow + +// Path-like types +fn read_file(path: impl AsRef) { ... } // AsRef for borrowed access +fn write_file(path: impl Into) { ... } // Into when storing + +// Duration +fn set_timeout(duration: impl Into) { ... } +set_timeout(Duration::from_secs(5)); +// Note: no blanket impl for integers, would need custom wrapper +``` + +## AsRef vs Into + +```rust +// AsRef: borrow as &T, no conversion cost +fn count_bytes(data: impl AsRef<[u8]>) -> usize { + data.as_ref().len() // Just borrows, no allocation +} +count_bytes("hello"); // &str -> &[u8] +count_bytes(b"hello"); // &[u8] -> &[u8] +count_bytes(vec![1, 2, 3]); // &Vec -> &[u8] + +// Into: convert to owned T, may allocate +fn store_data(data: impl Into>) { + let owned: Vec = data.into(); // Takes ownership + // ... +} +``` + +## When NOT to Use impl Into + +```rust +// ❌ Trait objects need Sized +fn process(handler: impl Into>) { } +// Better: just take Box directly + +// ❌ Recursive types +struct Node { + children: Vec>, // Error: impl Trait not allowed here +} + +// ❌ Performance-critical hot paths (minor overhead of trait dispatch) +fn hot_path(value: impl Into) { + // Consider taking u64 directly if called billions of times +} + +// ❌ When you need to name the type +fn returns_impl() -> impl Into { } // Opaque, hard to use +``` + +## Builder Pattern with Into + +```rust +struct Config { + name: String, + path: PathBuf, +} + +impl Config { + fn new(name: impl Into) -> Self { + Config { + name: name.into(), + path: PathBuf::new(), + } + } + + fn path(mut self, path: impl Into) -> Self { + self.path = path.into(); + self + } +} + +// Clean builder calls +let config = Config::new("myapp") + .path("/etc/myapp"); +``` + +## See Also + +- [api-impl-asref](./api-impl-asref.md) - When to use AsRef instead +- [api-from-not-into](./api-from-not-into.md) - Why From is preferred +- [err-from-impl](./err-from-impl.md) - From for error conversion diff --git a/.opencode/skills/rust-skills/rules/api-must-use.md b/.opencode/skills/rust-skills/rules/api-must-use.md new file mode 100644 index 0000000..1e94a21 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-must-use.md @@ -0,0 +1,125 @@ +# api-must-use + +> Mark types and functions with `#[must_use]` when ignoring results is likely a bug + +## Why It Matters + +Some return values should never be ignored—`Result`, locks, RAII guards, computed values that have no side effects. Without `#[must_use]`, silently discarding these values can introduce subtle bugs that are hard to detect. The attribute generates compiler warnings when the value is unused. + +## Bad + +```rust +// Result ignored - error silently dropped +fn send_email(to: &str, body: &str) -> Result<(), EmailError> { ... } + +send_email("user@example.com", "Hello!"); // No warning if Result ignored! +// Email may have failed, but we don't know + +// Computed value ignored - likely a bug +fn compute_checksum(data: &[u8]) -> u32 { ... } + +let data = vec![1, 2, 3, 4]; +compute_checksum(&data); // Result discarded - pointless call +``` + +## Good + +```rust +#[must_use = "this `Result` may be an `Err` that should be handled"] +fn send_email(to: &str, body: &str) -> Result<(), EmailError> { ... } + +send_email("user@example.com", "Hello!"); +// Warning: unused `Result` that must be used + +// Mark pure functions +#[must_use = "this returns a new value and does not modify the input"] +fn compute_checksum(data: &[u8]) -> u32 { ... } + +compute_checksum(&data); +// Warning: unused return value of `compute_checksum` that must be used +``` + +## Apply to Types + +```rust +// Mark the type itself when it should always be used +#[must_use = "futures do nothing unless polled"] +struct MyFuture { ... } + +// Mark RAII guards +#[must_use = "if unused, the lock will be immediately released"] +struct MutexGuard<'a, T> { ... } + +// Mark results/errors +#[must_use = "errors should be handled"] +enum AppError { ... } +``` + +## Standard Library Examples + +```rust +// Result and Option are #[must_use] +let v: Vec = vec![1, 2, 3]; +v.first(); // Warning: unused Option + +// Iterator adapters are #[must_use] +v.iter().map(|x| x * 2); // Warning: iterators are lazy + +// String methods that return new values +let s = "hello"; +s.to_uppercase(); // Warning: unused String +``` + +## When to Apply + +```rust +// ✅ Pure functions (no side effects) +#[must_use] +fn add(a: i32, b: i32) -> i32 { a + b } + +// ✅ Builder methods returning Self +#[must_use = "builder methods return a new builder"] +fn with_timeout(self, t: Duration) -> Self { ... } + +// ✅ Fallible operations +#[must_use] +fn try_parse(s: &str) -> Result { ... } + +// ✅ Iterators and futures (lazy) +#[must_use = "iterators are lazy and do nothing unless consumed"] +struct Map { ... } + +// ❌ Side-effecting functions where result is optional +fn log(msg: &str) -> Result<(), io::Error> { ... } // Might be ok to ignore + +// ❌ Methods with useful side effects +fn vec.push(item); // Mutates vec, no return to use +``` + +## Custom Messages + +```rust +#[must_use = "creating a guard does nothing without assignment"] +struct ScopeGuard { ... } + +#[must_use = "this returns the old value"] +fn replace(&mut self, new: T) -> T { ... } + +#[must_use = "use `.await` to execute the future"] +async fn fetch() -> Data { ... } +``` + +## Clippy Lints + +```toml +[lints.clippy] +must_use_candidate = "warn" # Suggests where to add #[must_use] +unused_must_use = "deny" # Built-in, treat warnings as errors +double_must_use = "warn" # Redundant #[must_use] +``` + +## See Also + +- [api-builder-must-use](./api-builder-must-use.md) - Builder pattern must_use +- [err-result-over-panic](./err-result-over-panic.md) - Result types require handling +- [lint-deny-correctness](./lint-deny-correctness.md) - Enabling useful lints diff --git a/.opencode/skills/rust-skills/rules/api-newtype-safety.md b/.opencode/skills/rust-skills/rules/api-newtype-safety.md new file mode 100644 index 0000000..9afbb09 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-newtype-safety.md @@ -0,0 +1,162 @@ +# api-newtype-safety + +> Use newtypes to prevent mixing semantically different values + +## Why It Matters + +Raw primitives like `u64` or `String` carry no semantic meaning. A function taking `(u64, u64)` can easily be called with arguments swapped. Newtypes wrap primitives in distinct types, making the compiler catch mistakes at compile time rather than runtime. + +## Bad + +```rust +struct User { + id: u64, + group_id: u64, + created_at: u64, // Unix timestamp +} + +fn add_user_to_group(user_id: u64, group_id: u64) { ... } + +// Bug: arguments swapped - compiles fine, fails at runtime +let user = User { id: 100, group_id: 5, created_at: 1234567890 }; +add_user_to_group(user.group_id, user.id); // Silent bug! + +// Bug: wrong field used - timestamp passed as ID +add_user_to_group(user.created_at, user.group_id); // Compiles fine! +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct UserId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct GroupId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct Timestamp(u64); + +struct User { + id: UserId, + group_id: GroupId, + created_at: Timestamp, +} + +fn add_user_to_group(user_id: UserId, group_id: GroupId) { ... } + +// Compile error: expected UserId, found GroupId +let user = User { ... }; +add_user_to_group(user.group_id, user.id); // Error! + +// Compile error: expected UserId, found Timestamp +add_user_to_group(user.created_at, user.group_id); // Error! +``` + +## Derive Common Traits + +```rust +// Minimal: just enough for your use case +#[derive(Debug, Clone, Copy)] +struct MeterId(u32); + +// Full ID type: hashable, comparable, displayable +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct OrderId(u64); + +impl std::fmt::Display for OrderId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ORD-{:08}", self.0) + } +} + +// With serde for serialization +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] // Serializes as raw u64 +struct ProductId(u64); +``` + +## Constructor Patterns + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct Email(String); + +impl Email { + /// Creates a new Email, validating the format. + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(EmailError::InvalidFormat) + } + } + + /// Returns the email as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// Usage enforces validation +let email = Email::new("user@example.com")?; // Must go through validation +``` + +## Zero-Cost Abstraction + +```rust +use std::mem::size_of; + +#[derive(Clone, Copy)] +struct Miles(f64); + +#[derive(Clone, Copy)] +struct Kilometers(f64); + +// Same size as raw f64 +assert_eq!(size_of::(), size_of::()); +assert_eq!(size_of::(), size_of::()); + +// But can't accidentally mix them +fn drive(distance: Miles) { ... } + +let km = Kilometers(100.0); +drive(km); // Error: expected Miles, found Kilometers + +// Explicit conversion +impl From for Miles { + fn from(km: Kilometers) -> Self { + Miles(km.0 * 0.621371) + } +} + +drive(km.into()); // Explicit, visible conversion +``` + +## When Newtypes Help Most + +```rust +// ✅ IDs that could be confused +fn transfer(from: AccountId, to: AccountId, amount: Money) { ... } + +// ✅ Units that shouldn't mix +struct Celsius(f64); +struct Fahrenheit(f64); + +// ✅ Validated strings +struct Username(String); // Validated alphanumeric +struct Password(String); // Never logged + +// ✅ Different meanings of same type +struct Milliseconds(u64); +struct Seconds(u64); + +// ❌ Overkill: single use, no confusion possible +struct X(i32); // Just use i32 +``` + +## See Also + +- [type-newtype-ids](./type-newtype-ids.md) - Newtype pattern for IDs +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven validation +- [own-copy-small](./own-copy-small.md) - Making newtypes Copy diff --git a/.opencode/skills/rust-skills/rules/api-non-exhaustive.md b/.opencode/skills/rust-skills/rules/api-non-exhaustive.md new file mode 100644 index 0000000..621e4bb --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-non-exhaustive.md @@ -0,0 +1,177 @@ +# api-non-exhaustive + +> Use `#[non_exhaustive]` on public enums and structs for forward compatibility + +## Why It Matters + +Adding a variant to a public enum or a field to a public struct is normally a breaking change—downstream code may match exhaustively or use struct literal syntax. `#[non_exhaustive]` forces external code to use wildcards in matches and constructors, allowing you to add variants/fields in minor versions without breaking callers. + +## Bad + +```rust +// Public enum - adding variant breaks downstream matches +pub enum ErrorKind { + NotFound, + PermissionDenied, + TimedOut, +} + +// Downstream code +match error.kind() { + ErrorKind::NotFound => ..., + ErrorKind::PermissionDenied => ..., + ErrorKind::TimedOut => ..., + // No wildcard - will break when you add ErrorKind::Interrupted +} + +// Public struct - adding field breaks downstream construction +pub struct Config { + pub name: String, + pub value: i32, +} + +// Downstream code +let config = Config { name: "test".into(), value: 42 }; +// Will break when you add `pub enabled: bool` +``` + +## Good + +```rust +// Can add variants in minor versions +#[non_exhaustive] +pub enum ErrorKind { + NotFound, + PermissionDenied, + TimedOut, + // Future: can add Interrupted here without breaking changes +} + +// Downstream code MUST have wildcard +match error.kind() { + ErrorKind::NotFound => ..., + ErrorKind::PermissionDenied => ..., + ErrorKind::TimedOut => ..., + _ => ..., // Required by non_exhaustive +} + +// Can add fields in minor versions +#[non_exhaustive] +pub struct Config { + pub name: String, + pub value: i32, +} + +// Downstream CANNOT use struct literal syntax +// let config = Config { name: "test".into(), value: 42 }; // Error! + +// Must use constructor +impl Config { + pub fn new(name: impl Into, value: i32) -> Self { + Config { name: name.into(), value } + } +} +``` + +## How It Works + +```rust +#[non_exhaustive] +pub enum Status { + Active, + Inactive, +} + +// Inside your crate: exhaustive match is allowed +fn internal(s: Status) { + match s { + Status::Active => {}, + Status::Inactive => {}, + // No wildcard needed inside defining crate + } +} + +// Outside your crate: wildcard required +fn external(s: my_crate::Status) { + match s { + my_crate::Status::Active => {}, + my_crate::Status::Inactive => {}, + _ => {}, // REQUIRED + } +} +``` + +## Struct Usage + +```rust +#[non_exhaustive] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + // Provide constructor + pub fn new(x: f64, y: f64) -> Self { + Point { x, y } + } +} + +// External code can read fields but not construct with literals +fn external(p: Point) { + println!("x: {}, y: {}", p.x, p.y); // Reading is fine + + // let p2 = Point { x: 1.0, y: 2.0 }; // Error! + let p2 = Point::new(1.0, 2.0); // Must use constructor +} +``` + +## Non-Exhaustive Variants + +```rust +pub enum Message { + // Specific variant is non-exhaustive + #[non_exhaustive] + Error { code: u32, message: String }, + + Ok(Data), +} + +// Can destructure Ok normally +// But Error requires `..` to handle future fields +match msg { + Message::Ok(data) => {}, + Message::Error { code, message, .. } => {}, // `..` required +} +``` + +## When to Use + +```rust +// ✅ Use for public API types that may evolve +#[non_exhaustive] +pub enum ApiError { ... } + +#[non_exhaustive] +pub struct Options { ... } + +// ✅ Use for error types +#[non_exhaustive] +pub enum MyError { ... } + +// ❌ Don't use for internal types +enum InternalState { ... } // Not public, no concern + +// ❌ Don't use for stable, complete types +pub enum Ordering { // Less, Equal, Greater is complete + Less, + Equal, + Greater, +} +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Controlling trait implementations +- [err-custom-type](./err-custom-type.md) - Error type design +- [api-builder-pattern](./api-builder-pattern.md) - Alternative to struct literals diff --git a/.opencode/skills/rust-skills/rules/api-operator-overload.md b/.opencode/skills/rust-skills/rules/api-operator-overload.md new file mode 100644 index 0000000..d95dc68 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-operator-overload.md @@ -0,0 +1,109 @@ +# api-operator-overload + +> Overload operators only when the semantics are natural and unsurprising + +## Why It Matters + +Rust allows operator overloading through traits in `std::ops` (`Add`, `Sub`, `Mul`, `Index`, `Neg`, etc.). The Rust API Guidelines (C-OVERLOAD) permit this — but only when the operator meaning is immediately obvious to any reader. Natural fits: arithmetic on numeric newtypes, vector/matrix math, set union/intersection with `+`/`|`, duration arithmetic. Surprising overloads — `+` that mutates state, `*` that performs a network call, `Index` that panics unconditionally — mislead readers and violate the principle of least surprise. When in doubt, name a method instead. Also implement the operator for references to avoid forcing callers to clone. + +## Bad + +```rust +use std::ops::Add; + +struct Logger(Vec); + +// Anti-pattern: + mutates internal state and has a side effect +impl Add for Logger { + type Output = Logger; + + fn add(mut self, msg: String) -> Logger { + self.0.push(msg.clone()); + println!("logged: {msg}"); // side effect in an operator + self + } +} +``` + +## Good + +```rust +use std::ops::{Add, Neg}; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct Vector2 { + x: f64, + y: f64, +} + +impl Vector2 { + pub fn new(x: f64, y: f64) -> Self { + Vector2 { x, y } + } + + pub fn dot(self, other: Vector2) -> f64 { + self.x * other.x + self.y * other.y + } +} + +// Add for owned values +impl Add for Vector2 { + type Output = Vector2; + + fn add(self, rhs: Vector2) -> Vector2 { + Vector2::new(self.x + rhs.x, self.y + rhs.y) + } +} + +// Also implement for references — avoids forcing callers to clone +impl Add for &Vector2 { + type Output = Vector2; + + fn add(self, rhs: &Vector2) -> Vector2 { + Vector2::new(self.x + rhs.x, self.y + rhs.y) + } +} + +impl Neg for Vector2 { + type Output = Vector2; + + fn neg(self) -> Vector2 { + Vector2::new(-self.x, -self.y) + } +} + +fn main() { + let a = Vector2::new(1.0, 2.0); + let b = Vector2::new(3.0, 4.0); + + let c = a + b; // owned + let d = &a + &b; // borrowed — no clone needed + let e = -a; + + assert_eq!(c, Vector2::new(4.0, 6.0)); + assert_eq!(d, Vector2::new(4.0, 6.0)); + assert_eq!(e, Vector2::new(-1.0, -2.0)); +} +``` + +## Decision Guide + +| Situation | Verdict | +|-----------|---------| +| Arithmetic on a numeric newtype or geometric type | Implement | +| Set operations (`|` for union, `&` for intersection) | Implement | +| String concatenation on a custom string type | Implement | +| `Index`/`IndexMut` on a container that holds items | Implement | +| `+` or `*` with visible side effects | Never | +| Operator meaning depends on context not captured by types | Use a named method | + +## Notes + +- Always implement the corresponding assignment operator (`AddAssign`, `SubAssign`, etc.) when you implement the binary op. +- Prefer implementing for both owned and `&` forms to give callers flexibility without extra copies. +- Consider `std::ops::Mul for Vector2` (scalar multiply) alongside `Mul` — real-world numeric types often need several rhs types. + +## See Also + +- [type-newtype-ids](type-newtype-ids.md) - wrapping values in newtypes that may need operators +- [api-common-traits](api-common-traits.md) - implement `Debug`, `Clone`, `PartialEq` eagerly diff --git a/.opencode/skills/rust-skills/rules/api-parse-dont-validate.md b/.opencode/skills/rust-skills/rules/api-parse-dont-validate.md new file mode 100644 index 0000000..39667f3 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-parse-dont-validate.md @@ -0,0 +1,186 @@ +# api-parse-dont-validate + +> Parse into validated types at boundaries + +## Why It Matters + +Instead of validating data and hoping you remember to check everywhere, parse it into a type that can only be constructed from valid data. The type system then guarantees validity - you can't forget to validate because invalid states are unrepresentable. + +## Bad + +```rust +// Validation scattered throughout codebase +fn send_email(email: &str) -> Result<(), Error> { + // Did someone validate this already? Who knows! + if !is_valid_email(email) { + return Err(Error::InvalidEmail); + } + // Send email... +} + +fn add_to_mailing_list(email: &str) -> Result<(), Error> { + // Duplicate validation, or did we forget? + if !is_valid_email(email) { + return Err(Error::InvalidEmail); + } + // Add to list... +} + +// Easy to forget validation +fn process_user_email(email: &str) { + // Oops, no validation! + database.store_email(email); +} +``` + +## Good + +```rust +/// A validated email address. +/// Can only be constructed via `Email::parse()`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Email(String); + +impl Email { + /// Parses and validates an email address. + pub fn parse(s: impl Into) -> Result { + let s = s.into(); + if Self::is_valid(&s) { + Ok(Email(s)) + } else { + Err(EmailError::Invalid) + } + } + + fn is_valid(s: &str) -> bool { + s.contains('@') && s.len() > 3 // Simplified + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// Now functions can accept Email - guaranteed valid! +fn send_email(email: &Email) -> Result<(), Error> { + // No validation needed - Email is always valid + smtp_send(email.as_str()) +} + +fn add_to_mailing_list(email: Email) { + // No validation needed + list.push(email); +} +``` + +## More Examples + +```rust +// Port number (1-65535) +pub struct Port(u16); + +impl Port { + pub fn new(n: u16) -> Option { + if n > 0 { Some(Port(n)) } else { None } + } + + pub fn get(&self) -> u16 { + self.0 + } +} + +// Non-empty string +pub struct NonEmptyString(String); + +impl NonEmptyString { + pub fn new(s: impl Into) -> Option { + let s = s.into(); + if s.is_empty() { None } else { Some(Self(s)) } + } +} + +// Positive integer +pub struct PositiveI32(i32); + +impl PositiveI32 { + pub fn new(n: i32) -> Option { + if n > 0 { Some(Self(n)) } else { None } + } +} + +// Bounded value +pub struct Percentage(u8); + +impl Percentage { + pub fn new(n: u8) -> Option { + if n <= 100 { Some(Self(n)) } else { None } + } +} +``` + +## Parsing at Boundaries + +```rust +// Parse at the system boundary (API, CLI, config file) +fn handle_request(raw: RawRequest) -> Result { + // Parse ALL inputs upfront + let email = Email::parse(&raw.email)?; + let age = Age::parse(raw.age)?; + let username = Username::parse(&raw.username)?; + + // Now work with validated types + process_user(email, age, username) +} + +fn process_user(email: Email, age: Age, username: Username) { + // All inputs guaranteed valid - no checks needed +} +``` + +## Evidence from sqlx + +```rust +// sqlx parses SQL at compile time, ensuring query validity +// https://github.com/launchbadge/sqlx/blob/master/src/macros/mod.rs + +// The query! macro parses and validates SQL +let user = sqlx::query!("SELECT * FROM users WHERE id = ?", id) + .fetch_one(&pool) + .await?; + +// If SQL is invalid, compilation fails - invalid state unrepresentable +``` + +## Combining with Display + +```rust +use std::fmt; + +pub struct Email(String); + +impl Email { + pub fn parse(s: &str) -> Result { ... } +} + +// Implement Display for easy printing +impl fmt::Display for Email { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// Implement AsRef for easy borrowing +impl AsRef for Email { + fn as_ref(&self) -> &str { + &self.0 + } +} +``` + +## See Also + +- [api-newtype-safety](api-newtype-safety.md) - Use newtypes for type safety +- [type-newtype-validated](type-newtype-validated.md) - Newtypes for validated data +- [api-typestate](api-typestate.md) - Compile-time state machines +- [conv-tryfrom-fallible](conv-tryfrom-fallible.md) - Parse via TryFrom +- [serde-try-from-validate](serde-try-from-validate.md) - Validate at the serde boundary diff --git a/.opencode/skills/rust-skills/rules/api-sealed-trait.md b/.opencode/skills/rust-skills/rules/api-sealed-trait.md new file mode 100644 index 0000000..0d2df3a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-sealed-trait.md @@ -0,0 +1,168 @@ +# api-sealed-trait + +> Use sealed traits to prevent external implementations while allowing use + +## Why It Matters + +Public traits can be implemented by anyone, which may be undesirable when you need to guarantee behavior or add methods in future versions. A sealed trait can be used by external code but not implemented by it, giving you control over implementations while maintaining a usable API. + +## Bad + +```rust +// Anyone can implement this trait +pub trait DatabaseDriver { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; +} + +// External crate implements it incorrectly +impl DatabaseDriver for MyBadDriver { + fn connect(&self, url: &str) -> Connection { + // Buggy implementation that doesn't handle errors + unsafe { force_connect(url) } + } +} + +// Later, you want to add a required method - BREAKING CHANGE +pub trait DatabaseDriver { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; + fn transaction(&self) -> Transaction; // External impls now broken! +} +``` + +## Good + +```rust +// Create a private module with a private trait +mod private { + pub trait Sealed {} +} + +// Public trait requires the private trait +pub trait DatabaseDriver: private::Sealed { + fn connect(&self, url: &str) -> Connection; + fn execute(&self, query: &str) -> Result; +} + +// Only your crate can implement Sealed, thus DatabaseDriver +pub struct PostgresDriver; +impl private::Sealed for PostgresDriver {} +impl DatabaseDriver for PostgresDriver { + fn connect(&self, url: &str) -> Connection { ... } + fn execute(&self, query: &str) -> Result { ... } +} + +pub struct MySqlDriver; +impl private::Sealed for MySqlDriver {} +impl DatabaseDriver for MySqlDriver { + fn connect(&self, url: &str) -> Connection { ... } + fn execute(&self, query: &str) -> Result { ... } +} + +// External crate cannot implement - private::Sealed is not accessible +// impl DatabaseDriver for ExternalDriver { } // Error! + +// But external code CAN use the trait +fn use_driver(driver: &impl DatabaseDriver) { + let conn = driver.connect("postgres://localhost"); +} +``` + +## Full Pattern + +```rust +pub mod db { + mod private { + pub trait Sealed {} + } + + /// Database driver trait. + /// + /// This trait is sealed and cannot be implemented outside this crate. + pub trait Driver: private::Sealed { + /// Connects to the database. + fn connect(&self, url: &str) -> Result; + + /// Executes a query. + fn execute(&self, sql: &str) -> Result; + } + + pub struct Postgres; + impl private::Sealed for Postgres {} + impl Driver for Postgres { ... } + + pub struct Sqlite; + impl private::Sealed for Sqlite {} + impl Driver for Sqlite { ... } +} + +// Usage works fine +use db::{Driver, Postgres}; + +fn query(driver: &impl Driver) { + driver.execute("SELECT 1")?; +} + +query(&Postgres); +``` + +## Benefits of Sealing + +```rust +// 1. Add methods without breaking changes +pub trait Format: private::Sealed { + fn format(&self) -> String; + + // Added later - not breaking because no external impls exist + fn format_pretty(&self) -> String { + self.format() // Default implementation + } +} + +// 2. Guarantee invariants +pub trait SafeBuffer: private::Sealed { + // You control all implementations, so you know they're all correct + fn get(&self, index: usize) -> Option<&u8>; +} + +// 3. Use as marker traits +pub trait ValidConfig: private::Sealed {} +// Only validated configs implement this +``` + +## Partially Sealed + +```rust +// Allow implementing some methods but not all +mod private { + pub trait SealedCore {} +} + +pub trait Plugin: private::SealedCore { + // Sealed - only we implement + fn initialize(&self); + fn shutdown(&self); + + // Open - users can override + fn name(&self) -> &str { "unnamed" } +} + +// Only we can add new required sealed methods +// Users can customize open methods +``` + +## When to Seal + +| Seal When | Don't Seal When | +|-----------|-----------------| +| API stability is critical | You want extension points | +| Implementation correctness is hard | Users need custom implementations | +| You'll add methods later | Trait is simple and stable | +| Safety invariants required | Standard patterns (Iterator, etc.) | + +## See Also + +- [api-non-exhaustive](./api-non-exhaustive.md) - Related pattern for enums/structs +- [api-extension-trait](./api-extension-trait.md) - Adding methods to external types +- [api-typestate](./api-typestate.md) - Compile-time state guarantees diff --git a/.opencode/skills/rust-skills/rules/api-serde-optional.md b/.opencode/skills/rust-skills/rules/api-serde-optional.md new file mode 100644 index 0000000..4862332 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-serde-optional.md @@ -0,0 +1,184 @@ +# api-serde-optional + +> Make serde a feature flag, not a hard dependency for library crates + +## Why It Matters + +Not all users of your library need serialization. Making serde a required dependency adds compile time and binary size for everyone. Feature flags let users opt-in to serde support only when needed, following Rust's philosophy of zero-cost abstractions and minimal dependencies. + +## Bad + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"] } + +// lib.rs +use serde::{Serialize, Deserialize}; + +// Every user pays for serde, even if they don't need it +#[derive(Serialize, Deserialize)] +pub struct Config { + pub name: String, + pub value: i32, +} +``` + +## Good + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } + +[features] +default = [] +serde = ["dep:serde"] + +// lib.rs +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Config { + pub name: String, + pub value: i32, +} + +// Users opt-in: +// my_crate = { version = "1.0", features = ["serde"] } +``` + +## Macro Pattern + +```rust +// Reusable macro for serde derives +#[cfg(feature = "serde")] +macro_rules! impl_serde { + ($($t:ty),*) => { + $( + impl serde::Serialize for $t { + // ... + } + impl<'de> serde::Deserialize<'de> for $t { + // ... + } + )* + }; +} + +#[cfg(not(feature = "serde"))] +macro_rules! impl_serde { + ($($t:ty),*) => {}; +} + +// Or use cfg_attr for derived impls +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Point { + pub x: f64, + pub y: f64, +} +``` + +## Feature Documentation + +```rust +// lib.rs + +//! # Features +//! +//! - `serde`: Enables `Serialize` and `Deserialize` implementations for all types. +//! +//! # Example with serde +//! +//! ```toml +//! [dependencies] +//! my_crate = { version = "1.0", features = ["serde"] } +//! ``` + +#![cfg_attr(docsrs, feature(doc_cfg))] + +/// A configuration type. +/// +/// When the `serde` feature is enabled, this type implements +/// `Serialize` and `Deserialize`. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(docsrs, doc(cfg(feature = "serde")))] +pub struct Config { + pub name: String, +} +``` + +## Multiple Optional Dependencies + +```rust +// Cargo.toml +[dependencies] +serde = { version = "1.0", features = ["derive"], optional = true } +rkyv = { version = "0.7", optional = true } +borsh = { version = "0.10", optional = true } + +[features] +default = [] +serde = ["dep:serde"] +rkyv = ["dep:rkyv"] +borsh = ["dep:borsh"] + +// lib.rs +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "rkyv", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))] +#[cfg_attr(feature = "borsh", derive(borsh::BorshSerialize, borsh::BorshDeserialize))] +pub struct Message { + pub id: u64, + pub content: String, +} +``` + +## Testing with Features + +```bash +# Test without serde +cargo test + +# Test with serde +cargo test --features serde + +# Test all feature combinations +cargo test --all-features +``` + +```rust +// Test serde round-trip when feature enabled +#[cfg(feature = "serde")] +#[test] +fn test_serde_roundtrip() { + let config = Config { name: "test".into() }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(config, parsed); +} +``` + +## When to Make Serde Required + +```rust +// ✅ Required: Library is about serialization +// (e.g., json-schema, config-file parser) +[dependencies] +serde = "1.0" + +// ✅ Required: Domain heavily uses serde +// (e.g., API client, data format library) + +// ❌ Optional: General-purpose utility library +// ❌ Optional: Math/algorithm library +// ❌ Optional: Most libraries! +``` + +## See Also + +- [proj-lib-main-split](./proj-lib-main-split.md) - Library structure +- [api-common-traits](./api-common-traits.md) - Core trait implementations +- [lint-deny-correctness](./lint-deny-correctness.md) - Feature testing +- [serde-try-from-validate](./serde-try-from-validate.md) - Validate while deserializing +- [serde-rename-all](./serde-rename-all.md) - Match external naming conventions diff --git a/.opencode/skills/rust-skills/rules/api-typestate.md b/.opencode/skills/rust-skills/rules/api-typestate.md new file mode 100644 index 0000000..94b970e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/api-typestate.md @@ -0,0 +1,199 @@ +# api-typestate + +> Use typestate pattern to encode state machine invariants in the type system + +## Why It Matters + +State machines with runtime state checks ("are we connected?", "is the transaction started?") can have invalid transitions. The typestate pattern uses different types for each state, making invalid state transitions compile errors. The compiler enforces your state machine. + +## Bad + +```rust +struct Connection { + state: ConnectionState, + socket: Option, +} + +enum ConnectionState { + Disconnected, + Connected, + Authenticated, +} + +impl Connection { + fn send(&mut self, data: &[u8]) -> Result<(), Error> { + // Runtime check - can fail if called in wrong state + if self.state != ConnectionState::Authenticated { + return Err(Error::NotAuthenticated); + } + self.socket.as_mut().unwrap().write_all(data)?; + Ok(()) + } + + fn authenticate(&mut self, password: &str) -> Result<(), Error> { + // Runtime check - can fail + if self.state != ConnectionState::Connected { + return Err(Error::NotConnected); + } + // ... + } +} + +// Bug: forgot to authenticate +let mut conn = Connection::new(); +conn.connect()?; +conn.send(b"data")?; // Runtime error: NotAuthenticated +``` + +## Good + +```rust +// Different types for each state +struct Disconnected; +struct Connected { socket: TcpStream } +struct Authenticated { socket: TcpStream, session: Session } + +struct Connection { + state: State, +} + +impl Connection { + fn new() -> Self { + Connection { state: Disconnected } + } + + fn connect(self, addr: &str) -> Result, Error> { + let socket = TcpStream::connect(addr)?; + Ok(Connection { state: Connected { socket } }) + } +} + +impl Connection { + fn authenticate(self, password: &str) -> Result, Error> { + let session = do_auth(&self.state.socket, password)?; + Ok(Connection { + state: Authenticated { socket: self.state.socket, session } + }) + } +} + +impl Connection { + fn send(&mut self, data: &[u8]) -> Result<(), Error> { + // No runtime check needed - type guarantees we're authenticated + self.state.socket.write_all(data)?; + Ok(()) + } +} + +// Bug: forgot to authenticate +let conn = Connection::new(); +let conn = conn.connect("server:8080")?; +conn.send(b"data"); // Compile error! send() not available on Connection + +// Correct usage +let conn = Connection::new(); +let conn = conn.connect("server:8080")?; +let mut conn = conn.authenticate("secret")?; +conn.send(b"data")?; // Works - type is Connection +``` + +## Builder Typestate + +```rust +// Enforce required fields via typestate +struct BuilderNoUrl; +struct BuilderWithUrl { url: String } + +struct RequestBuilder { + state: State, + timeout: Option, +} + +impl RequestBuilder { + fn new() -> Self { + RequestBuilder { + state: BuilderNoUrl, + timeout: None, + } + } + + fn url(self, url: &str) -> RequestBuilder { + RequestBuilder { + state: BuilderWithUrl { url: url.to_string() }, + timeout: self.timeout, + } + } +} + +impl RequestBuilder { + fn timeout(mut self, t: Duration) -> Self { + self.timeout = Some(t); + self + } + + // Only available once URL is set + fn build(self) -> Request { + Request { + url: self.state.url, + timeout: self.timeout, + } + } +} + +// Compile error: build() not available +let bad = RequestBuilder::new().build(); + +// Correct: must set URL first +let good = RequestBuilder::new() + .url("https://example.com") + .timeout(Duration::from_secs(30)) + .build(); +``` + +## Transaction Example + +```rust +struct NotStarted; +struct InProgress { tx_id: u64 } +struct Committed; + +struct Transaction { + conn: Connection, + state: State, +} + +impl Transaction { + fn begin(conn: Connection) -> Result, Error> { + let tx_id = conn.execute("BEGIN")?; + Ok(Transaction { + conn, + state: InProgress { tx_id }, + }) + } +} + +impl Transaction { + fn execute(&mut self, sql: &str) -> Result<(), Error> { + self.conn.execute(sql) + } + + fn commit(self) -> Result, Error> { + self.conn.execute("COMMIT")?; + Ok(Transaction { + conn: self.conn, + state: Committed, + }) + } + + fn rollback(self) -> Connection { + let _ = self.conn.execute("ROLLBACK"); + self.conn + } +} +``` + +## See Also + +- [api-builder-pattern](./api-builder-pattern.md) - Basic builder pattern +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven invariants +- [api-sealed-trait](./api-sealed-trait.md) - Restricting trait implementations diff --git a/.opencode/skills/rust-skills/rules/async-async-fn-bounds.md b/.opencode/skills/rust-skills/rules/async-async-fn-bounds.md new file mode 100644 index 0000000..c2ad51b --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-async-fn-bounds.md @@ -0,0 +1,101 @@ +# async-async-fn-bounds + +> Use `AsyncFn`/`AsyncFnMut`/`AsyncFnOnce` bounds instead of `F: Fn() -> Fut, Fut: Future` + +## Why It Matters + +The `AsyncFn`, `AsyncFnMut`, and `AsyncFnOnce` traits stabilized in Rust 1.85 (February 2025). They let you express higher-order async function bounds in a single, readable constraint — and, critically, they handle lifetime capture correctly. The old two-generic pattern `F: Fn() -> Fut, Fut: Future` cannot accept `async ||` closures that borrow from their environment, because the future's lifetime is not linked to the closure's call. `AsyncFn` solves this structurally. + +## Bad + +```rust +use std::future::Future; + +// two-generic pattern: verbose, and cannot accept async closures +// that borrow from their environment across the call +async fn retry(times: usize, f: F) -> Result +where + F: Fn() -> Fut, + Fut: Future>, +{ + let mut last_err; + let mut i = 0; + loop { + match f().await { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e; + i += 1; + if i >= times { + return Err(last_err); + } + } + } + } +} +``` + +## Good + +```rust +// AsyncFn bound: concise, correct lifetime semantics, accepts async closures +async fn retry(times: usize, f: F) -> Result +where + F: AsyncFn() -> Result, +{ + let mut last_err; + let mut i = 0; + loop { + match f().await { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e; + i += 1; + if i >= times { + return Err(last_err); + } + } + } + } +} + +// callers can pass plain async functions or async closures +async fn fetch_data() -> Result { + Ok("data".to_owned()) +} + +async fn example() { + // async function reference + let _ = retry(3, fetch_data).await; + + // async closure (impossible with the old F: Fn() -> Fut pattern + // when the closure borrows a local across calls) + let prefix = "prefix".to_owned(); + let _ = retry(3, async || { + Ok::<_, std::io::Error>(format!("{prefix}-data")) + }) + .await; +} +``` + +## The Three Variants + +| Bound | Receiver | Use when | +|---|---|---| +| `AsyncFn()` | `&self` | callable multiple times, no mutation | +| `AsyncFnMut()` | `&mut self` | callable multiple times, may mutate captured state | +| `AsyncFnOnce()` | `self` | callable exactly once, may consume captured state | + +These mirror `Fn` / `FnMut` / `FnOnce` semantics, so the same rules apply: prefer `AsyncFn` first; fall back to `AsyncFnMut` or `AsyncFnOnce` when needed. + +## Key Points + +- `AsyncFn()` desugars the bound `F: AsyncFn<(), Output = T>` — the call syntax mirrors `Fn()`. +- Plain `async fn` items (not closures) automatically implement `AsyncFn` when their signature matches. +- `AsyncFn` is in `std::ops` but the traits are brought in scope automatically in the 2024 edition; in older editions you may need `use std::ops::AsyncFn;`. +- The compiler still infers `Send`-ness from the closure body; if you need `Send` futures (Tokio multi-threaded), add `+ Send` to the bound or use a `Send` wrapper. + +## See Also + +- [async-fn-in-trait](async-fn-in-trait.md) - native async fn in trait definitions +- [async-tokio-runtime](async-tokio-runtime.md) - use Tokio for production async runtime diff --git a/.opencode/skills/rust-skills/rules/async-bounded-channel.md b/.opencode/skills/rust-skills/rules/async-bounded-channel.md new file mode 100644 index 0000000..0c31e4c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-bounded-channel.md @@ -0,0 +1,175 @@ +# async-bounded-channel + +> Use bounded channels to apply backpressure and prevent unbounded memory growth + +## Why It Matters + +Unbounded channels grow without limit when producers outpace consumers. In production, this leads to memory exhaustion. Bounded channels apply backpressure—producers wait when the channel is full, naturally throttling the system. This prevents OOM and makes resource usage predictable. + +## Bad + +```rust +use tokio::sync::mpsc; + +// Unbounded channel - can grow forever +let (tx, mut rx) = mpsc::unbounded_channel::(); + +// Fast producer, slow consumer = unbounded memory growth +tokio::spawn(async move { + loop { + let msg = generate_message(); + tx.send(msg).unwrap(); // Never blocks, never fails (until OOM) + } +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + slow_process(msg).await; // Can't keep up + } +}); +// Memory grows unboundedly until crash +``` + +## Good + +```rust +use tokio::sync::mpsc; + +// Bounded channel - backpressure when full +let (tx, mut rx) = mpsc::channel::(100); // Max 100 items + +// Producer waits when channel full +tokio::spawn(async move { + loop { + let msg = generate_message(); + // Blocks if channel is full - natural backpressure + tx.send(msg).await.unwrap(); + } +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + slow_process(msg).await; + } +}); +// Memory usage capped at ~100 messages +``` + +## Choosing Buffer Size + +```rust +// Too small: frequent blocking, reduced throughput +let (tx, rx) = mpsc::channel::(1); + +// Too large: delayed backpressure, memory waste +let (tx, rx) = mpsc::channel::(1_000_000); + +// Guidelines: +// - Start with expected burst size +// - Measure actual usage in production +// - Err on the smaller side initially + +// Small items, high throughput +let (tx, rx) = mpsc::channel::(1000); + +// Large items, moderate throughput +let (tx, rx) = mpsc::channel::(100); + +// Low latency requirement +let (tx, rx) = mpsc::channel::(10); +``` + +## Handling Full Channel + +```rust +use tokio::sync::mpsc; +use tokio::time::{timeout, Duration}; + +let (tx, mut rx) = mpsc::channel::(100); + +// Option 1: Wait indefinitely (default) +tx.send(msg).await?; + +// Option 2: Try send, fail if full +match tx.try_send(msg) { + Ok(()) => println!("Sent"), + Err(TrySendError::Full(msg)) => { + println!("Channel full, dropping message"); + } + Err(TrySendError::Closed(msg)) => { + println!("Receiver dropped"); + } +} + +// Option 3: Timeout +match timeout(Duration::from_secs(1), tx.send(msg)).await { + Ok(Ok(())) => println!("Sent"), + Ok(Err(_)) => println!("Channel closed"), + Err(_) => println!("Timeout - channel full for too long"), +} + +// Option 4: send with permit reservation +let permit = tx.reserve().await?; +permit.send(msg); // Guaranteed to succeed +``` + +## Channel Types + +```rust +// mpsc: many producers, single consumer +let (tx, rx) = mpsc::channel::(100); +let tx2 = tx.clone(); // Can clone sender + +// oneshot: single value, one producer, one consumer +let (tx, rx) = oneshot::channel::(); +tx.send(response); // Can only send once + +// broadcast: multiple consumers, each gets all messages +let (tx, _) = broadcast::channel::(100); +let mut rx1 = tx.subscribe(); +let mut rx2 = tx.subscribe(); + +// watch: single latest value, multiple consumers +let (tx, rx) = watch::channel::(initial); +// Receivers see latest value, not all values +``` + +## Worker Pool Pattern + +```rust +async fn process_with_workers(items: Vec) -> Vec { + let (tx, rx) = mpsc::channel(100); + let rx = Arc::new(Mutex::new(rx)); + + // Spawn worker pool + let workers: Vec<_> = (0..4).map(|_| { + let rx = rx.clone(); + tokio::spawn(async move { + loop { + let item = { + let mut rx = rx.lock().await; + rx.recv().await + }; + match item { + Some(item) => process(item).await, + None => break, + } + } + }) + }).collect(); + + // Send items + for item in items { + tx.send(item).await.unwrap(); + } + drop(tx); // Signal workers to stop + + futures::future::join_all(workers).await; +} +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Multi-producer patterns +- [async-oneshot-response](./async-oneshot-response.md) - Request-response pattern +- [async-watch-latest](./async-watch-latest.md) - Latest-value broadcasting diff --git a/.opencode/skills/rust-skills/rules/async-broadcast-pubsub.md b/.opencode/skills/rust-skills/rules/async-broadcast-pubsub.md new file mode 100644 index 0000000..86ea938 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-broadcast-pubsub.md @@ -0,0 +1,185 @@ +# async-broadcast-pubsub + +> Use `broadcast` channel for pub/sub where all subscribers receive all messages + +## Why It Matters + +Unlike `mpsc` where one consumer receives each message, `broadcast` delivers each message to all subscribers. This is ideal for event broadcasting, real-time notifications, or when multiple components need to react to the same events independently. + +## Bad + +```rust +use tokio::sync::mpsc; + +// mpsc only delivers to ONE consumer +let (tx, mut rx) = mpsc::channel::(100); + +// Only one of these receives each message! +let mut rx2 = ???; // Can't clone receiver +``` + +## Good + +```rust +use tokio::sync::broadcast; + +// broadcast delivers to ALL subscribers +let (tx, _) = broadcast::channel::(100); + +// Each subscriber gets ALL messages +let mut rx1 = tx.subscribe(); +let mut rx2 = tx.subscribe(); + +tokio::spawn(async move { + while let Ok(event) = rx1.recv().await { + handle_in_logger(event); + } +}); + +tokio::spawn(async move { + while let Ok(event) = rx2.recv().await { + handle_in_metrics(event); + } +}); + +// Both subscribers receive this +tx.send(Event::UserLogin { user_id: 42 })?; +``` + +## Broadcast Semantics + +```rust +use tokio::sync::broadcast; + +let (tx, mut rx1) = broadcast::channel::(16); +let mut rx2 = tx.subscribe(); + +tx.send(1)?; +tx.send(2)?; + +// Both receive all messages +assert_eq!(rx1.recv().await?, 1); +assert_eq!(rx1.recv().await?, 2); +assert_eq!(rx2.recv().await?, 1); +assert_eq!(rx2.recv().await?, 2); +``` + +## Handling Lagging Receivers + +```rust +use tokio::sync::broadcast::{self, error::RecvError}; + +let (tx, mut rx) = broadcast::channel::(16); + +loop { + match rx.recv().await { + Ok(event) => { + process(event); + } + Err(RecvError::Lagged(count)) => { + // Receiver couldn't keep up, missed `count` messages + log::warn!("Missed {} events", count); + // Continue receiving new messages + } + Err(RecvError::Closed) => { + break; // All senders dropped + } + } +} +``` + +## Event Bus Pattern + +```rust +use tokio::sync::broadcast; + +#[derive(Clone, Debug)] +enum AppEvent { + UserLoggedIn { user_id: u64 }, + OrderCreated { order_id: u64 }, + SystemShutdown, +} + +struct EventBus { + tx: broadcast::Sender, +} + +impl EventBus { + fn new() -> Self { + let (tx, _) = broadcast::channel(1000); + EventBus { tx } + } + + fn publish(&self, event: AppEvent) { + // Ignore error if no subscribers + let _ = self.tx.send(event); + } + + fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +// Usage +let bus = EventBus::new(); + +// Logger subscribes +let mut log_rx = bus.subscribe(); +tokio::spawn(async move { + while let Ok(event) = log_rx.recv().await { + log::info!("Event: {:?}", event); + } +}); + +// Metrics subscribes +let mut metrics_rx = bus.subscribe(); +tokio::spawn(async move { + while let Ok(event) = metrics_rx.recv().await { + record_metric(&event); + } +}); + +// Publish events +bus.publish(AppEvent::UserLoggedIn { user_id: 42 }); +``` + +## Broadcast vs Watch + +```rust +// broadcast: subscribers get ALL messages +// Good for: events, logs, notifications +let (tx, _) = broadcast::channel::(100); + +// watch: subscribers get LATEST value only +// Good for: config changes, state updates +let (tx, _) = watch::channel(initial_state); + +// If subscriber is slow: +// - broadcast: they receive old messages (or lag) +// - watch: they skip to latest (no history) +``` + +## Clone Requirement + +```rust +// broadcast requires Clone because message is cloned to each receiver +use tokio::sync::broadcast; + +#[derive(Clone)] // Required for broadcast +struct Event { + data: String, +} + +let (tx, _) = broadcast::channel::(100); + +// For non-Clone types, wrap in Arc +use std::sync::Arc; + +let (tx, _) = broadcast::channel::>(100); +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Single-consumer channels +- [async-watch-latest](./async-watch-latest.md) - Latest-value only +- [async-bounded-channel](./async-bounded-channel.md) - Buffer sizing diff --git a/.opencode/skills/rust-skills/rules/async-cancel-safety.md b/.opencode/skills/rust-skills/rules/async-cancel-safety.md new file mode 100644 index 0000000..5e84af1 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-cancel-safety.md @@ -0,0 +1,112 @@ +# async-cancel-safety + +> Ensure futures used in `tokio::select!` branches are cancellation-safe + +## Why It Matters + +`tokio::select!` polls multiple futures concurrently and, the moment one branch completes, it **drops every other branch** — including any state those futures held. A future that was halfway through reading bytes into a local buffer, or halfway through draining a channel into a `Vec`, loses that progress silently. This is not a compiler error; it compiles fine and the bug only surfaces under concurrent load. Tokio documents which of its primitives are cancellation-safe; everything else must be treated with care. + +## Bad + +```rust +use tokio::io::{AsyncReadExt, BufReader}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; + +// Non-cancel-safe: `read_exact` owns an internal buffer inside the future. +// If select! drops this branch, the partially-read bytes are gone. +async fn bad_example(stream: &mut BufReader, rx: &mut mpsc::Receiver) { + let mut buf = [0u8; 1024]; + tokio::select! { + // BUG: if the `recv` branch fires first, the bytes already read + // into buf inside `read_exact` are silently discarded + result = stream.read_exact(&mut buf) => { + println!("read {} bytes", result.unwrap()); + } + msg = rx.recv() => { + println!("got message: {:?}", msg); + } + } +} +``` + +## Good + +```rust +use tokio::io::{AsyncReadExt, BufReader}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; + +// Cancel-safe: the buffer lives OUTSIDE the select loop. +// If the recv branch fires, buf retains whatever was already read, +// and the next iteration continues filling it. +async fn good_example( + stream: &mut BufReader, + rx: &mut mpsc::Receiver, +) -> std::io::Result<()> { + let mut buf = [0u8; 1024]; + let mut filled = 0; + + loop { + tokio::select! { + n = stream.read(&mut buf[filled..]) => { + // `read` (not `read_exact`) is cancel-safe: it either + // reads some bytes or returns immediately with 0. + filled += n?; + if filled == buf.len() { + println!("buffer full: {:?}", &buf[..]); + filled = 0; + } + } + msg = rx.recv() => { + println!("got message: {:?}", msg); + } + } + } +} +``` + +## What Is and Is Not Cancel-Safe + +| Operation | Cancel-safe? | Notes | +|---|---|---| +| `mpsc::Receiver::recv()` | Yes | drops nothing on cancel | +| `broadcast::Receiver::recv()` | Yes | position tracked in receiver | +| `watch::Receiver::changed()` | Yes | no data consumed on cancel | +| `oneshot::Receiver` | Yes | message remains in channel | +| `tokio::time::sleep` | Yes | timer resets cleanly | +| `AsyncRead::read()` | Yes | partial reads surfaced to caller | +| `AsyncRead::read_exact()` | **No** | partially filled buffer is lost | +| `AsyncRead::read_to_end()` | **No** | accumulation is inside the future | +| `Mutex::lock()` | Yes | lock not acquired if dropped | +| collecting into a `Vec` inside a future | **No** | partial state is inside the future | + +## Patterns for Non-Cancel-Safe Operations + +1. **Move state outside `select!`** — keep the accumulation buffer or partial result as a local variable in the surrounding scope, not inside the future passed to `select!`. + +2. **Use a pinned future held across iterations** — pin the future once and reuse it: + +```rust +use std::pin::pin; +use tokio::io::AsyncReadExt; + +async fn pinned_read_example(mut reader: R) { + let mut buf = vec![0u8; 64]; + // pin the future so it survives across select iterations + let read_fut = pin!(reader.read_to_end(&mut buf)); + // ... use read_fut inside select! across multiple iterations + // When the future completes it won't be cancelled mid-way + let _ = read_fut.await; +} +``` + +3. **Use `tokio_util::io::poll_read_buf`** or similar cancel-safe adapters from `tokio-util`. + +4. **Wrap in a `JoinHandle`** — spawn the operation as a task; the task keeps running even if `select!` drops the handle, and the handle itself is cancel-safe. + +## See Also + +- [async-select-racing](async-select-racing.md) - use `tokio::select!` for racing/timeouts +- [async-bounded-channel](async-bounded-channel.md) - use bounded channels for backpressure +- [async-no-lock-await](async-no-lock-await.md) - never hold locks across `.await` diff --git a/.opencode/skills/rust-skills/rules/async-cancellation-token.md b/.opencode/skills/rust-skills/rules/async-cancellation-token.md new file mode 100644 index 0000000..549f305 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-cancellation-token.md @@ -0,0 +1,203 @@ +# async-cancellation-token + +> Use `CancellationToken` for graceful shutdown and task cancellation + +## Why It Matters + +Dropping a `JoinHandle` doesn't cancel the task—it just detaches it. For graceful shutdown, you need explicit cancellation. `tokio_util::sync::CancellationToken` provides a cooperative cancellation mechanism that tasks can check and respond to, enabling clean resource cleanup. + +## Bad + +```rust +// Dropping handle doesn't stop the task +let handle = tokio::spawn(async { + loop { + do_work().await; + } +}); + +drop(handle); // Task continues running in background! + +// Using bool flag - not async-aware +let running = Arc::new(AtomicBool::new(true)); + +tokio::spawn({ + let running = running.clone(); + async move { + while running.load(Ordering::Relaxed) { + do_work().await; // Can't wake up if blocked here + } + } +}); + +running.store(false, Ordering::Relaxed); +// Task won't stop until current do_work() completes +``` + +## Good + +```rust +use tokio_util::sync::CancellationToken; + +let token = CancellationToken::new(); + +let handle = tokio::spawn({ + let token = token.clone(); + async move { + loop { + tokio::select! { + _ = token.cancelled() => { + println!("Shutting down gracefully"); + cleanup().await; + break; + } + _ = do_work() => { + // Work completed + } + } + } + } +}); + +// Later: trigger cancellation +token.cancel(); +handle.await?; // Task completes cleanly +``` + +## CancellationToken API + +```rust +use tokio_util::sync::CancellationToken; + +// Create token +let token = CancellationToken::new(); + +// Clone for sharing (cheap Arc-based clone) +let token2 = token.clone(); + +// Check if cancelled (non-blocking) +if token.is_cancelled() { + return; +} + +// Wait for cancellation (async) +token.cancelled().await; + +// Trigger cancellation +token.cancel(); + +// Child tokens - cancelled when parent is cancelled +let child = token.child_token(); +``` + +## Hierarchical Cancellation + +```rust +async fn run_server(shutdown: CancellationToken) { + let listener = TcpListener::bind("0.0.0.0:8080").await?; + + loop { + tokio::select! { + _ = shutdown.cancelled() => { + println!("Server shutting down"); + break; + } + result = listener.accept() => { + let (socket, _) = result?; + // Each connection gets child token + let conn_token = shutdown.child_token(); + tokio::spawn(handle_connection(socket, conn_token)); + } + } + } + + // Child tokens auto-cancelled when we exit +} + +async fn handle_connection(socket: TcpStream, token: CancellationToken) { + loop { + tokio::select! { + _ = token.cancelled() => { + // Connection cleanup + break; + } + data = socket.read() => { + // Handle data + } + } + } +} +``` + +## Graceful Shutdown Pattern + +```rust +use tokio::signal; + +async fn main() -> Result<()> { + let shutdown = CancellationToken::new(); + + // Spawn signal handler + let shutdown_trigger = shutdown.clone(); + tokio::spawn(async move { + signal::ctrl_c().await.expect("failed to listen for Ctrl+C"); + println!("Received Ctrl+C, initiating shutdown..."); + shutdown_trigger.cancel(); + }); + + // Run application with shutdown token + run_app(shutdown).await +} + +async fn run_app(shutdown: CancellationToken) -> Result<()> { + let mut tasks = JoinSet::new(); + + tasks.spawn(worker_task(shutdown.child_token())); + tasks.spawn(server_task(shutdown.child_token())); + + // Wait for shutdown or task completion + tokio::select! { + _ = shutdown.cancelled() => { + println!("Shutdown requested, waiting for tasks..."); + } + Some(result) = tasks.join_next() => { + // A task completed/failed + result??; + } + } + + // Wait for remaining tasks with timeout + tokio::time::timeout( + Duration::from_secs(30), + async { while tasks.join_next().await.is_some() {} } + ).await.ok(); + + Ok(()) +} +``` + +## DropGuard Pattern + +```rust +use tokio_util::sync::CancellationToken; + +// Auto-cancel on drop +let token = CancellationToken::new(); +let guard = token.clone().drop_guard(); + +tokio::spawn({ + let token = token.clone(); + async move { + token.cancelled().await; + println!("Cancelled!"); + } +}); + +drop(guard); // Automatically calls token.cancel() +``` + +## See Also + +- [async-joinset-structured](./async-joinset-structured.md) - Managing multiple tasks +- [async-select-racing](./async-select-racing.md) - select! for cancellation +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime shutdown diff --git a/.opencode/skills/rust-skills/rules/async-clone-before-await.md b/.opencode/skills/rust-skills/rules/async-clone-before-await.md new file mode 100644 index 0000000..2a01999 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-clone-before-await.md @@ -0,0 +1,171 @@ +# async-clone-before-await + +> Clone Arc/Rc data before await points to avoid holding references across suspension + +## Why It Matters + +References held across `.await` points extend the future's lifetime and can cause borrow checker issues or prevent `Send` bounds. Cloning `Arc`/`Rc` before the await ensures the future only holds owned data, making it `Send` and avoiding lifetime complications. + +## Bad + +```rust +use std::sync::Arc; + +async fn process(data: Arc) { + // Borrow extends across await - future is not Send + let slice = &data.items[..]; // Borrow of Arc contents + + expensive_async_operation().await; // Await with active borrow + + use_slice(slice); // Still using the borrow +} + +// Error: future cannot be sent between threads safely +// because `&[Item]` cannot be sent between threads safely +tokio::spawn(process(data)); +``` + +## Good + +```rust +use std::sync::Arc; + +async fn process(data: Arc) { + // Clone what you need before await + let items = data.items.clone(); // Owned Vec + + expensive_async_operation().await; + + use_items(&items); // Using owned data +} + +// Or clone the Arc itself +async fn share_data(data: Arc) { + let data = data.clone(); // Another Arc handle + + some_async_work().await; + + process(&data); // Safe - we own the Arc +} +``` + +## The Send Problem + +```rust +// Futures must be Send to spawn on multi-threaded runtime +async fn not_send() { + let rc = Rc::new(42); // Rc is !Send + + tokio::time::sleep(Duration::from_secs(1)).await; + + println!("{}", rc); // rc held across await +} + +tokio::spawn(not_send()); // ERROR: future is not Send + +// Fix: use Arc or don't hold across await +async fn is_send() { + let arc = Arc::new(42); // Arc is Send + + tokio::time::sleep(Duration::from_secs(1)).await; + + println!("{}", arc); +} + +tokio::spawn(is_send()); // OK +``` + +## Minimizing Clones + +```rust +// Bad: clone everything eagerly +async fn wasteful(data: Arc) { + let data = (*data).clone(); // Clones entire LargeData + async_work().await; + use_one_field(&data.small_field); +} + +// Good: clone only what you need +async fn efficient(data: Arc) { + let small = data.small_field.clone(); // Clone only needed field + async_work().await; + use_one_field(&small); +} + +// Good: if you need the whole thing, keep the Arc +async fn arc_efficient(data: Arc) { + let data = data.clone(); // Cheap Arc clone + async_work().await; + use_data(&data); // Access through Arc +} +``` + +## Spawn Pattern + +```rust +// Common pattern: clone for spawned task +let shared = Arc::new(SharedState::new()); + +for i in 0..10 { + let shared = shared.clone(); // Clone before moving into spawn + tokio::spawn(async move { + // Task owns its Arc clone + shared.do_something(i).await; + }); +} +``` + +## Scope-Based Approach + +```rust +// Limit borrow scope to before await +async fn scoped(data: Arc) { + // Scope 1: borrow, compute, drop borrow + let computed = { + let slice = &data.items[..]; // Borrow + compute_something(slice) // Use + }; // Borrow ends here + + // Now safe to await + expensive_async_operation().await; + + use_computed(computed); +} +``` + +## MutexGuard Across Await + +```rust +use tokio::sync::Mutex; + +// BAD: holding guard across await +async fn bad(mutex: Arc>) { + let mut guard = mutex.lock().await; + guard.value += 1; + + slow_operation().await; // Guard held during await! + + guard.value += 1; +} + +// GOOD: release before await +async fn good(mutex: Arc>) { + { + let mut guard = mutex.lock().await; + guard.value += 1; + } // Guard released + + slow_operation().await; + + { + let mut guard = mutex.lock().await; + guard.value += 1; + } +} +``` + +## See Also + +- [async-no-lock-await](./async-no-lock-await.md) - Lock guards across await +- [own-arc-shared](./own-arc-shared.md) - Arc usage patterns +- [async-spawn-blocking](./async-spawn-blocking.md) - Blocking in async diff --git a/.opencode/skills/rust-skills/rules/async-fn-in-trait.md b/.opencode/skills/rust-skills/rules/async-fn-in-trait.md new file mode 100644 index 0000000..1ac621c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-fn-in-trait.md @@ -0,0 +1,105 @@ +# async-fn-in-trait + +> Use native `async fn` in traits (stable 1.75) instead of the `async_trait` macro + +## Why It Matters + +Since Rust 1.75, you can write `async fn` directly inside trait definitions (AFIT — async functions in traits). This eliminates the `#[async_trait]` proc-macro dependency and removes the hidden `Box` allocation it inserts on every call. Fewer allocations, no macro expansion overhead, and no extra crate to audit. However, native async fn in traits carries two precise caveats you must understand before migrating. + +## Bad + +```rust +// requires async_trait crate; boxes every future on the heap +use async_trait::async_trait; + +#[async_trait] +trait Repo { + async fn get(&self, id: u64) -> anyhow::Result; + async fn save(&self, value: String) -> anyhow::Result<()>; +} + +struct PgRepo; + +#[async_trait] +impl Repo for PgRepo { + async fn get(&self, id: u64) -> anyhow::Result { + Ok(format!("row-{id}")) + } + + async fn save(&self, value: String) -> anyhow::Result<()> { + let _ = value; + Ok(()) + } +} +``` + +## Good + +```rust +// native async fn in traits — no macro, no boxing +trait Repo { + async fn get(&self, id: u64) -> anyhow::Result; + async fn save(&self, value: String) -> anyhow::Result<()>; +} + +struct PgRepo; + +impl Repo for PgRepo { + async fn get(&self, id: u64) -> anyhow::Result { + Ok(format!("row-{id}")) + } + + async fn save(&self, value: String) -> anyhow::Result<()> { + let _ = value; + Ok(()) + } +} +``` + +## Caveats + +**Caveat 1 — not dyn-compatible.** Native async fn in traits is not yet object-safe. You cannot write `Box` with the definition above. For dynamic dispatch you have two options: + +- Keep `#[async_trait]` (it boxes the future, which makes the trait object-safe). +- Use the `trait-variant` crate's `#[trait_variant::make]` macro, which generates a boxed-future variant alongside your native async trait. + +```rust +// using trait-variant to get both a static and a dyn-compatible variant +#[trait_variant::make(RepoSend: Send)] +trait Repo { + async fn get(&self, id: u64) -> anyhow::Result; +} + +// `RepoSend` is the Send-bounded version; it IS dyn-compatible via boxing +fn make_repo() -> Box { + // ... + # unimplemented!() +} +``` + +**Caveat 2 — futures are not `Send` by default.** On a multi-threaded Tokio runtime, spawned tasks require `Send` futures. The auto-generated future from a native `async fn` in a trait captures `&self` but does not promise `Send`. If you need `Send`, either: + +- Use `#[trait_variant::make(TraitNameSend: Send)]` from the `trait-variant` crate to generate a `Send`-bounded variant. +- Bound the return type explicitly: `fn get(&self, id: u64) -> impl Future> + Send`. + +```rust +// explicit Send bound on the return future +trait Repo { + fn get(&self, id: u64) -> impl Future> + Send; +} +``` + +## When to Use Each Approach + +| Scenario | Recommended approach | +|---|---| +| Static dispatch only (generics, `impl Trait`) | Native `async fn` in trait | +| Need `dyn Trait` | `#[async_trait]` or `trait-variant` | +| Multi-threaded Tokio, spawned tasks | `trait-variant` `Send` variant or explicit `+ Send` | +| Single-threaded runtime / `LocalSet` | Native `async fn` in trait (no `Send` needed) | + +## See Also + +- [anti-type-erasure](anti-type-erasure.md) - prefer `impl Trait` over `Box` when possible +- [async-async-fn-bounds](async-async-fn-bounds.md) - use `AsyncFn` bounds for higher-order async functions +- [async-tokio-runtime](async-tokio-runtime.md) - use Tokio for production async runtime diff --git a/.opencode/skills/rust-skills/rules/async-join-parallel.md b/.opencode/skills/rust-skills/rules/async-join-parallel.md new file mode 100644 index 0000000..bab3c68 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-join-parallel.md @@ -0,0 +1,158 @@ +# async-join-parallel + +> Use `join!` or `try_join!` for concurrent independent futures + +## Why It Matters + +Awaiting futures sequentially takes the sum of their durations. `join!` runs futures concurrently, taking only as long as the slowest one. For independent operations like multiple API calls or parallel file reads, this can dramatically reduce latency. + +## Bad + +```rust +async fn fetch_data() -> (User, Posts, Comments) { + // Sequential: 300ms total (100 + 100 + 100) + let user = fetch_user().await; // 100ms + let posts = fetch_posts().await; // 100ms + let comments = fetch_comments().await; // 100ms + + (user, posts, comments) +} + +async fn read_configs() -> Result<(Config, Settings)> { + // Sequential: 20ms + 20ms = 40ms + let config = fs::read_to_string("config.toml").await?; + let settings = fs::read_to_string("settings.json").await?; + + Ok((parse_config(&config)?, parse_settings(&settings)?)) +} +``` + +## Good + +```rust +use tokio::join; + +async fn fetch_data() -> (User, Posts, Comments) { + // Concurrent: ~100ms total (max of all three) + let (user, posts, comments) = join!( + fetch_user(), + fetch_posts(), + fetch_comments(), + ); + + (user, posts, comments) +} + +use tokio::try_join; + +async fn read_configs() -> Result<(Config, Settings)> { + // Concurrent: ~20ms total + let (config_str, settings_str) = try_join!( + fs::read_to_string("config.toml"), + fs::read_to_string("settings.json"), + )?; + + Ok((parse_config(&config_str)?, parse_settings(&settings_str)?)) +} +``` + +## join! vs try_join! + +```rust +// join! - all futures run to completion, returns tuple +let (a, b, c) = join!(future_a, future_b, future_c); + +// try_join! - short-circuits on first error +let (a, b, c) = try_join!(fallible_a, fallible_b, fallible_c)?; +// If fallible_b fails, returns Err immediately +// Other futures may still be running (cancellation is async) +``` + +## futures::join_all for Dynamic Collections + +```rust +use futures::future::join_all; + +async fn fetch_all_users(ids: &[u64]) -> Vec { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + join_all(futures).await +} + +// With fallible futures +use futures::future::try_join_all; + +async fn fetch_all_users(ids: &[u64]) -> Result> { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + try_join_all(futures).await +} +``` + +## Limiting Concurrency + +```rust +use futures::stream::{self, StreamExt}; + +async fn fetch_with_limit(ids: &[u64]) -> Vec> { + stream::iter(ids) + .map(|id| fetch_user(*id)) + .buffer_unordered(10) // Max 10 concurrent requests + .collect() + .await +} + +// Or with tokio::sync::Semaphore +use tokio::sync::Semaphore; + +async fn fetch_with_semaphore(ids: &[u64]) -> Vec { + let semaphore = Arc::new(Semaphore::new(10)); + + let futures: Vec<_> = ids.iter().map(|id| { + let semaphore = semaphore.clone(); + async move { + let _permit = semaphore.acquire().await.unwrap(); + fetch_user(*id).await + } + }).collect(); + + join_all(futures).await +} +``` + +## When NOT to Use join! + +```rust +// ❌ Dependent futures - must be sequential +async fn create_and_populate() -> Result<()> { + let db = create_database().await?; // Must complete first + populate_tables(&db).await?; // Depends on db + Ok(()) +} + +// ❌ Short-circuiting logic +async fn find_first() -> Option { + // Want to stop when one succeeds + // Use select! instead +} + +// ❌ Shared mutable state +async fn bad_shared_state() { + let counter = Arc::new(Mutex::new(0)); + // This might work but can cause contention + join!( + increment(counter.clone()), + increment(counter.clone()), + ); +} +``` + +## See Also + +- [async-try-join](./async-try-join.md) - Error handling in concurrent futures +- [async-select-racing](./async-select-racing.md) - Racing futures +- [async-joinset-structured](./async-joinset-structured.md) - Dynamic task sets diff --git a/.opencode/skills/rust-skills/rules/async-joinset-structured.md b/.opencode/skills/rust-skills/rules/async-joinset-structured.md new file mode 100644 index 0000000..a98c522 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-joinset-structured.md @@ -0,0 +1,195 @@ +# async-joinset-structured + +> Use `JoinSet` for managing dynamic collections of spawned tasks + +## Why It Matters + +When spawning a variable number of tasks, collecting `JoinHandle`s in a `Vec` and using `join_all` works but lacks flexibility. `JoinSet` provides a better abstraction: add/remove tasks dynamically, get results as they complete, and abort all on drop. It's the idiomatic way to manage task collections. + +## Bad + +```rust +// Manual handle management +let mut handles: Vec>> = Vec::new(); + +for url in urls { + handles.push(tokio::spawn(fetch(url))); +} + +// Wait for all, in order (not as they complete) +let results = futures::future::join_all(handles).await; + +// No easy way to cancel all, handle errors progressively, or add more tasks +``` + +## Good + +```rust +use tokio::task::JoinSet; + +let mut set = JoinSet::new(); + +for url in urls { + set.spawn(fetch(url.clone())); +} + +// Process results as they complete +while let Some(result) = set.join_next().await { + match result { + Ok(Ok(data)) => process(data), + Ok(Err(e)) => log::error!("Task failed: {}", e), + Err(e) => log::error!("Task panicked: {}", e), + } +} + +// All tasks done, set is empty +``` + +## Dynamic Task Addition + +```rust +use tokio::task::JoinSet; + +async fn worker_pool(mut rx: mpsc::Receiver) { + let mut set = JoinSet::new(); + let max_concurrent = 10; + + loop { + tokio::select! { + // Accept new tasks if under limit + Some(task) = rx.recv(), if set.len() < max_concurrent => { + set.spawn(process_task(task)); + } + + // Process completed tasks + Some(result) = set.join_next() => { + handle_result(result); + } + + // Exit when no tasks and channel closed + else => break, + } + } +} +``` + +## Abort on Drop + +```rust +use tokio::task::JoinSet; + +{ + let mut set = JoinSet::new(); + set.spawn(long_running_task()); + set.spawn(another_task()); + + // Early exit + return; +} // JoinSet dropped here - all tasks are aborted! + +// Explicit abort +let mut set = JoinSet::new(); +set.spawn(task()); +set.abort_all(); // Cancel all tasks +``` + +## Error Handling Pattern + +```rust +use tokio::task::JoinSet; + +async fn fetch_all(urls: &[String]) -> Vec> { + let mut set = JoinSet::new(); + let mut results = Vec::new(); + + for url in urls { + set.spawn(fetch(url.clone())); + } + + while let Some(join_result) = set.join_next().await { + let result = match join_result { + Ok(task_result) => task_result, + Err(join_error) => { + if join_error.is_panic() { + Err(Error::TaskPanicked) + } else { + Err(Error::TaskCancelled) + } + } + }; + results.push(result); + } + + results +} +``` + +## With Cancellation + +```rust +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +async fn run_workers(shutdown: CancellationToken) { + let mut set = JoinSet::new(); + + for i in 0..4 { + let token = shutdown.child_token(); + set.spawn(async move { + loop { + tokio::select! { + _ = token.cancelled() => break, + _ = do_work(i) => {} + } + } + }); + } + + // Wait for shutdown + shutdown.cancelled().await; + + // Abort remaining tasks + set.abort_all(); + + // Wait for all to finish (drain aborted tasks) + while set.join_next().await.is_some() {} +} +``` + +## Spawning with Context + +```rust +use tokio::task::JoinSet; + +let mut set: JoinSet<(usize, Result)> = JoinSet::new(); + +for (index, url) in urls.iter().enumerate() { + let url = url.clone(); + set.spawn(async move { + (index, fetch(&url).await) + }); +} + +// Results include their index +while let Some(result) = set.join_next().await { + if let Ok((index, data)) = result { + results[index] = Some(data); + } +} +``` + +## JoinSet vs join_all + +| Feature | JoinSet | join_all | +|---------|---------|----------| +| Add tasks dynamically | Yes | No | +| Results as-completed | Yes | No (all at once) | +| Abort all on drop | Yes | No | +| Cancel individual | Yes | No | +| Memory efficient | Yes | Pre-allocates | + +## See Also + +- [async-join-parallel](./async-join-parallel.md) - Static concurrent futures +- [async-cancellation-token](./async-cancellation-token.md) - Cancellation patterns +- [async-try-join](./async-try-join.md) - Error handling in joins diff --git a/.opencode/skills/rust-skills/rules/async-mpsc-queue.md b/.opencode/skills/rust-skills/rules/async-mpsc-queue.md new file mode 100644 index 0000000..765b02f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-mpsc-queue.md @@ -0,0 +1,171 @@ +# async-mpsc-queue + +> Use `mpsc` channels for async message queues between tasks + +## Why It Matters + +`tokio::sync::mpsc` (multi-producer, single-consumer) is the workhorse channel for async Rust. It provides async send/receive, backpressure via bounded capacity, and efficient cloning of senders. It's the default choice for task-to-task communication. + +## Bad + +```rust +use std::sync::mpsc; // Wrong! Blocks the async runtime + +let (tx, rx) = std::sync::mpsc::channel(); + +tokio::spawn(async move { + tx.send("hello").unwrap(); // Might block +}); + +tokio::spawn(async move { + let msg = rx.recv().unwrap(); // BLOCKS the executor thread! +}); +``` + +## Good + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); + +tokio::spawn(async move { + tx.send("hello".to_string()).await.unwrap(); +}); + +tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + println!("Received: {}", msg); + } +}); +``` + +## Sender Cloning + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); + +// Multiple producers +for i in 0..10 { + let tx = tx.clone(); // Cheap clone + tokio::spawn(async move { + tx.send(Event { source: i }).await.unwrap(); + }); +} + +// Drop original sender so channel closes when all clones dropped +drop(tx); + +// Consumer +while let Some(event) = rx.recv().await { + process(event); +} +// Loop exits when all senders dropped +``` + +## Message Handler Pattern + +```rust +use tokio::sync::mpsc; + +enum Command { + Get { key: String, reply: oneshot::Sender> }, + Set { key: String, value: Value }, + Delete { key: String }, +} + +async fn run_store(mut commands: mpsc::Receiver) { + let mut store = HashMap::new(); + + while let Some(cmd) = commands.recv().await { + match cmd { + Command::Get { key, reply } => { + let _ = reply.send(store.get(&key).cloned()); + } + Command::Set { key, value } => { + store.insert(key, value); + } + Command::Delete { key } => { + store.remove(&key); + } + } + } +} + +// Usage +async fn client(tx: mpsc::Sender) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Command::Get { + key: "foo".to_string(), + reply: reply_tx + }).await.unwrap(); + + reply_rx.await.unwrap() +} +``` + +## Graceful Shutdown + +```rust +async fn worker(mut rx: mpsc::Receiver, shutdown: CancellationToken) { + loop { + tokio::select! { + _ = shutdown.cancelled() => { + // Drain remaining messages + while let Ok(task) = rx.try_recv() { + process(task).await; + } + break; + } + Some(task) = rx.recv() => { + process(task).await; + } + else => break, // Channel closed + } + } +} +``` + +## WeakSender for Optional Producers + +```rust +use tokio::sync::mpsc; + +let (tx, mut rx) = mpsc::channel::(100); +let weak = tx.downgrade(); // Doesn't keep channel alive + +tokio::spawn(async move { + // Strong sender - keeps channel alive + tx.send("from strong".into()).await.unwrap(); +}); + +tokio::spawn(async move { + // Weak sender - may fail if strong senders dropped + if let Some(tx) = weak.upgrade() { + tx.send("from weak".into()).await.unwrap(); + } +}); +``` + +## Permit Pattern + +```rust +// Reserve slot before preparing message +let permit = tx.reserve().await?; + +// Now we have guaranteed capacity +let message = expensive_to_create_message(); +permit.send(message); // Never fails + +// Useful when message creation is expensive +// and you don't want to create it if channel is full +``` + +## See Also + +- [async-bounded-channel](./async-bounded-channel.md) - Why bounded channels +- [async-oneshot-response](./async-oneshot-response.md) - Request-response with oneshot +- [async-broadcast-pubsub](./async-broadcast-pubsub.md) - Multiple consumers diff --git a/.opencode/skills/rust-skills/rules/async-no-lock-await.md b/.opencode/skills/rust-skills/rules/async-no-lock-await.md new file mode 100644 index 0000000..9d4bed4 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-no-lock-await.md @@ -0,0 +1,153 @@ +# async-no-lock-await + +> Never hold `Mutex`/`RwLock` across `.await` + +## Why It Matters + +Holding a lock across an `.await` point can cause deadlocks and severely hurt performance. The task may be suspended while holding the lock, blocking all other tasks waiting for it - potentially indefinitely. + +## Bad + +```rust +use tokio::sync::Mutex; + +async fn bad_update(state: &Mutex) { + let mut guard = state.lock().await; + + // BAD: Lock held across await! + let data = fetch_from_network().await; + + guard.value = data; +} // Lock finally released + +// This can deadlock or starve other tasks +``` + +## Good + +```rust +use tokio::sync::Mutex; + +async fn good_update(state: &Mutex) { + // Fetch data BEFORE taking the lock + let data = fetch_from_network().await; + + // Lock only for the quick update + let mut guard = state.lock().await; + guard.value = data; +} // Lock released immediately + +// Alternative: Clone data out, process, then update +async fn good_update_v2(state: &Mutex) { + // Extract what we need + let id = { + let guard = state.lock().await; + guard.id.clone() + }; // Lock released! + + // Do async work without lock + let data = fetch_by_id(id).await; + + // Quick update + state.lock().await.value = data; +} +``` + +## The Problem Visualized + +```rust +// Task A: +let guard = mutex.lock().await; // Acquires lock +expensive_io().await; // Suspended, still holding lock! +// ... many milliseconds pass ... +drop(guard); // Finally releases + +// Task B, C, D: +let guard = mutex.lock().await; // All blocked waiting for A! +``` + +## Patterns for Extraction + +```rust +use tokio::sync::Mutex; + +// Pattern 1: Clone out, process, update +async fn pattern_clone(state: &Mutex) { + let config = state.lock().await.config.clone(); + let result = process_with_io(&config).await; + state.lock().await.result = result; +} + +// Pattern 2: Compute closure, apply +async fn pattern_closure(state: &Mutex) { + let update = compute_update().await; + + state.lock().await.apply(update); +} + +// Pattern 3: Message passing +async fn pattern_message( + state: &Mutex, + tx: mpsc::Sender, +) { + let update = compute_update().await; + tx.send(update).await.unwrap(); +} + +// Separate task handles updates +async fn state_manager( + state: Arc>, + mut rx: mpsc::Receiver, +) { + while let Some(update) = rx.recv().await { + state.lock().await.apply(update); + } +} +``` + +## Using RwLock + +```rust +use tokio::sync::RwLock; + +async fn read_heavy(state: &RwLock) { + // Multiple readers OK, but still don't hold across await + let value = { + let guard = state.read().await; + guard.value.clone() + }; + + // Process without lock + let result = process(value).await; + + // Write lock for update + state.write().await.result = result; +} +``` + +## std::sync::Mutex vs tokio::sync::Mutex + +```rust +// Prefer std::sync::Mutex for work that does NOT span an .await — +// it is simpler, faster, and avoids the async overhead. +// Only reach for tokio::sync::Mutex when you genuinely must hold +// the lock across an .await point (rare; usually a sign to redesign). + +// std::sync::Mutex in async (quick, non-awaiting operation — preferred): +async fn quick_update(state: &std::sync::Mutex) { + state.lock().unwrap().counter += 1; // No await inside lock scope, OK +} + +// tokio::sync::Mutex (only when the lock scope must span an .await): +async fn must_await_inside(state: &tokio::sync::Mutex) { + let mut guard = state.lock().await; + // Only justified if you truly need the lock held across an async op + // (usually you don't — extract data first, then release the lock) +} +``` + +## See Also + +- [async-spawn-blocking](async-spawn-blocking.md) - Use spawn_blocking for CPU work +- [async-clone-before-await](async-clone-before-await.md) - Clone data before await +- [anti-lock-across-await](anti-lock-across-await.md) - Anti-pattern reference diff --git a/.opencode/skills/rust-skills/rules/async-oneshot-response.md b/.opencode/skills/rust-skills/rules/async-oneshot-response.md new file mode 100644 index 0000000..c7fc821 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-oneshot-response.md @@ -0,0 +1,191 @@ +# async-oneshot-response + +> Use `oneshot` channel for request-response patterns + +## Why It Matters + +When one task needs to send a request and wait for exactly one response, `oneshot` is the perfect fit. It's a single-use channel optimized for this pattern—no buffering, no clone overhead. Combined with `mpsc`, it enables clean actor-style message passing. + +## Bad + +```rust +// Using mpsc for single response - wasteful +let (tx, mut rx) = mpsc::channel::(1); +send_request().await; +let response = rx.recv().await.unwrap(); +// Channel persists, could accidentally receive more + +// Using shared state - complex +let result = Arc::new(Mutex::new(None)); +send_request(result.clone()).await; +while result.lock().await.is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; // Polling! +} +``` + +## Good + +```rust +use tokio::sync::oneshot; + +let (tx, rx) = oneshot::channel::(); + +// Send request with reply channel +send_request(Request { data, reply: tx }).await; + +// Wait for response +let response = rx.await?; + +// Channel is consumed - can't accidentally reuse +``` + +## Request-Response Pattern + +```rust +use tokio::sync::{mpsc, oneshot}; + +enum Request { + Get { + key: String, + reply: oneshot::Sender>, + }, + Set { + key: String, + value: Value, + reply: oneshot::Sender, + }, +} + +// Service handler +async fn service(mut rx: mpsc::Receiver) { + let mut store = HashMap::new(); + + while let Some(req) = rx.recv().await { + match req { + Request::Get { key, reply } => { + let value = store.get(&key).cloned(); + let _ = reply.send(value); // Ignore if receiver dropped + } + Request::Set { key, value, reply } => { + store.insert(key, value); + let _ = reply.send(true); + } + } + } +} + +// Client +async fn get_value(tx: &mpsc::Sender, key: &str) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Request::Get { + key: key.to_string(), + reply: reply_tx, + }).await.ok()?; + + reply_rx.await.ok()? +} +``` + +## With Timeout + +```rust +use tokio::time::{timeout, Duration}; + +async fn request_with_timeout( + tx: &mpsc::Sender, + key: &str, +) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + + tx.send(Request::Get { + key: key.to_string(), + reply: reply_tx, + }).await.map_err(|_| Error::ServiceDown)?; + + timeout(Duration::from_secs(5), reply_rx) + .await + .map_err(|_| Error::Timeout)? + .map_err(|_| Error::ServiceDown)? + .ok_or(Error::NotFound) +} +``` + +## Error Handling + +```rust +use tokio::sync::oneshot; + +let (tx, rx) = oneshot::channel::(); + +// Sender dropped without sending +drop(tx); +match rx.await { + Ok(value) => println!("Got: {}", value), + Err(oneshot::error::RecvError { .. }) => { + println!("Sender dropped"); + } +} + +// Receiver dropped before send +let (tx, rx) = oneshot::channel::(); +drop(rx); +match tx.send("hello".to_string()) { + Ok(()) => println!("Sent"), + Err(value) => println!("Receiver dropped, value: {}", value), +} +``` + +## Closed Detection + +```rust +// Check if receiver is still waiting +let (tx, rx) = oneshot::channel::(); + +// In producer +if tx.is_closed() { + println!("Receiver already gone, skip expensive computation"); +} else { + let result = expensive_computation(); + tx.send(result).ok(); +} + +// Async wait for close +let tx_clone = tx.clone(); // Note: can't actually clone, just showing concept +tokio::select! { + _ = tx.closed() => println!("Receiver dropped"), + result = compute() => { tx.send(result).ok(); } +} +``` + +## Response Type Wrapper + +```rust +// Standardize request-response pattern +struct RpcRequest { + request: Req, + reply: oneshot::Sender, +} + +impl RpcRequest { + fn new(request: Req) -> (Self, oneshot::Receiver) { + let (tx, rx) = oneshot::channel(); + (RpcRequest { request, reply: tx }, rx) + } + + fn respond(self, response: Res) { + let _ = self.reply.send(response); + } +} + +// Usage +let (req, rx) = RpcRequest::new(GetUser { id: 42 }); +tx.send(req).await?; +let user = rx.await?; +``` + +## See Also + +- [async-mpsc-queue](./async-mpsc-queue.md) - Pair with oneshot for request-response +- [async-bounded-channel](./async-bounded-channel.md) - Channel sizing +- [async-select-racing](./async-select-racing.md) - Timeout patterns diff --git a/.opencode/skills/rust-skills/rules/async-select-racing.md b/.opencode/skills/rust-skills/rules/async-select-racing.md new file mode 100644 index 0000000..ba71893 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-select-racing.md @@ -0,0 +1,198 @@ +# async-select-racing + +> Use `select!` to race futures and handle the first to complete + +## Why It Matters + +Sometimes you need the first result from multiple futures—timeout vs operation, cancellation vs work, or competing alternatives. `tokio::select!` lets you race futures and handle whichever completes first, while properly cancelling the others. + +## Bad + +```rust +// Can't express "whichever finishes first" +async fn fetch_with_fallback() -> Data { + match fetch_primary().await { + Ok(data) => data, + Err(_) => fetch_fallback().await.unwrap(), // Sequential, not racing + } +} + +// Manual timeout is error-prone +async fn fetch_with_timeout() -> Option { + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(5) { + return None; + } + // How do we check timeout while awaiting? + } +} +``` + +## Good + +```rust +use tokio::select; + +async fn fetch_with_timeout() -> Result { + select! { + result = fetch_data() => result, + _ = tokio::time::sleep(Duration::from_secs(5)) => { + Err(Error::Timeout) + } + } +} + +async fn fetch_with_fallback() -> Data { + select! { + result = fetch_primary() => { + match result { + Ok(data) => data, + Err(_) => fetch_fallback().await.unwrap() + } + } + _ = tokio::time::sleep(Duration::from_secs(1)) => { + // Primary too slow, use fallback + fetch_fallback().await.unwrap() + } + } +} +``` + +## select! Syntax + +```rust +select! { + // Pattern = future => handler + result = async_operation() => { + // Handle result + println!("Got: {:?}", result); + } + + // Can bind with pattern matching + Ok(data) = fallible_operation() => { + process(data); + } + + // Conditional branches with if guards + msg = channel.recv(), if should_receive => { + handle_message(msg); + } + + // else branch for when all futures are disabled + else => { + println!("All branches disabled"); + } +} +``` + +## Cancellation Behavior + +```rust +async fn select_example() { + select! { + _ = operation_a() => { + println!("A completed first"); + // operation_b() is dropped/cancelled + } + _ = operation_b() => { + println!("B completed first"); + // operation_a() is dropped/cancelled + } + } +} + +// Futures are cancelled at their next .await point +// For immediate cancellation, futures must be cancel-safe +``` + +## Biased Selection + +```rust +// By default, select! randomly picks when multiple are ready +// Use biased mode for deterministic priority +select! { + biased; // Check branches in order + + msg = high_priority.recv() => handle_high(msg), + msg = low_priority.recv() => handle_low(msg), +} + +// Without biased, both channels have equal chance +// when both have messages ready +``` + +## Loop with select! + +```rust +async fn event_loop( + mut commands: mpsc::Receiver, + shutdown: CancellationToken, +) { + loop { + select! { + _ = shutdown.cancelled() => { + println!("Shutting down"); + break; + } + Some(cmd) = commands.recv() => { + process_command(cmd).await; + } + else => { + // commands channel closed + break; + } + } + } +} +``` + +## Racing Multiple of Same Type + +```rust +// Race multiple servers for fastest response +async fn fastest_response(servers: &[String]) -> Result { + let futures = servers.iter() + .map(|s| fetch_from(s)) + .collect::>(); + + // select! requires static branches, use select_all for dynamic + let (result, _index, _remaining) = + futures::future::select_all(futures).await; + + result +} +``` + +## Common Patterns + +```rust +// Timeout +select! { + result = operation() => result, + _ = sleep(Duration::from_secs(5)) => Err(Timeout), +} + +// Cancellation +select! { + result = operation() => result, + _ = cancel_token.cancelled() => Err(Cancelled), +} + +// Interval with cancellation +let mut interval = tokio::time::interval(Duration::from_secs(1)); +loop { + select! { + _ = shutdown.cancelled() => break, + _ = interval.tick() => { + do_periodic_work().await; + } + } +} +``` + +## See Also + +- [async-cancellation-token](./async-cancellation-token.md) - Cancellation patterns +- [async-join-parallel](./async-join-parallel.md) - All futures, not racing +- [async-bounded-channel](./async-bounded-channel.md) - Channel operations in select diff --git a/.opencode/skills/rust-skills/rules/async-spawn-blocking.md b/.opencode/skills/rust-skills/rules/async-spawn-blocking.md new file mode 100644 index 0000000..8312d33 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-spawn-blocking.md @@ -0,0 +1,154 @@ +# async-spawn-blocking + +> Use `spawn_blocking` for CPU-intensive work + +## Why It Matters + +Async runtimes like Tokio use a small number of threads to handle many tasks. CPU-intensive or blocking operations on these threads starve other tasks. `spawn_blocking` moves such work to a dedicated thread pool. + +## Bad + +```rust +// BAD: Blocks the async runtime thread +async fn process_image(data: &[u8]) -> ProcessedImage { + // CPU-intensive work on async thread! + let resized = resize_image(data); // Blocks! + let compressed = compress(resized); // Blocks! + compressed +} + +// BAD: Synchronous file I/O in async context +async fn read_large_file(path: &Path) -> Vec { + std::fs::read(path).unwrap() // Blocks the runtime! +} +``` + +## Good + +```rust +use tokio::task; + +// GOOD: Offload CPU work to blocking pool +async fn process_image(data: Vec) -> ProcessedImage { + task::spawn_blocking(move || { + let resized = resize_image(&data); + compress(resized) + }) + .await + .expect("spawn_blocking failed") +} + +// GOOD: Use async file I/O +async fn read_large_file(path: &Path) -> tokio::io::Result> { + tokio::fs::read(path).await +} + +// GOOD: Or spawn_blocking for unavoidable sync I/O +async fn read_with_sync_lib(path: PathBuf) -> Vec { + task::spawn_blocking(move || { + sync_library::read_file(&path) + }) + .await + .unwrap() +} +``` + +## What Counts as Blocking + +```rust +// CPU-intensive operations +- Cryptographic operations (hashing, encryption) +- Image/video processing +- Compression/decompression +- Complex parsing +- Mathematical computations + +// Blocking I/O +- std::fs operations +- Synchronous database drivers +- Synchronous HTTP clients +- Thread::sleep + +// Example thresholds (rough guidelines): +// < 10µs: OK on async thread +// 10µs - 1ms: Consider spawn_blocking +// > 1ms: Definitely spawn_blocking +``` + +## Practical Examples + +```rust +// Password hashing (CPU-intensive) +async fn hash_password(password: String) -> String { + task::spawn_blocking(move || { + bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap() +} + +// JSON parsing of large documents +async fn parse_large_json(data: String) -> serde_json::Value { + task::spawn_blocking(move || { + serde_json::from_str(&data).unwrap() + }) + .await + .unwrap() +} + +// Compression +async fn compress_data(data: Vec) -> Vec { + task::spawn_blocking(move || { + let mut encoder = flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + ); + encoder.write_all(&data).unwrap(); + encoder.finish().unwrap() + }) + .await + .unwrap() +} +``` + +## spawn_blocking vs spawn + +```rust +// spawn: Runs async code on runtime threads +tokio::spawn(async { + // Async code here + some_async_operation().await; +}); + +// spawn_blocking: Runs sync code on blocking thread pool +tokio::task::spawn_blocking(|| { + // Synchronous, possibly CPU-intensive code + heavy_computation(); +}); + +// spawn_blocking returns JoinHandle that can be awaited +let result = tokio::task::spawn_blocking(|| { + expensive_sync_operation() +}).await?; +``` + +## Rayon for Parallel CPU Work + +```rust +// For parallel CPU work, consider Rayon inside spawn_blocking +async fn parallel_process(items: Vec) -> Vec { + task::spawn_blocking(move || { + use rayon::prelude::*; + items.par_iter() + .map(|item| cpu_intensive_transform(item)) + .collect() + }) + .await + .unwrap() +} +``` + +## See Also + +- [async-tokio-fs](async-tokio-fs.md) - Use tokio::fs for async file I/O +- [async-no-lock-await](async-no-lock-await.md) - Don't hold locks across await diff --git a/.opencode/skills/rust-skills/rules/async-tokio-fs.md b/.opencode/skills/rust-skills/rules/async-tokio-fs.md new file mode 100644 index 0000000..afe599c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-tokio-fs.md @@ -0,0 +1,167 @@ +# async-tokio-fs + +> Use `tokio::fs` instead of `std::fs` in async code + +## Why It Matters + +`std::fs` operations are blocking—they stop the current thread until the syscall completes. In async code, this blocks the executor thread, preventing it from running other tasks. `tokio::fs` wraps filesystem operations in `spawn_blocking`, keeping the executor responsive. + +## Bad + +```rust +async fn process_files(paths: &[PathBuf]) -> Result> { + let mut contents = Vec::new(); + + for path in paths { + // BLOCKS the entire executor thread! + let data = std::fs::read_to_string(path)?; + contents.push(data); + } + + Ok(contents) +} + +// While reading a file, NO other tasks can run on this thread +``` + +## Good + +```rust +use tokio::fs; + +async fn process_files(paths: &[PathBuf]) -> Result> { + let mut contents = Vec::new(); + + for path in paths { + // Non-blocking: allows other tasks to run + let data = fs::read_to_string(path).await?; + contents.push(data); + } + + Ok(contents) +} + +// Even better: concurrent reads +async fn process_files_concurrent(paths: &[PathBuf]) -> Result> { + let futures: Vec<_> = paths.iter() + .map(|path| fs::read_to_string(path)) + .collect(); + + futures::future::try_join_all(futures).await +} +``` + +## tokio::fs API + +```rust +use tokio::fs; + +// Reading +let contents = fs::read_to_string("file.txt").await?; +let bytes = fs::read("file.bin").await?; + +// Writing +fs::write("output.txt", "contents").await?; + +// File operations +let file = fs::File::open("file.txt").await?; +let file = fs::File::create("new.txt").await?; + +// Directory operations +fs::create_dir("new_dir").await?; +fs::create_dir_all("nested/dir/path").await?; +fs::remove_dir("empty_dir").await?; +fs::remove_dir_all("dir_with_contents").await?; + +// Metadata +let metadata = fs::metadata("file.txt").await?; +let canonical = fs::canonicalize("./relative").await?; + +// Rename/remove +fs::rename("old.txt", "new.txt").await?; +fs::remove_file("file.txt").await?; + +// Read directory +let mut entries = fs::read_dir("some_dir").await?; +while let Some(entry) = entries.next_entry().await? { + println!("{}", entry.path().display()); +} +``` + +## Async File I/O + +```rust +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncWriteExt, AsyncBufReadExt, BufReader}; + +// Read with buffer +let mut file = File::open("large.bin").await?; +let mut buffer = vec![0u8; 4096]; +let bytes_read = file.read(&mut buffer).await?; + +// Read all +let mut contents = Vec::new(); +file.read_to_end(&mut contents).await?; + +// Write +let mut file = File::create("output.bin").await?; +file.write_all(b"data").await?; +file.flush().await?; + +// Buffered line reading +let file = File::open("lines.txt").await?; +let reader = BufReader::new(file); +let mut lines = reader.lines(); + +while let Some(line) = lines.next_line().await? { + println!("{}", line); +} +``` + +## When std::fs is Acceptable + +```rust +// Startup/initialization (before async runtime) +fn main() { + let config = std::fs::read_to_string("config.toml") + .expect("config file required"); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(run_with_config(config)); +} + +// Single-threaded current_thread runtime (less impact) +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Still prefer tokio::fs, but impact is lower +} + +// When file operations are rare and quick +// (e.g., reading small config once per hour) +``` + +## Performance Considerations + +```rust +// tokio::fs uses spawn_blocking internally +// For many small files, the overhead adds up + +// Batch operations when possible +let paths: Vec<_> = entries.iter() + .map(|e| e.path()) + .collect(); + +let contents = futures::future::try_join_all( + paths.iter().map(|p| fs::read_to_string(p)) +).await?; + +// For heavy I/O, consider memory-mapped files +// (requires unsafe or mmap crate) +``` + +## See Also + +- [async-spawn-blocking](./async-spawn-blocking.md) - How tokio::fs works internally +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime configuration +- [err-context-chain](./err-context-chain.md) - Adding path context to IO errors diff --git a/.opencode/skills/rust-skills/rules/async-tokio-runtime.md b/.opencode/skills/rust-skills/rules/async-tokio-runtime.md new file mode 100644 index 0000000..aa73b15 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-tokio-runtime.md @@ -0,0 +1,175 @@ +# async-tokio-runtime + +> Configure Tokio runtime appropriately for your workload + +## Why It Matters + +Tokio's default multi-threaded runtime isn't always optimal. CPU-bound work needs different configuration than IO-bound work. Incorrect configuration leads to poor performance, blocked workers, or resource exhaustion. Understanding runtime options lets you tune for your specific use case. + +## Bad + +```rust +// Default runtime for everything - not optimal +#[tokio::main] +async fn main() { + // CPU-heavy work on async executor starves IO tasks + for data in datasets { + let result = heavy_computation(data).await; + } +} + +// Single-threaded when multi-threaded is needed +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Can't utilize multiple cores for concurrent tasks + for _ in 0..1000 { + tokio::spawn(async { /* IO work */ }); + } +} +``` + +## Good + +```rust +// Multi-threaded for concurrent IO (default) +#[tokio::main] +async fn main() { + // Good for many concurrent network connections + let handles: Vec<_> = urls.iter() + .map(|url| tokio::spawn(fetch(url.clone()))) + .collect(); + + futures::future::join_all(handles).await; +} + +// Current-thread for single-threaded scenarios +#[tokio::main(flavor = "current_thread")] +async fn main() { + // Good for single-connection clients, simpler debugging + let client = Client::new(); + client.run().await; +} + +// Custom configuration +#[tokio::main(worker_threads = 4)] +async fn main() { + // Limit to 4 worker threads +} + +// Or manual setup for more control +fn main() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .thread_name("my-worker") + .build() + .unwrap(); + + runtime.block_on(async_main()); +} +``` + +## Runtime Types + +| Runtime | Use Case | Configuration | +|---------|----------|---------------| +| Multi-thread | IO-bound, many connections | `#[tokio::main]` (default) | +| Current-thread | CLI tools, tests, single connection | `flavor = "current_thread"` | +| Custom | Fine-tuned performance | `Builder::new_*()` | + +## Worker Thread Tuning + +```rust +use tokio::runtime::Builder; + +// std::thread::available_parallelism() is stable since Rust 1.59 +// and respects cgroup CPU quotas (unlike the unmaintained num_cpus crate) +let parallelism = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1); + +// IO-bound: more threads than cores can help +let io_runtime = Builder::new_multi_thread() + .worker_threads(parallelism * 2) // IO can benefit from oversubscription + .max_blocking_threads(32) // For spawn_blocking calls + .enable_io() + .enable_time() + .build()?; + +// CPU-bound: match core count +let cpu_runtime = Builder::new_multi_thread() + .worker_threads(parallelism) // No benefit from more than cores + .build()?; +``` + +## Multiple Runtimes + +```rust +// Separate runtimes for different workloads +struct App { + io_runtime: Runtime, + cpu_runtime: Runtime, +} + +impl App { + fn new() -> Self { + Self { + io_runtime: Builder::new_multi_thread() + .worker_threads(8) + .thread_name("io-worker") + .build() + .unwrap(), + cpu_runtime: Builder::new_multi_thread() + .worker_threads(4) + .thread_name("cpu-worker") + .build() + .unwrap(), + } + } + + fn spawn_io(&self, future: F) + where F: Future + Send + 'static, F::Output: Send + 'static + { + self.io_runtime.spawn(future); + } + + fn spawn_cpu(&self, task: F) + where F: FnOnce() + Send + 'static + { + self.cpu_runtime.spawn_blocking(task); + } +} +``` + +## Runtime in Tests + +```rust +// Single test runtime +#[tokio::test] +async fn test_single() { + assert!(true); +} + +// Multi-threaded test +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_concurrent() { + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { tx.send(42).unwrap() }); + assert_eq!(rx.await.unwrap(), 42); +} + +// Custom runtime in test +#[test] +fn test_with_custom_runtime() { + let rt = Builder::new_current_thread().build().unwrap(); + rt.block_on(async { + // test code + }); +} +``` + +## See Also + +- [async-spawn-blocking](./async-spawn-blocking.md) - Handling blocking code +- [async-no-lock-await](./async-no-lock-await.md) - Avoiding lock issues +- [async-joinset-structured](./async-joinset-structured.md) - Managing spawned tasks diff --git a/.opencode/skills/rust-skills/rules/async-try-join.md b/.opencode/skills/rust-skills/rules/async-try-join.md new file mode 100644 index 0000000..9170a0e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-try-join.md @@ -0,0 +1,172 @@ +# async-try-join + +> Use `try_join!` for concurrent fallible operations with early return on error + +## Why It Matters + +When running multiple fallible operations concurrently, `try_join!` returns `Err` as soon as any future fails, without waiting for the others. This provides fail-fast behavior while still running operations in parallel. For many operations, use `futures::future::try_join_all`. + +## Bad + +```rust +// Sequential - slow and no early return benefit +async fn fetch_all() -> Result<(A, B, C)> { + let a = fetch_a().await?; // If this fails, we wait for nothing + let b = fetch_b().await?; // But if this fails, we waited for A + let c = fetch_c().await?; + Ok((a, b, c)) +} + +// join! ignores errors +async fn fetch_all() -> (Result, Result, Result) { + let (a, b, c) = join!(fetch_a(), fetch_b(), fetch_c()); + // All complete even if first one failed + (a, b, c) // Now we have to handle three Results +} +``` + +## Good + +```rust +use tokio::try_join; + +async fn fetch_all() -> Result<(A, B, C)> { + // Concurrent AND fail-fast + let (a, b, c) = try_join!( + fetch_a(), + fetch_b(), + fetch_c(), + )?; + + Ok((a, b, c)) +} + +// For dynamic collections +use futures::future::try_join_all; + +async fn fetch_users(ids: &[u64]) -> Result> { + let futures: Vec<_> = ids.iter() + .map(|id| fetch_user(*id)) + .collect(); + + try_join_all(futures).await +} +``` + +## Error Handling Patterns + +```rust +// Different error types - need common error type +async fn mixed_operations() -> Result<(A, B), Error> { + let (a, b) = try_join!( + fetch_a().map_err(Error::from), // Convert errors + fetch_b().map_err(Error::from), + )?; + Ok((a, b)) +} + +// Collect all results, then handle errors +async fn all_or_nothing(ids: &[u64]) -> Result> { + try_join_all(ids.iter().map(|id| fetch_user(*id))).await +} + +// Collect successes, log failures +async fn best_effort(ids: &[u64]) -> Vec { + let results = futures::future::join_all( + ids.iter().map(|id| fetch_user(*id)) + ).await; + + results.into_iter() + .filter_map(|r| match r { + Ok(user) => Some(user), + Err(e) => { + log::warn!("Failed to fetch user: {}", e); + None + } + }) + .collect() +} +``` + +## Cancellation Behavior + +```rust +// try_join! cancels remaining futures on error +async fn with_cancellation() -> Result<()> { + // If fetch_a() fails, fetch_b() and fetch_c() are dropped + // But "dropped" != "immediately stopped" + // They stop at their next .await point + + try_join!( + async { + fetch_a().await?; + cleanup_a().await; // May not run if other future fails + Ok::<_, Error>(()) + }, + async { + fetch_b().await?; + cleanup_b().await; // May not run if other future fails + Ok::<_, Error>(()) + }, + )?; + + Ok(()) +} + +// For guaranteed cleanup, use Drop guards or explicit handling +``` + +## With Timeout + +```rust +use tokio::time::{timeout, Duration}; + +async fn fetch_with_timeout() -> Result<(A, B)> { + timeout( + Duration::from_secs(10), + try_join!(fetch_a(), fetch_b()) + ) + .await + .map_err(|_| Error::Timeout)? +} + +// Per-operation timeout +async fn individual_timeouts() -> Result<(A, B)> { + try_join!( + timeout(Duration::from_secs(5), fetch_a()) + .map_err(|_| Error::Timeout) + .and_then(|r| async { r }), + timeout(Duration::from_secs(5), fetch_b()) + .map_err(|_| Error::Timeout) + .and_then(|r| async { r }), + ) +} +``` + +## try_join! vs FuturesUnordered + +```rust +use futures::stream::{FuturesUnordered, StreamExt}; + +// try_join!: wait for all, fail fast +let (a, b, c) = try_join!(fa, fb, fc)?; + +// FuturesUnordered: process as they complete +let mut futures = FuturesUnordered::new(); +futures.push(fetch_a()); +futures.push(fetch_b()); +futures.push(fetch_c()); + +while let Some(result) = futures.next().await { + match result { + Ok(data) => process(data), + Err(e) => return Err(e), // Can fail fast manually + } +} +``` + +## See Also + +- [async-join-parallel](./async-join-parallel.md) - Non-fallible concurrent futures +- [async-select-racing](./async-select-racing.md) - First-to-complete semantics +- [err-question-mark](./err-question-mark.md) - Error propagation diff --git a/.opencode/skills/rust-skills/rules/async-watch-latest.md b/.opencode/skills/rust-skills/rules/async-watch-latest.md new file mode 100644 index 0000000..5ed4baf --- /dev/null +++ b/.opencode/skills/rust-skills/rules/async-watch-latest.md @@ -0,0 +1,189 @@ +# async-watch-latest + +> Use `watch` channel for sharing the latest value with multiple observers + +## Why It Matters + +`watch` is optimized for scenarios where receivers only care about the most recent value, not the history of changes. Unlike `broadcast`, slow receivers don't lag—they simply skip intermediate values. This is perfect for configuration, state, or status that should always reflect the current situation. + +## Bad + +```rust +// Using broadcast when only latest value matters +let (tx, _) = broadcast::channel::(100); + +// Receivers might process stale configs if they're slow +// And they waste time processing intermediate values + +// Using mpsc with buffered stale values +let (tx, mut rx) = mpsc::channel::(100); +// Receiver might process outdated statuses +``` + +## Good + +```rust +use tokio::sync::watch; + +let (tx, rx) = watch::channel(Config::default()); + +// Multiple observers +let rx1 = rx.clone(); +let rx2 = rx.clone(); + +// Observer 1: waits for changes +tokio::spawn(async move { + let mut rx = rx1; + while rx.changed().await.is_ok() { + let config = rx.borrow(); + apply_config(&*config); + } +}); + +// Observer 2: also sees all changes +tokio::spawn(async move { + let mut rx = rx2; + while rx.changed().await.is_ok() { + let config = rx.borrow(); + log_config_change(&*config); + } +}); + +// Update the value +tx.send(Config::new())?; +``` + +## watch Semantics + +```rust +use tokio::sync::watch; + +let (tx, mut rx) = watch::channel("initial"); + +// Immediate read - no waiting +assert_eq!(*rx.borrow(), "initial"); + +// Wait for change +tx.send("updated")?; +rx.changed().await?; +assert_eq!(*rx.borrow(), "updated"); + +// Multiple rapid updates - receiver sees latest +tx.send("v1")?; +tx.send("v2")?; +tx.send("v3")?; +rx.changed().await?; +assert_eq!(*rx.borrow(), "v3"); // Skipped v1, v2 +``` + +## Configuration Reload Pattern + +```rust +use tokio::sync::watch; +use std::sync::Arc; + +struct AppConfig { + log_level: Level, + max_connections: usize, +} + +async fn config_watcher(tx: watch::Sender>) { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + + if let Ok(new_config) = reload_config_from_disk() { + // Only notifies if value actually changed + tx.send_if_modified(|current| { + if *current != new_config { + *current = Arc::new(new_config); + true + } else { + false + } + }); + } + } +} + +async fn worker(mut config_rx: watch::Receiver>) { + loop { + tokio::select! { + _ = config_rx.changed() => { + let config = config_rx.borrow().clone(); + reconfigure(&config); + } + _ = do_work() => {} + } + } +} +``` + +## State Machine Updates + +```rust +#[derive(Clone, PartialEq)] +enum ConnectionState { + Disconnected, + Connecting, + Connected, + Error(String), +} + +struct Connection { + state_tx: watch::Sender, + state_rx: watch::Receiver, +} + +impl Connection { + async fn wait_connected(&mut self) -> Result<(), Error> { + loop { + let state = self.state_rx.borrow().clone(); + match state { + ConnectionState::Connected => return Ok(()), + ConnectionState::Error(e) => return Err(Error::Connection(e)), + _ => { + self.state_rx.changed().await?; + } + } + } + } +} +``` + +## Borrow vs Clone + +```rust +use tokio::sync::watch; + +let (tx, rx) = watch::channel(vec![1, 2, 3]); + +// borrow() returns Ref - must not hold across await +{ + let data = rx.borrow(); + println!("{:?}", *data); +} // Ref dropped here + +// For use across await, clone the data +let data = rx.borrow().clone(); +some_async_operation().await; +use_data(&data); // Safe + +// Or use borrow_and_update() to mark as seen +let data = rx.borrow_and_update().clone(); +``` + +## watch vs broadcast vs mpsc + +| Feature | watch | broadcast | mpsc | +|---------|-------|-----------|------| +| Receivers | Multiple | Multiple | Single | +| Message delivery | Latest only | All messages | All messages | +| Slow receiver | Skips to latest | Lags/misses | Backpressure | +| Clone required | No | Yes | No | +| Best for | Config, status | Events | Work queues | + +## See Also + +- [async-broadcast-pubsub](./async-broadcast-pubsub.md) - When history matters +- [async-mpsc-queue](./async-mpsc-queue.md) - Work queue patterns +- [async-cancellation-token](./async-cancellation-token.md) - Related pattern diff --git a/.opencode/skills/rust-skills/rules/closure-disjoint-capture.md b/.opencode/skills/rust-skills/rules/closure-disjoint-capture.md new file mode 100644 index 0000000..448f9e8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/closure-disjoint-capture.md @@ -0,0 +1,85 @@ +# closure-disjoint-capture + +> Capture only what you use; lean on edition-2021 disjoint closure captures + +## Why It Matters + +Before the 2021 edition, a closure captured entire variables — using `config.threshold` pulled in the whole `config` struct, preventing other code from using any other field of `config` concurrently. Since Rust 2021, closures capture individual fields (`config.threshold` only), so sibling fields remain independently accessible. Take advantage of this: write closures that reference only the specific fields or values they need, and add `move` only when ownership is genuinely required. When you do need to `move` a single field, bind it to a local first so the rest of the struct stays usable. + +## Bad + +```rust +struct Config { + threshold: i32, + label: String, +} + +fn demo_bad() { + let config = Config { threshold: 10, label: String::from("demo") }; + + // In pre-2021 editions the whole `config` is captured, blocking access + // to `config.label` below. In 2021 this compiles, but the pattern of + // capturing the whole struct via `move` is the real footgun: + let threshold = config.threshold; // copy out the field first + let check = move || threshold > 0; // now `config` is NOT fully moved + + // If instead you wrote: let check = move || config.threshold > 0; + // `config` would be moved in, making `config.label` inaccessible afterwards. + // Demonstrate the problematic pattern (commented out to allow compilation): + // let check2 = move || config.threshold > 0; + // println!("{}", config.label); // error: use of moved value + + println!("label still accessible: {}", config.label); + assert!(check()); +} +``` + +## Good + +```rust +struct Config { + threshold: i32, + label: String, +} + +fn demo_good() { + let config = Config { threshold: 10, label: String::from("active") }; + + // Edition 2021: the closure captures only `config.threshold` (a Copy field). + // `config.label` is NOT captured, so it remains accessible. + let check = || config.threshold > 0; + + // Both are usable simultaneously. + println!("label: {}", config.label); // fine — not captured by `check` + assert!(check()); +} + +// When you need `move` for one field, bind it first. +fn make_checker(config: Config) -> (impl Fn() -> bool, String) { + // Bind the field to a local, then move only that local into the closure. + let threshold = config.threshold; + let checker = move || threshold > 0; // moves `threshold` (i32, Copy), not `config` + + // `config.label` is still available here. + (checker, config.label) +} + +fn demo_bind_first() { + let cfg = Config { threshold: 5, label: String::from("info") }; + let (check, label) = make_checker(cfg); + println!("label returned: {label}"); + assert!(check()); +} +``` + +## Key Points + +- **Edition 2021 rule:** closures capture the *minimal* path used — `foo.bar` rather than `foo`. This reduces spurious borrow conflicts. +- **`move` captures the whole named place.** Writing `move || self.field` inside a method moves `*self`, not just `self.field`. Bind to a local to narrow the capture. +- **Copy types** (integers, booleans) are copied into the closure rather than moved, so the original remains valid even with `move`. +- **Borrow by reference first:** only escalate to `move` when the closure must outlive the scope (see [closure-move-capture](closure-move-capture.md)). + +## See Also + +- [own-borrow-over-clone](own-borrow-over-clone.md) - prefer borrowing over cloning +- [closure-move-capture](closure-move-capture.md) - when to use `move` and how to clone selectively diff --git a/.opencode/skills/rust-skills/rules/closure-fn-trait-bounds.md b/.opencode/skills/rust-skills/rules/closure-fn-trait-bounds.md new file mode 100644 index 0000000..7472a84 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/closure-fn-trait-bounds.md @@ -0,0 +1,89 @@ +# closure-fn-trait-bounds + +> Require the least restrictive `Fn` trait a callback needs (`FnOnce` ⊇ `FnMut` ⊇ `Fn`) + +## Why It Matters + +`FnOnce` is implemented by every closure — it may consume its captures and can only be called once. `FnMut` is implemented by closures that mutate captures, and implies `FnOnce`. `Fn` is the strictest: it only reads captures and can be called any number of times concurrently. Bounding a parameter with the weakest trait the body actually requires accepts the widest set of callers. Requiring `Fn` when you only call the closure once needlessly rejects move-consuming closures. + +## Bad + +```rust +// F: Fn is too strict — the closure is only called once, +// so move-consuming closures are unnecessarily rejected. +fn run_once_bad String>(f: F) -> String { + f() +} + +fn demo_bad() { + let s = String::from("hello"); + // This closure consumes `s`, so it only implements FnOnce, not Fn. + // run_once_bad(move || s) // compile error: `s` moved in closure + let _ = run_once_bad(|| String::from("ok")); // forced to use non-consuming closure +} +``` + +## Good + +```rust +// Use FnOnce when you call the closure exactly once. +fn run_once String>(f: F) -> String { + f() +} + +// Use FnMut when you call the closure multiple times and it may mutate state. +fn retry bool>(mut f: F, attempts: usize) -> bool { + for _ in 0..attempts { + if f() { + return true; + } + } + false +} + +// Use Fn when you call the closure multiple times and need it shareable/re-entrant. +fn for_each(items: &[T], f: F) { + for item in items { + f(item); + } +} + +fn demo() { + // FnOnce: move-consuming closure is accepted + let s = String::from("hello"); + let result = run_once(move || s.to_uppercase()); + assert_eq!(result, "HELLO"); + + // FnMut: closure mutates a counter + let mut count = 0usize; + let found = retry( + || { + count += 1; + count == 3 + }, + 5, + ); + assert!(found); + + // Fn: read-only closure, called once per element + let nums = vec![1, 2, 3]; + for_each(&nums, |n| println!("{n}")); +} +``` + +## Key Points + +| Trait | Captures | Calls | Accepts | +|-------|----------|-------|---------| +| `FnOnce` | may consume | exactly once | all closures | +| `FnMut` | may mutate | multiple | non-consuming | +| `Fn` | read-only | multiple / shared | pure closures | + +- `FnMut` requires `mut f` at the call site (the parameter or binding must be `mut`). +- `Fn: FnMut: FnOnce` — a `Fn` closure satisfies an `FnOnce` bound, not the other way around. +- Standard library examples: `Iterator::map` uses `FnMut`; `thread::spawn` uses `FnOnce + Send + 'static`. + +## See Also + +- [closure-static-vs-dyn](closure-static-vs-dyn.md) - generic vs dynamic dispatch for callbacks +- [closure-move-capture](closure-move-capture.md) - when and how to use `move` closures diff --git a/.opencode/skills/rust-skills/rules/closure-impl-fn-return.md b/.opencode/skills/rust-skills/rules/closure-impl-fn-return.md new file mode 100644 index 0000000..b6b2dd5 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/closure-impl-fn-return.md @@ -0,0 +1,115 @@ +# closure-impl-fn-return + +> Return closures as `impl Fn`/`FnMut`/`FnOnce`, not `Box` + +## Why It Matters + +`impl Fn` in return position names the closure's concrete (but unnameable) type and enables static dispatch with no heap allocation. `Box` adds an allocation and a virtual call every time the closure is invoked. The opaque `impl Trait` syntax was designed precisely for this use case. Reach for `Box` only when the function must return *different* closure types depending on runtime conditions, or when the closure must be stored in a struct field or collection. + +## Bad + +```rust +// Allocates on the heap for no benefit — single concrete closure type. +fn adder_bad(n: i32) -> Box i32> { + Box::new(move |x| x + n) +} + +fn multiplier_bad(n: i32) -> Box i32> { + Box::new(move |x| x * n) +} +``` + +## Good + +```rust +// Zero allocation, statically dispatched. +fn adder(n: i32) -> impl Fn(i32) -> i32 { + move |x| x + n +} + +fn multiplier(n: i32) -> impl Fn(i32) -> i32 { + move |x| x * n +} + +fn apply(f: impl Fn(i32) -> i32, value: i32) -> i32 { + f(value) +} + +fn demo() { + let add5 = adder(5); + let mul3 = multiplier(3); + + assert_eq!(apply(add5, 10), 15); + assert_eq!(apply(mul3, 10), 30); +} +``` + +## Key Points + +**When `Box` is required:** + +Different `if`/`match` arms return distinct closure types, so `impl Fn` cannot unify them: + +```rust +fn make_transform(double: bool) -> Box i32> { + if double { + Box::new(|x| x * 2) // one concrete type + } else { + Box::new(|x| x + 100) // different concrete type + } + // `impl Fn` would fail: "expected closure, found a different closure" +} + +// Storing heterogeneous closures also requires boxing: +struct Pipeline { + steps: Vec i32>>, +} + +impl Pipeline { + fn new() -> Self { + Self { steps: Vec::new() } + } + + fn add_step(&mut self, f: impl Fn(i32) -> i32 + 'static) { + self.steps.push(Box::new(f)); + } + + fn run(&self, mut value: i32) -> i32 { + for step in &self.steps { + value = step(value); + } + value + } +} + +fn demo_pipeline() { + let mut p = Pipeline::new(); + p.add_step(|x| x + 1); + p.add_step(|x| x * 3); + assert_eq!(p.run(4), 15); +} +``` + +**Returning `FnMut`:** The binding at the call site must be `mut`. + +```rust +fn counter(start: i32) -> impl FnMut() -> i32 { + let mut n = start; + move || { + let current = n; + n += 1; + current + } +} + +fn demo_counter() { + let mut next = counter(0); + assert_eq!(next(), 0); + assert_eq!(next(), 1); +} +``` + +## See Also + +- [anti-type-erasure](anti-type-erasure.md) - avoid `Box` when `impl Trait` works +- [closure-static-vs-dyn](closure-static-vs-dyn.md) - static vs dynamic dispatch trade-offs for callbacks diff --git a/.opencode/skills/rust-skills/rules/closure-move-capture.md b/.opencode/skills/rust-skills/rules/closure-move-capture.md new file mode 100644 index 0000000..39ca835 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/closure-move-capture.md @@ -0,0 +1,70 @@ +# closure-move-capture + +> Use `move` for closures that outlive the current scope; clone before `move` to keep the original + +## Why It Matters + +A closure that borrows its environment can only live as long as that environment. When a closure escapes — passed to a new thread, stored in a struct, or returned from a function — it must own its captures and typically must be `'static`. The `move` keyword transfers ownership of every captured variable into the closure. If you need the value in both the closure and the surrounding code, clone it first and move the clone. + +## Bad + +```rust +fn make_greeter_bad(name: String) -> impl Fn() { + // `name` is borrowed, but the closure outlives the function frame — + // the compiler rejects this with a lifetime error. + // || println!("hello, {name}") // error: `name` does not live long enough + move || println!("hello, {name}") // must be move — shown here to illustrate the fix +} + +fn spawn_bad() { + let data = vec![1, 2, 3]; + // Borrowing `data` across a thread boundary is rejected: + // std::thread::spawn(|| println!("{data:?}")); // error: borrowed value does not live long enough + let _ = data; // suppress unused warning +} +``` + +## Good + +```rust +fn process(data: &[i32]) -> i32 { + data.iter().sum() +} + +// Return a closure that owns its capture via `move`. +fn make_greeter(name: String) -> impl Fn() { + move || println!("hello, {name}") +} + +// Clone before `move` when you need the value in both places. +fn spawn_and_keep(data: Vec) -> std::thread::JoinHandle { + let data_for_thread = data.clone(); // clone goes into the closure + let handle = std::thread::spawn(move || process(&data_for_thread)); + // `data` is still available here + println!("original still owned: {data:?}"); + handle +} + +fn demo() { + let greet = make_greeter(String::from("world")); + greet(); // prints: hello, world + + let nums = vec![10, 20, 30]; + let handle = spawn_and_keep(nums); + let sum = handle.join().unwrap(); + assert_eq!(sum, 60); +} +``` + +## Key Points + +- **Thread closures** passed to `std::thread::spawn` must be `'static`, which `move` enables when all captured types are `'static + Send`. +- **Clone selectively:** clone only what the closure needs, not the whole owning struct — see [closure-disjoint-capture](closure-disjoint-capture.md). +- **Async tasks** follow the same rule: `tokio::spawn(async move { … })` takes ownership; clone shared data before the `async move` block. +- A `move` closure can still implement `Fn` or `FnMut` — `move` only controls *how* captures are taken, not how many times the closure can be called. + +## See Also + +- [async-clone-before-await](async-clone-before-await.md) - clone data before async move blocks +- [own-move-large](own-move-large.md) - move large data instead of cloning +- [closure-disjoint-capture](closure-disjoint-capture.md) - capture only the fields you use diff --git a/.opencode/skills/rust-skills/rules/closure-static-vs-dyn.md b/.opencode/skills/rust-skills/rules/closure-static-vs-dyn.md new file mode 100644 index 0000000..ec290ef --- /dev/null +++ b/.opencode/skills/rust-skills/rules/closure-static-vs-dyn.md @@ -0,0 +1,96 @@ +# closure-static-vs-dyn + +> Accept `impl Fn` (generic) for hot callbacks; use `&dyn Fn`/`Box` to cut code size or to store them + +## Why It Matters + +A generic parameter `F: Fn(…) -> …` (or `impl Fn`) monomorphizes at each call site: the compiler emits a specialized copy of the function, enabling inlining and zero-cost dispatch. The trade-off is binary bloat when many different closure types are substituted. `&dyn Fn`/`Box` share a single compiled copy via a vtable, which reduces code size and is the only option for storing heterogeneous closures (e.g. an event handler registry). Choose by profiling requirements, not habit. + +## Bad + +```rust +// Storing closures generically in a struct is impossible — the struct +// would need a type parameter per handler, making it unusable. +struct BadRegistry { + // Can only hold ONE concrete closure type — defeats the purpose. + handler: F, +} + +// Equally, using Box on a hot, single-call-site inner loop +// pays a vtable cost for no benefit. +fn transform_slow(xs: &[i32], f: &dyn Fn(i32) -> i32) -> Vec { + xs.iter().map(|&x| f(x)).collect() +} +``` + +## Good + +```rust +// Generic / static dispatch: preferred for hot paths — inlinable, zero allocation. +fn transform i32>(xs: &[i32], f: F) -> Vec { + xs.iter().map(|&x| f(x)).collect() +} + +// Dynamic dispatch: required when storing heterogeneous closures. +struct Registry { + handlers: Vec>, +} + +impl Registry { + fn new() -> Self { + Self { handlers: Vec::new() } + } + + fn register(&mut self, handler: impl Fn(&str) + 'static) { + self.handlers.push(Box::new(handler)); + } + + fn dispatch(&self, event: &str) { + for handler in &self.handlers { + handler(event); + } + } +} + +fn demo() { + // Static dispatch — the compiler may inline the closure entirely. + let doubled = transform(&[1, 2, 3], |x| x * 2); + assert_eq!(doubled, vec![2, 4, 6]); + + // Dynamic dispatch — one compiled copy, heterogeneous handlers. + let mut reg = Registry::new(); + reg.register(|e| println!("logger: {e}")); + reg.register(|e| println!("metrics: {e}")); + reg.dispatch("user_signup"); +} +``` + +## Decision Table + +| Situation | Use | +|-----------|-----| +| Hot inner loop, single call site | `impl Fn` / generic `F: Fn` | +| Callback stored in a struct field | `Box` | +| Collection of mixed closures | `Vec>` | +| Pass-through, one level deep, not stored | `&dyn Fn` (avoids allocation) | +| Called across an `await` point | `Box` | + +**Note:** `&dyn Fn` is useful to avoid an allocation when you only need to borrow the closure for one call and do not store it. Pass `&closure` (reference to a stack-allocated closure) rather than boxing. + +```rust +fn call_once_dyn(f: &dyn Fn() -> i32) -> i32 { + f() +} + +fn demo_ref() { + let x = 7; + let result = call_once_dyn(&|| x + 1); + assert_eq!(result, 8); +} +``` + +## See Also + +- [anti-type-erasure](anti-type-erasure.md) - prefer `impl Trait` over `Box` when possible +- [type-generic-bounds](type-generic-bounds.md) - add trait bounds only where needed +- [closure-fn-trait-bounds](closure-fn-trait-bounds.md) - choose the weakest `Fn` trait diff --git a/.opencode/skills/rust-skills/rules/coll-binaryheap.md b/.opencode/skills/rust-skills/rules/coll-binaryheap.md new file mode 100644 index 0000000..114f932 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/coll-binaryheap.md @@ -0,0 +1,108 @@ +# coll-binaryheap + +> Use `BinaryHeap` for a priority queue or repeated max-extraction + +## Why It Matters + +`std::collections::BinaryHeap` is a max-heap with O(log n) `push` and `pop` (extract-max) and O(1) `peek`. When you repeatedly need the largest (or smallest) element from a growing or shrinking set, it beats sorting a `Vec` after every insertion — sorting is O(n log n) each time, while a heap amortises that cost to O(log n) per operation. For a **min-heap**, wrap your values in `std::cmp::Reverse`; the heap's ordering flips, and the smallest value is extracted first. + +## Bad + +```rust +fn top_priority_task(tasks: &mut Vec<(u32, String)>) -> Option { + if tasks.is_empty() { + return None; + } + // O(n) scan to find the max, then O(n) shift to remove it — O(n) per call. + let max_idx = tasks + .iter() + .enumerate() + .max_by_key(|(_, (p, _))| *p) + .map(|(i, _)| i)?; + Some(tasks.remove(max_idx).1) +} + +fn main() { + let mut tasks = vec![ + (3, "low priority".to_string()), + (10, "urgent".to_string()), + (7, "medium priority".to_string()), + ]; + // Repeated calls become O(n²) overall. + while let Some(task) = top_priority_task(&mut tasks) { + println!("running: {task}"); + } +} +``` + +## Good + +```rust +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +#[derive(Eq, PartialEq, Ord, PartialOrd)] +struct Task { + priority: u32, // higher = more urgent + name: String, +} + +fn run_scheduler(tasks: impl IntoIterator) { + // BinaryHeap: O(log n) push and pop — max-priority task extracted first. + let mut heap: BinaryHeap = tasks + .into_iter() + .map(|(priority, name)| Task { + priority, + name: name.to_string(), + }) + .collect(); + + while let Some(task) = heap.pop() { + println!("running [priority={}]: {}", task.priority, task.name); + } +} + +fn top_k_largest(values: &[i32], k: usize) -> Vec { + // Min-heap of size k using Reverse: keeps the k largest elements. + let mut min_heap: BinaryHeap> = BinaryHeap::with_capacity(k + 1); + for &v in values { + min_heap.push(Reverse(v)); + if min_heap.len() > k { + min_heap.pop(); // discard the current minimum + } + } + // Drain from min to max for a sorted result. + let mut result: Vec = min_heap.into_iter().map(|Reverse(v)| v).collect(); + result.sort_unstable_by(|a, b| b.cmp(a)); + result +} + +fn main() { + run_scheduler([ + (3, "low priority task"), + (10, "urgent task"), + (7, "medium priority task"), + (10, "equally urgent task"), + ]); + // Output order: urgent, equally urgent, medium, low + + let top3 = top_k_largest(&[4, 1, 9, 2, 7, 5, 8], 3); + println!("top 3: {top3:?}"); // [9, 8, 7] +} +``` + +## Max-heap vs Min-heap at a glance + +| Want | How | +|---|---| +| Largest item first (max-heap) | `BinaryHeap` directly | +| Smallest item first (min-heap) | `BinaryHeap>` | +| Peek without removing | `.peek()` — O(1) | +| Drain in sorted order | `.into_sorted_vec()` — O(n log n) | + +**Note:** `BinaryHeap` does not support efficient arbitrary removal or priority updates. If you need those, consider a third-party crate like `keyed_priority_queue` or restructure with lazy deletion. + +## See Also + +- [coll-seq-choice](coll-seq-choice.md) - choosing between `Vec`, `VecDeque`, and `LinkedList` +- [perf-iter-over-index](perf-iter-over-index.md) - prefer iterators over manual indexing when draining results diff --git a/.opencode/skills/rust-skills/rules/coll-map-choice.md b/.opencode/skills/rust-skills/rules/coll-map-choice.md new file mode 100644 index 0000000..919103a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/coll-map-choice.md @@ -0,0 +1,109 @@ +# coll-map-choice + +> Pick the map by access pattern: `HashMap` (fast, unordered), `BTreeMap` (sorted / range queries), `IndexMap` (insertion order) + +## Why It Matters + +`HashMap` is the right default: O(1) average lookup and insertion with no ordering overhead. `BTreeMap` keeps keys sorted in a B-tree, enabling efficient range queries and ordered iteration at the cost of O(log n) operations. The `indexmap` crate's `IndexMap` preserves insertion order with O(1) average lookup — valuable for deterministic output, config files, or any API where output order must match input order. Choosing the wrong map wastes CPU cycles on sorting you don't need or forces you to sort after the fact. + +## Bad + +```rust +use std::collections::HashMap; + +fn word_counts(text: &str) -> HashMap<&str, usize> { + let mut counts = HashMap::new(); + for word in text.split_whitespace() { + *counts.entry(word).or_insert(0) += 1; + } + counts + // Iterating this for a report produces random order every run. + // Caller has to sort externally — meaning repeated, avoidable work. +} + +fn main() { + let counts = word_counts("the quick brown fox jumps over the lazy dog"); + // Non-deterministic output: order changes between runs. + for (word, count) in &counts { + println!("{word}: {count}"); + } +} +``` + +## Good + +```rust +// --- 1. HashMap: default, fast, unordered --- +use std::collections::HashMap; + +fn total_scores<'a>(records: &[(&'a str, u32)]) -> HashMap<&'a str, u32> { + let mut scores: HashMap<&'a str, u32> = HashMap::new(); + for &(name, score) in records { + *scores.entry(name).or_insert(0) += score; + } + scores +} + +// --- 2. BTreeMap: sorted keys, range queries --- +use std::collections::BTreeMap; + +fn events_in_range( + log: &BTreeMap, + start: u64, + end: u64, +) -> Vec<(&u64, &String)> { + // range() is only possible because BTreeMap keeps keys sorted. + log.range(start..=end).collect() +} + +fn build_log() -> BTreeMap { + let mut log = BTreeMap::new(); + log.insert(1_000, "server started".to_string()); + log.insert(2_000, "request received".to_string()); + log.insert(3_000, "response sent".to_string()); + log +} + +// --- 3. IndexMap: insertion order + O(1) lookup --- +use indexmap::IndexMap; + +fn parse_config(pairs: &[(&str, &str)]) -> IndexMap { + // Keys iterated in the order they were inserted — deterministic output. + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn main() { + // BTreeMap range query + let log = build_log(); + let window = events_in_range(&log, 1_000, 2_500); + for (ts, msg) in window { + println!("{ts}: {msg}"); + } + + // IndexMap preserves insertion order + let cfg = parse_config(&[("host", "localhost"), ("port", "8080"), ("debug", "true")]); + for (k, v) in &cfg { + println!("{k} = {v}"); // always: host, port, debug + } +} +``` + +## Which to Use + +| Requirement | Choose | +|---|---| +| Fast lookup, order irrelevant | `HashMap` | +| Sorted iteration or range queries | `BTreeMap` | +| Insertion-order iteration + fast lookup | `IndexMap` (indexmap crate) | +| Tiny map (≤ 8 entries), no heap | consider an array of tuples | + +**Note on hashing:** The default `HashMap` uses SipHash-1-3, which is DOS-resistant but not the fastest. For non-adversarial hot paths, see `perf-ahash` for a faster hasher that drops in as a type parameter. + +## See Also + +- [perf-ahash](perf-ahash.md) - swap in a faster hasher for non-adversarial maps +- [perf-entry-api](perf-entry-api.md) - use `entry()` to avoid double lookups +- [coll-seq-choice](coll-seq-choice.md) - choosing the right sequence type diff --git a/.opencode/skills/rust-skills/rules/coll-seq-choice.md b/.opencode/skills/rust-skills/rules/coll-seq-choice.md new file mode 100644 index 0000000..6d1e12c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/coll-seq-choice.md @@ -0,0 +1,85 @@ +# coll-seq-choice + +> Default to `Vec`; use `VecDeque` for queue/deque behaviour; avoid `LinkedList` + +## Why It Matters + +`Vec` is a contiguous growable array — the right sequence type for almost every use case. Contiguous layout means the CPU prefetcher works in your favour and each element is a single pointer dereference away. `VecDeque` is a ring buffer with O(1) amortised push and pop at **both** ends; reach for it when you need a FIFO queue or a sliding window. `LinkedList` is heap-allocated per node and pointer-chased on every access; in practice it is almost always slower than `Vec` or `VecDeque` for real workloads, and the standard library itself discourages its use. + +## Bad + +```rust +fn process_queue(items: Vec) { + let mut queue = items; + while !queue.is_empty() { + // O(n): every element shifts left after removal. + let item = queue.remove(0); + println!("processing: {item}"); + } +} + +fn main() { + process_queue(vec![ + "first".to_string(), + "second".to_string(), + "third".to_string(), + ]); +} +``` + +`remove(0)` on a `Vec` is O(n) because it must shift every remaining element. A loop of n items becomes O(n²). + +## Good + +```rust +use std::collections::VecDeque; + +fn process_queue(items: impl IntoIterator) { + // VecDeque: O(1) pop_front — the right tool for a FIFO queue. + let mut queue: VecDeque = items.into_iter().collect(); + while let Some(item) = queue.pop_front() { + println!("processing: {item}"); + } +} + +fn sliding_window_max(values: &[i32], k: usize) -> Vec { + // VecDeque also shines as a fixed-size sliding window. + let mut window: VecDeque = VecDeque::with_capacity(k); + let mut result = Vec::with_capacity(values.len().saturating_sub(k) + 1); + + for &v in values { + window.push_back(v); + if window.len() > k { + window.pop_front(); + } + if window.len() == k { + result.push(*window.iter().max().unwrap()); + } + } + result +} + +fn main() { + process_queue(["alpha".to_string(), "beta".to_string(), "gamma".to_string()]); + + let maxima = sliding_window_max(&[3, 1, 2, 5, 4], 3); + println!("{maxima:?}"); // [3, 5, 5] +} +``` + +## Which to Use + +| Requirement | Choose | +|---|---| +| General-purpose growable list | `Vec` | +| FIFO queue, deque, or sliding window | `VecDeque` | +| Need O(1) split/splice at arbitrary positions | consider `VecDeque` or rethink the design | +| Linked list (almost never) | `LinkedList` — only if profiling proves it faster | + +**Conversion:** `Vec` and `VecDeque` convert cheaply into each other via `From`/`Into` when you need to switch representation. + +## See Also + +- [mem-with-capacity](mem-with-capacity.md) - pre-allocate with `with_capacity` when size is known +- [perf-drain-reuse](perf-drain-reuse.md) - drain a collection to reuse its allocation +- [coll-map-choice](coll-map-choice.md) - choosing the right map type diff --git a/.opencode/skills/rust-skills/rules/coll-set-membership.md b/.opencode/skills/rust-skills/rules/coll-set-membership.md new file mode 100644 index 0000000..41e4d5d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/coll-set-membership.md @@ -0,0 +1,87 @@ +# coll-set-membership + +> Use `HashSet`/`BTreeSet` for membership tests and dedup, not linear `Vec::contains` + +## Why It Matters + +`Vec::contains` is O(n) per call. When you check membership for m items against a list of n items in a loop, the total cost is O(n × m) — quadratic. A `HashSet` reduces each check to O(1) average, making the same loop O(n + m). Use `BTreeSet` when you also need sorted iteration or range queries. Keep a `Vec` only when order matters, duplicates are intentional, or the collection is so small (say, ≤ 8 items) that the overhead of hashing outweighs the savings. + +Deduplication follows the same rule: collecting into a `HashSet` is one line and O(n), while repeatedly removing duplicates from a sorted `Vec` is more code and no faster. + +## Bad + +```rust +fn find_common(all_users: &[String], active_ids: &[String]) -> Vec { + let mut common = Vec::new(); + for user in all_users { + // O(n) per iteration → O(n * m) total + if active_ids.contains(user) { + common.push(user.clone()); + } + } + common +} + +fn deduplicate(items: Vec) -> Vec { + let mut seen: Vec = Vec::new(); + for item in items { + // O(n) per item — quadratic overall + if !seen.contains(&item) { + seen.push(item); + } + } + seen +} +``` + +## Good + +```rust +use std::collections::{BTreeSet, HashSet}; + +// O(n + m): build the set once, then test each user in O(1). +fn find_common(all_users: &[String], active_ids: &[String]) -> Vec { + let active: HashSet<&String> = active_ids.iter().collect(); + all_users + .iter() + .filter(|u| active.contains(u)) + .cloned() + .collect() +} + +// Dedup while preserving order: track seen items in a HashSet. +fn deduplicate_ordered(items: Vec) -> Vec { + let mut seen = HashSet::with_capacity(items.len()); + items.into_iter().filter(|s| seen.insert(s.clone())).collect() +} + +// Dedup into a sorted, unique collection — use BTreeSet. +fn unique_sorted(items: Vec) -> Vec { + items.into_iter().collect::>().into_iter().collect() +} + +fn main() { + let users = vec!["alice".to_string(), "bob".to_string(), "carol".to_string()]; + let active = vec!["bob".to_string(), "carol".to_string(), "dave".to_string()]; + println!("{:?}", find_common(&users, &active)); // ["bob", "carol"] + + let raw = vec!["x".to_string(), "y".to_string(), "x".to_string(), "z".to_string()]; + println!("{:?}", deduplicate_ordered(raw)); // ["x", "y", "z"] +} +``` + +## Which to Use + +| Requirement | Choose | +|---|---| +| Fast membership test, no ordering needed | `HashSet` | +| Membership test + sorted iteration | `BTreeSet` | +| Tiny set (≤ 8 items) | `Vec` with `.contains` may be fine | +| Keep duplicates or care about insertion order | `Vec` | + +**Tip:** when building a `HashSet` that will be queried many times, call `HashSet::with_capacity(n)` upfront to avoid rehashing. See `mem-with-capacity`. + +## See Also + +- [coll-map-choice](coll-map-choice.md) - choosing between `HashMap`, `BTreeMap`, and `IndexMap` +- [perf-ahash](perf-ahash.md) - swap in a faster hasher for non-adversarial sets diff --git a/.opencode/skills/rust-skills/rules/conc-atomic-ordering.md b/.opencode/skills/rust-skills/rules/conc-atomic-ordering.md new file mode 100644 index 0000000..0d6e116 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/conc-atomic-ordering.md @@ -0,0 +1,106 @@ +# conc-atomic-ordering + +> Use the weakest correct memory `Ordering` for every atomic operation + +## Why It Matters + +Defaulting to `SeqCst` (sequentially consistent) on every atomic is a common correctness-first shortcut, but it carries real cost: on x86 the difference is small, but on ARM and RISC-V weaker orderings map to cheaper instructions while `SeqCst` requires full memory barriers. More importantly, choosing the wrong ordering — even a weaker one — is a correctness bug that causes data races the compiler won't catch. Understanding the four practical levels lets you write both correct and efficient concurrent code. + +## Bad + +```rust +use std::sync::atomic::{AtomicU64, AtomicBool, Ordering}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); +static READY: AtomicBool = AtomicBool::new(false); +static mut DATA: u64 = 0; + +// SeqCst everywhere — correct, but unnecessarily expensive +fn increment() { + COUNTER.fetch_add(1, Ordering::SeqCst); +} + +fn producer() { + unsafe { DATA = 42; } + READY.store(true, Ordering::SeqCst); // overkill for a single flag +} + +fn consumer() -> Option { + if READY.load(Ordering::SeqCst) { + Some(unsafe { DATA }) + } else { + None + } +} +``` + +## Good + +```rust +use std::sync::atomic::{AtomicU64, AtomicBool, Ordering}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +// Relaxed: no ordering relative to other memory — fine for independent counters +fn increment() { + COUNTER.fetch_add(1, Ordering::Relaxed); +} + +fn total() -> u64 { + COUNTER.load(Ordering::Relaxed) +} + +// Acquire/Release: paired handoff — producer writes data THEN sets flag (Release); +// consumer loads flag (Acquire) and is guaranteed to see the preceding write. +static READY: AtomicBool = AtomicBool::new(false); +static VALUE: AtomicU64 = AtomicU64::new(0); + +fn producer(value: u64) { + VALUE.store(value, Ordering::Relaxed); // write payload first + READY.store(true, Ordering::Release); // publish with Release +} + +fn consumer() -> Option { + if READY.load(Ordering::Acquire) { // synchronize with Release store + Some(VALUE.load(Ordering::Relaxed)) // payload visible after Acquire + } else { + None + } +} + +// SeqCst: only when you need a single total order across *multiple* atomics. +// Example: Dekker-style mutual exclusion involving two independent flags. +``` + +## Ordering Quick Reference + +| Ordering | Use when | +|----------|----------| +| `Relaxed` | Operation is atomic but needs no ordering relative to other memory (counters, stats) | +| `Acquire` | Load that must see all stores preceding a matching `Release` on another thread | +| `Release` | Store that must be visible before a matching `Acquire` load on another thread | +| `AcqRel` | Read-modify-write (e.g. `compare_exchange`) acting as both Acquire and Release | +| `SeqCst` | Need a single global order observed by all threads across multiple atomic variables | + +## Verification with loom + +Use the `loom` crate to exhaustively verify ordering choices for small concurrent units — it explores all interleavings the C11 memory model permits: + +```rust +#[cfg(loom)] +use loom::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(loom)] +#[test] +fn test_handoff() { + loom::model(|| { + // ... spawn threads, assert invariants + }); +} +``` + +## See Also + +- [own-mutex-interior](own-mutex-interior.md) - prefer `Mutex` when lock-free isn't required +- [test-loom-concurrency](test-loom-concurrency.md) - exhaustively test concurrent code with loom +- [conc-scoped-threads](conc-scoped-threads.md) - safely share stack data across threads diff --git a/.opencode/skills/rust-skills/rules/conc-rayon-par-iter.md b/.opencode/skills/rust-skills/rules/conc-rayon-par-iter.md new file mode 100644 index 0000000..1a25940 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/conc-rayon-par-iter.md @@ -0,0 +1,67 @@ +# conc-rayon-par-iter + +> Use rayon's `par_iter()` for CPU-bound data parallelism + +## Why It Matters + +Rayon's work-stealing scheduler parallelizes data-parallel workloads with an API nearly identical to standard iterators — often a one-word change from `.iter()` to `.par_iter()` yields near-linear speedup across cores. It automatically balances load across threads, handles chunking, and composes with the full iterator adapter chain. For IO-bound concurrency, use async instead; rayon is strictly for CPU-bound computation. + +## Bad + +```rust +// single-threaded — wastes available cores on a CPU-bound workload +fn sum_squares(data: &[f64]) -> f64 { + data.iter().map(|x| x * x).sum() +} + +fn normalize(data: &mut [f64]) { + let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + data.iter_mut().for_each(|x| *x /= max); +} +``` + +## Good + +```rust +use rayon::prelude::*; + +fn sum_squares(data: &[f64]) -> f64 { + data.par_iter().map(|x| x * x).sum() +} + +fn normalize(data: &mut [f64]) { + let max = data.par_iter().cloned().reduce(|| f64::NEG_INFINITY, f64::max); + data.par_iter_mut().for_each(|x| *x /= max); +} + +fn keep_positive(data: &[f64]) -> Vec { + data.par_iter().copied().filter(|&x| x > 0.0).collect() +} + +fn sort_large(data: &mut [f64]) { + // parallel unstable sort — faster than std sort for large slices + data.par_sort_unstable_by(|a, b| a.partial_cmp(b).unwrap()); +} +``` + +## Key Points + +| Concern | Guidance | +|---------|----------| +| Import | `use rayon::prelude::*;` enables `.par_iter()` on slices and most collections | +| IO-bound work | Use async (`tokio`, `async-std`), not rayon — rayon threads block | +| Small collections | Sequential is often faster due to thread-spawn overhead; profile first | +| Minimum chunk size | Rayon's `with_min_len()` / `with_max_len()` tune granularity | +| Shared state | Use `Mutex` or atomic operations; rayon does not prevent data races | + +## When to Use + +- Processing large arrays, slices, or collections (image pixels, number crunching, parsing batches) +- CPU-bound transformations: map, filter, fold, sort +- When the per-element work is non-trivial (at least a few hundred nanoseconds) + +## See Also + +- [conc-scoped-threads](conc-scoped-threads.md) - borrow stack data across short-lived threads +- [perf-iter-over-index](perf-iter-over-index.md) - prefer iterators over manual indexing +- [async-spawn-blocking](async-spawn-blocking.md) - offload CPU work from async runtimes diff --git a/.opencode/skills/rust-skills/rules/conc-scoped-threads.md b/.opencode/skills/rust-skills/rules/conc-scoped-threads.md new file mode 100644 index 0000000..5602efe --- /dev/null +++ b/.opencode/skills/rust-skills/rules/conc-scoped-threads.md @@ -0,0 +1,73 @@ +# conc-scoped-threads + +> Use `std::thread::scope` to borrow stack data across threads + +## Why It Matters + +Scoped threads (stable since Rust 1.63) guarantee that all threads spawned inside the scope join before `thread::scope` returns. This lifetime guarantee lets spawned threads borrow non-`'static` data from the enclosing stack frame — no `Arc`, no cloning, no heap allocation required. For short parallel tasks that need to read or write local data, scoped threads are simpler and cheaper than wrapping everything in `Arc>`. + +## Bad + +```rust +use std::sync::{Arc, Mutex}; +use std::thread; + +fn parallel_sum(data: &[i64]) -> i64 { + // Arc + clone just to share a slice — heap overhead, boilerplate + let data = Arc::new(data.to_vec()); // unnecessary clone of entire slice + let mid = data.len() / 2; + + let data1 = Arc::clone(&data); + let h1 = thread::spawn(move || data1[..mid].iter().sum::()); + + let data2 = Arc::clone(&data); + let h2 = thread::spawn(move || data2[mid..].iter().sum::()); + + h1.join().unwrap() + h2.join().unwrap() +} +``` + +## Good + +```rust +use std::thread; + +fn parallel_sum(data: &[i64]) -> i64 { + let mid = data.len() / 2; + let (left, right) = data.split_at(mid); + + thread::scope(|s| { + let h1 = s.spawn(|| left.iter().sum::()); + let h2 = s.spawn(|| right.iter().sum::()); + h1.join().unwrap() + h2.join().unwrap() + }) +} + +// Mutable borrows work too — as long as they don't alias +fn parallel_fill(left: &mut [u8], right: &mut [u8]) { + thread::scope(|s| { + s.spawn(|| left.fill(0xAA)); + s.spawn(|| right.fill(0xBB)); + }); + // both halves have been written; scope guarantees completion +} +``` + +## Key Points + +- The closure passed to `thread::scope` receives a `Scope<'env, '_>` handle; threads spawned via `s.spawn(...)` can borrow anything alive in `'env`. +- All threads are joined automatically when the scope returns — even on panic. +- A panicking child thread causes `thread::scope` itself to panic after joining all remaining children. +- For CPU-bound data-parallel work over large collections, prefer rayon's `par_iter()`, which handles chunking and work-stealing automatically. + +## When to Use + +- Short-lived parallel tasks that share read-only or non-overlapping mutable references to local data +- You need explicit thread control (pinning, priority) that rayon doesn't expose +- Parallelizing a fixed number of distinct sub-tasks (not a homogeneous collection) + +## See Also + +- [own-arc-shared](own-arc-shared.md) - use `Arc` when data genuinely outlives the parallel task +- [conc-rayon-par-iter](conc-rayon-par-iter.md) - higher-level data parallelism for collections +- [async-spawn-blocking](async-spawn-blocking.md) - offload blocking work from async runtimes diff --git a/.opencode/skills/rust-skills/rules/conc-thread-local.md b/.opencode/skills/rust-skills/rules/conc-thread-local.md new file mode 100644 index 0000000..6d77f19 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/conc-thread-local.md @@ -0,0 +1,81 @@ +# conc-thread-local + +> Prefer `thread_local!` with `Cell`/`RefCell` over `static mut` + +## Why It Matters + +`static mut` requires `unsafe` at every access and is undefined behavior if any two threads touch it simultaneously — the compiler cannot rule that out. In Rust 2024, taking a shared or mutable reference to a `static mut` is a hard error (`static_mut_refs`). `thread_local!` gives each thread its own independent copy of the value, accessed through safe APIs via `Cell` (for `Copy` types) or `RefCell` (for anything else), with no synchronization overhead and no unsafe code needed. + +## Bad + +```rust +// Rust 2024: referencing static mut is a hard error (static_mut_refs lint) +static mut BUFFER: Vec = Vec::new(); + +fn append_to_buffer(data: &[u8]) { + // UB if called from multiple threads; hard error in 2024 edition + unsafe { + BUFFER.extend_from_slice(data); + } +} + +fn flush_buffer() -> Vec { + unsafe { + std::mem::take(&mut BUFFER) // still requires unsafe + } +} +``` + +## Good + +```rust +use std::cell::RefCell; + +thread_local! { + static BUFFER: RefCell> = RefCell::new(Vec::with_capacity(4096)); +} + +fn append_to_buffer(data: &[u8]) { + BUFFER.with_borrow_mut(|buf| buf.extend_from_slice(data)); +} + +fn flush_buffer() -> Vec { + BUFFER.with_borrow_mut(|buf| std::mem::take(buf)) +} +``` + +For `Copy` types, `Cell` is simpler (no borrow overhead): + +```rust +use std::cell::Cell; + +thread_local! { + static CALL_COUNT: Cell = Cell::new(0); +} + +fn record_call() { + CALL_COUNT.with(|c| c.set(c.get() + 1)); +} + +fn get_call_count() -> u32 { + CALL_COUNT.with(|c| c.get()) +} +``` + +## Key Points + +- `with_borrow` / `with_borrow_mut` are stable convenience methods (since 1.73) on `LocalKey>` — prefer them over the longer `with(|v| v.borrow_mut())` form. +- Thread-local destructors run when the thread exits, so cleanup happens automatically. +- Avoid storing thread-locals in types that are sent across threads; the value is strictly per-thread and is not visible to other threads. +- For read-only global constants shared across threads, use `static` with an immutable type instead. + +## When to Use + +- Scratch buffers, caches, or accumulators that are logically per-thread and don't need to be shared +- Reusing allocations across calls within a single thread (e.g., formatting buffers, temporary vecs) +- Replacing `unsafe static mut` with a safe alternative + +## See Also + +- [own-refcell-interior](own-refcell-interior.md) - interior mutability for single-threaded code +- [own-mutex-interior](own-mutex-interior.md) - shared mutable state across threads requires `Mutex` diff --git a/.opencode/skills/rust-skills/rules/const-block.md b/.opencode/skills/rust-skills/rules/const-block.md new file mode 100644 index 0000000..0558085 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/const-block.md @@ -0,0 +1,77 @@ +# const-block + +> Use inline `const { }` blocks for compile-time evaluation and assertions + +## Why It Matters + +A `const { }` block (stabilized in Rust 1.79) forces the enclosed expression to be evaluated at compile time, even inside a regular function. This has three practical uses: compile-time assertions that fail the build rather than panicking at runtime; precomputed values inlined at the exact call site without a named `const` item; and initializing arrays of non-`Copy` types that require a per-element constant expression. Catching invariant violations at compile time gives a clearer error message and zero runtime cost. + +## Bad + +```rust +const SIZE: usize = 64; + +fn process(buf: &[u8]) { + // runtime panic — error surface is deferred until execution + assert!(SIZE.is_power_of_two(), "SIZE must be a power of two"); + assert!(buf.len() <= SIZE); +} + +// repeated magic number — easy to get out of sync +fn header() -> [u8; 4] { + [0u8; 4] +} + +// initializing array of non-Copy type requires unsafe or a workaround without const blocks +// std::array::from_fn is fine, but const blocks make the intent clearer for const values +``` + +## Good + +```rust +const SIZE: usize = 64; + +fn process(buf: &[u8]) { + // compile-time assertion — build fails immediately if SIZE changes to a bad value + const { assert!(SIZE.is_power_of_two(), "SIZE must be a power of two") }; + + // runtime assertion for dynamic data still makes sense here + assert!(buf.len() <= SIZE); +} + +// inline const block used as a value — evaluated once, inlined at each use +fn magic_header() -> u32 { + const { 0xDEAD_BEEFu32.swap_bytes() } +} + +// compile-time bounds check on a type-level relationship +struct Packet; + +impl Packet { + fn new() -> Self { + // fails at compile time if the relationship is violated, not at runtime + const { assert!(HDR + BODY <= 1500, "packet exceeds ethernet MTU") }; + Packet + } +} + +// array of non-Copy type using a const block per element +// (each element is its own const expression — legal since 1.79) +fn make_table() -> [u64; 4] { + [ + const { u64::MAX / 1 }, + const { u64::MAX / 2 }, + const { u64::MAX / 3 }, + const { u64::MAX / 4 }, + ] +} +``` + +## Notes + +`const { expr }` is an expression, so it can appear anywhere an expression is valid: in function bodies, match arms, array initializers, and as default values. It is distinct from a named `const ITEM: T = expr;` declaration — a `const` block is anonymous and scoped to its enclosing expression. For long-lived or widely shared values, prefer a named `const`; reach for `const { }` when you want a one-off compile-time guarantee or value inline. + +## See Also + +- [const-fn](const-fn.md) - write functions callable in const contexts +- [mem-assert-type-size](mem-assert-type-size.md) - assert hot type sizes to prevent layout regressions diff --git a/.opencode/skills/rust-skills/rules/const-fn.md b/.opencode/skills/rust-skills/rules/const-fn.md new file mode 100644 index 0000000..c55047c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/const-fn.md @@ -0,0 +1,63 @@ +# const-fn + +> Make functions `const fn` when they can run at compile time + +## Why It Matters + +A `const fn` can be called in const contexts — array lengths, `const`/`static` initializers, const-generic arguments — as well as at runtime, so marking a pure, simple function `const` widens where it can be used at zero cost. The compiler evaluates calls in const contexts during compilation, eliminating the work entirely from the binary. Current stable Rust supports most arithmetic, bitwise ops, conditionals, and slice operations in `const fn`; the main restrictions are heap allocation and most trait method calls. + +## Bad + +```rust +// not const — cannot use result as an array length or const initializer +fn header_len() -> usize { + 4 +} + +fn magic_mask() -> u32 { + 0xFF00_FF00 +} + +fn make_buf() -> [u8; 8] { + // runtime call — compiler cannot inline the length into the type + [0u8; header_len()] // error: `header_len` is not a `const fn` +} +``` + +## Good + +```rust +const fn header_len() -> usize { + 4 +} + +const fn magic_mask() -> u32 { + 0xFF00_FF00 +} + +// usable as an array length — evaluated at compile time +let buf = [0u8; header_len()]; + +// usable in a const initializer +const MASK: u32 = magic_mask(); + +// usable in a static +static HEADER: [u8; header_len()] = [0u8; header_len()]; + +// const fn with logic — still fine on stable +const fn align_up(n: usize, align: usize) -> usize { + (n + align - 1) & !(align - 1) +} + +const ALIGNED: usize = align_up(13, 8); // 16, computed at compile time +``` + +## Notes + +Adding `const` to a function is a backwards-compatible, non-breaking change for library authors. Start with `const fn` for any pure function without heap allocation or dynamic dispatch; you can always remove it if you later need a capability that is not yet `const`-stable. Avoid `const fn` for functions that call unstable `const` features not yet stabilized on the channel you target. + +## See Also + +- [const-block](const-block.md) - force compile-time evaluation and assertions inline +- [const-generics](const-generics.md) - parameterize types and functions over const values +- [opt-inline-small](opt-inline-small.md) - inline small hot functions diff --git a/.opencode/skills/rust-skills/rules/const-generics.md b/.opencode/skills/rust-skills/rules/const-generics.md new file mode 100644 index 0000000..30e3fb2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/const-generics.md @@ -0,0 +1,90 @@ +# const-generics + +> Parameterize over values with const generics `` + +## Why It Matters + +Const generics let a single type or function work for any array size — or other constant value — without macros, trait objects, or carrying a runtime length field. The compiler monomorphizes one copy per distinct value, so there is no indirection and no overhead compared to hand-writing the same code for each size. This is the idiomatic way to write generic array-based data structures and algorithms on stable Rust. + +## Bad + +```rust +// works only for one fixed size — must be copy-pasted per size +fn sum_4(arr: [i32; 4]) -> i32 { + arr.iter().sum() +} + +fn sum_8(arr: [i32; 8]) -> i32 { + arr.iter().sum() +} + +// carries runtime length — extra field, heap allocation, no compile-time bounds +struct Buffer { + data: Vec, + capacity: usize, +} +``` + +## Good + +```rust +// one generic function works for any array size; N is inferred from the argument +fn sum(arr: [i32; N]) -> i32 { + arr.iter().sum() +} + +let total = sum([1, 2, 3, 4]); // N = 4, inferred +let total8 = sum([0i32; 8]); // N = 8, inferred + +// stack-allocated buffer parameterized by capacity — no heap, no runtime length +struct Buffer { + data: [u8; N], + len: usize, +} + +impl Buffer { + const fn new() -> Self { + Self { data: [0u8; N], len: 0 } + } + + fn push(&mut self, byte: u8) -> bool { + if self.len < N { + self.data[self.len] = byte; + self.len += 1; + true + } else { + false + } + } + + fn as_slice(&self) -> &[u8] { + &self.data[..self.len] + } +} + +// capacity is part of the type — mismatches caught at compile time +let mut small: Buffer<8> = Buffer::new(); +let mut large: Buffer<1024> = Buffer::new(); +small.push(42); +large.push(99); + +// const generic used as an array length computed from another const +const BLOCK: usize = 16; +fn xor_block(a: [u8; N], b: [u8; N]) -> [u8; N] { + let mut out = [0u8; N]; + for i in 0..N { + out[i] = a[i] ^ b[i]; + } + out +} +let result = xor_block([0u8; BLOCK], [0xFF; BLOCK]); +``` + +## Notes + +Rust 1.65+ stabilized const generic defaults (`struct Buf`), letting you provide a sensible default while still allowing callers to override it. Const generics currently support integer, bool, and char types; floating-point and custom types are not yet stable. Where N can be inferred from a function argument, you rarely need to write it explicitly. + +## See Also + +- [const-fn](const-fn.md) - mark functions `const fn` so they can be called in const contexts +- [mem-assert-type-size](mem-assert-type-size.md) - assert type sizes to catch layout regressions diff --git a/.opencode/skills/rust-skills/rules/const-vs-static.md b/.opencode/skills/rust-skills/rules/const-vs-static.md new file mode 100644 index 0000000..c3ba817 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/const-vs-static.md @@ -0,0 +1,78 @@ +# const-vs-static + +> Use `const` for an inlined value and `static` for a single addressed instance + +## Why It Matters + +`const` items are substituted at each use site: the compiler copies the value inline, there is no fixed memory address, and the value counts against no storage. This is ideal for small configuration values, bitmasks, and magic numbers. `static` items live at a single address for the entire program lifetime and expose a `'static` reference — use them for large lookup tables you do not want duplicated, or when a `&'static T` is required. Avoid `static mut`: referencing it is a hard error in the 2024 edition. For mutable global state, use atomics (`AtomicUsize`, etc.) or `std::sync::OnceLock`/`std::sync::LazyLock`. + +## Bad + +```rust +// large table as `const` — potentially duplicated at every use site +const LOOKUP: [u8; 256] = [0u8; 256]; + +// `static mut` — unsafe to read and a hard error in edition 2024 +static mut COUNTER: u64 = 0; + +// `static` for a tiny value — needlessly takes an address +static TIMEOUT_MS: u64 = 5000; +``` + +## Good + +```rust +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, OnceLock}; + +// small value: `const` — inlined at each use, no address needed +const MAX_RETRIES: u32 = 3; +const TIMEOUT_MS: u64 = 5_000; +const FLAG_MASK: u8 = 0b0000_1111; + +// large data: `static` — one copy in the binary, shareable as `&'static` +static LOOKUP: [u8; 256] = [0u8; 256]; + +fn process(byte: u8) -> u8 { + LOOKUP[byte as usize] +} + +// `&'static str` requires a `static` (or a string literal) +static APP_NAME: &str = "my-app"; + +// mutable global state — use atomics, not `static mut` +static REQUEST_COUNT: AtomicU64 = AtomicU64::new(0); + +fn record_request() { + REQUEST_COUNT.fetch_add(1, Ordering::Relaxed); +} + +// lazily initialized global — `LazyLock` (stable since 1.80) +static CONFIG_PATH: LazyLock = LazyLock::new(|| { + std::env::var("CONFIG_PATH").unwrap_or_else(|_| "/etc/app/config.toml".to_owned()) +}); + +// single-assignment global — `OnceLock` +static GREETING: OnceLock = OnceLock::new(); + +fn set_greeting(name: &str) { + let _ = GREETING.set(format!("hello, {name}")); +} +``` + +## When to Prefer Which + +| Situation | Use | +|-----------|-----| +| Small constant (number, bool, tiny array) | `const` | +| String literal | `const` or `static` (both work; prefer `const`) | +| Large lookup table | `static` | +| Need `&'static T` | `static` | +| Mutable counter / flag | `static AtomicXxx` | +| Lazily initialized value | `static LazyLock` | +| Single-writer initialization | `static OnceLock` | + +## See Also + +- [name-consts-screaming](name-consts-screaming.md) - naming `SCREAMING_SNAKE_CASE` for `const` and `static` +- [own-mutex-interior](own-mutex-interior.md) - use `Mutex` for interior mutability in multi-threaded code diff --git a/.opencode/skills/rust-skills/rules/conv-asmut-mutable.md b/.opencode/skills/rust-skills/rules/conv-asmut-mutable.md new file mode 100644 index 0000000..1b4389b --- /dev/null +++ b/.opencode/skills/rust-skills/rules/conv-asmut-mutable.md @@ -0,0 +1,67 @@ +# conv-asmut-mutable + +> Accept `impl AsMut` for flexible mutable borrowed inputs instead of concrete mutable references + +## Why It Matters + +`AsMut` is the mutable mirror of `AsRef`. Accepting `impl AsMut<[u8]>` instead of `&mut Vec` lets callers pass `&mut Vec`, `&mut [u8]`, or `&mut [u8; N]` arrays without any conversion overhead. This widens the function's usefulness without changing its implementation or runtime cost. Reserve it for genuinely generic write targets — not every `&mut T` parameter needs this treatment. + +## Bad + +```rust +// Only accepts &mut Vec; arrays and slices are excluded +fn fill_zeros(buf: &mut Vec) { + for b in buf.iter_mut() { + *b = 0; + } +} + +fn main() { + let mut data = vec![1u8, 2, 3]; + fill_zeros(&mut data); + + // Compile error — cannot pass &mut [u8; 3] or &mut [u8] + // let mut arr = [1u8, 2, 3]; + // fill_zeros(&mut arr); +} +``` + +## Good + +```rust +// Accepts Vec, [u8; N], &mut [u8] — any type that lends &mut [u8] +fn fill_zeros(mut buf: impl AsMut<[u8]>) { + for b in buf.as_mut().iter_mut() { + *b = 0; + } +} + +fn verify(mut buf: impl AsMut<[u8]>) -> bool { + buf.as_mut().iter().all(|&b| b == 0) +} + +fn main() { + let mut vec_buf = vec![1u8, 2, 3]; + fill_zeros(&mut vec_buf); + assert!(verify(&mut vec_buf)); + + let mut arr_buf = [1u8, 2, 3, 4]; + fill_zeros(&mut arr_buf); + assert!(verify(&mut arr_buf)); + + let mut slice_buf = [5u8, 6, 7]; + fill_zeros(slice_buf.as_mut()); + assert!(verify(slice_buf.as_mut())); +} +``` + +## When Not to Use + +- If the function only ever receives `&mut SomeConcreteType`, keep the concrete type — generics add compile time and cognitive overhead for no gain. +- Avoid combining `AsMut` with `AsRef` on the same parameter unless the API genuinely needs both read and write access. +- Don't use `AsMut` as a substitute for a trait that captures domain semantics; name the abstraction properly. + +## See Also + +- [api-impl-asref](api-impl-asref.md) - the read-only counterpart for borrowed inputs +- [own-slice-over-vec](own-slice-over-vec.md) - prefer `&[T]` over `&Vec` for immutable slices diff --git a/.opencode/skills/rust-skills/rules/conv-fromstr-parsing.md b/.opencode/skills/rust-skills/rules/conv-fromstr-parsing.md new file mode 100644 index 0000000..c944b38 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/conv-fromstr-parsing.md @@ -0,0 +1,82 @@ +# conv-fromstr-parsing + +> Implement `FromStr` to enable `str::parse` for string-to-type conversions + +## Why It Matters + +`FromStr` is the single standard hook for parsing a `&str` into a typed value. Implementing it unlocks the idiomatic `.parse::()` call, integrates with CLI argument parsers (clap, argh), and is the expected interface for serde string-deserializable types. A bespoke `fn parse_foo(s: &str)` forces callers to learn a private name and breaks generic code that constrains `T: FromStr`. + +## Bad + +```rust +#[derive(Debug)] +enum Color { Red, Green, Blue } + +// Callers must know this private name; no `.parse()` support +fn parse_color(s: &str) -> Result { + match s { + "red" => Ok(Color::Red), + "green" => Ok(Color::Green), + "blue" => Ok(Color::Blue), + other => Err(format!("unknown color: {other}")), + } +} + +fn main() { + let c = parse_color("red").unwrap(); +} +``` + +## Good + +```rust +use std::str::FromStr; +use std::fmt; + +#[derive(Debug, PartialEq)] +enum Color { Red, Green, Blue } + +#[derive(Debug)] +struct ParseColorError(String); + +impl fmt::Display for ParseColorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unknown color: {}", self.0) + } +} + +impl std::error::Error for ParseColorError {} + +impl FromStr for Color { + type Err = ParseColorError; + + fn from_str(s: &str) -> Result { + match s { + "red" => Ok(Color::Red), + "green" => Ok(Color::Green), + "blue" => Ok(Color::Blue), + other => Err(ParseColorError(other.to_owned())), + } + } +} + +fn main() -> Result<(), Box> { + // Standard idiom — works with clap, config parsers, etc. + let c: Color = "green".parse()?; + assert_eq!(c, Color::Green); + Ok(()) +} +``` + +## Notes + +- Use a concrete `Err` type, not `String`, so callers can pattern-match on parse failures. +- `FromStr` pairs naturally with `Display`: if you can parse it in, you should be able to print it out. +- For infallible string conversions (e.g., wrapping a `String` in a newtype), consider `From<&str>` or `From` instead. +- CLI crates like `clap` detect `FromStr` automatically via the `value_parser` attribute macro. + +## See Also + +- [conv-tryfrom-fallible](conv-tryfrom-fallible.md) - `TryFrom` for fallible non-string conversions +- [type-newtype-validated](type-newtype-validated.md) - newtypes for validated data like `Email`, `Url` +- [api-parse-dont-validate](api-parse-dont-validate.md) - parse into validated types at boundaries diff --git a/.opencode/skills/rust-skills/rules/conv-tryfrom-fallible.md b/.opencode/skills/rust-skills/rules/conv-tryfrom-fallible.md new file mode 100644 index 0000000..6f4d32a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/conv-tryfrom-fallible.md @@ -0,0 +1,80 @@ +# conv-tryfrom-fallible + +> Implement `TryFrom` for fallible conversions instead of ad-hoc conversion functions + +## Why It Matters + +`TryFrom`/`TryInto` is the standard trait pair for conversions that can fail. Implementing `TryFrom` automatically provides `TryInto` for callers (API Guidelines C-CONV-TRAITS), enabling ergonomic `.try_into()?` at call sites. It integrates naturally with the `?` operator, ecosystem crates, and generic bounds that constrain `T: TryFrom`. Ad-hoc conversion functions scatter the conversion surface and deprive callers of these benefits. + +## Bad + +```rust +use std::io; + +struct Port(u16); + +// Bespoke function — callers must know its name, can't use `.try_into()` +fn port_from_u32(n: u32) -> Result { + if n > u16::MAX as u32 { + return Err(format!("port {} out of range", n)); + } + Ok(Port(n as u16)) +} + +fn main() { + let p = port_from_u32(8080).unwrap(); +} +``` + +## Good + +```rust +use std::fmt; + +#[derive(Debug)] +struct Port(u16); + +#[derive(Debug)] +struct PortError(u32); + +impl fmt::Display for PortError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "port {} is out of range (0–65535)", self.0) + } +} + +impl std::error::Error for PortError {} + +impl TryFrom for Port { + type Error = PortError; + + fn try_from(value: u32) -> Result { + u16::try_from(value) + .map(Port) + .map_err(|_| PortError(value)) + } +} + +fn accept_port(n: u32) -> Result { + // Callers use the standard `.try_into()?` idiom + n.try_into() +} + +fn main() -> Result<(), Box> { + let p: Port = 8080_u32.try_into()?; + println!("port: {}", p.0); + Ok(()) +} +``` + +## Notes + +- Implement `TryFrom`, not `TryInto`; the blanket impl provides `TryInto` automatically (mirrors the `From`/`Into` relationship). +- Use a concrete error type, not `String` or `Box`, so callers can match on it. +- When the conversion is truly infallible, implement `From` instead. + +## See Also + +- [api-from-not-into](api-from-not-into.md) - implement `From`, not `Into`, for the same reason +- [conv-fromstr-parsing](conv-fromstr-parsing.md) - standard hook for string→type parsing +- [api-parse-dont-validate](api-parse-dont-validate.md) - parse into validated types at boundaries diff --git a/.opencode/skills/rust-skills/rules/doc-all-public.md b/.opencode/skills/rust-skills/rules/doc-all-public.md new file mode 100644 index 0000000..00f3df0 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-all-public.md @@ -0,0 +1,113 @@ +# doc-all-public + +> Document all public items with `///` doc comments + +## Why It Matters + +Public items define your crate's API contract. Without documentation, users must read source code to understand how to use your library. Well-documented APIs reduce support burden, improve adoption, and serve as the primary reference for users. + +Rust's `cargo doc` generates beautiful HTML documentation from doc comments, but only if you write them. + +## Bad + +```rust +pub struct Config { + pub timeout: Duration, + pub retries: u32, + pub base_url: String, +} + +pub fn connect(config: Config) -> Result { + // ... +} + +pub enum Status { + Pending, + Active, + Failed, +} +``` + +## Good + +```rust +/// Configuration for establishing a connection to the service. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Config; +/// use std::time::Duration; +/// +/// let config = Config { +/// timeout: Duration::from_secs(30), +/// retries: 3, +/// base_url: "https://api.example.com".to_string(), +/// }; +/// ``` +pub struct Config { + /// Maximum time to wait for a response before timing out. + pub timeout: Duration, + + /// Number of retry attempts for failed requests. + pub retries: u32, + + /// Base URL for all API requests. + pub base_url: String, +} + +/// Establishes a connection using the provided configuration. +/// +/// # Errors +/// +/// Returns an error if the connection cannot be established +/// or if the configuration is invalid. +pub fn connect(config: Config) -> Result { + // ... +} + +/// Represents the current status of a job. +pub enum Status { + /// Job is waiting to be processed. + Pending, + /// Job is currently being processed. + Active, + /// Job has failed and will not be retried. + Failed, +} +``` + +## What to Document + +| Item Type | Required Content | +|-----------|------------------| +| Structs | Purpose, usage example | +| Struct fields | What the field represents | +| Enums | When to use each variant | +| Enum variants | What state it represents | +| Functions | What it does, parameters, return value | +| Traits | Contract and expected behavior | +| Trait methods | Default implementation behavior | +| Type aliases | Why the alias exists | +| Constants | What the value represents | + +## Enforcement + +Enable the `missing_docs` lint to catch undocumented public items: + +```rust +#![warn(missing_docs)] +``` + +Or in `Cargo.toml` for workspace-wide enforcement: + +```toml +[workspace.lints.rust] +missing_docs = "warn" +``` + +## See Also + +- [doc-module-inner](./doc-module-inner.md) - Module-level documentation +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [lint-missing-docs](./lint-missing-docs.md) - Enforcing documentation diff --git a/.opencode/skills/rust-skills/rules/doc-cargo-metadata.md b/.opencode/skills/rust-skills/rules/doc-cargo-metadata.md new file mode 100644 index 0000000..482a8d9 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-cargo-metadata.md @@ -0,0 +1,147 @@ +# doc-cargo-metadata + +> Fill `Cargo.toml` metadata for published crates + +## Why It Matters + +Cargo.toml metadata appears on crates.io, in search results, and helps users evaluate your crate. Missing metadata makes your crate look unprofessional, harder to find, and harder to trust. Complete metadata improves discoverability and adoption. + +## Bad + +```toml +[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" + +[dependencies] +# ... +``` + +## Good + +```toml +[package] +name = "my-awesome-crate" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# Required for crates.io +description = "A fast, ergonomic HTTP client for Rust" +license = "MIT OR Apache-2.0" +repository = "https://github.com/username/my-awesome-crate" + +# Highly recommended +documentation = "https://docs.rs/my-awesome-crate" +readme = "README.md" +keywords = ["http", "client", "async", "networking"] +categories = ["network-programming", "web-programming::http-client"] +authors = ["Your Name "] +homepage = "https://my-awesome-crate.dev" + +# Optional but helpful +include = ["src/**/*", "Cargo.toml", "LICENSE*", "README.md"] +exclude = ["tests/fixtures/*", ".github/*"] + +[badges] +maintenance = { status = "actively-developed" } + +[dependencies] +# ... +``` + +## Required Fields for Publishing + +| Field | Purpose | +|-------|---------| +| `name` | Crate name on crates.io | +| `version` | Semver version | +| `license` or `license-file` | SPDX license identifier | +| `description` | One-line summary (≤256 chars) | + +## Recommended Fields + +| Field | Purpose | Example | +|-------|---------|---------| +| `repository` | Link to source code | `https://github.com/user/repo` | +| `documentation` | Link to docs | `https://docs.rs/crate` | +| `readme` | Path to README | `README.md` | +| `keywords` | Search terms (max 5) | `["http", "async"]` | +| `categories` | crates.io categories | `["network-programming"]` | +| `rust-version` | MSRV | `"1.70"` | + +## Keywords Best Practices + +```toml +# Good: specific, searchable terms +keywords = ["json", "serialization", "serde", "parsing"] + +# Bad: too generic or redundant +keywords = ["rust", "library", "awesome", "fast", "best"] +``` + +## Categories + +Choose from [crates.io categories](https://crates.io/category_slugs): + +```toml +categories = [ + "network-programming", + "web-programming::http-client", + "asynchronous", +] +``` + +## License Patterns + +```toml +# Single license +license = "MIT" + +# Dual license (common in Rust ecosystem) +license = "MIT OR Apache-2.0" + +# Custom license file +license-file = "LICENSE" +``` + +## Include/Exclude + +Control what gets published: + +```toml +# Explicit include (whitelist) +include = [ + "src/**/*", + "Cargo.toml", + "LICENSE*", + "README.md", + "CHANGELOG.md", +] + +# Or exclude (blacklist) +exclude = [ + "tests/fixtures/large-file.bin", + ".github/*", + "benches/*", +] +``` + +## Verification + +Check your package before publishing: + +```bash +# See what will be included +cargo package --list + +# Check metadata +cargo publish --dry-run +``` + +## See Also + +- [doc-module-inner](./doc-module-inner.md) - Crate-level documentation +- [lint-cargo-metadata](./lint-cargo-metadata.md) - Linting Cargo.toml +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace management diff --git a/.opencode/skills/rust-skills/rules/doc-crate-readme.md b/.opencode/skills/rust-skills/rules/doc-crate-readme.md new file mode 100644 index 0000000..7670812 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-crate-readme.md @@ -0,0 +1,75 @@ +# doc-crate-readme + +> Unify the README and crate root docs with `#![doc = include_str!("../README.md")]` + +## Why It Matters + +Maintaining a `README.md` and a separate crate-level doc comment in `lib.rs` leads to inevitable drift: the README gets updated for GitHub/crates.io visitors while the rustdoc front page grows stale, or vice versa. The `include_str!` attribute macro (stable since Rust 1.54) makes the README the single source of truth for both surfaces. Set `readme = "README.md"` in `Cargo.toml` so crates.io also picks up the same file. The result: one file, three consistent rendering targets — GitHub, crates.io, and docs.rs. + +## Bad + +```rust +// src/lib.rs — separate doc comment that will drift from README.md +//! # my-crate +//! +//! A library for doing things. (duplicate, will get out of date) +//! +//! ## Usage +//! ... + +pub fn do_thing() {} +``` + +```toml +# Cargo.toml — readme field absent; crates.io shows nothing +[package] +name = "my-crate" +version = "0.1.0" +edition = "2024" +``` + +## Good + +```rust +// src/lib.rs — README is the single source of truth +#![doc = include_str!("../README.md")] + +pub fn do_thing() {} +``` + +```toml +# Cargo.toml +[package] +name = "my-crate" +version = "0.1.0" +edition = "2024" +readme = "README.md" # crates.io landing page +documentation = "https://docs.rs/my-crate" +``` + +## Handling Non-Rust Code Blocks in README + +When the README contains code blocks that are not valid Rust, rustdoc will try to compile them as doc-tests and fail. Fix this by tagging those blocks: + +````markdown +```bash +cargo add my-crate +``` + +```text +output that should not be compiled +``` + +```rust,no_run +// example that should be shown but not executed +let x = long_running_operation(); +``` +```` + +For TOML or shell blocks already tagged with their language (` ```toml `, ` ```bash `), rustdoc ignores them automatically — no extra annotation needed. + +## See Also + +- [doc-module-inner](doc-module-inner.md) - use `//!` for module-level documentation +- [doc-cargo-metadata](doc-cargo-metadata.md) - fill Cargo.toml metadata fields +- [doc-all-public](doc-all-public.md) - document all public items with `///` diff --git a/.opencode/skills/rust-skills/rules/doc-errors-section.md b/.opencode/skills/rust-skills/rules/doc-errors-section.md new file mode 100644 index 0000000..2c861ec --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-errors-section.md @@ -0,0 +1,122 @@ +# doc-errors-section + +> Include `# Errors` section for fallible functions + +## Why It Matters + +Functions returning `Result` can fail in specific, documented ways. The `# Errors` section tells users exactly when and why a function might return an error, enabling them to handle failures appropriately without reading source code. + +This is especially critical for library code where users cannot easily inspect implementation details. + +## Bad + +```rust +/// Opens a file and reads its contents. +pub fn read_file(path: &Path) -> Result { + // Users have no idea what errors to expect +} + +/// Connects to the database. +pub async fn connect(url: &str) -> Result { + // Multiple failure modes, none documented +} +``` + +## Good + +```rust +/// Opens a file and reads its contents as a UTF-8 string. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file does not exist ([`Error::NotFound`]) +/// - The process lacks permission to read the file ([`Error::PermissionDenied`]) +/// - The file contains invalid UTF-8 ([`Error::InvalidUtf8`]) +pub fn read_file(path: &Path) -> Result { + // ... +} + +/// Establishes a connection to the database. +/// +/// # Errors +/// +/// This function will return an error if: +/// - The URL is malformed ([`DbError::InvalidUrl`]) +/// - The database server is unreachable ([`DbError::ConnectionFailed`]) +/// - Authentication fails ([`DbError::AuthenticationFailed`]) +/// - The connection pool is exhausted ([`DbError::PoolExhausted`]) +pub async fn connect(url: &str) -> Result { + // ... +} +``` + +## Error Documentation Patterns + +### Simple Single Error + +```rust +/// Parses a string as an integer. +/// +/// # Errors +/// +/// Returns [`ParseIntError`] if the string is not a valid integer. +pub fn parse_int(s: &str) -> Result { + s.parse() +} +``` + +### Multiple Error Variants + +```rust +/// Sends an HTTP request and returns the response. +/// +/// # Errors +/// +/// | Error | Condition | +/// |-------|-----------| +/// | [`HttpError::Timeout`] | Request exceeded timeout duration | +/// | [`HttpError::InvalidUrl`] | URL could not be parsed | +/// | [`HttpError::ConnectionRefused`] | Server refused connection | +/// | [`HttpError::TlsError`] | TLS handshake failed | +pub fn send(request: Request) -> Result { + // ... +} +``` + +### Propagated Errors + +```rust +/// Loads configuration from a file. +/// +/// # Errors +/// +/// Returns an error if: +/// - The configuration file cannot be read (IO error) +/// - The file contains invalid TOML syntax +/// - Required fields are missing from the configuration +/// +/// The underlying error is wrapped with context about which +/// configuration file failed to load. +pub fn load_config(path: &Path) -> Result { + // ... +} +``` + +## Linking to Error Types + +Use intra-doc links to connect error variants to their definitions: + +```rust +/// # Errors +/// +/// Returns [`ValidationError::TooShort`] if the input is less than +/// the minimum length, or [`ValidationError::InvalidChars`] if it +/// contains forbidden characters. +``` + +## See Also + +- [doc-panics-section](./doc-panics-section.md) - Documenting panics +- [err-doc-errors](./err-doc-errors.md) - Error documentation patterns +- [doc-intra-links](./doc-intra-links.md) - Linking to types diff --git a/.opencode/skills/rust-skills/rules/doc-examples-section.md b/.opencode/skills/rust-skills/rules/doc-examples-section.md new file mode 100644 index 0000000..c8036ec --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-examples-section.md @@ -0,0 +1,161 @@ +# doc-examples-section + +> Include `# Examples` with runnable code + +## Why It Matters + +Examples are the most valuable part of documentation. They show users exactly how to use your API. Rust's doc tests ensure examples stay correct as code evolves. + +## Bad + +```rust +/// Parses a string into a Foo. +pub fn parse(s: &str) -> Result { + // No examples - users have to guess usage +} + +/// A widget for doing things. +/// +/// This widget is very useful. +pub struct Widget { + // Still no examples +} +``` + +## Good + +```rust +/// Parses a string into a Foo. +/// +/// # Examples +/// +/// ``` +/// use my_crate::parse; +/// +/// let foo = parse("hello").unwrap(); +/// assert_eq!(foo.name(), "hello"); +/// ``` +/// +/// Handles empty strings: +/// +/// ``` +/// use my_crate::parse; +/// +/// let foo = parse("").unwrap(); +/// assert!(foo.is_empty()); +/// ``` +pub fn parse(s: &str) -> Result { + // ... +} +``` + +## Use ? Not unwrap() + +```rust +/// Loads configuration from a file. +/// +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// use my_crate::Config; +/// +/// let config = Config::load("config.toml")?; +/// println!("Port: {}", config.port); +/// # Ok(()) +/// # } +/// ``` +pub fn load(path: &str) -> Result { + // ... +} +``` + +## Hide Setup Code + +```rust +/// Processes items from a database. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Database, Item}; +/// # fn get_db() -> Database { Database::mock() } +/// let db = get_db(); +/// let items = db.process_items()?; +/// assert!(!items.is_empty()); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +pub fn process_items(&self) -> Result, Error> { + // ... +} +``` + +## Multiple Examples + +```rust +/// Creates a new buffer with the specified capacity. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ``` +/// use my_crate::Buffer; +/// +/// let buf = Buffer::with_capacity(1024); +/// assert_eq!(buf.capacity(), 1024); +/// ``` +/// +/// Zero capacity creates an empty buffer: +/// +/// ``` +/// use my_crate::Buffer; +/// +/// let buf = Buffer::with_capacity(0); +/// assert!(buf.is_empty()); +/// ``` +pub fn with_capacity(cap: usize) -> Self { + // ... +} +``` + +## Show Error Cases + +```rust +/// Divides two numbers. +/// +/// # Examples +/// +/// ``` +/// use my_crate::divide; +/// +/// assert_eq!(divide(10, 2), Ok(5)); +/// ``` +/// +/// Division by zero returns an error: +/// +/// ``` +/// use my_crate::{divide, MathError}; +/// +/// assert_eq!(divide(10, 0), Err(MathError::DivisionByZero)); +/// ``` +pub fn divide(a: i32, b: i32) -> Result { + // ... +} +``` + +## Running Doc Tests + +```bash +# Run all doc tests +cargo test --doc + +# Run doc tests for specific item +cargo test --doc my_function +``` + +## See Also + +- [doc-question-mark](doc-question-mark.md) - Use ? in examples +- [doc-hidden-setup](doc-hidden-setup.md) - Hide setup code with # +- [doc-errors-section](doc-errors-section.md) - Document error conditions diff --git a/.opencode/skills/rust-skills/rules/doc-hidden-setup.md b/.opencode/skills/rust-skills/rules/doc-hidden-setup.md new file mode 100644 index 0000000..5ef3b30 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-hidden-setup.md @@ -0,0 +1,149 @@ +# doc-hidden-setup + +> Use `# ` prefix to hide example setup code + +## Why It Matters + +Doc examples often require setup code (imports, struct initialization, mock data) that distracts from the main point. The `# ` prefix hides lines from rendered documentation while keeping them in the compiled test, showing users only the relevant code. + +This keeps examples focused and readable while ensuring they still compile and run. + +## Bad + +```rust +/// Processes a batch of items. +/// +/// # Examples +/// +/// ``` +/// use my_crate::{Processor, Config, Item}; +/// use std::sync::Arc; +/// +/// let config = Config { +/// batch_size: 100, +/// timeout_ms: 5000, +/// retry_count: 3, +/// }; +/// let processor = Processor::new(Arc::new(config)); +/// let items = vec![ +/// Item::new("a"), +/// Item::new("b"), +/// Item::new("c"), +/// ]; +/// +/// // This is the actual example - buried after 15 lines of setup +/// let results = processor.process_batch(&items)?; +/// assert!(results.all_succeeded()); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +pub fn process_batch(&self, items: &[Item]) -> Result { + // ... +} +``` + +## Good + +```rust +/// Processes a batch of items. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Processor, Config, Item, Error}; +/// # use std::sync::Arc; +/// # let config = Config { batch_size: 100, timeout_ms: 5000, retry_count: 3 }; +/// # let processor = Processor::new(Arc::new(config)); +/// # let items = vec![Item::new("a"), Item::new("b"), Item::new("c")]; +/// let results = processor.process_batch(&items)?; +/// assert!(results.all_succeeded()); +/// # Ok::<(), Error>(()) +/// ``` +pub fn process_batch(&self, items: &[Item]) -> Result { + // ... +} +``` + +Users see only: + +```rust +let results = processor.process_batch(&items)?; +assert!(results.all_succeeded()); +``` + +## What to Hide + +| Hide | Show | +|------|------| +| `use` statements | Core API usage | +| Type definitions | Method calls | +| Mock/test data setup | Key parameters | +| Error handling boilerplate | Return value handling | +| `Ok(())` return | Assertions (sometimes) | + +## Pattern: Hiding Multi-Line Setup + +```rust +/// # Examples +/// +/// ``` +/// # use my_crate::{Client, Request}; +/// # fn main() -> Result<(), Box> { +/// # let client = Client::builder() +/// # .timeout(30) +/// # .retry(3) +/// # .build()?; +/// let response = client.send(Request::get("/users"))?; +/// println!("Status: {}", response.status()); +/// # Ok(()) +/// # } +/// ``` +``` + +## Pattern: Showing Setup When Relevant + +Sometimes setup IS the point—don't hide it: + +```rust +/// Creates a new client with custom configuration. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Client; +/// +/// // Configuration IS the example - show it +/// let client = Client::builder() +/// .base_url("https://api.example.com") +/// .timeout_secs(30) +/// .max_retries(3) +/// .build()?; +/// # Ok::<(), my_crate::Error>(()) +/// ``` +``` + +## Pattern: `ignore` and `no_run` + +For examples that shouldn't run in tests: + +```rust +/// # Examples +/// +/// ```no_run +/// # use my_crate::Server; +/// // This would actually start a server - don't run in tests +/// let server = Server::bind("0.0.0.0:8080").await?; +/// server.run().await?; +/// # Ok::<(), my_crate::Error>(()) +/// ``` + +/// ```ignore +/// // Pseudocode or incomplete example +/// let magic = do_something_undefined(); +/// ``` +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Writing examples +- [doc-question-mark](./doc-question-mark.md) - Using `?` in examples +- [test-doctest-examples](./test-doctest-examples.md) - Doctests as tests diff --git a/.opencode/skills/rust-skills/rules/doc-intra-links.md b/.opencode/skills/rust-skills/rules/doc-intra-links.md new file mode 100644 index 0000000..47c693f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-intra-links.md @@ -0,0 +1,138 @@ +# doc-intra-links + +> Use intra-doc links to reference types and items + +## Why It Matters + +Intra-doc links (`[TypeName]`, `[method](Self::method)`) create clickable references in generated documentation. They're verified at doc-build time, catching broken links early. Unlike URL links, they automatically update when items are renamed or moved. + +## Bad + +```rust +/// Returns the length of the buffer. +/// +/// See also `capacity()` for the allocated size, and the +/// `Buffer` struct for more details. +pub fn len(&self) -> usize { + self.data.len() +} + +/// Parses the input using std::str::FromStr trait. +/// Check the Error enum for possible failures. +pub fn parse(input: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Returns the length of the buffer. +/// +/// See also [`capacity()`](Self::capacity) for the allocated size, and +/// [`Buffer`] for more details. +pub fn len(&self) -> usize { + self.data.len() +} + +/// Parses the input using [`FromStr`] trait. +/// Check [`Error`] for possible failures. +/// +/// [`FromStr`]: std::str::FromStr +pub fn parse(input: &str) -> Result { + // ... +} +``` + +## Link Syntax + +| Syntax | Links To | Example | +|--------|----------|---------| +| `[Name]` | Item in scope | `[Vec]`, `[Option]` | +| `[path::Name]` | Fully qualified item | `[std::vec::Vec]` | +| `[Self::method]` | Method on current type | `[Self::new]` | +| `[Type::method]` | Method on other type | `[String::new]` | +| `[Type::CONST]` | Associated constant | `[usize::MAX]` | +| `[text](path)` | Custom text | `[see here](Self::len)` | + +## Common Patterns + +### Linking to Self Members + +```rust +impl Buffer { + /// Creates an empty buffer. + /// + /// Use [`with_capacity`](Self::with_capacity) if you know the size. + pub fn new() -> Self { /* ... */ } + + /// Creates a buffer with pre-allocated capacity. + /// + /// See [`new`](Self::new) for the default constructor. + pub fn with_capacity(cap: usize) -> Self { /* ... */ } +} +``` + +### Linking to Trait Methods + +```rust +/// Converts to a string representation. +/// +/// This is the implementation of [`Display::fmt`](std::fmt::Display::fmt). +impl Display for MyType { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + // ... + } +} +``` + +### Disambiguation + +When names conflict, use suffixes: + +```rust +/// See [`foo()`](fn@foo) for the function and [`foo`](mod@foo) for the module. + +/// Works with [`Error`](struct@Error) struct or [`Error`](trait@Error) trait. +``` + +| Suffix | Item Type | +|--------|-----------| +| `fn@` | Function | +| `mod@` | Module | +| `struct@` | Struct | +| `enum@` | Enum | +| `trait@` | Trait | +| `type@` | Type alias | +| `const@` | Constant | +| `macro@` | Macro | + +### Reference-Style Links + +For repeated links or long paths: + +```rust +/// Parses using [`serde`] with [`Deserialize`] trait. +/// Returns a [`Result`] that may contain [`Error`]. +/// +/// [`serde`]: https://serde.rs +/// [`Deserialize`]: serde::Deserialize +/// [`Result`]: std::result::Result +/// [`Error`]: crate::Error +``` + +## Verification + +Enable link checking in CI: + +```bash +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps +``` + +This fails if any intra-doc links are broken. + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documenting public items +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [doc-errors-section](./doc-errors-section.md) - Documenting errors diff --git a/.opencode/skills/rust-skills/rules/doc-link-types.md b/.opencode/skills/rust-skills/rules/doc-link-types.md new file mode 100644 index 0000000..f2b5b0f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-link-types.md @@ -0,0 +1,169 @@ +# doc-link-types + +> Use intra-doc links to connect related types and functions + +## Why It Matters + +Intra-doc links (`[`TypeName`]`) create clickable references in generated documentation. They enable navigation between related items, verify that referenced items exist at compile time, and update automatically when items are renamed. Plain text references become stale and unclickable. + +## Bad + +```rust +/// Parses input and returns a ParseResult. +/// +/// See also: ParseError for error types. +/// Uses the Tokenizer internally. +pub fn parse(input: &str) -> ParseResult { + // "ParseResult", "ParseError", "Tokenizer" are not clickable + // No verification they exist +} +``` + +## Good + +```rust +/// Parses input and returns a [`ParseResult`]. +/// +/// # Errors +/// +/// Returns [`ParseError::InvalidSyntax`] if the input contains invalid tokens. +/// Returns [`ParseError::UnexpectedEof`] if the input ends prematurely. +/// +/// # Related +/// +/// - [`Tokenizer`] - The underlying tokenizer used by this parser +/// - [`parse_file`] - Convenience function for parsing files +/// - [`ParseOptions`] - Configuration options for parsing +pub fn parse(input: &str) -> ParseResult { + // All links are clickable and verified +} +``` + +## Link Syntax + +```rust +/// Basic link to type in same module +/// See [`MyType`] for details. + +/// Link to method +/// Use [`MyType::new`] to create instances. + +/// Link to associated type +/// Returns [`Iterator::Item`]. + +/// Link to module +/// See the [`parser`] module. + +/// Link to external crate type +/// Works with [`std::collections::HashMap`]. + +/// Link with custom text +/// See [the parser][`parse`] for details. + +/// Link to module item +/// See [`crate::utils::helper`]. + +/// Link to parent module item +/// See [`super::Parent`]. +``` + +## Common Patterns + +```rust +/// A configuration builder. +/// +/// # Example +/// +/// ``` +/// use my_crate::Config; +/// +/// let config = Config::builder() +/// .timeout(30) +/// .build()?; +/// ``` +/// +/// # Methods +/// +/// - [`Config::builder`] - Create a new builder +/// - [`Config::default`] - Create with defaults +/// +/// # Related Types +/// +/// - [`ConfigBuilder`] - The builder returned by [`Config::builder`] +/// - [`ConfigError`] - Errors that can occur when building +pub struct Config { ... } + +impl Config { + /// Creates a new [`ConfigBuilder`]. + /// + /// This is equivalent to [`ConfigBuilder::new`]. + pub fn builder() -> ConfigBuilder { ... } +} +``` + +## Linking to Trait Items + +```rust +/// Implements [`Iterator`] for lazy evaluation. +/// +/// The [`Iterator::next`] method advances the cursor. +/// +/// For parallel iteration, see [`rayon::ParallelIterator`]. +pub struct MyIterator { ... } + +impl Iterator for MyIterator { + /// Advances and returns the next value. + /// + /// See also [`Iterator::nth`] for skipping elements. + fn next(&mut self) -> Option { ... } +} +``` + +## Broken Link Detection + +```bash +# Catch broken intra-doc links +RUSTDOCFLAGS="-D warnings" cargo doc + +# Or in CI +cargo doc --no-deps 2>&1 | grep "warning: unresolved link" +``` + +```toml +# Cargo.toml - deny broken links +[lints.rustdoc] +broken_intra_doc_links = "deny" +``` + +## Module-Level Documentation + +```rust +//! # Parser Module +//! +//! This module provides parsing utilities. +//! +//! ## Main Types +//! +//! - [`Parser`] - The main parser struct +//! - [`Token`] - Tokens produced by tokenization +//! - [`Ast`] - The abstract syntax tree +//! +//! ## Functions +//! +//! - [`parse`] - Parse a string +//! - [`parse_file`] - Parse a file +//! +//! ## Errors +//! +//! All functions return [`ParseError`] on failure. + +pub struct Parser { ... } +pub enum Token { ... } +pub struct Ast { ... } +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Code examples in docs +- [err-doc-errors](./err-doc-errors.md) - Documenting errors +- [lint-deny-correctness](./lint-deny-correctness.md) - Lint settings diff --git a/.opencode/skills/rust-skills/rules/doc-module-inner.md b/.opencode/skills/rust-skills/rules/doc-module-inner.md new file mode 100644 index 0000000..9a4e028 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-module-inner.md @@ -0,0 +1,116 @@ +# doc-module-inner + +> Use `//!` for module-level documentation + +## Why It Matters + +Inner doc comments (`//!`) document the module itself, not the next item. They appear at the top of module files and describe the module's purpose, contents, and usage patterns. This helps users understand what a module provides before diving into individual items. + +Module docs are the first thing users see in `cargo doc` when navigating to a module. + +## Bad + +```rust +// This module handles authentication +// It provides JWT and session-based auth + +mod auth; + +pub use auth::*; +``` + +```rust +// auth.rs +/// Authentication utilities // Wrong: this documents nothing useful +use std::collections::HashMap; + +pub struct Session { /* ... */ } +``` + +## Good + +```rust +//! Authentication and authorization utilities. +//! +//! This module provides multiple authentication strategies: +//! +//! - [`JwtAuth`] - JSON Web Token based authentication +//! - [`SessionAuth`] - Cookie-based session authentication +//! - [`ApiKeyAuth`] - API key authentication for services +//! +//! # Examples +//! +//! ``` +//! use my_crate::auth::{JwtAuth, Authenticator}; +//! +//! let auth = JwtAuth::new("secret-key"); +//! let token = auth.generate_token(&user)?; +//! ``` +//! +//! # Feature Flags +//! +//! - `jwt` - Enables JWT authentication (enabled by default) +//! - `sessions` - Enables session-based authentication + +use std::collections::HashMap; + +pub struct Session { /* ... */ } +``` + +## Where to Use Inner Docs + +| Location | Purpose | +|----------|---------| +| `lib.rs` | Crate-level documentation (appears on crate root) | +| `mod.rs` | Module documentation for directory modules | +| `module.rs` | Module documentation for single-file modules | + +## Crate Root Example + +```rust +//! # My Awesome Crate +//! +//! `my_crate` provides utilities for handling complex workflows. +//! +//! ## Quick Start +//! +//! ```rust +//! use my_crate::prelude::*; +//! +//! let workflow = Workflow::builder() +//! .add_step(Step::new("fetch")) +//! .add_step(Step::new("process")) +//! .build(); +//! ``` +//! +//! ## Modules +//! +//! - [`workflow`] - Core workflow engine +//! - [`steps`] - Built-in workflow steps +//! - [`prelude`] - Common imports +//! +//! ## Feature Flags +//! +//! | Feature | Description | +//! |---------|-------------| +//! | `async` | Async workflow execution | +//! | `serde` | Serialization support | + +pub mod workflow; +pub mod steps; +pub mod prelude; +``` + +## Key Sections for Module Docs + +1. **Brief description** - One-line summary +2. **Overview** - What the module provides +3. **Examples** - How to use it +4. **Feature flags** - Optional functionality +5. **See Also** - Related modules + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documenting public items +- [doc-examples-section](./doc-examples-section.md) - Adding examples +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Crate metadata diff --git a/.opencode/skills/rust-skills/rules/doc-panics-section.md b/.opencode/skills/rust-skills/rules/doc-panics-section.md new file mode 100644 index 0000000..30a8b88 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-panics-section.md @@ -0,0 +1,128 @@ +# doc-panics-section + +> Include `# Panics` section for functions that can panic + +## Why It Matters + +Panics are exceptional conditions that crash the program (or unwind the stack). Users need to know when a function might panic so they can ensure preconditions are met or avoid the function in contexts where panics are unacceptable (e.g., `no_std`, embedded, FFI). + +If a function can panic, document exactly when. + +## Bad + +```rust +/// Returns the element at the given index. +pub fn get(index: usize) -> &T { + &self.data[index] // Panics if out of bounds - not documented! +} + +/// Divides two numbers. +pub fn divide(a: i32, b: i32) -> i32 { + a / b // Panics on division by zero - not documented! +} +``` + +## Good + +```rust +/// Returns the element at the given index. +/// +/// # Panics +/// +/// Panics if `index` is out of bounds (i.e., `index >= self.len()`). +/// +/// # Examples +/// +/// ``` +/// let v = vec![1, 2, 3]; +/// assert_eq!(v.get(1), &2); +/// ``` +pub fn get(&self, index: usize) -> &T { + &self.data[index] +} + +/// Divides two numbers. +/// +/// # Panics +/// +/// Panics if `divisor` is zero. +/// +/// For a non-panicking version, use [`checked_divide`]. +pub fn divide(dividend: i32, divisor: i32) -> i32 { + dividend / divisor +} + +/// Divides two numbers, returning `None` if the divisor is zero. +pub fn checked_divide(dividend: i32, divisor: i32) -> Option { + if divisor == 0 { + None + } else { + Some(dividend / divisor) + } +} +``` + +## Common Panic Conditions + +| Operation | Panic Condition | +|-----------|-----------------| +| Index access `[i]` | Index out of bounds | +| Division `/`, `%` | Division by zero | +| `.unwrap()` | `None` or `Err` value | +| `.expect()` | `None` or `Err` value | +| `slice::split_at(mid)` | `mid > len` | +| `Vec::remove(i)` | `i >= len` | +| Overflow (debug) | Integer overflow | + +## Pattern: Panic vs Return Error + +Document why you chose to panic vs return `Result`: + +```rust +/// Creates a new buffer with the given capacity. +/// +/// # Panics +/// +/// Panics if `capacity` is zero. A buffer must have at least +/// one byte of capacity. +/// +/// This panics rather than returning an error because a zero-capacity +/// buffer represents a programming error, not a runtime condition. +pub fn new(capacity: usize) -> Self { + assert!(capacity > 0, "capacity must be non-zero"); + // ... +} +``` + +## Pattern: Debug-Only Panics + +```rust +/// Adds an item to the collection. +/// +/// # Panics +/// +/// In debug builds, panics if the collection is at capacity. +/// In release builds, this is a no-op when at capacity. +pub fn push(&mut self, item: T) { + debug_assert!(self.len < self.capacity, "collection at capacity"); + // ... +} +``` + +## Provide Non-Panicking Alternatives + +When documenting a panicking function, point to safe alternatives: + +```rust +/// # Panics +/// +/// Panics if the index is out of bounds. +/// +/// For a non-panicking version, use [`get`] which returns `Option<&T>`. +``` + +## See Also + +- [doc-errors-section](./doc-errors-section.md) - Documenting errors +- [doc-safety-section](./doc-safety-section.md) - Documenting unsafe +- [err-result-over-panic](./err-result-over-panic.md) - Preferring Result diff --git a/.opencode/skills/rust-skills/rules/doc-question-mark.md b/.opencode/skills/rust-skills/rules/doc-question-mark.md new file mode 100644 index 0000000..6c85233 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-question-mark.md @@ -0,0 +1,136 @@ +# doc-question-mark + +> Use `?` in examples, not `.unwrap()` + +## Why It Matters + +Doc examples should model best practices. Using `.unwrap()` teaches users to ignore errors, while `?` demonstrates proper error propagation. Examples with `?` also fail the doctest if an error occurs, catching bugs in documentation. + +Rust doctests wrap examples in a function that returns `Result<(), E>` by default when you use `?`, making this pattern easy to adopt. + +## Bad + +```rust +/// Reads a configuration file. +/// +/// # Examples +/// +/// ``` +/// let config = Config::from_file("config.toml").unwrap(); +/// println!("{:?}", config.database_url); +/// ``` +pub fn from_file(path: &str) -> Result { + // ... +} + +/// Fetches data from the API. +/// +/// # Examples +/// +/// ``` +/// let client = Client::new(); +/// let response = client.get("https://api.example.com").unwrap(); +/// let data: Data = response.json().unwrap(); +/// ``` +pub async fn get(&self, url: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Reads a configuration file. +/// +/// # Examples +/// +/// ``` +/// # use my_crate::{Config, Error}; +/// # fn main() -> Result<(), Error> { +/// let config = Config::from_file("config.toml")?; +/// println!("{:?}", config.database_url); +/// # Ok(()) +/// # } +/// ``` +pub fn from_file(path: &str) -> Result { + // ... +} + +/// Fetches data from the API. +/// +/// # Examples +/// +/// ```no_run +/// # use my_crate::{Client, Data, Error}; +/// # async fn example() -> Result<(), Error> { +/// let client = Client::new(); +/// let response = client.get("https://api.example.com").await?; +/// let data: Data = response.json().await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn get(&self, url: &str) -> Result { + // ... +} +``` + +## Doctest Wrapper Pattern + +Rust wraps doc examples in a function. You can make this explicit: + +```rust +/// # Examples +/// +/// ``` +/// # fn main() -> Result<(), Box> { +/// let value = parse_config("key=value")?; +/// assert_eq!(value.key, "value"); +/// # Ok(()) +/// # } +/// ``` +``` + +Or use the implicit wrapper (Rust 2021+): + +```rust +/// # Examples +/// +/// ``` +/// # use my_crate::parse_config; +/// let value = parse_config("key=value")?; +/// assert_eq!(value.key, "value"); +/// # Ok::<(), my_crate::Error>(()) +/// ``` +``` + +## When to Use `.unwrap()` + +There are specific cases where `.unwrap()` is acceptable in examples: + +```rust +/// # Examples +/// +/// ``` +/// // Static regex that is known at compile time to be valid +/// let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); +/// +/// // Parsing a literal that cannot fail +/// let n: i32 = "42".parse().unwrap(); +/// ``` +``` + +But still prefer `?` when demonstrating error handling patterns. + +## Comparison + +| Pattern | Behavior on Error | Teaches | +|---------|-------------------|---------| +| `.unwrap()` | Panics with generic message | Bad habits | +| `.expect()` | Panics with custom message | Slightly better | +| `?` | Propagates error, test fails | Best practices | + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Writing examples +- [doc-hidden-setup](./doc-hidden-setup.md) - Hiding setup code +- [err-question-mark](./err-question-mark.md) - Error propagation diff --git a/.opencode/skills/rust-skills/rules/doc-safety-section.md b/.opencode/skills/rust-skills/rules/doc-safety-section.md new file mode 100644 index 0000000..52206f1 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/doc-safety-section.md @@ -0,0 +1,131 @@ +# doc-safety-section + +> Include `# Safety` section for unsafe functions + +## Why It Matters + +Unsafe functions require callers to uphold invariants that the compiler cannot verify. The `# Safety` section documents exactly what the caller must guarantee for the function to be sound. Without this, users cannot safely call the function. + +This is not optional—it's a requirement for sound unsafe code. + +## Bad + +```rust +/// Reads a value from a raw pointer. +pub unsafe fn read_ptr(ptr: *const T) -> T { + // What guarantees must the caller provide? Unknown! + ptr.read() +} + +/// Creates a string from raw parts. +pub unsafe fn string_from_raw(ptr: *mut u8, len: usize, cap: usize) -> String { + String::from_raw_parts(ptr, len, cap) +} +``` + +## Good + +```rust +/// Reads a value from a raw pointer. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `ptr` is valid for reads of `size_of::()` bytes +/// - `ptr` is properly aligned for type `T` +/// - `ptr` points to a properly initialized value of type `T` +/// - The memory referenced by `ptr` is not mutated during this call +pub unsafe fn read_ptr(ptr: *const T) -> T { + ptr.read() +} + +/// Creates a `String` from raw parts. +/// +/// # Safety +/// +/// The caller must guarantee that: +/// - `ptr` was allocated by the same allocator that `String` uses +/// - `len` is less than or equal to `cap` +/// - The first `len` bytes at `ptr` are valid UTF-8 +/// - `cap` is the capacity that `ptr` was allocated with +/// - No other code will use `ptr` after this call (ownership is transferred) +/// +/// Violating these requirements leads to undefined behavior including +/// memory corruption, use-after-free, or invalid UTF-8 in strings. +pub unsafe fn string_from_raw(ptr: *mut u8, len: usize, cap: usize) -> String { + String::from_raw_parts(ptr, len, cap) +} +``` + +## Key Elements of Safety Documentation + +| Element | Description | +|---------|-------------| +| **Preconditions** | What must be true before calling | +| **Pointer validity** | Alignment, null-ness, lifetime | +| **Memory ownership** | Who owns what, transfer semantics | +| **Invariants** | Type invariants that must hold | +| **Consequences** | What happens if violated | + +## Pattern: Unsafe Trait Implementations + +```rust +/// A type that can be safely zeroed. +/// +/// # Safety +/// +/// Implementing this trait guarantees that: +/// - All bit patterns of zeros represent a valid value of this type +/// - The type has no padding bytes that could leak data +/// - The type contains no references or pointers +pub unsafe trait Zeroable { + fn zeroed() -> Self; +} + +// SAFETY: u32 is a primitive integer type where all zero bits +// represent a valid value (0). +unsafe impl Zeroable for u32 { + fn zeroed() -> Self { + 0 + } +} +``` + +## Pattern: Unsafe Blocks in Safe Functions + +When a safe function contains unsafe blocks, document the invariants: + +```rust +/// Returns a reference to the element at the given index. +/// +/// Returns `None` if the index is out of bounds. +pub fn get(&self, index: usize) -> Option<&T> { + if index < self.len { + // SAFETY: We just verified that index < len, so this + // access is within bounds. + Some(unsafe { self.data.get_unchecked(index) }) + } else { + None + } +} +``` + +## Common Safety Requirements + +```rust +/// # Safety +/// +/// - Pointer must be non-null +/// - Pointer must be aligned to `align_of::()` +/// - Pointer must be valid for reads/writes of `size_of::()` bytes +/// - Pointer must point to an initialized value of `T` +/// - The referenced memory must not be accessed through any other pointer +/// for the duration of the returned reference +/// - The total size must not exceed `isize::MAX` +``` + +## See Also + +- [doc-panics-section](./doc-panics-section.md) - Documenting panics +- [lint-unsafe-doc](./lint-unsafe-doc.md) - Enforcing unsafe documentation +- [doc-errors-section](./doc-errors-section.md) - Documenting errors diff --git a/.opencode/skills/rust-skills/rules/err-anyhow-app.md b/.opencode/skills/rust-skills/rules/err-anyhow-app.md new file mode 100644 index 0000000..b9eca49 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-anyhow-app.md @@ -0,0 +1,179 @@ +# err-anyhow-app + +> Use `anyhow` for application error handling + +## Why It Matters + +Applications often don't need typed errors - they just need to report what went wrong with good context. `anyhow` provides easy error handling with context chaining, backtraces, and conversion from any error type. + +## Bad + +```rust +// Tedious type management +fn load_config() -> Result> { + let path = find_config()?; // Returns FindError + let content = std::fs::read_to_string(&path)?; // Returns io::Error + let config: Config = toml::from_str(&content)?; // Returns toml::Error + validate(&config)?; // Returns ValidationError + Ok(config) +} + +// No context - hard to debug +fn process() -> Result<(), Box> { + let data = fetch()?; // Which fetch failed? + transform(data)?; // What was being transformed? + save()?; // Where was it saving to? + Ok(()) +} +``` + +## Good + +```rust +use anyhow::{Context, Result}; + +fn load_config() -> Result { + let path = find_config() + .context("failed to locate config file")?; + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read config from {}", path.display()))?; + + let config: Config = toml::from_str(&content) + .context("failed to parse config as TOML")?; + + validate(&config) + .context("config validation failed")?; + + Ok(config) +} + +// Error message: "config validation failed: field 'port' must be > 0" +// Full chain preserved for debugging +``` + +## Key Features + +```rust +use anyhow::{anyhow, bail, ensure, Context, Result}; + +fn example() -> Result<()> { + // Create ad-hoc errors + let err = anyhow!("something went wrong"); + + // Early return with error + bail!("aborting due to {}", reason); + + // Assert with error + ensure!(condition, "condition was false"); + + // Add context to any error + risky_operation() + .context("risky operation failed")?; + + // Dynamic context + fetch(url) + .with_context(|| format!("failed to fetch {}", url))?; + + Ok(()) +} +``` + +## Main Function Pattern + +```rust +use anyhow::Result; + +fn main() -> Result<()> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} + +// Or with custom exit handling +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); // Pretty-print with causes + std::process::exit(1); + } +} + +fn run() -> Result<()> { + // Application logic + Ok(()) +} +``` + +## Error Display Formats + +```rust +use anyhow::Result; + +fn show_error(err: anyhow::Error) { + // Just the top-level message + println!("{}", err); + // "config validation failed" + + // With cause chain (# alternate format) + println!("{:#}", err); + // "config validation failed: field 'port' must be > 0" + + // Debug format with backtrace + println!("{:?}", err); + // Full backtrace if RUST_BACKTRACE=1 + + // Iterate through cause chain + for cause in err.chain() { + println!("Caused by: {}", cause); + } +} +``` + +## Combining with thiserror + +```rust +// In your library crate - typed errors +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("rate limited")] + RateLimited, + #[error("not found: {0}")] + NotFound(String), +} + +// In your application - anyhow for handling +use anyhow::{Context, Result}; + +fn fetch_user(id: u64) -> Result { + api::get_user(id) + .with_context(|| format!("failed to fetch user {}", id)) +} + +// Can still downcast if needed +fn handle_error(err: anyhow::Error) { + if let Some(api_err) = err.downcast_ref::() { + match api_err { + ApiError::RateLimited => wait_and_retry(), + ApiError::NotFound(id) => log_missing(id), + } + } +} +``` + +## When to Use Which + +| Situation | Use | +|-----------|-----| +| Library public API | `thiserror` | +| Application code | `anyhow` | +| CLI tools | `anyhow` | +| Internal library code | Either | +| Need to match error variants | `thiserror` | +| Just need to report errors | `anyhow` | + +## See Also + +- [err-thiserror-lib](err-thiserror-lib.md) - Use thiserror for libraries +- [err-context-chain](err-context-chain.md) - Add context to errors diff --git a/.opencode/skills/rust-skills/rules/err-context-chain.md b/.opencode/skills/rust-skills/rules/err-context-chain.md new file mode 100644 index 0000000..7ab41cc --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-context-chain.md @@ -0,0 +1,144 @@ +# err-context-chain + +> Add context with `.context()` or `.with_context()` + +## Why It Matters + +Raw errors often lack information about what operation failed. Adding context creates an error chain that tells the full story: what you were trying to do, and why it failed. + +## Bad + +```rust +// Raw error - no context +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + let content = std::fs::read_to_string(&path)?; + Ok(serde_json::from_str(&content)?) +} + +// Error message: "No such file or directory (os error 2)" +// Which file? What were we doing? +``` + +## Good + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read user file: {}", path))?; + + let user: User = serde_json::from_str(&content) + .with_context(|| format!("failed to parse user {} JSON", id))?; + + Ok(user) +} + +// Error: "failed to parse user 42 JSON" +// Caused by: "expected ':' at line 5 column 12" +``` + +## context() vs with_context() + +```rust +// context() - static string (slight allocation) +fs::read_to_string(path) + .context("failed to read config")?; + +// with_context() - lazy evaluation (only allocates on error) +fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path))?; + +// Use with_context() when: +// - Message includes runtime data (format!) +// - Computing the message is expensive +// - Error path is cold (most of the time) +``` + +## Building Context Chains + +```rust +fn process_order(order_id: u64) -> Result<()> { + let order = fetch_order(order_id) + .with_context(|| format!("failed to fetch order {}", order_id))?; + + let user = load_user(order.user_id) + .with_context(|| format!("failed to load user for order {}", order_id))?; + + let payment = process_payment(&order, &user) + .context("payment processing failed")?; + + ship_order(&order, &payment) + .context("shipping failed")?; + + Ok(()) +} + +// Full error chain: +// "shipping failed" +// Caused by: "carrier API returned 503" +// Caused by: "connection refused" +``` + +## Displaying Error Chains + +```rust +fn main() { + if let Err(e) = run() { + // Just top-level message + eprintln!("Error: {}", e); + + // Full chain with alternate format + eprintln!("Error: {:#}", e); + + // Debug format (includes backtrace if enabled) + eprintln!("Error: {:?}", e); + + // Iterate through chain + for (i, cause) in e.chain().enumerate() { + eprintln!(" {}: {}", i, cause); + } + } +} +``` + +## With thiserror + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("failed to load config from {path}")] + ConfigLoad { + path: String, + #[source] + cause: std::io::Error, + }, + + #[error("failed to connect to database")] + Database { + #[source] + cause: sqlx::Error, + }, +} + +// Usage +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::ConfigLoad { + path: path.to_string(), + cause: e, + })?; + // ... +} +``` + +## See Also + +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications +- [err-source-chain](err-source-chain.md) - Use #[source] to chain errors +- [err-question-mark](err-question-mark.md) - Use ? for propagation diff --git a/.opencode/skills/rust-skills/rules/err-custom-type.md b/.opencode/skills/rust-skills/rules/err-custom-type.md new file mode 100644 index 0000000..6dbaf84 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-custom-type.md @@ -0,0 +1,152 @@ +# err-custom-type + +> Define custom error types for domain-specific failures + +## Why It Matters + +Generic errors like `String`, `Box`, or catch-all enums obscure what can actually go wrong. Custom error types document failure modes in the type system, enable pattern matching for specific handling, and provide clear API contracts. They make your code self-documenting and help callers handle errors appropriately. + +## Bad + +```rust +// Generic string errors - no structure +fn validate_user(user: &User) -> Result<(), String> { + if user.name.is_empty() { + return Err("Name is empty".to_string()); + } + if user.age > 150 { + return Err("Age is invalid".to_string()); + } + Ok(()) +} + +// Caller can't match on specific errors +match validate_user(&user) { + Ok(()) => save(user), + Err(msg) => { + // Can only do string comparison - fragile! + if msg.contains("Name") { + prompt_for_name() + } + } +} +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ValidationError { + #[error("name cannot be empty")] + EmptyName, + + #[error("name exceeds maximum length of {max} characters")] + NameTooLong { max: usize, actual: usize }, + + #[error("invalid age {0}: must be between 0 and 150")] + InvalidAge(u8), + + #[error("email format is invalid: {0}")] + InvalidEmail(String), +} + +fn validate_user(user: &User) -> Result<(), ValidationError> { + if user.name.is_empty() { + return Err(ValidationError::EmptyName); + } + if user.name.len() > 100 { + return Err(ValidationError::NameTooLong { + max: 100, + actual: user.name.len() + }); + } + if user.age > 150 { + return Err(ValidationError::InvalidAge(user.age)); + } + Ok(()) +} + +// Caller can match specifically +match validate_user(&user) { + Ok(()) => save(user), + Err(ValidationError::EmptyName) => prompt_for_name(), + Err(ValidationError::InvalidAge(age)) => { + show_error(&format!("Please enter a valid age (you entered {})", age)) + } + Err(e) => show_error(&e.to_string()), +} +``` + +## Error Type Design Guidelines + +```rust +// 1. Group related errors in domain-specific enums +#[derive(Error, Debug)] +pub enum AuthError { + #[error("invalid credentials")] + InvalidCredentials, + #[error("account locked after {attempts} failed attempts")] + AccountLocked { attempts: u32 }, + #[error("token expired")] + TokenExpired, +} + +#[derive(Error, Debug)] +pub enum PaymentError { + #[error("insufficient funds: need {required}, have {available}")] + InsufficientFunds { required: Decimal, available: Decimal }, + #[error("card declined: {reason}")] + CardDeclined { reason: String }, +} + +// 2. Include relevant data for error handling/display +#[derive(Error, Debug)] +pub enum FileError { + #[error("file not found: {path}")] + NotFound { path: PathBuf }, + #[error("permission denied for {path}")] + PermissionDenied { path: PathBuf }, +} + +// 3. Consider #[non_exhaustive] for public APIs +#[derive(Error, Debug)] +#[non_exhaustive] // Allows adding variants without breaking changes +pub enum ApiError { + #[error("rate limited")] + RateLimited, + #[error("not found")] + NotFound, +} +``` + +## When to Use What + +| Error Pattern | Use Case | +|---------------|----------| +| Custom enum | Library with specific failure modes | +| `thiserror` | Libraries needing `std::error::Error` | +| `anyhow::Error` | Applications, prototypes | +| Struct with source | Single error type with wrapped cause | + +## Struct-Based Errors + +For single error types with rich context: + +```rust +#[derive(Error, Debug)] +#[error("query failed for table '{table}' with filter '{filter}'")] +pub struct QueryError { + pub table: String, + pub filter: String, + #[source] + pub source: DatabaseError, +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - thiserror for error definitions +- [err-anyhow-app](./err-anyhow-app.md) - When to use anyhow instead +- [api-non-exhaustive](./api-non-exhaustive.md) - Forward-compatible enums diff --git a/.opencode/skills/rust-skills/rules/err-doc-errors.md b/.opencode/skills/rust-skills/rules/err-doc-errors.md new file mode 100644 index 0000000..b1262a3 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-doc-errors.md @@ -0,0 +1,145 @@ +# err-doc-errors + +> Document error conditions with `# Errors` section in doc comments + +## Why It Matters + +Users of your API need to know what can go wrong and why. The `# Errors` documentation section is the standard Rust convention for describing when a function returns `Err`. Good error documentation helps callers handle errors appropriately and understand the contract of your API. + +## Bad + +```rust +/// Loads a configuration from the specified path. +pub fn load_config(path: &Path) -> Result { + // No documentation of error conditions + // Caller must read source code to understand what can fail +} + +/// Parses and validates the input string. +/// +/// Returns the parsed value. // What about errors? +pub fn parse_input(input: &str) -> Result { + // ... +} +``` + +## Good + +```rust +/// Loads a configuration from the specified path. +/// +/// # Errors +/// +/// Returns an error if: +/// - The file at `path` does not exist or cannot be read +/// - The file contents are not valid TOML +/// - Required configuration keys are missing +/// - Configuration values are out of valid ranges +/// +/// # Examples +/// +/// ``` +/// # use mylib::{load_config, ConfigError}; +/// # fn main() -> Result<(), ConfigError> { +/// let config = load_config("app.toml")?; +/// # Ok(()) +/// # } +/// ``` +pub fn load_config(path: &Path) -> Result { + // ... +} + +/// Parses and validates the input string as a positive integer. +/// +/// # Errors +/// +/// Returns [`ParseError::Empty`] if the input is empty. +/// Returns [`ParseError::InvalidFormat`] if the input contains non-digit characters. +/// Returns [`ParseError::Overflow`] if the value exceeds `i64::MAX`. +/// Returns [`ParseError::NotPositive`] if the value is zero or negative. +pub fn parse_positive_int(input: &str) -> Result { + // ... +} +``` + +## Linking to Error Variants + +```rust +/// Attempts to connect to the database. +/// +/// # Errors +/// +/// This function will return an error if: +/// +/// - [`DbError::ConnectionFailed`] - The database server is unreachable +/// - [`DbError::AuthenticationFailed`] - Invalid credentials +/// - [`DbError::Timeout`] - Connection attempt exceeded timeout +/// - [`DbError::TlsError`] - TLS handshake failed +/// +/// See [`DbError`] for more details on each variant. +pub fn connect(config: &DbConfig) -> Result { + // ... +} +``` + +## Panic vs Error Documentation + +```rust +/// Divides two numbers. +/// +/// # Errors +/// +/// Returns [`MathError::DivisionByZero`] if `divisor` is zero. +/// +/// # Panics +/// +/// Panics if called from a non-main thread (debug builds only). +pub fn divide(dividend: i64, divisor: i64) -> Result { + // ... +} +``` + +## Error Section Format Options + +```rust +// Style 1: Bullet list (good for multiple conditions) +/// # Errors +/// +/// Returns an error if: +/// - The file does not exist +/// - The file cannot be read +/// - The content is invalid UTF-8 + +// Style 2: Returns statements (good for mapping to variants) +/// # Errors +/// +/// Returns [`Error::NotFound`] if the item doesn't exist. +/// Returns [`Error::PermissionDenied`] if access is forbidden. + +// Style 3: Prose (good for complex conditions) +/// # Errors +/// +/// This function returns an error when the input fails validation. +/// Validation includes checking that all required fields are present, +/// that numeric fields are within allowed ranges, and that string +/// fields match their expected formats. +``` + +## Clippy Lint + +```toml +# Cargo.toml - require error documentation +[lints.clippy] +missing_errors_doc = "warn" +``` + +```rust +// This will warn without # Errors section +pub fn might_fail() -> Result<(), Error> { Ok(()) } +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Examples in documentation +- [err-thiserror-lib](./err-thiserror-lib.md) - Defining error types +- [api-must-use](./api-must-use.md) - Marking Results as must_use diff --git a/.opencode/skills/rust-skills/rules/err-expect-bugs-only.md b/.opencode/skills/rust-skills/rules/err-expect-bugs-only.md new file mode 100644 index 0000000..d437042 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-expect-bugs-only.md @@ -0,0 +1,133 @@ +# err-expect-bugs-only + +> Use `expect()` only for invariants that indicate bugs, not user errors + +## Why It Matters + +`expect()` is better than `unwrap()` because it provides context, but it still panics. Reserve it for situations where failure indicates a bug in your code—a violated invariant, not a user error or external failure. The message should explain why the invariant should hold, helping future developers understand and fix the bug. + +## Bad + +```rust +// User input can legitimately fail - don't expect +fn parse_user_input(input: &str) -> Config { + serde_json::from_str(input) + .expect("Invalid JSON") // User error, not a bug! +} + +// Network can fail - don't expect +fn fetch_data(url: &str) -> Data { + reqwest::get(url) + .expect("Network request failed") // External failure! + .json() + .expect("Invalid response") +} + +// File might not exist - don't expect +fn load_config() -> Config { + let content = fs::read_to_string("config.json") + .expect("Config file missing"); // Environment issue! +} +``` + +## Good + +```rust +// Invariant: after insert, key exists +fn cache_and_get(&mut self, key: String, value: Value) -> &Value { + self.cache.insert(key.clone(), value); + self.cache.get(&key) + .expect("BUG: key must exist immediately after insert") +} + +// Invariant: regex is compile-time constant +fn create_parser() -> Regex { + Regex::new(r"^\d{4}-\d{2}-\d{2}$") + .expect("BUG: date regex is invalid - this is a compile-time constant") +} + +// Invariant: already validated +fn process_validated(data: ValidatedData) -> Result { + let value = data.required_field + .expect("BUG: ValidatedData guarantees required_field is Some"); + // ... +} + +// Invariant: type system guarantees +fn get_first(vec: Vec) -> T +where + Vec: NonEmpty, // Hypothetical trait +{ + vec.into_iter().next() + .expect("BUG: NonEmpty Vec cannot be empty") +} +``` + +## expect() Message Guidelines + +Messages should: +1. Start with "BUG:" or similar to indicate it's an invariant +2. Explain WHY the invariant should hold +3. Help developers fix the issue + +```rust +// ❌ Bad messages +.expect("failed") // No context +.expect("should not be None") // Doesn't explain why +.expect("Invalid state") // Vague + +// ✅ Good messages +.expect("BUG: HashMap entry exists after insert") +.expect("BUG: validated input must parse - validation is broken") +.expect("BUG: static regex compilation failed - regex syntax error in source") +``` + +## Pattern: Validate Once, expect() After + +```rust +struct ValidatedEmail(String); + +impl ValidatedEmail { + pub fn new(email: &str) -> Result { + // Validation happens here, returns Result + if !is_valid_email(email) { + return Err(EmailError::Invalid); + } + Ok(ValidatedEmail(email.to_string())) + } + + pub fn domain(&self) -> &str { + // After validation, expect() is fine + self.0.split('@').nth(1) + .expect("BUG: ValidatedEmail must contain @") + } +} +``` + +## Alternatives When expect() Is Wrong + +```rust +// Don't: expect on user data +let port: u16 = input.parse().expect("Invalid port"); + +// Do: Return Result +let port: u16 = input.parse().map_err(|_| ConfigError::InvalidPort)?; + +// Do: Provide default +let port: u16 = input.parse().unwrap_or(8080); + +// Do: Handle explicitly +let port: u16 = match input.parse() { + Ok(p) => p, + Err(_) => { + log::warn!("Invalid port '{}', using default", input); + 8080 + } +}; +``` + +## See Also + +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoiding unwrap in production +- [err-result-over-panic](./err-result-over-panic.md) - When to return Result +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Type-driven validation diff --git a/.opencode/skills/rust-skills/rules/err-from-impl.md b/.opencode/skills/rust-skills/rules/err-from-impl.md new file mode 100644 index 0000000..b542c29 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-from-impl.md @@ -0,0 +1,153 @@ +# err-from-impl + +> Implement `From` for error conversions to enable `?` operator + +## Why It Matters + +The `?` operator automatically converts errors using `From` trait. By implementing `From for YourError`, you enable seamless error propagation without explicit `.map_err()` calls. This makes error handling code cleaner and ensures consistent error wrapping throughout your codebase. + +## Bad + +```rust +#[derive(Debug)] +enum AppError { + Io(std::io::Error), + Parse(serde_json::Error), + Database(diesel::result::Error), +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| AppError::Io(e))?; // Manual conversion everywhere + + let config: Config = serde_json::from_str(&content) + .map_err(|e| AppError::Parse(e))?; // Repeated boilerplate + + save_to_db(&config) + .map_err(|e| AppError::Database(e))?; // Gets tedious + + Ok(config) +} +``` + +## Good + +```rust +#[derive(Debug)] +enum AppError { + Io(std::io::Error), + Parse(serde_json::Error), + Database(diesel::result::Error), +} + +// Implement From for each source error type +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::Io(err) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + AppError::Parse(err) + } +} + +impl From for AppError { + fn from(err: diesel::result::Error) -> Self { + AppError::Database(err) + } +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // Auto-converts + let config: Config = serde_json::from_str(&content)?; // Clean! + save_to_db(&config)?; + Ok(config) +} +``` + +## Use thiserror for Automatic From + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), // Auto-generates From impl + + #[error("Parse error: {0}")] + Parse(#[from] serde_json::Error), // #[from] does the work + + #[error("Database error: {0}")] + Database(#[from] diesel::result::Error), +} + +// Now ? just works +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let config: Config = serde_json::from_str(&content)?; + save_to_db(&config)?; + Ok(config) +} +``` + +## From with Context + +Sometimes you need to add context during conversion: + +```rust +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config from '{path}': {source}")] + ReadFailed { + path: String, + #[source] + source: std::io::Error, + }, +} + +// Can't use #[from] when you need extra context +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|source| ConfigError::ReadFailed { + path: path.to_string(), + source, + })?; + // ... +} + +// Or use anyhow for ad-hoc context +use anyhow::{Context, Result}; + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config from '{}'", path))?; + // ... +} +``` + +## Blanket From Implementations + +Be careful with blanket implementations: + +```rust +// ❌ Too broad - conflicts with other From impls +impl From for AppError { + fn from(err: E) -> Self { + AppError::Other(err.to_string()) + } +} + +// ✅ Specific implementations +impl From for AppError { ... } +impl From for AppError { ... } +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Using thiserror for libraries +- [err-source-chain](./err-source-chain.md) - Preserving error chains +- [err-question-mark](./err-question-mark.md) - The ? operator +- [conv-tryfrom-fallible](./conv-tryfrom-fallible.md) - TryFrom for fallible conversions diff --git a/.opencode/skills/rust-skills/rules/err-lowercase-msg.md b/.opencode/skills/rust-skills/rules/err-lowercase-msg.md new file mode 100644 index 0000000..0ffd325 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-lowercase-msg.md @@ -0,0 +1,124 @@ +# err-lowercase-msg + +> Start error messages lowercase, no trailing punctuation + +## Why It Matters + +Error messages are often chained, logged, or displayed with additional context. Consistent formatting—lowercase start, no trailing period—allows clean composition: "failed to load config: invalid JSON: unexpected token". Mixed case and punctuation create awkward output: "Failed to load config.: Invalid JSON.: Unexpected token.". + +## Bad + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file.")] // Capital F, trailing period + ReadFailed(#[from] std::io::Error), + + #[error("Invalid JSON format!")] // Capital I, exclamation + ParseFailed(#[from] serde_json::Error), + + #[error("The requested key was not found")] // Reads like a sentence + KeyNotFound(String), +} + +// Chained output: "Config load error: Failed to read config file.: No such file" +// Awkward capitalization and punctuation +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("failed to read config file")] // lowercase, no period + ReadFailed(#[from] std::io::Error), + + #[error("invalid JSON format")] // lowercase, no period + ParseFailed(#[from] serde_json::Error), + + #[error("key not found: {0}")] // lowercase, data at end + KeyNotFound(String), +} + +// Chained output: "config load error: failed to read config file: no such file" +// Clean, consistent +``` + +## Rust Standard Library Convention + +The standard library follows this convention: + +```rust +// std::io::Error messages +"entity not found" +"permission denied" +"connection refused" + +// std::num::ParseIntError +"invalid digit found in string" + +// std::str::Utf8Error +"invalid utf-8 sequence" +``` + +## Formatting Guidelines + +| Do | Don't | +|----|-------| +| `"failed to parse config"` | `"Failed to parse config."` | +| `"invalid input: expected number"` | `"Invalid input - expected a number!"` | +| `"connection timed out after {0}s"` | `"Connection Timed Out After {0} seconds."` | +| `"key '{0}' not found"` | `"Key Not Found: {0}"` | + +## Context Addition Pattern + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let data = fetch(id) + .with_context(|| format!("failed to fetch user {}", id))?; + + parse_user(data) + .with_context(|| "failed to parse user data")? +} + +// Output: "failed to fetch user 42: connection refused" +// All lowercase, clean chain +``` + +## Display vs Debug + +```rust +#[derive(Error, Debug)] +#[error("invalid configuration")] // Display: for users/logs +pub struct ConfigError { + path: PathBuf, + source: io::Error, +} + +// Debug output (for developers) can have more detail +// Display output (for users) should be clean +``` + +## When to Use Capitals + +```rust +// Proper nouns / acronyms keep their case +#[error("invalid JSON syntax")] // JSON is an acronym +#[error("OAuth token expired")] // OAuth is a proper noun +#[error("HTTP request failed")] // HTTP is an acronym + +// Error codes can be uppercase +#[error("error code E0001: invalid input")] +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Error definition with thiserror +- [err-context-chain](./err-context-chain.md) - Adding context to errors +- [doc-examples-section](./doc-examples-section.md) - Documentation conventions diff --git a/.opencode/skills/rust-skills/rules/err-no-unwrap-prod.md b/.opencode/skills/rust-skills/rules/err-no-unwrap-prod.md new file mode 100644 index 0000000..87c8156 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-no-unwrap-prod.md @@ -0,0 +1,115 @@ +# err-no-unwrap-prod + +> Avoid `unwrap()` in production code; use `?`, `expect()`, or handle errors + +## Why It Matters + +`unwrap()` panics on `None` or `Err` without any context about what went wrong. In production, this creates cryptic crash messages that are hard to debug. Either propagate errors with `?`, use `expect()` with a message explaining the invariant, or handle the error explicitly. + +## Bad + +```rust +fn process_request(req: Request) -> Response { + let user_id = req.headers.get("X-User-Id").unwrap(); // Why did it fail? + let user = database.find_user(user_id).unwrap(); // Which operation? + let data = user.preferences.get("theme").unwrap(); // No context + + Response::new(data) +} + +// Crash message: "called `Option::unwrap()` on a `None` value" +// Where? Why? No idea. +``` + +## Good + +```rust +// Option 1: Propagate with ? +fn process_request(req: Request) -> Result { + let user_id = req.headers + .get("X-User-Id") + .ok_or(AppError::MissingHeader("X-User-Id"))?; + + let user = database.find_user(user_id)?; + + let data = user.preferences + .get("theme") + .ok_or(AppError::MissingPreference("theme"))?; + + Ok(Response::new(data)) +} + +// Option 2: expect() for invariants (not user input) +fn get_config_value(&self, key: &str) -> &str { + self.config + .get(key) + .expect("BUG: required config key missing after validation") +} + +// Option 3: Provide defaults +fn get_theme(user: &User) -> &str { + user.preferences + .get("theme") + .unwrap_or(&"default") +} + +// Option 4: Match for complex handling +fn process_optional(value: Option) -> ProcessedData { + match value { + Some(data) => process(data), + None => { + log::warn!("No data provided, using fallback"); + ProcessedData::default() + } + } +} +``` + +## `expect()` vs `unwrap()` + +```rust +// Bad: no context +let port = config.get("port").unwrap(); + +// Better: explains the invariant +let port = config.get("port") + .expect("config must contain 'port' after validation"); + +// Best: propagate if it's not truly an invariant +let port = config.get("port") + .ok_or_else(|| ConfigError::MissingKey("port"))?; +``` + +## Alternatives to unwrap() + +| Situation | Use Instead | +|-----------|-------------| +| Can propagate error | `?` operator | +| Has sensible default | `unwrap_or()`, `unwrap_or_default()` | +| Default requires computation | `unwrap_or_else(\|\| ...)` | +| Internal invariant | `expect("explanation")` | +| Need to handle both cases | `match` or `if let` | + +## Clippy Lints + +```toml +# Cargo.toml +[lints.clippy] +unwrap_used = "warn" # Warn on unwrap() +expect_used = "warn" # Also warn on expect() (stricter) +``` + +```rust +// Allow in specific places where it's justified +#[allow(clippy::unwrap_used)] +fn definitely_safe() { + // Unwrap is safe here because... + let x = Some(5).unwrap(); +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Return Result instead of panicking +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When expect() is appropriate +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - Patterns for avoiding unwrap diff --git a/.opencode/skills/rust-skills/rules/err-question-mark.md b/.opencode/skills/rust-skills/rules/err-question-mark.md new file mode 100644 index 0000000..2d5ec42 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-question-mark.md @@ -0,0 +1,151 @@ +# err-question-mark + +> Use `?` operator for clean propagation + +## Why It Matters + +The `?` operator is Rust's idiomatic way to propagate errors. It's concise, readable, and automatically converts between compatible error types using `From`. It replaces verbose `match` or `unwrap()` calls. + +## Bad + +```rust +// Verbose match-based error handling +fn load_config() -> Result { + let content = match std::fs::read_to_string("config.toml") { + Ok(c) => c, + Err(e) => return Err(Error::Io(e)), + }; + + let config = match toml::from_str(&content) { + Ok(c) => c, + Err(e) => return Err(Error::Parse(e)), + }; + + Ok(config) +} + +// Or worse - using unwrap +fn load_config_bad() -> Config { + let content = std::fs::read_to_string("config.toml").unwrap(); + toml::from_str(&content).unwrap() +} +``` + +## Good + +```rust +fn load_config() -> Result { + let content = std::fs::read_to_string("config.toml")?; + let config = toml::from_str(&content)?; + Ok(config) +} + +// Even more concise +fn load_config() -> Result { + Ok(toml::from_str(&std::fs::read_to_string("config.toml")?)?) +} +``` + +## How ? Works + +```rust +// This: +let x = expr?; + +// Expands roughly to: +let x = match expr { + Ok(val) => val, + Err(err) => return Err(From::from(err)), +}; +``` + +## Combining with Context + +```rust +use anyhow::{Context, Result}; + +fn load_user(id: u64) -> Result { + let path = format!("users/{}.json", id); + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read user file: {}", path))?; + + let user: User = serde_json::from_str(&content) + .context("failed to parse user JSON")?; + + Ok(user) +} +``` + +## ? with Option + +```rust +fn get_first_word(text: &str) -> Option<&str> { + let first_line = text.lines().next()?; + let first_word = first_line.split_whitespace().next()?; + Some(first_word) +} + +// Convert Option to Result +fn get_required_config(key: &str) -> Result { + config.get(key) + .cloned() + .ok_or_else(|| Error::MissingConfig(key.to_string())) +} +``` + +## Error Type Conversion + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum MyError { + #[error("io error")] + Io(#[from] std::io::Error), // Auto From impl + + #[error("parse error")] + Parse(#[from] serde_json::Error), // Auto From impl +} + +fn process() -> Result<(), MyError> { + // ? automatically converts io::Error to MyError via From + let content = std::fs::read_to_string("file.txt")?; + + // ? automatically converts serde_json::Error to MyError + let data: Data = serde_json::from_str(&content)?; + + Ok(()) +} +``` + +## In main() + +```rust +// Option 1: Return Result from main +fn main() -> Result<(), Box> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} + +// Option 2: Handle in main, exit on error +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); + std::process::exit(1); + } +} + +fn run() -> anyhow::Result<()> { + let config = load_config()?; + run_app(config)?; + Ok(()) +} +``` + +## See Also + +- [err-context-chain](err-context-chain.md) - Add context with .context() +- [err-from-impl](err-from-impl.md) - Use #[from] for automatic conversion +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications diff --git a/.opencode/skills/rust-skills/rules/err-result-over-panic.md b/.opencode/skills/rust-skills/rules/err-result-over-panic.md new file mode 100644 index 0000000..4e294d2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-result-over-panic.md @@ -0,0 +1,130 @@ +# err-result-over-panic + +> Return `Result` instead of panicking for recoverable errors + +## Why It Matters + +Panics unwind the stack and crash the thread (or program). They're unrecoverable from the caller's perspective. `Result` gives callers the ability to decide how to handle errors—retry, fallback, propagate, or log. Libraries should almost never panic; applications should minimize panics to truly unrecoverable situations. + +## Bad + +```rust +fn parse_config(path: &str) -> Config { + let content = std::fs::read_to_string(path) + .expect("Failed to read config"); // Crashes on missing file + + serde_json::from_str(&content) + .expect("Invalid config format") // Crashes on bad JSON +} + +fn divide(a: i32, b: i32) -> i32 { + if b == 0 { + panic!("Division by zero!"); // Crashes the program + } + a / b +} +``` + +Caller has no chance to recover or provide a fallback. + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file: {0}")] + Io(#[from] std::io::Error), + #[error("Invalid config format: {0}")] + Parse(#[from] serde_json::Error), +} + +fn parse_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let config = serde_json::from_str(&content)?; + Ok(config) +} + +fn divide(a: i32, b: i32) -> Result { + if b == 0 { + return Err("Division by zero"); + } + Ok(a / b) +} + +// Caller decides how to handle +match parse_config("app.json") { + Ok(config) => run_app(config), + Err(e) => { + eprintln!("Using default config: {}", e); + run_app(Config::default()) + } +} +``` + +## When Panic IS Appropriate + +```rust +// 1. Bug in the program (invariant violation) +fn get_cached_value(&self, key: &str) -> &Value { + self.cache.get(key).expect("BUG: key was verified to exist") +} + +// 2. Setup/initialization that can't reasonably fail +fn main() { + let config = Config::load().expect("Failed to load required config"); + // Can't run without config, panic is reasonable +} + +// 3. Tests +#[test] +fn test_parse() { + let result = parse("valid input").unwrap(); // unwrap OK in tests + assert_eq!(result, expected); +} + +// 4. Examples and prototypes +fn main() { + // Quick prototype, panic is fine + let data = fetch_data().unwrap(); +} +``` + +## Panic vs Result Decision Guide + +| Situation | Use | +|-----------|-----| +| File not found | `Result` | +| Network error | `Result` | +| Invalid user input | `Result` | +| Parse error | `Result` | +| Index out of bounds (from user data) | `Result` | +| Index out of bounds (internal bug) | Panic | +| Violated internal invariant | Panic | +| Unimplemented code path | Panic (`unimplemented!()`) | +| Impossible state reached | Panic (`unreachable!()`) | + +## Library vs Application + +```rust +// Library: NEVER panic on user input +pub fn parse(input: &str) -> Result { + // Always return Result +} + +// Application: Can panic at top level for critical failures +fn main() { + if let Err(e) = run() { + eprintln!("Fatal error: {}", e); + std::process::exit(1); + } +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Define error types for libraries +- [err-anyhow-app](./err-anyhow-app.md) - Ergonomic errors for applications +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Avoid unwrap in production code +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - When unwrap is acceptable diff --git a/.opencode/skills/rust-skills/rules/err-source-chain.md b/.opencode/skills/rust-skills/rules/err-source-chain.md new file mode 100644 index 0000000..c6390c1 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-source-chain.md @@ -0,0 +1,155 @@ +# err-source-chain + +> Preserve error chains with `#[source]` or `source()` method + +## Why It Matters + +Errors often have underlying causes. Preserving the error chain (via `source()` method) allows logging frameworks and error reporters to show the full context: "config parse failed → JSON syntax error at line 5 → unexpected token". Without chaining, you lose valuable debugging information. + +## Bad + +```rust +#[derive(Debug)] +enum ConfigError { + ParseFailed(String), // Lost the original serde_json::Error +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::ParseFailed(e.to_string()))?; // Chain lost! + + serde_json::from_str(&content) + .map_err(|e| ConfigError::ParseFailed(e.to_string()))? // No source +} + +// Error output: "Parse failed: invalid type: ..." +// Missing: which file? what line? what was the parent error? +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum ConfigError { + #[error("Failed to read config file '{path}'")] + ReadFailed { + path: String, + #[source] // Preserves the error chain + source: std::io::Error, + }, + + #[error("Failed to parse config file '{path}'")] + ParseFailed { + path: String, + #[source] // Original parse error preserved + source: serde_json::Error, + }, +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|source| ConfigError::ReadFailed { + path: path.to_string(), + source, // Chain preserved + })?; + + serde_json::from_str(&content) + .map_err(|source| ConfigError::ParseFailed { + path: path.to_string(), + source, + }) +} +``` + +## Manual source() Implementation + +```rust +use std::error::Error; + +#[derive(Debug)] +struct MyError { + message: String, + source: Option>, +} + +impl std::fmt::Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl Error for MyError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source.as_ref().map(|e| e.as_ref() as &(dyn Error + 'static)) + } +} +``` + +## Walking the Error Chain + +```rust +fn print_error_chain(error: &dyn std::error::Error) { + eprintln!("Error: {}", error); + + let mut source = error.source(); + while let Some(err) = source { + eprintln!("Caused by: {}", err); + source = err.source(); + } +} + +// With anyhow, use {:?} for full chain +let result: anyhow::Result<()> = do_something(); +if let Err(e) = result { + eprintln!("{:?}", e); // Prints full chain with backtraces +} +``` + +## anyhow Context + +```rust +use anyhow::{Context, Result}; + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read '{}'", path))?; + + let config: Config = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse '{}'", path))?; + + Ok(config) +} + +// Output: +// Error: Failed to parse 'config.json' +// Caused by: expected `:` at line 5 column 10 +``` + +## #[from] vs #[source] + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +enum MyError { + // #[from] = implements From + sets source + #[error("IO error")] + Io(#[from] std::io::Error), + + // #[source] = only sets source (no From impl) + #[error("Parse error in file '{path}'")] + Parse { + path: String, + #[source] + source: serde_json::Error, + }, +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - thiserror for error definitions +- [err-context-chain](./err-context-chain.md) - Adding context to errors +- [err-from-impl](./err-from-impl.md) - From implementations for ? diff --git a/.opencode/skills/rust-skills/rules/err-thiserror-lib.md b/.opencode/skills/rust-skills/rules/err-thiserror-lib.md new file mode 100644 index 0000000..6a7d0c8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/err-thiserror-lib.md @@ -0,0 +1,171 @@ +# err-thiserror-lib + +> Use `thiserror` for library error types + +## Why It Matters + +Libraries should expose typed, matchable errors so users can handle specific error conditions. `thiserror` generates `Error` trait implementations with minimal boilerplate, creating ergonomic error types that are easy to match against. + +## Bad + +```rust +// String errors - not matchable +fn parse(input: &str) -> Result { + Err("parse error".to_string()) +} + +// Box - not matchable +fn load(path: &Path) -> Result> { + Err(Box::new(std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"))) +} + +// Manual implementation - verbose +#[derive(Debug)] +enum MyError { + Io(std::io::Error), + Parse(String), +} + +impl std::fmt::Display for MyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MyError::Io(e) => write!(f, "io error: {}", e), + MyError::Parse(s) => write!(f, "parse error: {}", s), + } + } +} + +impl std::error::Error for MyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + MyError::Io(e) => Some(e), + MyError::Parse(_) => None, + } + } +} +``` + +## Good + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ParseError { + #[error("invalid syntax at line {line}: {message}")] + Syntax { line: usize, message: String }, + + #[error("unexpected end of file")] + UnexpectedEof, + + #[error("invalid utf-8 encoding")] + Utf8(#[from] std::str::Utf8Error), + + #[error("io error reading input")] + Io(#[from] std::io::Error), +} + +// Usage +fn parse(input: &str) -> Result { + if input.is_empty() { + return Err(ParseError::UnexpectedEof); + } + // ... +} + +// Users can match specific errors +match parse(input) { + Ok(ast) => process(ast), + Err(ParseError::Syntax { line, message }) => { + eprintln!("Syntax error on line {}: {}", line, message); + } + Err(ParseError::UnexpectedEof) => { + eprintln!("File ended unexpectedly"); + } + Err(e) => eprintln!("Error: {}", e), +} +``` + +## Key Attributes + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MyError { + // Simple message + #[error("operation failed")] + Failed, + + // Interpolated fields + #[error("invalid value: {0}")] + InvalidValue(String), + + // Named fields + #[error("connection to {host}:{port} failed")] + Connection { host: String, port: u16 }, + + // Automatic From impl with #[from] + #[error("database error")] + Database(#[from] sqlx::Error), + + // Source without From (manual conversion needed) + #[error("validation failed")] + Validation { + #[source] + cause: ValidationError, + field: String, + }, + + // Transparent - delegates Display and source to inner + #[error(transparent)] + Other(#[from] anyhow::Error), +} +``` + +## Error Chaining + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("failed to read config file")] + Read(#[source] std::io::Error), + + #[error("failed to parse config")] + Parse(#[source] toml::de::Error), + + #[error("invalid config value for '{key}'")] + InvalidValue { + key: String, + #[source] + cause: ValueError, + }, +} + +// Error chain is preserved +fn load_config(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(ConfigError::Read)?; + + let config: Config = toml::from_str(&content) + .map_err(ConfigError::Parse)?; + + Ok(config) +} +``` + +## Library vs Application + +| Context | Crate | Why | +|---------|-------|-----| +| Library | `thiserror` | Typed errors users can match | +| Application | `anyhow` | Easy error handling with context | +| Both | `thiserror` for public API, `anyhow` internally | Best of both | + +## See Also + +- [err-anyhow-app](err-anyhow-app.md) - Use anyhow for applications +- [err-from-impl](err-from-impl.md) - Use #[from] for automatic conversion +- [err-source-chain](err-source-chain.md) - Use #[source] to chain errors diff --git a/.opencode/skills/rust-skills/rules/lint-cargo-metadata.md b/.opencode/skills/rust-skills/rules/lint-cargo-metadata.md new file mode 100644 index 0000000..8e5b16a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-cargo-metadata.md @@ -0,0 +1,138 @@ +# lint-cargo-metadata + +> Enable clippy::cargo for published crates + +## Why It Matters + +The `clippy::cargo` lint group checks Cargo.toml for issues that affect publishing and dependency management. For crates intended for crates.io, these checks help ensure a professional, well-configured package. + +## Configuration + +```toml +# Cargo.toml +[lints.clippy] +cargo = "warn" +``` + +Or in code: + +```rust +#![warn(clippy::cargo)] +``` + +## What It Catches + +### Missing Metadata + +```toml +# WARN: missing package.description +# WARN: missing package.license or package.license-file +# WARN: missing package.repository +[package] +name = "my-crate" +version = "0.1.0" +``` + +### Dependency Issues + +```toml +# WARN: feature used but not defined +# WARN: dependency version not specified +[dependencies] +serde = "*" # Bad: any version +tokio = { git = "..." } # WARN for published crates +``` + +### Feature Issues + +```toml +# WARN: negative_feature_names +[features] +no-std = [] # Should be: std = [] (opt-out vs opt-in) + +# WARN: redundant_feature_names +[features] +default = ["feature-a"] +feature-a = [] # Feature name matches crate name +``` + +## Notable Lints + +| Lint | Issue | +|------|-------| +| `cargo_common_metadata` | Missing description/license/repository | +| `multiple_crate_versions` | Same crate at different versions | +| `negative_feature_names` | Features like `no-std` instead of `std` | +| `redundant_feature_names` | Feature same as crate name | +| `wildcard_dependencies` | Using `*` for version | + +## Complete Cargo.toml + +```toml +[package] +name = "my-crate" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +# Required for cargo lint satisfaction +description = "A short description of what this crate does" +license = "MIT OR Apache-2.0" +repository = "https://github.com/user/my-crate" + +# Recommended +documentation = "https://docs.rs/my-crate" +readme = "README.md" +keywords = ["keyword1", "keyword2"] +categories = ["category-slug"] + +[dependencies] +# Specific versions, not wildcards +serde = "1.0" +tokio = { version = "1.0", features = ["full"] } + +[features] +default = ["std"] +std = [] # Opt-out, not no-std opt-in + +[lints.clippy] +cargo = "warn" +``` + +## Multiple Crate Versions + +``` +# WARN: multiple versions of `syn` in dependency tree +# syn v1.0.109 +# syn v2.0.48 +``` + +Fix by updating dependencies or using `[patch]`: + +```toml +[patch.crates-io] +old-dep = { git = "...", branch = "syn-2" } +``` + +## When to Disable + +For internal/unpublished crates: + +```toml +[lints.clippy] +cargo = "allow" # Not publishing, metadata not needed +``` + +Or selectively: + +```toml +[lints.clippy] +cargo = "warn" +multiple_crate_versions = "allow" # Acceptable in this project +``` + +## See Also + +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Cargo.toml metadata +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace dependencies +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints diff --git a/.opencode/skills/rust-skills/rules/lint-cfg-check.md b/.opencode/skills/rust-skills/rules/lint-cfg-check.md new file mode 100644 index 0000000..89085bb --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-cfg-check.md @@ -0,0 +1,69 @@ +# lint-cfg-check + +> Enable `unexpected_cfgs` and declare known cfgs to catch feature-gate typos + +## Why It Matters + +A typo in a cfg expression — `#[cfg(feature = "serde_")]` instead of `"serde"`, or `#[cfg(tokio_unstable)]` with no declaration — compiles silently and produces dead code or an always-disabled feature. The `unexpected_cfgs` lint (stabilized in Rust 1.80) flags any cfg name or value not known to the compiler. Cargo automatically teaches the compiler about your declared feature names; for custom cfgs (well-known ones like `tokio_unstable`, or your own) you must declare them via `check-cfg` in the `[lints.rust]` table. This catches bugs at compile time that would otherwise be invisible. + +## Bad + +```rust +// Typo: "serde_" will never match the "serde" feature. +// Compiles with no warning — the block is silently dead. +#[cfg(feature = "serde_")] +impl serde::Serialize for MyType {} + +// Custom cfg used without declaration — also silently ignored. +#[cfg(tokio_unstable)] +pub fn experimental() {} +``` + +## Good + +```toml +# Cargo.toml — declare custom cfgs in the lints table +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(tokio_unstable)', + 'cfg(coverage_nightly)', +] } +``` + +```rust +// Now "serde_" typo → compiler warning: unexpected `cfg` condition value +// and tokio_unstable is a known cfg, so it compiles cleanly. +#[cfg(feature = "serde")] // correct +impl serde::Serialize for MyType {} + +#[cfg(tokio_unstable)] // declared above — no warning +pub fn experimental() {} +``` + +## Workspace Projects + +Configure the lint once at the workspace level and inherit it in member crates: + +```toml +# Cargo.toml (workspace root) +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(tokio_unstable)', +] } + +# member/Cargo.toml +[lints] +workspace = true +``` + +## Notes + +- Feature names (`feature = "serde"`, `feature = "std"`) are registered automatically by Cargo — you do not need to list them in `check-cfg`. +- Values in `check-cfg` entries must be quoted cfg expressions: `'cfg(name)'` or `'cfg(name, values("v1", "v2"))'`. +- The lint fires at `warn` level; promote to `deny` in CI if you want hard failures. + +## See Also + +- [lint-workspace-lints](lint-workspace-lints.md) - configure lints at workspace level +- [proj-feature-additive](proj-feature-additive.md) - design features to be strictly additive +- [lint-warn-suspicious](lint-warn-suspicious.md) - enable suspicious lint group diff --git a/.opencode/skills/rust-skills/rules/lint-clippy-nursery-selected.md b/.opencode/skills/rust-skills/rules/lint-clippy-nursery-selected.md new file mode 100644 index 0000000..bd7de47 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-clippy-nursery-selected.md @@ -0,0 +1,75 @@ +# lint-clippy-nursery-selected + +> Enable high-value `clippy::nursery` lints selectively, not the whole group + +## Why It Matters + +The `clippy::nursery` group contains lints that are correct and useful but still being refined — their suggestions may be noisy or have edge cases that haven't been polished yet. Enabling the entire group (`#![warn(clippy::nursery)]`) floods you with false positives and creates churn as nursery lints graduate or change. Cherry-picking individual lints gives you the signal without the noise. Several nursery lints are especially valuable: `significant_drop_tightening` catches lock guards held across `.await` or longer than necessary, `redundant_clone` flags clones that could be moves, and `use_self` keeps type names DRY inside impl blocks. + +## Bad + +```toml +# Cargo.toml — enables every nursery lint, including noisy ones +[lints.clippy] +nursery = "warn" +``` + +## Good + +```toml +# Cargo.toml — selectively enable high-value nursery lints +[lints.clippy] +# Catches lock/guard held longer than necessary (overlaps with async issues) +significant_drop_tightening = "warn" +# Flags .clone() calls that could be avoided by moving +redundant_clone = "warn" +# Replace TypeName with Self inside impl blocks +use_self = "warn" +# Avoid redundant else after a diverging if +redundant_else = "warn" +# Prefer or_default() over or(Default::default()) +or_fun_call = "warn" +``` + +```rust +// significant_drop_tightening example — lint fires here: +fn process(state: &Mutex>) -> usize { + let guard = state.lock().unwrap(); + let len = guard.len(); + drop(guard); // lint suggests dropping earlier, before the return + expensive_work(); + len +} + +// use_self example — lint fires here: +impl MyStruct { + fn new() -> MyStruct { // should be -> Self + MyStruct { value: 0 } + } +} + +// Correct: +impl MyStruct { + fn new() -> Self { + Self { value: 0 } + } +} +``` + +## Suggested Starter Set + +| Lint | What it catches | +|------|----------------| +| `significant_drop_tightening` | Guards/locks held longer than needed | +| `redundant_clone` | `.clone()` where a move suffices | +| `use_self` | Type name repeated inside `impl` block | +| `redundant_else` | `else` after diverging `if` branch | +| `or_fun_call` | `or(Default::default())` → `or_default()` | + +Start with this set. Add more only after reviewing what they flag in your codebase. + +## See Also + +- [lint-pedantic-selective](lint-pedantic-selective.md) - same strategy for clippy::pedantic +- [lint-warn-perf](lint-warn-perf.md) - enable the performance lint group +- [anti-lock-across-await](anti-lock-across-await.md) - don't hold locks across `.await` diff --git a/.opencode/skills/rust-skills/rules/lint-deny-correctness.md b/.opencode/skills/rust-skills/rules/lint-deny-correctness.md new file mode 100644 index 0000000..bf6074d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-deny-correctness.md @@ -0,0 +1,107 @@ +# lint-deny-correctness + +> `#![deny(clippy::correctness)]` + +## Why It Matters + +Clippy's correctness lints catch code that is outright wrong - logic errors, undefined behavior, or code that doesn't do what you think. These should always be errors, not warnings. + +## Setup + +```rust +// At the top of lib.rs or main.rs +#![deny(clippy::correctness)] + +// Or in Cargo.toml for workspace-wide +[lints.clippy] +correctness = "deny" +``` + +## What It Catches + +```rust +// Infinite loop (iter::repeat without take) +for x in std::iter::repeat(1) { // ERROR: infinite iterator + println!("{}", x); +} + +// Comparison to NaN (always false) +if x == f64::NAN { // ERROR: NaN != NaN always + // This never executes +} + +// Use after free patterns +let r; +{ + let x = 5; + r = &x; // ERROR: x dropped here +} +println!("{}", r); + +// Wrong equality check +if x = 5 { // ERROR: assignment in condition (should be ==) +} + +// Useless comparisons +if x >= 0 && x < 0 { // ERROR: impossible condition +} +``` + +## Important Correctness Lints + +```rust +// approx_constant - using imprecise PI, E values +let pi = 3.14; // Use std::f64::consts::PI + +// invalid_regex - regex that won't compile +let re = Regex::new("["); // Invalid regex + +// iter_next_loop - using .next() in for loop incorrectly +for x in iter.next() { // Should be: for x in iter + +// never_loop - loop that never actually loops +loop { + break; // Always breaks immediately +} + +// nonsensical_open_options - impossible file options +File::options().read(false).write(false).open("f"); + +// unit_cmp - comparing unit type () +if foo() == bar() { } // Both return (), always true +``` + +## Full Recommended Lints + +```rust +#![deny(clippy::correctness)] +#![warn(clippy::suspicious)] +#![warn(clippy::style)] +#![warn(clippy::complexity)] +#![warn(clippy::perf)] + +// For published crates +#![warn(missing_docs)] +#![warn(clippy::cargo)] +``` + +## Running Clippy + +```bash +# Basic check +cargo clippy + +# With all warnings as errors +cargo clippy -- -D warnings + +# Check specific lint category +cargo clippy -- -W clippy::correctness + +# In CI (fail on warnings) +cargo clippy -- -D warnings -D clippy::correctness +``` + +## See Also + +- [lint-warn-suspicious](lint-warn-suspicious.md) - Warn on suspicious code +- [lint-warn-perf](lint-warn-perf.md) - Warn on performance issues diff --git a/.opencode/skills/rust-skills/rules/lint-missing-docs.md b/.opencode/skills/rust-skills/rules/lint-missing-docs.md new file mode 100644 index 0000000..17fa262 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-missing-docs.md @@ -0,0 +1,154 @@ +# lint-missing-docs + +> Warn on missing documentation for public items + +## Why It Matters + +The `missing_docs` lint ensures all public API items are documented. For libraries, documentation IS the user interface. Missing docs mean users can't understand your API without reading source code. + +## Configuration + +```rust +// In lib.rs +#![warn(missing_docs)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.rust] +missing_docs = "warn" +``` + +For strict enforcement: + +```rust +#![deny(missing_docs)] +``` + +## What It Catches + +```rust +#![warn(missing_docs)] + +pub struct User { // WARN: missing documentation for a struct + pub name: String, // WARN: missing documentation for a field + pub age: u32, // WARN: missing documentation for a field +} + +pub fn process() { } // WARN: missing documentation for a function + +pub trait Handler { // WARN: missing documentation for a trait + fn handle(&self); // WARN: missing documentation for a method +} +``` + +## Good + +```rust +#![warn(missing_docs)] + +//! User management module. + +/// Represents a registered user in the system. +pub struct User { + /// The user's display name. + pub name: String, + /// The user's age in years. + pub age: u32, +} + +/// Processes pending user requests. +/// +/// # Examples +/// +/// ``` +/// process(); +/// ``` +pub fn process() { } + +/// Handler trait for request processing. +pub trait Handler { + /// Handle an incoming request. + fn handle(&self); +} +``` + +## Private Items + +`missing_docs` only applies to `pub` items. Private items don't trigger warnings: + +```rust +#![warn(missing_docs)] + +struct Internal { } // No warning - private + +pub struct Public { } // WARN - public, needs docs +``` + +## Allow for Specific Items + +```rust +#![warn(missing_docs)] + +/// Documented module. +pub mod api { + /// Documented struct. + pub struct Config { } + + #[allow(missing_docs)] + pub mod internal { + // Internal API, docs not required + pub struct Helper { } + } +} +``` + +## Gradual Adoption + +For existing codebases, start with `warn` and fix incrementally: + +```rust +// Phase 1: Warn, fix critical items +#![warn(missing_docs)] + +// Phase 2: After cleanup, deny +#![deny(missing_docs)] +``` + +## Combining with doc Attributes + +```rust +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![warn(rustdoc::private_intra_doc_links)] +``` + +## Workspace Configuration + +```toml +# In workspace Cargo.toml +[workspace.lints.rust] +missing_docs = "warn" + +# Member crates inherit +[lints] +workspace = true +``` + +## What to Document + +| Item | Doc Focus | +|------|-----------| +| Structs | Purpose, usage example | +| Struct fields | What it represents | +| Enums | When to use each variant | +| Functions | What it does, params, return | +| Traits | Contract and expectations | +| Modules | What the module provides | + +## See Also + +- [doc-all-public](./doc-all-public.md) - Documentation patterns +- [lint-unsafe-doc](./lint-unsafe-doc.md) - Unsafe documentation +- [doc-examples-section](./doc-examples-section.md) - Adding examples diff --git a/.opencode/skills/rust-skills/rules/lint-pedantic-selective.md b/.opencode/skills/rust-skills/rules/lint-pedantic-selective.md new file mode 100644 index 0000000..896e9fc --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-pedantic-selective.md @@ -0,0 +1,118 @@ +# lint-pedantic-selective + +> Enable clippy::pedantic selectively + +## Why It Matters + +The `clippy::pedantic` group contains opinionated lints that aren't universally applicable. Enabling it wholesale produces noise; selectively enabling useful pedantic lints improves code quality without false positives. + +## Bad + +```rust +// Too noisy - will fight you constantly +#![warn(clippy::pedantic)] +``` + +## Good + +```toml +# Cargo.toml - cherry-pick useful pedantic lints +[lints.clippy] +# Enable pedantic as baseline +pedantic = "warn" + +# Disable noisy ones +missing_errors_doc = "allow" # Document errors separately +missing_panics_doc = "allow" # Document panics separately +module_name_repetitions = "allow" # Allow Foo::FooError pattern +too_many_lines = "allow" # Function length varies +must_use_candidate = "allow" # Too many suggestions +``` + +## Recommended Pedantic Lints + +| Lint | Why Enable | +|------|-----------| +| `doc_markdown` | Catch unmarked code in docs | +| `match_wildcard_for_single_variants` | Explicit variant matching | +| `semicolon_if_nothing_returned` | Consistent semicolons | +| `string_add_assign` | Use `+=` for string concatenation | +| `unnested_or_patterns` | Simplify match patterns | +| `unused_self` | Catch methods that should be functions | +| `used_underscore_binding` | Warn on using `_var` | +| `wildcard_imports` | Avoid glob imports | + +## Often Disabled + +| Lint | Why Disable | +|------|-------------| +| `missing_errors_doc` | Handle with `#[doc]` policy | +| `missing_panics_doc` | Handle with `#[doc]` policy | +| `module_name_repetitions` | Sometimes intentional | +| `must_use_candidate` | Too aggressive | +| `too_many_lines` | Arbitrary threshold | +| `struct_excessive_bools` | Valid for config structs | + +## Full Configuration + +```toml +# Cargo.toml +[lints.clippy] +# Start with pedantic +pedantic = "warn" + +# Keep these +doc_markdown = "warn" +match_wildcard_for_single_variants = "warn" +semicolon_if_nothing_returned = "warn" +unused_self = "warn" +wildcard_imports = "warn" + +# Disable these +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +must_use_candidate = "allow" +too_many_lines = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +``` + +## Alternative: Explicit Opt-in + +```toml +# Only enable specific lints, not the group +[lints.clippy] +# From pedantic, only these: +doc_markdown = "warn" +semicolon_if_nothing_returned = "warn" +unused_self = "warn" +wildcard_imports = "warn" +``` + +## Module-Level Overrides + +```rust +// Allow specific lint for a module +#![allow(clippy::module_name_repetitions)] + +// Or for specific items +#[allow(clippy::too_many_arguments)] +fn complex_function(/* many args */) { } +``` + +## Team Consensus + +Pedantic lints are style choices. Agree as a team: + +1. Enable `pedantic` as baseline +2. Run `cargo clippy` on codebase +3. Discuss each warning category +4. Disable ones that don't fit your style +5. Document decisions in `clippy.toml` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints diff --git a/.opencode/skills/rust-skills/rules/lint-rustfmt-check.md b/.opencode/skills/rust-skills/rules/lint-rustfmt-check.md new file mode 100644 index 0000000..6c7f9e6 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-rustfmt-check.md @@ -0,0 +1,157 @@ +# lint-rustfmt-check + +> Run cargo fmt --check in CI + +## Why It Matters + +Consistent formatting eliminates style debates and makes diffs cleaner. Running `cargo fmt --check` in CI ensures all code follows the same format. This catches formatting issues before merge, not after. + +## CI Configuration + +### GitHub Actions + +```yaml +name: CI + +on: [push, pull_request] + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check +``` + +### GitLab CI + +```yaml +fmt: + image: rust:latest + script: + - rustup component add rustfmt + - cargo fmt --all --check +``` + +### Pre-commit Hook + +```bash +#!/bin/sh +# .git/hooks/pre-commit +cargo fmt --all --check +``` + +## Configuration + +Create `rustfmt.toml` for custom settings: + +```toml +# rustfmt.toml +edition = "2021" +max_width = 100 +use_small_heuristics = "Max" +imports_granularity = "Module" +group_imports = "StdExternalCrate" +reorder_imports = true +``` + +## Common Options + +| Option | Default | Description | +|--------|---------|-------------| +| `max_width` | 100 | Maximum line width | +| `tab_spaces` | 4 | Spaces per indent | +| `edition` | "2015" | Rust edition | +| `use_small_heuristics` | "Default" | Layout heuristics | +| `imports_granularity` | "Preserve" | Import grouping | +| `group_imports` | "Preserve" | Import ordering | + +## Running Locally + +```bash +# Check formatting (doesn't modify files) +cargo fmt --all --check + +# Apply formatting +cargo fmt --all + +# Format specific file +cargo fmt -- src/main.rs + +# Check with verbose output +cargo fmt --all --check -- --verbose +``` + +## Workspace Formatting + +```bash +# Format all workspace members +cargo fmt --all + +# Format specific package +cargo fmt -p my-package +``` + +## Ignoring Files + +In `rustfmt.toml`: + +```toml +# Skip generated files +ignore = [ + "src/generated/*", + "build.rs", +] +``` + +Or in code: + +```rust +#[rustfmt::skip] +mod generated_code; + +#[rustfmt::skip] +const MATRIX: [[i32; 4]; 4] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], +]; +``` + +## Nightly Features + +Some options require nightly: + +```toml +# rustfmt.toml (nightly only) +unstable_features = true +imports_granularity = "Crate" +wrap_comments = true +format_code_in_doc_comments = true +``` + +```bash +# Use nightly rustfmt +cargo +nightly fmt +``` + +## IDE Integration + +Most IDEs format on save. Configure to use project `rustfmt.toml`: + +```json +// VS Code settings.json +{ + "rust-analyzer.rustfmt.extraArgs": ["--config-path", "./rustfmt.toml"] +} +``` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style lints +- [lint-pedantic-selective](./lint-pedantic-selective.md) - Pedantic lints +- [name-funcs-snake](./name-funcs-snake.md) - Naming conventions diff --git a/.opencode/skills/rust-skills/rules/lint-unsafe-doc.md b/.opencode/skills/rust-skills/rules/lint-unsafe-doc.md new file mode 100644 index 0000000..87112ed --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-unsafe-doc.md @@ -0,0 +1,133 @@ +# lint-unsafe-doc + +> Require documentation for unsafe blocks + +## Why It Matters + +The `undocumented_unsafe_blocks` lint ensures every unsafe block has a `// SAFETY:` comment explaining why the operation is sound. Unsafe code is the source of most memory safety bugs—documenting invariants catches mistakes and helps reviewers. + +## Configuration + +```rust +#![warn(clippy::undocumented_unsafe_blocks)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "warn" +``` + +For strict enforcement: + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "deny" +``` + +## Bad + +```rust +pub fn read_data(ptr: *const u8, len: usize) -> &[u8] { + unsafe { + std::slice::from_raw_parts(ptr, len) // WARN: undocumented + } +} + +impl Buffer { + pub fn get_unchecked(&self, index: usize) -> &u8 { + unsafe { self.data.get_unchecked(index) } // WARN + } +} +``` + +## Good + +```rust +pub fn read_data(ptr: *const u8, len: usize) -> &[u8] { + // SAFETY: Caller guarantees: + // - ptr is valid for reads of len bytes + // - ptr is properly aligned for u8 + // - the memory is initialized + // - no mutable references exist to this memory + unsafe { + std::slice::from_raw_parts(ptr, len) + } +} + +impl Buffer { + pub fn get_unchecked(&self, index: usize) -> &u8 { + debug_assert!(index < self.len(), "index out of bounds"); + // SAFETY: We verified index < len in debug builds. + // Callers must ensure index is within bounds. + unsafe { self.data.get_unchecked(index) } + } +} +``` + +## SAFETY Comment Format + +```rust +// SAFETY: +unsafe { + // ... +} +``` + +The comment should explain: +1. **What invariants are upheld** - preconditions that make this safe +2. **Why the invariants hold** - how you know they're satisfied +3. **What could go wrong** - if invariants are violated + +## Examples by Category + +### Pointer Operations + +```rust +// SAFETY: ptr was obtained from Box::into_raw, so it's valid +// and properly aligned. We're taking back ownership. +let boxed = unsafe { Box::from_raw(ptr) }; +``` + +### Unchecked Operations + +```rust +// SAFETY: We just checked that i < self.len() above. +// The bounds check cannot be elided by the optimizer +// because len() is not inlined. +unsafe { self.data.get_unchecked(i) } +``` + +### FFI Calls + +```rust +// SAFETY: libc::getenv is safe to call with a null-terminated +// string. We ensure null termination with CString::new. +// The returned pointer is valid for the lifetime of the environment. +let value = unsafe { libc::getenv(key.as_ptr()) }; +``` + +### Trait Implementations + +```rust +// SAFETY: MyType contains no pointers or interior mutability, +// and all bit patterns are valid MyType values. +unsafe impl Send for MyType {} +unsafe impl Sync for MyType {} +``` + +## Related Lints + +```toml +[lints.clippy] +undocumented_unsafe_blocks = "warn" +# Also consider: +multiple_unsafe_ops_per_block = "warn" # One operation per block +``` + +## See Also + +- [doc-safety-section](./doc-safety-section.md) - `# Safety` in docs +- [lint-deny-correctness](./lint-deny-correctness.md) - Correctness lints +- [type-repr-transparent](./type-repr-transparent.md) - FFI safety diff --git a/.opencode/skills/rust-skills/rules/lint-warn-complexity.md b/.opencode/skills/rust-skills/rules/lint-warn-complexity.md new file mode 100644 index 0000000..dd88b9c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-warn-complexity.md @@ -0,0 +1,131 @@ +# lint-warn-complexity + +> Enable clippy::complexity for simpler code + +## Why It Matters + +The `clippy::complexity` lint group identifies unnecessarily complex code that can be simplified. Complex code is harder to read, maintain, and often hides bugs. Clippy suggests cleaner alternatives. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::complexity)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +complexity = "warn" +``` + +## What It Catches + +### Unnecessary Complexity + +```rust +// WARN: Overly complex boolean expression +if !(x == 0) { } // Use: if x != 0 { } + +// WARN: Manual implementation of Option::map +match option { + Some(x) => Some(x + 1), + None => None, +} // Use: option.map(|x| x + 1) + +// WARN: Unnecessary filter before count +iter.filter(|x| predicate(x)).count() // Could simplify if only counting +``` + +### Redundant Operations + +```rust +// WARN: Redundant allocation +let s = format!("literal"); // Use: "literal".to_string() or just "literal" + +// WARN: Unnecessarily complicated match +match result { + Ok(ok) => Ok(ok), + Err(err) => Err(err), +} // Just use: result + +// WARN: Box::new in return position +fn make_error() -> Box { + Box::new(MyError) // Could use: MyError.into() +} +``` + +### Overly Verbose Code + +```rust +// WARN: bind_instead_of_map +option.and_then(|x| Some(x + 1)) // Use: option.map(|x| x + 1) + +// WARN: clone_on_copy +let y = x.clone(); // Where x is Copy type, just use: let y = x; + +// WARN: useless_let_if_seq +let result; +if condition { + result = 1; +} else { + result = 2; +} +// Use: let result = if condition { 1 } else { 2 }; +``` + +## Notable Lints in This Group + +| Lint | Simplification | +|------|---------------| +| `bind_instead_of_map` | Use `map` instead of `and_then(Some(...))` | +| `bool_comparison` | `if x == true` → `if x` | +| `clone_on_copy` | Remove `.clone()` for Copy types | +| `filter_next` | Use `.find()` instead | +| `option_map_unit_fn` | Use `if let` instead | +| `search_is_some` | Use `.any()` or `.contains()` | +| `unnecessary_cast` | Remove redundant casts | +| `useless_conversion` | Remove `.into()` when types match | + +## Examples + +```rust +// Before (complexity warnings) +fn find_positive(nums: &[i32]) -> Option { + let filtered: Vec<_> = nums.iter() + .cloned() + .filter(|x| *x > 0) + .collect(); + if filtered.len() == 0 { + None + } else { + Some(filtered[0]) + } +} + +// After (simplified) +fn find_positive(nums: &[i32]) -> Option { + nums.iter() + .copied() + .find(|&x| x > 0) +} +``` + +## Cognitive Load + +Complex code isn't just longer—it's harder to understand: + +```rust +// High cognitive load +let value = if x.is_some() { x.unwrap() } else { y.unwrap_or(z) }; + +// Lower cognitive load +let value = x.unwrap_or_else(|| y.unwrap_or(z)); +``` + +## See Also + +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-perf](./lint-warn-perf.md) - Performance warnings +- [lint-pedantic-selective](./lint-pedantic-selective.md) - Pedantic lints diff --git a/.opencode/skills/rust-skills/rules/lint-warn-perf.md b/.opencode/skills/rust-skills/rules/lint-warn-perf.md new file mode 100644 index 0000000..93ee454 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-warn-perf.md @@ -0,0 +1,136 @@ +# lint-warn-perf + +> Enable clippy::perf for performance improvements + +## Why It Matters + +The `clippy::perf` lint group catches performance anti-patterns—inefficient allocations, unnecessary copies, suboptimal API usage. While not all performance issues are critical, avoiding obvious inefficiencies is good practice. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::perf)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +perf = "warn" +``` + +## What It Catches + +### Unnecessary Allocations + +```rust +// WARN: Unnecessary to_string before into +fn take_string(s: impl Into) { } +take_string("hello".to_string()); // Just use: "hello" + +// WARN: Box::new in return with deref coercion +fn make_trait() -> Box { + Box::new(concrete) // Could use Into +} + +// WARN: Unnecessary vec! for iteration +for x in vec![1, 2, 3] { } // Use array: [1, 2, 3] +``` + +### Inefficient Operations + +```rust +// WARN: Single-character string patterns +s.starts_with("x") // Use char: 'x' +s.contains("a") // Use char: 'a' + +// WARN: iter().nth(0) instead of first() +iter.nth(0) // Use: iter.first() or iter.next() + +// WARN: Manual saturating arithmetic +if x > i32::MAX - y { i32::MAX } else { x + y } +// Use: x.saturating_add(y) +``` + +### Collection Inefficiencies + +```rust +// WARN: extend with a single element +vec.extend(std::iter::once(item)); // Use: vec.push(item) + +// WARN: Inefficient to_vec +slice.iter().cloned().collect::>() // Use: slice.to_vec() + +// WARN: Manual string concatenation +let s = format!("{}{}", a, b); // When both are &str, use: a.to_owned() + b +``` + +## Notable Lints in This Group + +| Lint | Improvement | +|------|-------------| +| `box_collection` | Use `Vec` not `Box>` | +| `iter_nth` | Use `.get(n)` or `.next()` | +| `large_enum_variant` | Box large variants | +| `manual_memcpy` | Use slice copy methods | +| `redundant_allocation` | Remove double boxing | +| `single_char_pattern` | Use `char` not `&str` | +| `slow_vector_initialization` | Use `vec![0; n]` | +| `unnecessary_to_owned` | Remove redundant `.to_owned()` | + +## Examples + +```rust +// Before (perf warnings) +fn process(input: &str) -> String { + let parts: Vec<_> = input.split(",").collect(); + let mut result = String::new(); + for part in parts.iter() { + if part.starts_with(" ") { + result = result + &part.trim().to_string(); + } + } + result +} + +// After (optimized) +fn process(input: &str) -> String { + input.split(',') + .filter(|part| part.starts_with(' ')) + .map(str::trim) + .collect() +} +``` + +## Allocation Patterns + +```rust +// Unnecessary allocation +let vec: Vec = vec![]; // Creates capacity +let vec: Vec = Vec::new(); // No allocation + +// Pre-allocation +let mut vec = Vec::with_capacity(100); // One allocation +for i in 0..100 { + vec.push(i); // No reallocation +} +``` + +## String Patterns + +```rust +// Slow: str pattern +s.contains("x"); +s.find("y"); + +// Fast: char pattern +s.contains('x'); +s.find('y'); +``` + +## See Also + +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation +- [perf-profile-first](./perf-profile-first.md) - Profile before optimizing diff --git a/.opencode/skills/rust-skills/rules/lint-warn-style.md b/.opencode/skills/rust-skills/rules/lint-warn-style.md new file mode 100644 index 0000000..4e017cd --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-warn-style.md @@ -0,0 +1,135 @@ +# lint-warn-style + +> Enable clippy::style for idiomatic code + +## Why It Matters + +The `clippy::style` lint group enforces idiomatic Rust patterns. While not bugs, style violations make code harder to read and maintain. Consistent style helps teams work together and makes code easier to review. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::style)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +style = "warn" +``` + +## What It Catches + +### Redundant Code + +```rust +// WARN: Redundant clone on Copy type +let x = 5; +let y = x.clone(); // Just use: let y = x; + +// WARN: Redundant closure +iter.map(|x| foo(x)) // Just use: iter.map(foo) + +// WARN: Redundant pattern matching +match result { + Ok(x) => Ok(x), + Err(e) => Err(e), +} // Just return result +``` + +### Non-Idiomatic Patterns + +```rust +// WARN: Should use if let +match option { + Some(x) => do_something(x), + None => {}, +} +// Better: if let Some(x) = option { do_something(x) } + +// WARN: Should use or_else +let value = if option.is_some() { + option.unwrap() +} else { + default() +}; +// Better: option.unwrap_or_else(default) + +// WARN: Collapsible if statements +if condition1 { + if condition2 { + do_something(); + } +} +// Better: if condition1 && condition2 { do_something() } +``` + +### Naming Issues + +```rust +// WARN: Function should not start with 'is_' returning non-bool +fn is_valid() -> i32 { 0 } // Misleading name + +// WARN: Method should not be named 'new' without returning Self +impl Foo { + fn new() -> Bar { Bar } // Confusing +} +``` + +## Notable Lints in This Group + +| Lint | Better Pattern | +|------|---------------| +| `len_zero` | Use `is_empty()` instead of `len() == 0` | +| `redundant_field_names` | Use shorthand `{ x }` not `{ x: x }` | +| `unused_unit` | Remove `-> ()` and trailing `()` | +| `collapsible_if` | Combine nested ifs with `&&` | +| `single_match` | Use `if let` instead | +| `match_like_matches_macro` | Use `matches!()` macro | +| `needless_return` | Remove explicit `return` at end | +| `question_mark` | Use `?` instead of `match` | + +## Examples + +```rust +// Before (style warnings) +fn process(data: Vec) -> Option { + if data.len() == 0 { + return None; + } + let first = match data.first() { + Some(x) => x, + None => return None, + }; + return Some(*first); +} + +// After (idiomatic) +fn process(data: Vec) -> Option { + if data.is_empty() { + return None; + } + let first = data.first()?; + Some(*first) +} +``` + +## Selective Allowance + +Some style lints may conflict with team preferences: + +```rust +// If your team prefers explicit returns +#[allow(clippy::needless_return)] +fn explicit_return() -> i32 { + return 42; +} +``` + +## See Also + +- [lint-warn-suspicious](./lint-warn-suspicious.md) - Suspicious patterns +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings +- [lint-rustfmt-check](./lint-rustfmt-check.md) - Formatting checks diff --git a/.opencode/skills/rust-skills/rules/lint-warn-suspicious.md b/.opencode/skills/rust-skills/rules/lint-warn-suspicious.md new file mode 100644 index 0000000..2172ebc --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-warn-suspicious.md @@ -0,0 +1,122 @@ +# lint-warn-suspicious + +> Enable clippy::suspicious for likely bugs + +## Why It Matters + +The `clippy::suspicious` lint group catches code patterns that are syntactically valid but almost always wrong. These are potential bugs that deserve investigation. Enabling this group as a warning helps catch mistakes early. + +## Configuration + +```rust +// In lib.rs or main.rs +#![warn(clippy::suspicious)] +``` + +Or in `Cargo.toml`: + +```toml +[lints.clippy] +suspicious = "warn" +``` + +Or in `clippy.toml`: + +```toml +warn = ["clippy::suspicious"] +``` + +## What It Catches + +### Suspicious Arithmetic + +```rust +// WARN: Suspicious use of + in a << expression +let bits = 1 << 4 + 1; // Probably meant (1 << 4) + 1 or 1 << (4 + 1) + +// WARN: Suspicious use of | in a + expression +let value = x | 1 + y; // Probably meant (x | 1) + y or x | (1 + y) +``` + +### Suspicious Comparisons + +```rust +// WARN: Almost swapped operands in a comparison +if 5 < x && x < 3 { } // Impossible condition + +// WARN: Suspicious assignment in a condition +if (x = 5) { } // Probably meant x == 5 +``` + +### Suspicious Method Calls + +```rust +// WARN: Suspicious map usage +let _: Vec<_> = vec.iter().map(|x| { + println!("{}", x); // Side effect in map + x +}).collect(); // Use for_each instead + +// WARN: Suspicious string formatting +let s = format!("{}", format!("{}", x)); // Redundant nested format +``` + +### Suspicious Casts + +```rust +// WARN: Suspicious use of not on a bool +let inverted = !x as i32; // Did you mean (!x) as i32 or !(x as i32)? + +// WARN: Cast of float to int may lose precision +let n = 3.14_f64 as i32; // May want .round() first +``` + +## Notable Lints in This Group + +| Lint | Description | +|------|-------------| +| `suspicious_arithmetic_impl` | Unusual operator in arithmetic trait | +| `suspicious_assignment_formatting` | Looks like typo in assignment | +| `suspicious_else_formatting` | Else on wrong line | +| `suspicious_map` | Map with side effects | +| `suspicious_op_assign_impl` | Unusual op-assign implementation | +| `suspicious_splitn` | splitn that can't produce n parts | +| `suspicious_unary_op_formatting` | Confusing unary operator spacing | + +## Example Catches + +```rust +// Caught: Suspicious double negation +let value = --x; // In Rust, this is -(-x), not pre-decrement + +// Caught: Suspicious modulo +let remainder = x % 1; // Always 0 for integers + +// Caught: Suspicious else formatting +if condition { + do_something(); +} +else { // Weird formatting, might be a mistake + do_other(); +} +``` + +## When to Allow + +Rarely. If you need to suppress, document why: + +```rust +#[allow(clippy::suspicious_arithmetic_impl)] +impl Mul for Matrix { + // Custom matrix multiplication using + for reduction step + fn mul(self, rhs: Self) -> Self::Output { + // ... + } +} +``` + +## See Also + +- [lint-deny-correctness](./lint-deny-correctness.md) - Deny definite bugs +- [lint-warn-style](./lint-warn-style.md) - Style warnings +- [lint-warn-complexity](./lint-warn-complexity.md) - Complexity warnings diff --git a/.opencode/skills/rust-skills/rules/lint-workspace-lints.md b/.opencode/skills/rust-skills/rules/lint-workspace-lints.md new file mode 100644 index 0000000..67944ea --- /dev/null +++ b/.opencode/skills/rust-skills/rules/lint-workspace-lints.md @@ -0,0 +1,172 @@ +# lint-workspace-lints + +> Configure lints at workspace level for consistent enforcement + +## Why It Matters + +Without centralized lint configuration, each crate develops its own standards (or none). Workspace-level lints (Rust 1.74+) ensure consistent code quality across all crates. Denied lints catch issues in CI before they reach production. + +## Bad + +```toml +# crate-a/Cargo.toml - strict +[lints.clippy] +unwrap_used = "deny" + +# crate-b/Cargo.toml - lenient +# No lint config + +# crate-c/Cargo.toml - different +[lints.clippy] +unwrap_used = "warn" + +# Inconsistent enforcement, some issues slip through +``` + +## Good + +```toml +# Root Cargo.toml +[workspace.lints.rust] +unsafe_code = "deny" +missing_docs = "warn" + +[workspace.lints.clippy] +# Correctness +unwrap_used = "deny" +expect_used = "warn" +panic = "deny" + +# Style +needless_pass_by_value = "warn" +redundant_clone = "warn" + +# Complexity +cognitive_complexity = "warn" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" + +# crate-a/Cargo.toml +[lints] +workspace = true + +# crate-b/Cargo.toml +[lints] +workspace = true +``` + +## Recommended Lint Configuration + +```toml +# Root Cargo.toml +[workspace.lints.rust] +# Safety +unsafe_code = "deny" +missing_debug_implementations = "warn" + +# Quality +unused_results = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +# === Correctness (deny) === +correctness = { level = "deny", priority = -1 } + +# === Suspicious (deny) === +suspicious = { level = "deny", priority = -1 } + +# === Style (warn) === +style = { level = "warn", priority = -1 } + +# === Complexity (warn) === +complexity = { level = "warn", priority = -1 } + +# === Perf (warn) === +perf = { level = "warn", priority = -1 } + +# === Pedantic (selective) === +# Not all pedantic lints are useful +doc_markdown = "warn" +needless_pass_by_value = "warn" +redundant_closure_for_method_calls = "warn" +semicolon_if_nothing_returned = "warn" + +# === Nursery (selective) === +cognitive_complexity = "warn" +useless_let_if_seq = "warn" + +# === Restriction (selective) === +unwrap_used = "deny" +expect_used = "warn" +dbg_macro = "warn" +print_stdout = "warn" # Use logging instead +todo = "warn" + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" +private_intra_doc_links = "warn" +``` + +## Per-Crate Overrides + +```toml +# crate-with-binary/Cargo.toml +[lints] +workspace = true + +# Binary entry point can use unwrap +[lints.clippy] +unwrap_used = "allow" + +# test-utils/Cargo.toml +[lints] +workspace = true + +# Test utilities can print +[lints.clippy] +print_stdout = "allow" +``` + +## CI Integration + +```yaml +# .github/workflows/ci.yml +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Rustdoc + run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps +``` + +## Lint Categories + +```toml +# Category-level configuration +[workspace.lints.clippy] +# All lints in category at once +correctness = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +style = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } + +# Then override specific lints (higher priority) +missing_errors_doc = "allow" # Override pedantic +``` + +## See Also + +- [lint-deny-correctness](./lint-deny-correctness.md) - Critical lints +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace configuration +- [anti-unwrap-abuse](./anti-unwrap-abuse.md) - unwrap lints diff --git a/.opencode/skills/rust-skills/rules/macro-export-crate-path.md b/.opencode/skills/rust-skills/rules/macro-export-crate-path.md new file mode 100644 index 0000000..6cd770b --- /dev/null +++ b/.opencode/skills/rust-skills/rules/macro-export-crate-path.md @@ -0,0 +1,87 @@ +# macro-export-crate-path + +> Export declarative macros with `#[macro_export]` and a clean import path + +## Why It Matters + +`#[macro_export]` lifts a macro to the crate root, making it importable like any other item. Combined with `$crate::` paths (see `macro-rules-hygiene`), the macro works regardless of how callers import it. Since Rust 2018, callers can use ordinary path imports (`use mycrate::my_macro;`) rather than the legacy `#[macro_use] extern crate mycrate;`, which polluted the global namespace and depended on item ordering. + +## Bad + +```rust +// lib.rs — legacy style +// Requires callers to write `#[macro_use] extern crate mylib;` +// and dumps all macros into the caller's global scope. +macro_rules! greet { + ($name:expr) => { + println!("hello, {}", $name); + }; +} +``` + +```rust +// consumer/src/main.rs — legacy +#[macro_use] +extern crate mylib; // order-sensitive; pollutes namespace + +fn main() { + greet!("world"); +} +``` + +## Good + +```rust +// lib.rs — modern style +#[macro_export] +macro_rules! greet { + ($name:expr) => { + $crate::__private::print_greeting($name); + }; +} + +// Re-export so `use mylib::greet;` resolves through the crate's public path. +// (The re-export is implicit when using #[macro_export]; this is just for clarity +// or when you want to place it under a module path.) +pub use greet; +``` + +```rust +// consumer/src/main.rs — modern +use mylib::greet; + +fn main() { + greet!("world"); +} +``` + +## Placing Macros in Modules + +`#[macro_export]` always places the macro at the crate root regardless of where the `macro_rules!` definition appears. To expose it under a module path, use a `pub use` re-export: + +```rust +// lib.rs +pub mod macros { + // The macro is defined at crate root by #[macro_export], but we + // also re-export it here so `use mycrate::macros::greet;` works. + pub use crate::greet; +} + +#[macro_export] +macro_rules! greet { + ($name:expr) => { println!("hello, {}", $name); }; +} +``` + +## Key Points + +- Prefer `use mycrate::my_macro;` — it is explicit and plays well with `rustfmt` and IDEs. +- Avoid `#[macro_use]` in new code; it is required only when supporting pre-2018 edition consumers. +- If the macro calls internal helpers, pair `#[macro_export]` with `$crate::__private::...` paths. +- Document macros with `///` just like any other public item. + +## See Also + +- [macro-rules-hygiene](macro-rules-hygiene.md) - using `$crate` for correct item resolution +- [macro-private-helpers](macro-private-helpers.md) - hiding helpers used by exported macros +- [proj-workspace-deps](proj-workspace-deps.md) - workspace dependency inheritance diff --git a/.opencode/skills/rust-skills/rules/macro-fragment-specifiers.md b/.opencode/skills/rust-skills/rules/macro-fragment-specifiers.md new file mode 100644 index 0000000..b3ff83f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/macro-fragment-specifiers.md @@ -0,0 +1,81 @@ +# macro-fragment-specifiers + +> Capture with precise fragment specifiers, not raw `:tt`, where you can + +## Why It Matters + +Fragment specifiers tell the compiler — and readers — exactly what syntactic category a macro arm expects. They produce targeted error messages ("expected expression" instead of "no rules expected token"), enable better IDE tooling, and prevent ambiguous parses. Using raw `:tt` (token tree) forces you to re-parse or validate by hand and leaks implementation details into error messages. + +Note the **follow-set restriction**: after `:expr`, `:ty`, `:pat`, and a few others, only a limited set of tokens may appear — most commonly `=>`, `,`, `;`, `|`, or another fragment. Plan your separator tokens accordingly. + +## Bad + +```rust +// Slurping everything as :tt, then trying to use $e as if it were an expression. +macro_rules! debug_val { + ($($t:tt)*) => { + println!("{} = {:?}", stringify!($($t)*), $($t)*); + // ^^^^^^^^ re-expanding :tt soup + }; +} + +fn main() { + debug_val!(1 + 2); // works by accident + debug_val!(let x = 1); // accepted by the macro; blows up at expansion +} +``` + +## Good + +```rust +macro_rules! debug_val { + // :expr captures a single expression; the follow-set allows `=>` and `,` after it. + ($e:expr) => { + println!("{} = {:?}", stringify!($e), $e); + }; +} + +fn main() { + debug_val!(1 + 2); + // debug_val!(let x = 1); // now correctly rejected at the macro call site +} +``` + +## Fragment Specifier Reference + +| Specifier | Matches | Common uses | +|-----------|---------|-------------| +| `:expr` | An expression | Values, arithmetic, closures | +| `:ty` | A type | Generic helpers, type aliases | +| `:ident` | An identifier | Field names, variable names | +| `:pat` | A pattern | `match` arm patterns | +| `:pat_param` | A pattern (no `|` at top level) | Fn param patterns | +| `:path` | A path (`a::b::c`) | Trait bounds, type paths | +| `:literal` | A literal (`42`, `"hi"`) | Constant values | +| `:block` | A `{ ... }` block | Inline code injection | +| `:stmt` | A statement | Statement-level macros | +| `:meta` | A meta item | `#[derive(Clone)]` content | +| `:vis` | A visibility qualifier | `pub`, `pub(crate)` | +| `:lifetime` | A lifetime (`'a`) | Generic lifetime params | +| `:tt` | Any single token tree | Last resort; combinators | + +## Trailing-Comma Pattern + +Allow an optional trailing comma in repetitions without requiring a follow-set workaround: + +```rust +macro_rules! my_vec { + ($($e:expr),* $(,)?) => { + // $(,)? consumes an optional trailing comma, which is legal after :expr + // because it appears as a separator/terminator, not in follow position. + vec![$($e),*] + }; +} + +let v = my_vec![1, 2, 3,]; // trailing comma accepted +``` + +## See Also + +- [macro-rules-hygiene](macro-rules-hygiene.md) - hygiene and `$crate` for declarative macros +- [macro-prefer-functions](macro-prefer-functions.md) - when a function is a better choice diff --git a/.opencode/skills/rust-skills/rules/macro-prefer-functions.md b/.opencode/skills/rust-skills/rules/macro-prefer-functions.md new file mode 100644 index 0000000..1e045de --- /dev/null +++ b/.opencode/skills/rust-skills/rules/macro-prefer-functions.md @@ -0,0 +1,61 @@ +# macro-prefer-functions + +> Reach for a macro only when a function or generic cannot express it + +## Why It Matters + +Macros operate on token streams before type checking, so they bypass type inference, resist IDE navigation, and produce opaque error messages. They also slow incremental compilation and cannot be passed as values. A generic function is almost always clearer, better-optimized by the compiler, and easier for contributors to reason about. + +Reach for a macro only when you genuinely need one of: variadic argument counts, a DSL with non-Rust syntax, blanket trait impls across an open-ended set of types, compile-time format/string checks, or eliminating mechanically repetitive boilerplate that a function truly cannot handle. + +## Bad + +```rust +// Nothing here requires a macro — no variadic args, no DSL, no trait impl. +macro_rules! double { + ($x:expr) => { + $x * 2 + }; +} + +fn main() { + let n = double!(21); + println!("{n}"); +} +``` + +## Good + +```rust +// A generic function is clearer, debuggable, and just as efficient. +#[inline] +fn double(x: T) -> T +where + T: std::ops::Mul + Copy, +{ + x * x // or x + x for integer-like types +} + +fn main() { + let n = double(21_i32); + println!("{n}"); +} +``` + +## When to Reach for a Macro + +| Situation | Use a macro? | +|-----------|-------------| +| Fixed argument count, any types | No — use generics | +| Truly variadic argument list (`vec![]`, `println!`) | Yes | +| Implementing a trait for many unrelated types | Yes — `macro_rules!` impl block | +| DSL / embedded syntax (SQL, HTML, regex literals) | Yes — proc-macro | +| Compile-time format string validation | Yes — `format_args!` style | +| Boilerplate a derive could generate | Yes — `#[derive(...)]` proc-macro | +| Simple computation or type conversion | No — use a function or trait | + +## See Also + +- [anti-over-abstraction](anti-over-abstraction.md) - avoid unnecessary abstraction layers +- [type-generic-bounds](type-generic-bounds.md) - add trait bounds only where needed +- [macro-rules-hygiene](macro-rules-hygiene.md) - hygiene and `$crate` for declarative macros diff --git a/.opencode/skills/rust-skills/rules/macro-private-helpers.md b/.opencode/skills/rust-skills/rules/macro-private-helpers.md new file mode 100644 index 0000000..4f224c6 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/macro-private-helpers.md @@ -0,0 +1,98 @@ +# macro-private-helpers + +> Hide macro-generated helper items behind a `#[doc(hidden)] pub mod __private` + +## Why It Matters + +Exported macros often need to call helper functions, use types, or invoke traits at the call site. Placing those helpers directly in the crate's public API pollutes the surface with implementation details and freezes them under semver — any rename or removal becomes a breaking change. Routing all generated references through a `#[doc(hidden)] pub mod __private` keeps the public API clean while letting you evolve internals freely. + +This is the pattern used by `serde`, `thiserror`, and many derive crates. + +## Bad + +```rust +// lib.rs — helper leaks into the public API +pub fn __format_value(v: &dyn std::fmt::Debug) -> String { + format!("{v:?}") +} + +#[macro_export] +macro_rules! debug_print { + ($val:expr) => { + println!("{}", $crate::__format_value(&$val)); + }; +} +``` + +```text +// A user sees `__format_value` in the docs and may depend on it. +// Removing it later is a semver-breaking change. +``` + +## Good + +```rust +// lib.rs + +#[doc(hidden)] +pub mod __private { + // Everything re-exported here is technically public (required for + // macro call sites), but hidden from rendered docs and clearly + // marked as an unstable implementation detail. + pub use crate::helpers::format_value; +} + +// Internal module — not public. +mod helpers { + pub fn format_value(v: &dyn std::fmt::Debug) -> String { + format!("{v:?}") + } +} + +#[macro_export] +macro_rules! debug_print { + ($val:expr) => { + // Reference through __private; never through the bare crate root. + println!("{}", $crate::__private::format_value(&$val)); + }; +} +``` + +## With a Derive Macro + +The same pattern applies when a derive crate generates code that needs helpers from the facade crate: + +```rust +// mycrate/src/lib.rs + +#[doc(hidden)] +pub mod __private { + // Traits and functions the generated `impl` blocks will call. + pub use crate::runtime::{Describe, describe_fields}; +} + +// Re-export the derive macro so users write `use mycrate::Describe;` +pub use mycrate_derive::Describe; +``` + +```rust +// Generated by the derive macro (inside mycrate-derive): +impl ::mycrate::__private::Describe for MyStruct { + fn describe(&self) -> String { + ::mycrate::__private::describe_fields(&[/* ... */]) + } +} +``` + +## Key Points + +- Name the module `__private` (double-underscore) — it signals "not part of the public API" by convention across the ecosystem. +- Always pair with `#[doc(hidden)]` so the module is invisible in rendered documentation. +- Reference items as `$crate::__private::...` inside macros — never as bare paths. +- Document in your crate's CHANGELOG or README that `__private` is exempt from semver guarantees. + +## See Also + +- [macro-rules-hygiene](macro-rules-hygiene.md) - `$crate` for correct path resolution +- [macro-proc-two-crate](macro-proc-two-crate.md) - separating proc-macro and facade crates +- [doc-all-public](doc-all-public.md) - documenting public items diff --git a/.opencode/skills/rust-skills/rules/macro-proc-error-spans.md b/.opencode/skills/rust-skills/rules/macro-proc-error-spans.md new file mode 100644 index 0000000..b2889ed --- /dev/null +++ b/.opencode/skills/rust-skills/rules/macro-proc-error-spans.md @@ -0,0 +1,118 @@ +# macro-proc-error-spans + +> Report proc-macro errors as spanned compile errors, never by panicking + +## Why It Matters + +A `panic!`, `.unwrap()`, or `.expect()` inside a proc-macro produces an opaque compiler message — "proc macro panicked" — with no source location. The user sees no indication of which part of their code triggered the error. Returning a `syn::Error` converted to a token stream instead gives a diagnostic that points directly at the offending span, exactly like an ordinary compiler error. + +## Bad + +```rust +use proc_macro::TokenStream; +use syn::{parse_macro_input, Data, DeriveInput}; + +#[proc_macro_derive(MyTrait)] +pub fn derive_my_trait(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let fields = match input.data { + Data::Struct(ref s) => &s.fields, + _ => panic!("MyTrait can only be derived on structs"), // WRONG + }; + + // `.unwrap()` here gives "called `Option::unwrap()` on a `None` value" + // with no location info in user code. + let first = fields.iter().next().unwrap(); + let name = first.ident.as_ref().unwrap(); + + quote::quote! { + impl MyTrait for #name {} + } + .into() +} +``` + +```text +error: proc macro panicked + --> src/main.rs:3:10 + | + 3 | #[derive(MyTrait)] + | ^^^^^^^ + | + = help: message: MyTrait can only be derived on structs +``` + +## Good + +```rust +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{parse_macro_input, spanned::Spanned, Data, DeriveInput, Error}; + +#[proc_macro_derive(MyTrait)] +pub fn derive_my_trait(input: TokenStream) -> TokenStream { + derive_my_trait_inner(input).unwrap_or_else(|e| e.to_compile_error().into()) +} + +fn derive_my_trait_inner(input: TokenStream) -> Result { + let input = parse_macro_input!(input as DeriveInput); + + let fields = match &input.data { + Data::Struct(s) => &s.fields, + Data::Enum(e) => { + return Err(Error::new_spanned( + &input.ident, + "MyTrait can only be derived on structs, not enums", + )); + } + Data::Union(u) => { + return Err(Error::new_spanned( + &input.ident, + "MyTrait can only be derived on structs, not unions", + )); + } + }; + + let first = fields.iter().next().ok_or_else(|| { + Error::new_spanned(&input.ident, "MyTrait requires at least one field") + })?; + + let field_name = first.ident.as_ref().ok_or_else(|| { + // Attach the error to the field's span, not the struct name. + Error::new_spanned(first, "MyTrait requires named fields") + })?; + + let struct_name = &input.ident; + Ok(quote! { + impl MyTrait for #struct_name { + fn first_field_name() -> &'static str { + stringify!(#field_name) + } + } + } + .into()) +} +``` + +```text +error: MyTrait requires named fields + --> src/main.rs:7:5 + | + 7 | u8, // tuple struct field + | ^^ +``` + +## Key Points + +- Wrap proc-macro logic in a `fn(...) -> Result` helper and convert at the entry point with `.unwrap_or_else(|e| e.to_compile_error().into())`. +- Use `Error::new_spanned(tokens, "message")` to attach the error to a specific part of the input AST. +- Use `Error::new(span, "message")` when you only have a `Span`, not a token. +- Combine multiple errors with `Error::combine` rather than returning early, so the user sees all problems at once. +- Error messages: lowercase, no trailing punctuation (consistent with Rust compiler style). + +## See Also + +- [macro-proc-syn-quote](macro-proc-syn-quote.md) - parsing with syn, quoting with quote +- [err-thiserror-lib](err-thiserror-lib.md) - idiomatic library error types diff --git a/.opencode/skills/rust-skills/rules/macro-proc-syn-quote.md b/.opencode/skills/rust-skills/rules/macro-proc-syn-quote.md new file mode 100644 index 0000000..41602e5 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/macro-proc-syn-quote.md @@ -0,0 +1,110 @@ +# macro-proc-syn-quote + +> Build procedural macros with `syn`, `quote`, and `proc-macro2` + +## Why It Matters + +Writing a proc-macro by hand-parsing `proc_macro::TokenStream` is fragile and verbose. The standard ecosystem trio — `syn` (parsing), `quote` (code generation), and `proc-macro2` (span-aware token types) — gives you a typed AST, readable quasi-quoting, and the ability to unit-test your macro logic outside the compiler. + +Enable only the `syn` features you actually use. The `full` feature parses all Rust syntax but adds compile time; `derive` is sufficient for most `#[proc_macro_derive]` implementations. + +## Bad + +```rust +// Manually iterating tokens to find a struct name — brittle and hard to read. +use proc_macro::TokenStream; + +#[proc_macro_derive(Hello)] +pub fn derive_hello(input: TokenStream) -> TokenStream { + let mut iter = input.into_iter(); + // skip `struct`, grab the next ident... error-prone and breaks on generics + iter.next(); // "struct" + let name = iter.next().unwrap().to_string(); + format!("impl Hello for {name} {{ fn hello(&self) {{ println!(\"hello\"); }} }}") + .parse() + .unwrap() +} +``` + +## Good + +```toml +# Cargo.toml for the derive crate +[dependencies] +syn = { version = "2", features = ["derive"] } +quote = "1" +proc-macro2 = "1" +``` + +```rust +// src/lib.rs +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::{quote, quote_spanned}; +use syn::{parse_macro_input, spanned::Spanned, DeriveInput}; + +#[proc_macro_derive(Hello)] +pub fn derive_hello(input: TokenStream) -> TokenStream { + // parse_macro_input! gives a typed DeriveInput or emits a compile error. + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + // quote! quasi-quotes Rust tokens; `#name` splices the identifier. + let expanded = quote! { + impl #impl_generics Hello for #name #ty_generics #where_clause { + fn hello(&self) { + println!("hello from {}", stringify!(#name)); + } + } + }; + + expanded.into() +} +``` + +## Using `quote_spanned!` for Better Diagnostics + +Attaching generated code to a meaningful span makes errors point at the right location in user code: + +```rust +use syn::{spanned::Spanned, Field}; +use quote::quote_spanned; + +fn require_named_field(field: &Field) -> proc_macro2::TokenStream { + let span = field.span(); + let name = field.ident.as_ref().expect("named field"); + + // Any error from the generated code will point at the field declaration. + quote_spanned! { span => + let _ = &self.#name; // generated access to prove the field exists + } +} +``` + +## Unit Testing with `proc-macro2` + +Because `proc-macro2::TokenStream` is usable outside the compiler, you can test generation logic in regular `#[test]` functions: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + use syn::parse_quote; + + #[test] + fn generates_impl_for_unit_struct() { + let input: DeriveInput = parse_quote! { struct Foo; }; + // Call the internal generation function (not the proc_macro entry point). + let tokens = generate_hello_impl(&input); + let code = tokens.to_string(); + assert!(code.contains("impl Hello for Foo")); + } +} +``` + +## See Also + +- [macro-proc-two-crate](macro-proc-two-crate.md) - separating proc-macro and facade crates +- [macro-proc-error-spans](macro-proc-error-spans.md) - reporting errors with spans, not panics diff --git a/.opencode/skills/rust-skills/rules/macro-proc-two-crate.md b/.opencode/skills/rust-skills/rules/macro-proc-two-crate.md new file mode 100644 index 0000000..04a1384 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/macro-proc-two-crate.md @@ -0,0 +1,143 @@ +# macro-proc-two-crate + +> Put procedural macros in a dedicated `proc-macro = true` crate and re-export from the facade + +## Why It Matters + +A crate marked `proc-macro = true` in `Cargo.toml` compiles for the host (the build machine) and can **only** export procedural macros — no regular types, traits, or functions. If your library needs both a derive/attribute macro and ordinary APIs, you must split into two crates: a `mycrate-derive` (or `mycrate-macros`) proc-macro crate and a `mycrate` facade crate that re-exports everything. + +The facade approach ensures: +- Users add only `mycrate` as a dependency. +- Generated code refers to types through `::mycrate::__private::...`, so the impl crate version is invisible. +- Workspace dependency inheritance keeps both crates locked to the same version without repetition. + +## Bad + +```rust +// A single crate with `proc-macro = true` in Cargo.toml that also tries +// to export regular items: +#[proc_macro_derive(Greet)] +pub fn derive_greet(input: TokenStream) -> TokenStream { /* ... */ } + +pub trait Greet { fn greet(&self) -> String; } // error: a proc-macro crate +pub struct Config; // can only export proc-macros +``` + +## Good + +Split into a `proc-macro = true` crate plus a facade that re-exports it (full manifests and code below): + +```rust +// users depend only on `mycrate`: +use mycrate::Greet; // the trait +#[derive(mycrate::Greet)] // the derive, re-exported from mycrate-derive +struct Robot; +``` + +## Crate Layout + +``` +my-workspace/ +├── Cargo.toml # workspace manifest +├── mycrate/ +│ ├── Cargo.toml +│ └── src/lib.rs +└── mycrate-derive/ + ├── Cargo.toml + └── src/lib.rs +``` + +## Cargo.toml Files + +```toml +# my-workspace/Cargo.toml +[workspace] +members = ["mycrate", "mycrate-derive"] +resolver = "3" # default for the 2024 edition; use "2" for 2021 + +[workspace.dependencies] +mycrate-derive = { path = "mycrate-derive", version = "0.1" } +syn = { version = "2", features = ["derive"] } +quote = "1" +proc-macro2 = "1" +``` + +```toml +# mycrate-derive/Cargo.toml +[package] +name = "mycrate-derive" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true # required — makes this a proc-macro crate + +[dependencies] +syn.workspace = true +quote.workspace = true +proc-macro2.workspace = true +``` + +```toml +# mycrate/Cargo.toml +[package] +name = "mycrate" +version = "0.1.0" +edition = "2024" + +[dependencies] +mycrate-derive.workspace = true +``` + +## Re-export from the Facade + +```rust +// mycrate/src/lib.rs + +// Re-export the derive macro so users write `use mycrate::Greet;` +// or `#[derive(mycrate::Greet)]`. +pub use mycrate_derive::Greet; + +/// The trait that `#[derive(Greet)]` implements. +pub trait Greet { + fn greet(&self) -> String; +} + +#[doc(hidden)] +pub mod __private { + // Helpers referenced by generated `impl` blocks. + pub fn format_greeting(name: &str) -> String { + format!("hello, {name}") + } +} +``` + +```rust +// mycrate-derive/src/lib.rs +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, DeriveInput}; + +#[proc_macro_derive(Greet)] +pub fn derive_greet(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + let name_str = name.to_string(); + + quote! { + impl ::mycrate::Greet for #name { + fn greet(&self) -> String { + ::mycrate::__private::format_greeting(#name_str) + } + } + } + .into() +} +``` + +## See Also + +- [macro-proc-syn-quote](macro-proc-syn-quote.md) - building proc-macros with syn and quote +- [macro-private-helpers](macro-private-helpers.md) - hiding helpers behind `__private` +- [proj-workspace-deps](proj-workspace-deps.md) - workspace dependency inheritance +- [err-thiserror-lib](err-thiserror-lib.md) - thiserror as a real-world two-crate example diff --git a/.opencode/skills/rust-skills/rules/macro-rules-hygiene.md b/.opencode/skills/rust-skills/rules/macro-rules-hygiene.md new file mode 100644 index 0000000..76a497e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/macro-rules-hygiene.md @@ -0,0 +1,98 @@ +# macro-rules-hygiene + +> Rely on `macro_rules!` hygiene and use `$crate` for paths to your crate's items + +## Why It Matters + +`macro_rules!` is hygienic for local bindings: identifiers introduced inside the macro (with `let`, loop labels, etc.) live in their own namespace and cannot shadow or clash with the caller's identifiers. This protects callers from surprising capture bugs. + +Item paths, however, are not automatically resolved. If your macro calls `crate::helper()`, it silently breaks when the macro is used from a different crate. Use `$crate::helper()` instead — `$crate` expands to the defining crate regardless of call site, so the macro works correctly even when re-exported. + +## Bad + +```rust +// lib.rs +pub fn log_value(v: &str) { + println!("[log] {v}"); +} + +#[macro_export] +macro_rules! log { + ($val:expr) => { + // WRONG: `crate::` resolves relative to the *caller's* crate, + // not to the crate that defined this macro. + crate::log_value(&format!("{:?}", $val)); + }; +} +``` + +```rust +// consumer/src/main.rs +use mylib::log; + +fn main() { + log!(42); // compile error: `crate::log_value` not found in consumer +} +``` + +## Good + +```rust +// lib.rs +pub fn log_value(v: &str) { + println!("[log] {v}"); +} + +#[macro_export] +macro_rules! log { + ($val:expr) => { + // `$crate` always expands to the crate that defined this macro. + $crate::log_value(&format!("{:?}", $val)); + }; +} +``` + +```rust +// consumer/src/main.rs +use mylib::log; + +fn main() { + log!(42); // correctly calls mylib::log_value +} +``` + +## Hygiene for Local Bindings + +Hygiene means that `let` bindings introduced inside a macro are invisible to the caller: + +```rust +macro_rules! swap { + ($a:expr, $b:expr) => {{ + // `tmp` here does NOT interfere with any `tmp` in the caller's scope. + let tmp = $a; + $a = $b; + $b = tmp; + }}; +} + +fn main() { + let tmp = "outer"; // unrelated to the macro's `tmp` + let (mut x, mut y) = (1, 2); + swap!(x, y); + assert_eq!((x, y), (2, 1)); + assert_eq!(tmp, "outer"); // still intact +} +``` + +## Key Points + +- Always use `$crate::` for any path to items in the defining crate. +- `$crate` works even when the macro is re-exported through another crate. +- Hygiene does NOT protect against identifier tokens you pass as `$name:ident` — those come from the caller's scope intentionally. +- For items meant to be called from generated code, combine `$crate::` with a `#[doc(hidden)] pub mod __private` to avoid polluting the public API (see `macro-private-helpers`). + +## See Also + +- [macro-export-crate-path](macro-export-crate-path.md) - exporting macros with clean import paths +- [macro-private-helpers](macro-private-helpers.md) - hiding helper items used by macros +- [macro-prefer-functions](macro-prefer-functions.md) - when to avoid macros entirely diff --git a/.opencode/skills/rust-skills/rules/mem-arena-allocator.md b/.opencode/skills/rust-skills/rules/mem-arena-allocator.md new file mode 100644 index 0000000..5a4445a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-arena-allocator.md @@ -0,0 +1,171 @@ +# mem-arena-allocator + +> Use arena allocators for batch allocations + +## Why It Matters + +Arena allocators (bump allocators) allocate memory from a contiguous region, making allocation extremely fast (just bump a pointer). All allocations are freed at once when the arena is dropped. Perfect for request-scoped or parse-tree allocations. + +## Bad + +```rust +// Many small allocations during parsing +fn parse(input: &str) -> Vec { + let mut nodes = Vec::new(); + for token in tokenize(input) { + nodes.push(Box::new(Node::new(token))); // Heap alloc per node! + } + nodes +} + +// Per-request allocations add up +fn handle_request(req: Request) -> Response { + let headers = parse_headers(&req); // Allocates + let body = parse_body(&req); // Allocates + let response = generate_response(); // Allocates + // All freed individually at end + response +} +``` + +## Good + +```rust +use bumpalo::Bump; + +// All nodes allocated from same arena +fn parse<'a>(input: &str, arena: &'a Bump) -> Vec<&'a Node> { + let mut nodes = Vec::new(); + for token in tokenize(input) { + let node = arena.alloc(Node::new(token)); // Fast bump! + nodes.push(node); + } + nodes +} // Arena freed all at once + +// Per-request arena +fn handle_request(req: Request) -> Response { + let arena = Bump::new(); + + let headers = parse_headers(&req, &arena); + let body = parse_body(&req, &arena); + let response = generate_response(&arena); + + // Convert to owned response before arena drops + response.to_owned() +} // All request memory freed instantly +``` + +## Thread-Local Scratch Arena Pattern + +```rust +use bumpalo::Bump; +use std::cell::RefCell; + +thread_local! { + static SCRATCH: RefCell = RefCell::new(Bump::with_capacity(4 * 1024)); +} + +fn with_scratch(f: impl FnOnce(&Bump) -> T) -> T { + SCRATCH.with(|scratch| { + let arena = scratch.borrow(); + let result = f(&arena); + result + }) +} + +fn reset_scratch() { + SCRATCH.with(|scratch| { + scratch.borrow_mut().reset(); + }); +} + +// Usage +fn process_batch(items: &[Item]) -> Vec { + with_scratch(|arena| { + let temp_data: Vec<&TempData> = items + .iter() + .map(|item| arena.alloc(compute_temp(item))) + .collect(); + + // Use temp_data... + let result = finalize(&temp_data); + + reset_scratch(); // Reuse arena memory + result + }) +} +``` + +## Evidence from ROC Compiler + +```rust +// https://github.com/roc-lang/roc/blob/main/crates/compiler/solve/src/to_var.rs +std::thread_local! { + static SCRATCHPAD: RefCell> = + RefCell::new(Some(bumpalo::Bump::with_capacity(4 * 1024))); +} + +fn take_scratchpad() -> bumpalo::Bump { + SCRATCHPAD.with(|f| f.take().unwrap()) +} + +fn put_scratchpad(scratchpad: bumpalo::Bump) { + SCRATCHPAD.with(|f| { + f.replace(Some(scratchpad)); + }); +} +``` + +## Bumpalo Collections + +```rust +use bumpalo::Bump; +use bumpalo::collections::{Vec, String}; + +fn process<'a>(arena: &'a Bump, input: &str) -> Vec<'a, String<'a>> { + let mut results = Vec::new_in(arena); + + for word in input.split_whitespace() { + let mut s = String::new_in(arena); + s.push_str(word); + s.push_str("_processed"); + results.push(s); + } + + results // All allocated in arena +} +``` + +## When to Use Arenas + +| Situation | Use Arena? | +|-----------|-----------| +| Parsing (AST nodes) | Yes | +| Request handling | Yes | +| Batch processing | Yes | +| Long-lived data | No | +| Data escaping scope | No (or copy out) | +| Simple programs | Overkill | + +## Performance Impact + +Arena/bump allocation removes per-allocation metadata overhead and can be +substantially faster than the global allocator — often an order of magnitude in +microbenchmarks — but the actual speedup depends on the allocator, workload, +and allocation size. Arena reset is O(1) regardless of how many allocations +were made. Measure with [criterion](https://crates.io/crates/criterion) to +confirm the benefit in your specific use case. + +```rust +// Memory trade-off: +// - Arena wastes some memory (unused capacity at the end) +// - But eliminates per-allocation metadata overhead +// - Frees everything in O(1) with a single bump reset +``` + +## See Also + +- [mem-with-capacity](mem-with-capacity.md) - Pre-allocate when size is known +- [mem-reuse-collections](mem-reuse-collections.md) - Reuse collections with clear() +- [opt-profile-first](perf-profile-first.md) - Profile to verify benefit diff --git a/.opencode/skills/rust-skills/rules/mem-arrayvec.md b/.opencode/skills/rust-skills/rules/mem-arrayvec.md new file mode 100644 index 0000000..c9b4826 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-arrayvec.md @@ -0,0 +1,143 @@ +# mem-arrayvec + +> Use `ArrayVec` for fixed-capacity collections that never heap-allocate + +## Why It Matters + +`ArrayVec` from the `arrayvec` crate provides Vec-like API with a compile-time maximum capacity, storing all elements inline on the stack. Unlike `SmallVec` which can spill to heap, `ArrayVec` guarantees no heap allocation—if you exceed capacity, it returns an error or panics. This is ideal for embedded systems, real-time code, or when you have a hard upper bound. + +## Bad + +```rust +// Vec always heap-allocates, even for small collections +fn parse_options(input: &str) -> Vec
    () == 32); +``` + +## Testing Size Stability + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn critical_types_have_expected_sizes() { + // Document expected sizes in tests too + assert_eq!(std::mem::size_of::(), 48); + assert_eq!(std::mem::size_of::(), 64); + assert_eq!(std::mem::size_of::
    (), 32); + } + + #[test] + fn cache_line_aligned() { + // Verify cache-friendly sizing + assert!(std::mem::size_of::() <= 64); + } +} +``` + +## When to Assert + +```rust +// ✅ Types stored in large collections +struct Node { /* ... */ } +const _: () = assert!(std::mem::size_of::() <= 64); + +// ✅ Types used in FFI / binary protocols +#[repr(C)] +struct WireFormat { /* ... */ } +const _: () = assert!(std::mem::size_of::() == 256); + +// ✅ Performance-critical types +struct HotPath { /* ... */ } +const _: () = assert!(std::mem::size_of::() <= 128); + +// ❌ Skip for rarely-instantiated types +struct AppConfig { /* many fields */ } +// Size doesn't matter, only one instance +``` + +## Cargo.toml + +```toml +[dependencies] +static_assertions = "1.1" +``` + +## See Also + +- [mem-smaller-integers](./mem-smaller-integers.md) - Choosing appropriate integer sizes +- [mem-box-large-variant](./mem-box-large-variant.md) - Managing enum variant sizes +- [opt-cache-friendly](./opt-cache-friendly.md) - Cache line considerations diff --git a/.opencode/skills/rust-skills/rules/mem-avoid-format.md b/.opencode/skills/rust-skills/rules/mem-avoid-format.md new file mode 100644 index 0000000..8c5ff90 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-avoid-format.md @@ -0,0 +1,147 @@ +# mem-avoid-format + +> Avoid `format!()` when string literals work + +## Why It Matters + +`format!()` always allocates a new String, even for constant text. In hot paths, these allocations add up. Use string literals, `write!()`, or pre-allocated buffers instead. + +## Bad + +```rust +// Allocates every time, even for static text +fn get_error_message() -> String { + format!("An error occurred") // Unnecessary allocation! +} + +// Allocates in a loop +for item in items { + log::info!("{}", format!("Processing item: {}", item)); // Double work! +} + +// format! in hot path +fn classify(n: i32) -> String { + if n > 0 { + format!("positive") // Allocates! + } else if n < 0 { + format!("negative") // Allocates! + } else { + format!("zero") // Allocates! + } +} +``` + +## Good + +```rust +// Return &'static str for constants +fn get_error_message() -> &'static str { + "An error occurred" // No allocation +} + +// Use format args directly +for item in items { + log::info!("Processing item: {}", item); // No intermediate String +} + +// Return Cow for mixed static/dynamic +use std::borrow::Cow; + +fn classify(n: i32) -> Cow<'static, str> { + if n > 0 { + Cow::Borrowed("positive") // No allocation + } else if n < 0 { + Cow::Borrowed("negative") // No allocation + } else { + Cow::Borrowed("zero") // No allocation + } +} + +// Or just &'static str if always static +fn classify_str(n: i32) -> &'static str { + if n > 0 { "positive" } + else if n < 0 { "negative" } + else { "zero" } +} +``` + +## Use write!() for Output + +```rust +use std::io::Write; + +// Bad: Allocate then write +fn bad_log(writer: &mut impl Write, msg: &str, code: u32) { + let formatted = format!("[ERROR {}] {}", code, msg); // Allocation! + writer.write_all(formatted.as_bytes()).unwrap(); +} + +// Good: Write directly +fn good_log(writer: &mut impl Write, msg: &str, code: u32) { + write!(writer, "[ERROR {}] {}", code, msg).unwrap(); // No allocation! +} +``` + +## Pre-allocate for Multiple Appends + +```rust +// Bad: Multiple allocations +fn build_message(parts: &[&str]) -> String { + let mut result = String::new(); + for part in parts { + result = format!("{}{}\n", result, part); // Allocates each iteration! + } + result +} + +// Good: Pre-allocate +fn build_message(parts: &[&str]) -> String { + let total_len: usize = parts.iter().map(|p| p.len() + 1).sum(); + let mut result = String::with_capacity(total_len); + for part in parts { + result.push_str(part); + result.push('\n'); + } + result +} + +// Good: Use join +fn build_message(parts: &[&str]) -> String { + parts.join("\n") +} +``` + +## CompactString for Small Strings + +```rust +use compact_str::CompactString; + +// Stack-allocated for strings <= 24 bytes +fn format_code(code: u32) -> CompactString { + compact_str::format_compact!("ERR-{:04}", code) + // Stack-allocated if result is small enough +} +``` + +## When format!() Is Fine + +```rust +// Rare/cold paths - clarity over micro-optimization +fn log_startup_message() { + println!("{}", format!("Starting {} v{}", APP_NAME, VERSION)); +} + +// When you need an owned String anyway +fn create_user_greeting(name: &str) -> String { + format!("Hello, {}!", name) // Need owned String +} + +// Error messages (already on error path) +return Err(format!("Invalid value: {}", value).into()); +``` + +## See Also + +- [mem-write-over-format](mem-write-over-format.md) - Use write!() instead of format!() +- [mem-with-capacity](mem-with-capacity.md) - Pre-allocate strings +- [own-cow-conditional](own-cow-conditional.md) - Use Cow for mixed static/dynamic diff --git a/.opencode/skills/rust-skills/rules/mem-box-large-variant.md b/.opencode/skills/rust-skills/rules/mem-box-large-variant.md new file mode 100644 index 0000000..8e2689e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-box-large-variant.md @@ -0,0 +1,158 @@ +# mem-box-large-variant + +> Box large enum variants to reduce overall enum size + +## Why It Matters + +An enum's size is determined by its largest variant. If one variant contains a large struct while others are small, every instance of the enum pays for the largest variant's size. Boxing the large variant puts that data on the heap, keeping the enum itself small. This can significantly reduce memory usage and improve cache performance. + +## Bad + +```rust +enum Message { + Quit, // 0 bytes of data + Move { x: i32, y: i32 }, // 8 bytes + Text(String), // 24 bytes + Image { + data: [u8; 1024], // 1024 bytes - forces entire enum to ~1032 bytes! + width: u32, + height: u32 + }, +} + +// Every Message is ~1032 bytes, even Quit and Move +let messages: Vec = vec![ + Message::Quit, // Wastes ~1032 bytes + Message::Quit, // Wastes ~1032 bytes + Message::Move { x: 0, y: 0 }, // Wastes ~1024 bytes +]; +``` + +## Good + +```rust +struct ImageData { + data: [u8; 1024], + width: u32, + height: u32, +} + +enum Message { + Quit, + Move { x: i32, y: i32 }, + Text(String), + Image(Box), // Now just 8 bytes (pointer) +} + +// Message is now ~32 bytes (String variant is largest) +let messages: Vec = vec![ + Message::Quit, // Uses ~32 bytes + Message::Quit, // Uses ~32 bytes + Message::Move { x: 0, y: 0 }, // Uses ~32 bytes +]; +``` + +## Check Enum Sizes + +```rust +use std::mem::size_of; + +// Before boxing +enum BadEvent { + Click { x: u32, y: u32 }, // 8 bytes + KeyPress(char), // 4 bytes + LargeData([u8; 256]), // 256 bytes +} +println!("BadEvent: {} bytes", size_of::()); // ~264 bytes + +// After boxing +enum GoodEvent { + Click { x: u32, y: u32 }, + KeyPress(char), + LargeData(Box<[u8; 256]>), // 8 bytes (pointer) +} +println!("GoodEvent: {} bytes", size_of::()); // ~16 bytes +``` + +## Clippy Lint + +```toml +[lints.clippy] +large_enum_variant = "warn" # Warns when variants differ significantly +``` + +```rust +// Clippy will suggest: +// warning: large size difference between variants +// help: consider boxing the large fields to reduce the total size +``` + +## When to Box + +| Largest Variant | Other Variants | Action | +|-----------------|----------------|--------| +| < 64 bytes | Similar size | Don't box | +| > 128 bytes | Much smaller | Box the large variant | +| > 256 bytes | Any | Definitely box | + +## Recursive Types Require Boxing + +```rust +// Won't compile - infinite size +enum List { + Cons(i32, List), + Nil, +} + +// Must box recursive variant +enum List { + Cons(i32, Box), // Now finite size + Nil, +} + +// Same for ASTs +enum Expr { + Number(i64), + BinOp { + op: Op, + left: Box, // Recursive - must box + right: Box, + }, +} +``` + +## Pattern Matching with Boxed Variants + +```rust +enum Event { + Small(u32), + Large(Box), +} + +fn handle(event: Event) { + match event { + Event::Small(n) => println!("Small: {}", n), + Event::Large(data) => { + // data is Box, dereference to access + println!("Large: {} bytes", data.size); + } + } +} + +// Or match on reference +fn handle_ref(event: &Event) { + match event { + Event::Small(n) => println!("Small: {}", n), + Event::Large(data) => { + // data is &Box, auto-derefs + println!("Large: {} bytes", data.size); + } + } +} +``` + +## See Also + +- [own-move-large](./own-move-large.md) - Boxing large types for cheap moves +- [mem-smallvec](./mem-smallvec.md) - Alternative for inline small collections +- [lint-deny-correctness](./lint-deny-correctness.md) - Enabling clippy lints diff --git a/.opencode/skills/rust-skills/rules/mem-boxed-slice.md b/.opencode/skills/rust-skills/rules/mem-boxed-slice.md new file mode 100644 index 0000000..fa91c9e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-boxed-slice.md @@ -0,0 +1,139 @@ +# mem-boxed-slice + +> Use `Box<[T]>` instead of `Vec` for fixed-size heap data + +## Why It Matters + +`Vec` stores three words: pointer, length, and capacity. When you know a collection won't grow, `Box<[T]>` stores only pointer and length (2 words), saving 8 bytes per instance. More importantly, it communicates intent: "this data is fixed-size." For large numbers of fixed collections, this adds up. + +## Bad + +```rust +struct Document { + // Vec signals "might grow" but we never push after creation + paragraphs: Vec, // 24 bytes: ptr + len + capacity +} + +fn load_document(data: &[u8]) -> Document { + let paragraphs: Vec = parse_paragraphs(data); + // paragraphs has capacity >= len, wasting the capacity field + Document { paragraphs } +} +``` + +## Good + +```rust +struct Document { + // Box<[T]> signals "fixed size" - clear intent + paragraphs: Box<[Paragraph]>, // 16 bytes: ptr + len (as fat pointer) +} + +fn load_document(data: &[u8]) -> Document { + let paragraphs: Vec = parse_paragraphs(data); + Document { + paragraphs: paragraphs.into_boxed_slice() // Shrinks + converts + } +} +``` + +## Memory Layout + +```rust +use std::mem::size_of; + +// Vec: 24 bytes on 64-bit +assert_eq!(size_of::>(), 24); // ptr(8) + len(8) + cap(8) + +// Box<[T]>: 16 bytes (fat pointer) +assert_eq!(size_of::>(), 16); // ptr(8) + len(8) + +// Savings per instance: 8 bytes +// For 1 million instances: 8 MB saved +``` + +## Conversion Patterns + +```rust +// Vec to Box<[T]> +let vec: Vec = vec![1, 2, 3, 4, 5]; +let boxed: Box<[i32]> = vec.into_boxed_slice(); + +// Box<[T]> back to Vec (if you need to grow) +let vec_again: Vec = boxed.into_vec(); + +// From iterator +let boxed: Box<[i32]> = (0..100).collect::>().into_boxed_slice(); + +// Shrink Vec first if it has excess capacity +let mut vec = Vec::with_capacity(1000); +vec.extend(0..10); +vec.shrink_to_fit(); // Reduce capacity to length +let boxed = vec.into_boxed_slice(); // Now no wasted allocation +``` + +## When to Use What + +| Type | Use When | +|------|----------| +| `Vec` | Collection may grow/shrink | +| `Box<[T]>` | Fixed-size, heap-allocated, many instances | +| `[T; N]` | Fixed-size, stack-allocated, size known at compile time | +| `&[T]` | Borrowed view, don't need ownership | + +## Box for Immutable Strings + +Same principle applies to strings: + +```rust +use std::mem::size_of; + +// String: 24 bytes (like Vec) +assert_eq!(size_of::(), 24); + +// Box: 16 bytes +assert_eq!(size_of::>(), 16); + +// For immutable strings +struct Name { + value: Box, // Saves 8 bytes vs String +} + +impl Name { + fn new(s: &str) -> Self { + Name { value: s.into() } // &str -> Box + } +} + +// Or from String +let s = String::from("hello"); +let boxed: Box = s.into_boxed_str(); +``` + +## Real-World Example + +```rust +// Cache with millions of entries +struct Cache { + // 8 bytes saved per entry adds up + entries: HashMap>, +} + +impl Cache { + fn insert(&mut self, key: Key, data: Vec) { + // Convert to boxed slice for storage + self.entries.insert(key, data.into_boxed_slice()); + } + + fn get(&self, key: &Key) -> Option<&[u8]> { + // Returns regular slice reference + self.entries.get(key).map(|b| b.as_ref()) + } +} +``` + +## See Also + +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocating when size is known +- [own-slice-over-vec](./own-slice-over-vec.md) - Using slices in function parameters +- [mem-compact-string](./mem-compact-string.md) - Compact string alternatives diff --git a/.opencode/skills/rust-skills/rules/mem-clone-from.md b/.opencode/skills/rust-skills/rules/mem-clone-from.md new file mode 100644 index 0000000..d19cf3f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-clone-from.md @@ -0,0 +1,147 @@ +# mem-clone-from + +> Use `clone_from()` to reuse allocations when repeatedly cloning + +## Why It Matters + +`x = y.clone()` drops x's allocation and creates a new one from y. `x.clone_from(&y)` reuses x's existing allocation if possible, avoiding the allocation overhead. For repeatedly cloning into the same variable (loops, buffers), this can significantly reduce allocator pressure. + +## Bad + +```rust +let mut buffer = String::with_capacity(1024); + +for source in sources { + buffer = source.clone(); // Drops old allocation, allocates new + process(&buffer); +} + +// Each iteration: +// 1. Drops buffer's 1024-byte allocation +// 2. Allocates new memory for source.clone() +// Allocator thrashing! +``` + +## Good + +```rust +let mut buffer = String::with_capacity(1024); + +for source in sources { + buffer.clone_from(source); // Reuses allocation if capacity sufficient + process(&buffer); +} + +// If source.len() <= 1024, no allocation happens +// Just copies bytes into existing buffer +``` + +## How clone_from Works + +```rust +impl Clone for String { + fn clone(&self) -> Self { + // Always allocates new memory + String::from(self.as_str()) + } + + fn clone_from(&mut self, source: &Self) { + // Reuse existing capacity if possible + self.clear(); + self.push_str(source); // Only reallocates if capacity insufficient + } +} +``` + +## Types That Benefit + +```rust +// String - reuses capacity +let mut s = String::with_capacity(100); +s.clone_from(&other_string); + +// Vec - reuses capacity +let mut v: Vec = Vec::with_capacity(1000); +v.clone_from(&other_vec); + +// HashMap - reuses buckets +let mut map = HashMap::with_capacity(100); +map.clone_from(&other_map); + +// PathBuf - reuses capacity +let mut path = PathBuf::with_capacity(256); +path.clone_from(&other_path); +``` + +## Benchmarking the Difference + +```rust +use criterion::{black_box, criterion_group, Criterion}; + +fn bench_clone_patterns(c: &mut Criterion) { + let source = "x".repeat(1000); + + c.bench_function("clone assignment", |b| { + let mut buffer = String::new(); + b.iter(|| { + buffer = black_box(&source).clone(); + }); + }); + + c.bench_function("clone_from", |b| { + let mut buffer = String::with_capacity(1000); + b.iter(|| { + buffer.clone_from(black_box(&source)); + }); + }); +} +// clone_from is typically 2-3x faster for this pattern +``` + +## Custom Implementations + +When implementing Clone for your types: + +```rust +#[derive(Debug)] +struct Buffer { + data: Vec, + metadata: Metadata, +} + +impl Clone for Buffer { + fn clone(&self) -> Self { + Buffer { + data: self.data.clone(), + metadata: self.metadata.clone(), + } + } + + // Optimize clone_from to reuse vec capacity + fn clone_from(&mut self, source: &Self) { + self.data.clone_from(&source.data); // Reuses allocation + self.metadata = source.metadata.clone(); + } +} +``` + +## When NOT Needed + +```rust +// Single clone - no benefit +let copy = original.clone(); // Can't reuse, no prior allocation + +// Small Copy types - no allocation anyway +let x: i32 = y; // Not even Clone, just Copy + +// Immutable context +fn process(data: &String) { + // Can't use clone_from - would need &mut self +} +``` + +## See Also + +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocating capacity +- [mem-reuse-collections](./mem-reuse-collections.md) - Reusing collection allocations +- [own-clone-explicit](./own-clone-explicit.md) - When Clone is appropriate diff --git a/.opencode/skills/rust-skills/rules/mem-compact-string.md b/.opencode/skills/rust-skills/rules/mem-compact-string.md new file mode 100644 index 0000000..c016a8d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-compact-string.md @@ -0,0 +1,151 @@ +# mem-compact-string + +> Use compact string types for memory-constrained string storage + +## Why It Matters + +Standard `String` is 24 bytes (pointer + length + capacity). For applications storing millions of short strings, this overhead dominates. Compact string libraries like `compact_str`, `smartstring`, or `ecow` store small strings inline (no heap allocation) and use optimized layouts for larger strings. + +## Bad + +```rust +struct User { + id: u64, + // Most usernames are < 24 chars, but String is always 24 bytes + heap + username: String, + email: String, +} + +// 1 million users = 24 bytes * 2 * 1M = 48MB just for String metadata +// Plus all the heap allocations for actual content +``` + +## Good + +```rust +use compact_str::CompactString; + +struct User { + id: u64, + // CompactString: 24 bytes, but strings ≤ 23 bytes are inline (no heap) + username: CompactString, + email: CompactString, +} + +// Most usernames fit inline = zero heap allocations +// Same memory footprint as String but way fewer allocations +``` + +## Compact String Libraries + +### compact_str + +```rust +use compact_str::CompactString; + +// Inline storage for strings ≤ 23 bytes +let small: CompactString = "hello".into(); // No heap allocation + +// Automatic heap fallback for larger strings +let large: CompactString = "x".repeat(100).into(); + +// String-like API +let mut s = CompactString::new("hello"); +s.push_str(" world"); +assert_eq!(s.as_str(), "hello world"); + +// Format macro +use compact_str::format_compact; +let s = format_compact!("value: {}", 42); +``` + +### smartstring + +```rust +use smartstring::{SmartString, LazyCompact}; + +// Default is LazyCompact: 24 bytes inline capacity +let s: SmartString = "short string".into(); + +// Compact mode: 23 bytes inline on 64-bit +use smartstring::Compact; +let s: SmartString = "hello".into(); +``` + +### ecow (copy-on-write) + +```rust +use ecow::EcoString; + +// Clone is O(1) - shares underlying data +let s1: EcoString = "shared data".into(); +let s2 = s1.clone(); // Cheap, shares allocation + +// Copy-on-write: only allocates on mutation +let mut s3 = s1.clone(); +s3.push_str(" modified"); // Now allocates +``` + +## Memory Comparison + +```rust +use std::mem::size_of; + +// All 24 bytes, but different inline capacities +assert_eq!(size_of::(), 24); +assert_eq!(size_of::(), 24); +assert_eq!(size_of::(), 24); +assert_eq!(size_of::(), 16); // Even smaller! +``` + +## Inline Capacity + +| Type | Size | Inline Capacity | +|------|------|-----------------| +| `String` | 24 | 0 (always heap) | +| `CompactString` | 24 | 23 bytes [^1] | +| `SmartString` | 24 | 23 bytes | +| `EcoString` | 16 | 15 bytes | + +[^1]: CompactString reserves the final byte of its 24-byte representation for a length tag, so the maximum inline string length is 23 bytes. + +## When to Use + +```rust +// ✅ Good: Many short strings in memory +struct Dictionary { + words: Vec, // Millions of short words +} + +// ✅ Good: Frequently cloned strings +struct Template { + parts: Vec, // O(1) clone +} + +// ❌ Don't: Hot path string manipulation +fn transform(s: &str) -> String { + // Standard String is optimized for manipulation + s.to_uppercase() +} + +// ❌ Don't: API boundaries (prefer &str or String for interop) +pub fn public_api(input: CompactString) { } // Forces dependency +pub fn public_api(input: impl Into) { } // Better +``` + +## Cargo.toml + +```toml +[dependencies] +compact_str = "0.9" +# or +smartstring = "1.0" +# or +ecow = "0.2" +``` + +## See Also + +- [mem-boxed-slice](./mem-boxed-slice.md) - Box for immutable strings +- [own-cow-conditional](./own-cow-conditional.md) - Cow for borrow-or-own +- [mem-smallvec](./mem-smallvec.md) - Similar concept for Vec diff --git a/.opencode/skills/rust-skills/rules/mem-drop-order.md b/.opencode/skills/rust-skills/rules/mem-drop-order.md new file mode 100644 index 0000000..adcb72a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-drop-order.md @@ -0,0 +1,128 @@ +# mem-drop-order + +> Know and control drop order: struct fields drop top-to-bottom, locals in reverse + +## Why It Matters + +Drop order is observable. RAII guards (mutex locks, file handles, database transactions, span guards) do meaningful work in their `Drop` implementations, and dropping them in the wrong order silently causes bugs: releasing a lock while a transaction that depends on it is still alive, closing a connection before its transaction has committed, or dropping a tracing span before the work it covers has finished. The rules are fixed and deterministic, but easy to overlook when fields and locals accumulate over time. + +## The Rules + +| Construct | Drop order | +|---|---| +| Struct fields | Declaration order: first field declared, first dropped | +| Tuple / array elements | In order: index 0, 1, 2, … | +| Local variables | Reverse declaration order: last declared, first dropped | +| Temporaries in a statement | End of the statement (with some exceptions) | +| Function arguments | Reverse order of the parameter list | + +## Bad + +```rust +use std::sync::{Mutex, MutexGuard}; + +struct DatabaseSession { + // BUG: `guard` is declared first, so it drops FIRST. + // But `guard` protects the connection — dropping the lock + // before the transaction is committed lets another thread + // see the connection in a partial state. + guard: MutexGuard<'static, ()>, + transaction: Transaction, +} + +struct Transaction; // pretend this commits on drop + +impl Drop for Transaction { + fn drop(&mut self) { + println!("transaction committed"); + } +} +``` + +In this struct, `guard` drops before `transaction`, releasing the mutex while the transaction is still in-flight. + +## Good + +```rust +use std::sync::{Mutex, MutexGuard}; + +struct Transaction; // commits on drop + +impl Drop for Transaction { + fn drop(&mut self) { + println!("transaction committed"); + } +} + +struct DatabaseSession { + // CORRECT: `transaction` is declared first, so it drops first + // (commit happens), THEN `guard` drops (lock released). + transaction: Transaction, + guard: MutexGuard<'static, ()>, +} +``` + +Fields drop in declaration order, so the field at the top of the struct drops first. + +## Controlling Drop Order for Locals + +Local variables drop in **reverse** declaration order, which is often what you want (last-in, first-out). When the natural order is wrong, use explicit `drop`: + +```rust +fn process() { + let conn = open_connection(); // dropped third (last to drop) + let txn = begin_transaction(); // dropped second + let guard = acquire_lock(); // dropped first — WRONG if txn needs the lock + + // fix: drop guard explicitly before txn and conn drop naturally + do_work(&txn); + drop(guard); // lock released here + txn.commit(); // runs before conn closes +} // conn drops here +# fn open_connection() -> () {} +# fn begin_transaction() -> () {} +# fn acquire_lock() -> () {} +# fn do_work(_: &()) {} +# trait Commit { fn commit(self); } +# impl Commit for () { fn commit(self) {} } +``` + +## `ManuallyDrop` for Full Control + +When you need to opt completely out of automatic drop (e.g., in `unsafe` code, or to move a value out of a struct in `Drop`), use `std::mem::ManuallyDrop`: + +```rust +use std::mem::ManuallyDrop; + +struct ResourcePair { + // child must be cleaned up before parent + child: ManuallyDrop, + parent: Parent, +} + +impl Drop for ResourcePair { + fn drop(&mut self) { + // SAFETY: `child` is not accessed after this point + unsafe { ManuallyDrop::drop(&mut self.child) }; + // `parent` drops automatically after this block + } +} + +struct Child; +struct Parent; +``` + +`ManuallyDrop` inhibits the compiler from inserting automatic drop calls, giving you precise control. + +## Key Points + +- Reorder struct fields to encode the correct drop sequence — the declaration is the contract. +- Add a comment explaining the ordering when it is non-obvious: `// NOTE: drop order matters — transaction before connection`. +- Prefer explicit `drop(x)` calls in functions over relying on scope-exit order when the correct sequence is not immediately clear from the code. +- `std::mem::forget` drops nothing at all (leaks); only use it for FFI hand-off or when transferring ownership to unmanaged code. + +## See Also + +- [test-fixture-raii](test-fixture-raii.md) - use RAII pattern (Drop) for test cleanup +- [own-mutex-interior](own-mutex-interior.md) - use `Mutex` for interior mutability (multi-thread) +- [mem-take-replace](mem-take-replace.md) - use `mem::take` / `mem::replace` to move out of `&mut` diff --git a/.opencode/skills/rust-skills/rules/mem-reuse-collections.md b/.opencode/skills/rust-skills/rules/mem-reuse-collections.md new file mode 100644 index 0000000..b307779 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-reuse-collections.md @@ -0,0 +1,174 @@ +# mem-reuse-collections + +> Clear and reuse collections instead of creating new ones in loops + +## Why It Matters + +Creating new `Vec`, `String`, or `HashMap` instances in hot loops generates significant allocator pressure. Clearing a collection and reusing it keeps the existing capacity, avoiding repeated allocation/deallocation cycles. This is especially impactful for frequently-executed code paths. + +## Bad + +```rust +fn process_batches(batches: &[Batch]) -> Vec { + let mut results = Vec::new(); + + for batch in batches { + let mut temp = Vec::new(); // Allocates every iteration + + for item in &batch.items { + temp.push(transform(item)); + } + + results.push(aggregate(&temp)); + // temp dropped here, deallocation + } + + results +} + +fn format_lines(items: &[Item]) -> String { + let mut output = String::new(); + + for item in items { + let line = format!("{}: {}", item.name, item.value); // Allocates + output.push_str(&line); + output.push('\n'); + } + + output +} +``` + +## Good + +```rust +fn process_batches(batches: &[Batch]) -> Vec { + let mut results = Vec::with_capacity(batches.len()); + let mut temp = Vec::new(); // Allocate once outside loop + + for batch in batches { + temp.clear(); // Reuse allocation, just reset length + + for item in &batch.items { + temp.push(transform(item)); + } + + results.push(aggregate(&temp)); + // temp keeps its capacity for next iteration + } + + results +} + +fn format_lines(items: &[Item]) -> String { + use std::fmt::Write; + + let mut output = String::new(); + let mut line = String::new(); // Reusable buffer + + for item in items { + line.clear(); + write!(&mut line, "{}: {}", item.name, item.value).unwrap(); + output.push_str(&line); + output.push('\n'); + } + + output +} +``` + +## Clear vs Drain vs New + +```rust +let mut vec = vec![1, 2, 3, 4, 5]; + +// clear(): keeps capacity, O(n) for Drop types +vec.clear(); +assert_eq!(vec.len(), 0); +assert!(vec.capacity() >= 5); + +// drain(): returns iterator, clears after iteration +let drained: Vec<_> = vec.drain(..).collect(); + +// truncate(): keeps first n elements +vec.truncate(2); + +// Creating new: loses all capacity +vec = Vec::new(); // Capacity gone +``` + +## HashMap Reuse + +```rust +use std::collections::HashMap; + +fn count_words_per_line(lines: &[&str]) -> Vec> { + let mut results = Vec::with_capacity(lines.len()); + let mut counts = HashMap::new(); // Reuse across iterations + + for line in lines { + counts.clear(); // Keeps bucket allocation + + for word in line.split_whitespace() { + *counts.entry(word.to_string()).or_insert(0) += 1; + } + + results.push(counts.clone()); + } + + results +} +``` + +## BufWriter Pattern + +```rust +use std::io::{BufWriter, Write}; + +fn write_many_records(records: &[Record], mut output: impl Write) -> std::io::Result<()> { + // BufWriter reuses its internal buffer + let mut writer = BufWriter::with_capacity(8192, &mut output); + let mut line = String::with_capacity(256); // Reusable formatting buffer + + for record in records { + line.clear(); + format_record(record, &mut line); + writer.write_all(line.as_bytes())?; + writer.write_all(b"\n")?; + } + + writer.flush() +} +``` + +## When to Create Fresh + +```rust +// When ownership transfer is needed +fn produce_results() -> Vec> { + let mut results = Vec::new(); + + for batch in batches { + let processed: Vec = batch.process(); // Ownership transferred + results.push(processed); // Moved into results + } + + results // Each inner Vec is independent +} + +// When thread safety requires it +std::thread::scope(|s| { + for _ in 0..4 { + s.spawn(|| { + let local_buffer = Vec::new(); // Thread-local, can't share + // ... + }); + } +}); +``` + +## See Also + +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocating capacity +- [mem-clone-from](./mem-clone-from.md) - Reusing allocations when cloning +- [mem-write-over-format](./mem-write-over-format.md) - Avoiding format! allocations diff --git a/.opencode/skills/rust-skills/rules/mem-smaller-integers.md b/.opencode/skills/rust-skills/rules/mem-smaller-integers.md new file mode 100644 index 0000000..6ffd290 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-smaller-integers.md @@ -0,0 +1,161 @@ +# mem-smaller-integers + +> Use appropriately-sized integers to reduce memory footprint + +## Why It Matters + +Using `i64` when `i16` suffices wastes 6 bytes per value. In arrays, vectors, and structs with millions of instances, this waste compounds dramatically. Choosing the smallest integer type that fits your domain reduces memory usage and improves cache utilization. + +## Bad + +```rust +struct Pixel { + r: u64, // Color channels 0-255 = 8 bits needed + g: u64, // Using 64 bits = 8x waste + b: u64, + a: u64, +} +// Size: 32 bytes per pixel + +struct HttpStatus { + code: i32, // HTTP codes 100-599 = 10 bits needed + version: i32, // HTTP 1.0, 1.1, 2, 3 = 2 bits needed +} +// Size: 8 bytes per status + +struct GeoPoint { + lat: f64, // -90 to 90 + lon: f64, // -180 to 180 +} +// Often f32 precision is sufficient for display +``` + +## Good + +```rust +struct Pixel { + r: u8, + g: u8, + b: u8, + a: u8, +} +// Size: 4 bytes per pixel (8x smaller!) + +struct HttpStatus { + code: u16, // 100-599 fits in u16 + version: u8, // 1, 2, 3 fits in u8 +} +// Size: 3 bytes (+ 1 padding = 4 bytes) + +struct GeoPoint { + lat: f32, // ~7 decimal digits precision + lon: f32, // Sufficient for most geo applications +} +// Size: 8 bytes vs 16 bytes +``` + +## Integer Size Reference + +| Type | Range | Use For | +|------|-------|---------| +| `u8` | 0 to 255 | Bytes, small counts, flags | +| `i8` | -128 to 127 | Small signed values | +| `u16` | 0 to 65,535 | Port numbers, small indices | +| `i16` | -32,768 to 32,767 | Audio samples | +| `u32` | 0 to 4 billion | Array indices, timestamps (seconds) | +| `i32` | ±2 billion | General integers, file offsets | +| `u64` | 0 to 18 quintillion | Large counts, nanosecond timestamps | +| `usize` | Platform-dependent | Array indexing (required by Rust) | + +## Struct Packing + +```rust +use std::mem::size_of; + +// Poor ordering - 24 bytes due to padding +struct Wasteful { + a: u8, // 1 byte + 7 padding + b: u64, // 8 bytes + c: u8, // 1 byte + 7 padding +} +assert_eq!(size_of::(), 24); + +// Better ordering - 16 bytes +struct Efficient { + b: u64, // 8 bytes (aligned) + a: u8, // 1 byte + c: u8, // 1 byte + 6 padding +} +assert_eq!(size_of::(), 16); + +// Even better with smaller types - 10 bytes +struct Compact { + b: u32, // 4 bytes (if u32 suffices) + a: u8, // 1 byte + c: u8, // 1 byte +} +assert_eq!(size_of::(), 8); // With padding +``` + +## Conversion Safety + +```rust +// Safe: always succeeds (widening) +let small: u8 = 42; +let big: u32 = small.into(); + +// Fallible: may overflow (narrowing) +let big: u32 = 1000; +let small: u8 = big.try_into().expect("value out of range"); + +// Or use checked conversion +if let Ok(small) = u8::try_from(big) { + use_small(small); +} else { + handle_overflow(); +} +``` + +## Bitflags for Boolean Sets + +```rust +use bitflags::bitflags; + +// Instead of 8 separate bool fields (8 bytes minimum) +bitflags! { + struct Permissions: u8 { + const READ = 0b0000_0001; + const WRITE = 0b0000_0010; + const EXECUTE = 0b0000_0100; + const DELETE = 0b0000_1000; + } +} +// All 8 flags in 1 byte! + +let perms = Permissions::READ | Permissions::WRITE; +if perms.contains(Permissions::READ) { + // ... +} +``` + +## NonZero Types for Option Optimization + +```rust +use std::num::NonZeroU64; + +// Option = 16 bytes (no null pointer optimization) +assert_eq!(size_of::>(), 16); + +// Option = 8 bytes (0 represents None) +assert_eq!(size_of::>(), 8); + +let id: Option = NonZeroU64::new(42); +``` + +## See Also + +- [mem-box-large-variant](./mem-box-large-variant.md) - Optimizing enum sizes +- [mem-assert-type-size](./mem-assert-type-size.md) - Compile-time size checks +- [type-newtype-ids](./type-newtype-ids.md) - Type safety for integer IDs +- [num-nonzero](num-nonzero.md) - NonZero* niche optimization +- [num-cast-try-from](num-cast-try-from.md) - Avoid lossy `as` casts diff --git a/.opencode/skills/rust-skills/rules/mem-smallvec.md b/.opencode/skills/rust-skills/rules/mem-smallvec.md new file mode 100644 index 0000000..3c42cf5 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-smallvec.md @@ -0,0 +1,138 @@ +# mem-smallvec + +> Use `SmallVec` for usually-small collections + +## Why It Matters + +`SmallVec<[T; N]>` stores up to N elements inline (on the stack), only allocating on the heap when the size exceeds N. This eliminates heap allocations for the common case while still allowing growth when needed. + +## Bad + +```rust +// Always heap-allocates, even for 1-2 elements +fn get_path_components(path: &str) -> Vec<&str> { + path.split('/').collect() // Usually 2-4 components +} + +// Always heap-allocates for error list +fn validate(input: &Input) -> Vec { + let mut errors = Vec::new(); // Usually 0-3 errors + // validation logic... + errors +} +``` + +## Good + +```rust +use smallvec::{smallvec, SmallVec}; + +// Stack-allocated for typical paths (1-8 components) +fn get_path_components(path: &str) -> SmallVec<[&str; 8]> { + path.split('/').collect() +} + +// Stack-allocated for typical error counts +fn validate(input: &Input) -> SmallVec<[ValidationError; 4]> { + let mut errors = SmallVec::new(); + // validation logic... + errors +} + +// Using smallvec! macro +let v: SmallVec<[i32; 4]> = smallvec![1, 2, 3]; +``` + +## Choosing Capacity N + +```rust +// Measure your actual data distribution! +// Guidelines: + +// Path components: 4-8 (most paths are shallow) +type PathParts<'a> = SmallVec<[&'a str; 8]>; + +// Function arguments: 4-8 (most functions have few args) +type Args = SmallVec<[Arg; 8]>; + +// AST children: 2-4 (binary ops, if/else, etc.) +type Children = SmallVec<[Node; 4]>; + +// Error accumulation: 2-4 (most inputs have few errors) +type Errors = SmallVec<[Error; 4]>; + +// Attribute lists: 4-8 (most items have few attributes) +type Attrs = SmallVec<[Attribute; 8]>; +``` + +## Evidence from rust-analyzer + +```rust +// https://github.com/rust-lang/rust/blob/main/compiler/rustc_expand/src/base.rs +macro_rules! make_stmts_default { + ($me:expr) => { + $me.make_expr().map(|e| { + smallvec![ast::Stmt { + id: ast::DUMMY_NODE_ID, + span: e.span, + kind: ast::StmtKind::Expr(e), + }] + }) + } +} +``` + +## Trade-offs + +```rust +// SmallVec is slightly larger than Vec +use std::mem::size_of; +// Vec: 24 bytes (ptr + len + cap) +// SmallVec<[i32; 4]>: 32 bytes (inline storage + len + discriminant) + +// SmallVec has branching overhead on every operation +// (must check if inline or heap) + +// Profile to verify benefit! +``` + +## When to Use SmallVec vs Alternatives + +| Situation | Use | +|-----------|-----| +| Usually small, sometimes large | `SmallVec<[T; N]>` | +| Always small, fixed max | `ArrayVec` | +| Rarely grows past initial | `Vec::with_capacity` | +| No `unsafe` allowed | `TinyVec` | +| Often empty | `ThinVec` | + +## ArrayVec Alternative + +```rust +use arrayvec::ArrayVec; + +// Fixed maximum capacity, never heap allocates +// Panics if you exceed capacity +fn parse_rgb(s: &str) -> ArrayVec { + let mut components = ArrayVec::new(); + for part in s.split(',').take(3) { + components.push(part.parse().unwrap()); + } + components +} +``` + +## TinyVec (No Unsafe) + +```rust +use tinyvec::{tiny_vec, TinyVec}; + +// Same concept as SmallVec but 100% safe code +let v: TinyVec<[i32; 4]> = tiny_vec![1, 2, 3]; +``` + +## See Also + +- [mem-arrayvec](mem-arrayvec.md) - Use ArrayVec for fixed-max collections +- [mem-with-capacity](mem-with-capacity.md) - Pre-allocate when size is known +- [mem-thinvec](mem-thinvec.md) - Use ThinVec for often-empty vectors diff --git a/.opencode/skills/rust-skills/rules/mem-take-replace.md b/.opencode/skills/rust-skills/rules/mem-take-replace.md new file mode 100644 index 0000000..b1bd792 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-take-replace.md @@ -0,0 +1,121 @@ +# mem-take-replace + +> Use `mem::take` / `mem::replace` to move a value out of a `&mut` without cloning + +## Why It Matters + +Rust's ownership rules prevent you from moving a field out of a `&mut self` reference — the compiler must guarantee the field is not left in an invalid state. The standard workaround many developers reach for is `.clone()`, but that allocates unnecessarily. `std::mem::take` swaps the field with `T::default()` and returns the original value; `std::mem::replace` swaps in an explicit value of your choosing. Both are zero-copy and work wherever you have `&mut T`. + +## Bad + +```rust +struct Processor { + items: Vec, +} + +impl Processor { + // clones the entire Vec just to drain it — unnecessary allocation + fn flush(&mut self) -> Vec { + let v = self.items.clone(); + self.items.clear(); + v + } +} +``` + +## Good + +```rust +use std::mem; + +struct Processor { + items: Vec, +} + +impl Processor { + // moves the Vec out in one step, leaving an empty Vec behind + fn flush(&mut self) -> Vec { + mem::take(&mut self.items) + } +} +``` + +`mem::take` is equivalent to `mem::replace(&mut self.items, Vec::new())` but shorter when the replacement value is `Default::default()`. + +## State-Machine Transition with `mem::replace` + +A common pattern in state machines and `Future::poll` implementations is replacing a field with an explicit next state rather than the default: + +```rust +use std::mem; + +#[derive(Debug)] +enum State { + Idle, + Loading { url: String }, + Done { body: String }, +} + +impl Default for State { + fn default() -> Self { + State::Idle + } +} + +struct Machine { + state: State, +} + +impl Machine { + fn start_load(&mut self, url: String) { + // replace Idle with Loading, getting Idle back (discarded here) + let _prev = mem::replace(&mut self.state, State::Loading { url }); + } + + fn complete(&mut self, body: String) { + // replace Loading with Done; capture old state if needed for logging + match mem::replace(&mut self.state, State::Done { body }) { + State::Loading { url } => { + println!("finished loading {url}"); + } + other => { + // unexpected transition — put it back or handle the error + self.state = other; + } + } + } +} +``` + +## Consuming a Field in `Drop` + +`mem::take` is also the idiomatic way to move out of a field inside `Drop`, where you only have `&mut self`: + +```rust +use std::mem; + +struct FileWriter { + buffer: Vec, + // imagine a real file handle here +} + +impl Drop for FileWriter { + fn drop(&mut self) { + let data = mem::take(&mut self.buffer); + // flush `data` to disk without an extra allocation + let _ = data; // pretend this writes somewhere + } +} +``` + +## Key Points + +- `mem::take` requires `T: Default`. If `T` has no meaningful default, use `mem::replace` with an explicit sentinel value (e.g., an `Option` field — `mem::take` an `Option` yields `None`, which is often exactly right). +- Both functions are `#[inline]` and compile away to a few register moves with no heap involvement. +- `Option` is a natural pairing: keep expensive values in `Option` and call `self.field.take()` (the `Option::take` method, same idea) to move ownership out cleanly. + +## See Also + +- [own-move-large](own-move-large.md) - move large data instead of cloning +- [mem-clone-from](mem-clone-from.md) - use `clone_from()` to reuse allocations +- [own-borrow-over-clone](own-borrow-over-clone.md) - prefer `&T` borrowing over `.clone()` diff --git a/.opencode/skills/rust-skills/rules/mem-thinvec.md b/.opencode/skills/rust-skills/rules/mem-thinvec.md new file mode 100644 index 0000000..a0b4936 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-thinvec.md @@ -0,0 +1,142 @@ +# mem-thinvec + +> Use `ThinVec` for nullable collections with minimal overhead + +## Why It Matters + +Standard `Vec` is 24 bytes even when empty. `ThinVec` from Mozilla's `thin_vec` crate uses a single pointer (8 bytes), storing length and capacity inline with the heap allocation. For Option> patterns or structs with many optional vecs, this significantly reduces memory overhead. + +## Bad + +```rust +struct TreeNode { + value: i32, + // Each node pays 24 bytes for children, even leaves + children: Vec, // Most nodes are leaves with empty Vec +} + +// Or using Option> +struct SparseData { + // Option = 24 bytes (Vec is never null-pointer optimized) + tags: Option>, + metadata: Option>, + // 48 bytes for usually-None fields +} +``` + +## Good + +```rust +use thin_vec::ThinVec; + +struct TreeNode { + value: i32, + // Empty ThinVec is just a null pointer - 8 bytes + children: ThinVec, +} + +struct SparseData { + // ThinVec empty = 8 bytes each + tags: ThinVec, + metadata: ThinVec, + // 16 bytes vs 48 bytes +} +``` + +## Memory Layout + +```rust +use std::mem::size_of; + +// Standard Vec: always 24 bytes +assert_eq!(size_of::>(), 24); +assert_eq!(size_of::>>(), 24); // No NPO benefit + +// ThinVec: 8 bytes (one pointer) +use thin_vec::ThinVec; +assert_eq!(size_of::>(), 8); +assert_eq!(size_of::>>(), 8); // Option is free! +``` + +## ThinVec vs Vec + +| Feature | `Vec` | `ThinVec` | +|---------|----------|--------------| +| Size (empty) | 24 bytes | 8 bytes | +| Size (non-empty) | 24 bytes | 8 bytes (header on heap) | +| Option optimization | No | Yes | +| Cache locality | Better (len/cap on stack) | Worse (len/cap on heap) | +| Iteration speed | Faster | Slightly slower | +| API compatibility | Full | Vec-like | + +## When to Use ThinVec + +```rust +// ✅ Good: Many instances, often empty +struct SparseGraph { + nodes: Vec, + // Most edges lists are empty or small + edges: Vec>, // Saves 16 bytes per node +} + +// ✅ Good: Nullable collection field +struct Document { + content: String, + attachments: ThinVec, // Often empty +} + +// ❌ Avoid: Hot loops, performance-critical iteration +fn process_hot_path(data: &ThinVec) { + // Every length check goes through pointer indirection + for item in data { // Vec would be faster here + process(item); + } +} + +// ❌ Avoid: Few instances +fn main() { + let single_vec: ThinVec = ThinVec::new(); + // Saving 16 bytes once is meaningless +} +``` + +## API Compatibility + +```rust +use thin_vec::{ThinVec, thin_vec}; + +// Constructor macro +let v: ThinVec = thin_vec![1, 2, 3]; + +// Familiar Vec-like API +let mut v = ThinVec::new(); +v.push(1); +v.push(2); +v.extend([3, 4, 5]); +v.pop(); + +// Iteration +for item in &v { + println!("{}", item); +} + +// Slicing +let slice: &[i32] = &v[..]; + +// Conversion +let vec: Vec = v.into(); +let thin: ThinVec = vec.into(); +``` + +## Cargo.toml + +```toml +[dependencies] +thin-vec = "0.2" +``` + +## See Also + +- [mem-smallvec](./mem-smallvec.md) - Stack-allocated small vecs +- [mem-boxed-slice](./mem-boxed-slice.md) - Fixed-size heap slices +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation strategies diff --git a/.opencode/skills/rust-skills/rules/mem-with-capacity.md b/.opencode/skills/rust-skills/rules/mem-with-capacity.md new file mode 100644 index 0000000..6d53aea --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-with-capacity.md @@ -0,0 +1,157 @@ +# mem-with-capacity + +> Use `with_capacity()` when size is known + +## Why It Matters + +When you know (or can estimate) the final size of a collection, pre-allocating avoids multiple reallocations as it grows. Each reallocation copies all existing elements, so avoiding them can dramatically improve performance. + +## Bad + +```rust +// Vec starts at capacity 0, reallocates at 4, 8, 16, 32... +let mut results = Vec::new(); +for i in 0..1000 { + results.push(process(i)); // ~10 reallocations! +} + +// String grows similarly +let mut output = String::new(); +for word in words { + output.push_str(word); + output.push(' '); +} + +// HashMap default capacity is small +let mut map = HashMap::new(); +for (k, v) in pairs { // Many reallocations + map.insert(k, v); +} +``` + +## Good + +```rust +// Pre-allocate exact size +let mut results = Vec::with_capacity(1000); +for i in 0..1000 { + results.push(process(i)); // Zero reallocations! +} + +// Or use collect with size hint (iterator provides capacity) +let results: Vec<_> = (0..1000).map(process).collect(); + +// Pre-allocate string +let estimated_len = words.iter().map(|w| w.len() + 1).sum(); +let mut output = String::with_capacity(estimated_len); +for word in words { + output.push_str(word); + output.push(' '); +} + +// Pre-allocate HashMap +let mut map = HashMap::with_capacity(pairs.len()); +for (k, v) in pairs { + map.insert(k, v); +} +``` + +## Collection Capacity Methods + +```rust +// Vec +let mut v = Vec::with_capacity(100); +v.reserve(50); // Ensure at least 50 more slots +v.reserve_exact(50); // Ensure exactly 50 more (no extra) +v.shrink_to_fit(); // Release unused capacity + +// String +let mut s = String::with_capacity(100); +s.reserve(50); + +// HashMap / HashSet +let mut m = HashMap::with_capacity(100); +m.reserve(50); + +// VecDeque +let mut d = VecDeque::with_capacity(100); +``` + +## Estimating Capacity + +```rust +// From iterator length +fn collect_results(items: &[Item]) -> Vec { + let mut results = Vec::with_capacity(items.len()); + for item in items { + results.push(process(item)); + } + results +} + +// From filter estimate (if ~10% pass filter) +fn filter_valid(items: &[Item]) -> Vec<&Item> { + let mut valid = Vec::with_capacity(items.len() / 10); + for item in items { + if item.is_valid() { + valid.push(item); + } + } + valid +} + +// String from parts +fn join_with_sep(parts: &[&str], sep: &str) -> String { + let total_len: usize = parts.iter().map(|p| p.len()).sum(); + let sep_len = if parts.is_empty() { 0 } else { sep.len() * (parts.len() - 1) }; + + let mut result = String::with_capacity(total_len + sep_len); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + result.push_str(sep); + } + result.push_str(part); + } + result +} +``` + +## Evidence from Production Code + +From fd (file finder): +```rust +// https://github.com/sharkdp/fd/blob/master/src/walk.rs +struct ReceiverBuffer<'a, W> { + buffer: Vec, + // ... +} + +impl<'a, W: Write> ReceiverBuffer<'a, W> { + fn new(...) -> Self { + Self { + buffer: Vec::with_capacity(MAX_BUFFER_LENGTH), + // ... + } + } +} +``` + +## When to Skip + +```rust +// Unknown size, small expected +let mut small: Vec = Vec::new(); // OK for small collections + +// Using collect() with good size_hint +let v: Vec<_> = iter.collect(); // collect() uses size_hint + +// Capacity overhead exceeds benefit +let mut rarely_used = Vec::new(); // OK if rarely grown +``` + +## See Also + +- [mem-reuse-collections](mem-reuse-collections.md) - Reuse collections with clear() +- [mem-smallvec](mem-smallvec.md) - Use SmallVec for usually-small collections +- [perf-extend-batch](perf-extend-batch.md) - Use extend() for batch insertions +- [coll-seq-choice](coll-seq-choice.md) - Pick the right sequence type diff --git a/.opencode/skills/rust-skills/rules/mem-write-over-format.md b/.opencode/skills/rust-skills/rules/mem-write-over-format.md new file mode 100644 index 0000000..3143e66 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-write-over-format.md @@ -0,0 +1,179 @@ +# mem-write-over-format + +> Use `write!()` into existing buffers instead of `format!()` allocations + +## Why It Matters + +`format!()` always allocates a new `String`. In hot paths or loops, these allocations add up. `write!()` writes directly into an existing buffer, reusing its capacity. For high-frequency formatting operations, this can eliminate significant allocator overhead. + +## Bad + +```rust +fn log_event(event: &Event, output: &mut Vec) { + // format! allocates a new String every call + let line = format!( + "[{}] {}: {}\n", + event.timestamp, + event.level, + event.message + ); + output.extend_from_slice(line.as_bytes()); +} + +fn build_response(items: &[Item]) -> String { + let mut result = String::new(); + + for item in items { + // format! allocates for each item + result.push_str(&format!("{}: {}\n", item.name, item.value)); + } + + result +} +``` + +## Good + +```rust +use std::fmt::Write; + +fn log_event(event: &Event, output: &mut Vec) { + use std::io::Write; + // write! to Vec directly, no intermediate allocation + write!( + output, + "[{}] {}: {}\n", + event.timestamp, + event.level, + event.message + ).unwrap(); +} + +fn build_response(items: &[Item]) -> String { + use std::fmt::Write; + + let mut result = String::with_capacity(items.len() * 64); + + for item in items { + // write! into existing String, reuses capacity + write!(&mut result, "{}: {}\n", item.name, item.value).unwrap(); + } + + result +} +``` + +## Write Trait Varieties + +```rust +// std::fmt::Write - for String, &mut String +use std::fmt::Write as FmtWrite; +let mut s = String::new(); +write!(&mut s, "Hello {}", 42).unwrap(); + +// std::io::Write - for Vec, File, TcpStream, etc. +use std::io::Write as IoWrite; +let mut v: Vec = Vec::new(); +write!(&mut v, "Hello {}", 42).unwrap(); + +// Both can fail in principle, but String/Vec never fail +// Still need .unwrap() due to Result return type +``` + +## Reusable Formatting Buffer + +```rust +use std::fmt::Write; + +struct Formatter { + buffer: String, +} + +impl Formatter { + fn new() -> Self { + Self { buffer: String::with_capacity(1024) } + } + + fn format_event(&mut self, event: &Event) -> &str { + self.buffer.clear(); // Reuse allocation + write!( + &mut self.buffer, + "[{}] {}", + event.timestamp, + event.message + ).unwrap(); + &self.buffer + } +} + +// Usage +let mut formatter = Formatter::new(); +for event in events { + let formatted = formatter.format_event(event); + send_log(formatted); +} +``` + +## writeln! for Lines + +```rust +use std::fmt::Write; + +let mut output = String::new(); + +// writeln! adds newline automatically +writeln!(&mut output, "Line 1: {}", value1).unwrap(); +writeln!(&mut output, "Line 2: {}", value2).unwrap(); + +// Equivalent to +write!(&mut output, "Line 1: {}\n", value1).unwrap(); +``` + +## When format! Is Fine + +```rust +// One-time formatting, not in loop +let message = format!("Starting server on port {}", port); +log::info!("{}", message); + +// Return value (can't return reference to local buffer) +fn describe(item: &Item) -> String { + format!("{}: {}", item.name, item.value) // Must allocate +} + +// Debug/error paths (not hot) +if condition { + panic!("Unexpected: {}", format!("details: {:?}", debug_info)); +} +``` + +## Benchmark Difference + +Writing into a pre-allocated buffer avoids per-call heap allocation and is +consistently faster than `format!()` in tight loops. The exact difference +depends on string length, allocator, and hardware — measure with +[criterion](https://crates.io/crates/criterion) in your own workload. + +```rust +use std::fmt::Write; // brings the write! target trait into scope + +// format! in loop: new heap allocation on every iteration +for i in 0..1000 { + let s = format!("item-{}", i); + process(&s); +} + +// write! with reuse: no allocation after the first iteration +let mut buf = String::with_capacity(32); +for i in 0..1000 { + buf.clear(); + write!(&mut buf, "item-{}", i).unwrap(); + process(&buf); +} +``` + +## See Also + +- [mem-avoid-format](./mem-avoid-format.md) - General format! avoidance patterns +- [mem-reuse-collections](./mem-reuse-collections.md) - Reusing buffers in loops +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocating string capacity diff --git a/.opencode/skills/rust-skills/rules/mem-zero-copy.md b/.opencode/skills/rust-skills/rules/mem-zero-copy.md new file mode 100644 index 0000000..bffcc83 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/mem-zero-copy.md @@ -0,0 +1,166 @@ +# mem-zero-copy + +> Use zero-copy patterns with slices and `Bytes` + +## Why It Matters + +Zero-copy means working with data without copying it. Instead of allocating new memory and copying bytes, you work with references to the original data. This dramatically reduces memory usage and improves performance, especially for large data. + +## Bad + +```rust +// Copies every line into a new String +fn get_lines(data: &str) -> Vec { + data.lines() + .map(|line| line.to_string()) // Allocates! + .collect() +} + +// Copies the entire buffer +fn process_packet(buffer: &[u8]) -> Vec { + let header = buffer[0..16].to_vec(); // Copy! + let body = buffer[16..].to_vec(); // Copy! + // Process... + [header, body].concat() // Another copy! +} +``` + +## Good + +```rust +// Zero-copy: returns references to original data +fn get_lines(data: &str) -> Vec<&str> { + data.lines().collect() // Just pointers! +} + +// Zero-copy with slices +fn process_packet(buffer: &[u8]) -> (&[u8], &[u8]) { + let header = &buffer[0..16]; // Just a pointer + length + let body = &buffer[16..]; // Just a pointer + length + (header, body) +} +``` + +## Using bytes::Bytes + +```rust +use bytes::Bytes; + +// Bytes provides zero-copy slicing with reference counting +let data = Bytes::from("hello world"); + +// Slicing doesn't copy - just increments refcount +let hello = data.slice(0..5); // Zero-copy! +let world = data.slice(6..11); // Zero-copy! + +// Both hello and world share the underlying allocation +// Memory is freed when all references are dropped +``` + +## Common Pattern: Cow for Static Strings + +A common pattern — seen in HTTP servers and similar code — returns +`Cow<'static, str>` to avoid allocating for well-known static values: + +```rust +fn method_to_cow(method: &http::Method) -> Cow<'static, str> { + match *method { + Method::GET => Cow::Borrowed("GET"), // Zero-copy + Method::POST => Cow::Borrowed("POST"), // Zero-copy + Method::PUT => Cow::Borrowed("PUT"), // Zero-copy + _ => Cow::Owned(method.to_string()), // Only copies for rare methods + } +} +``` + +## Zero-Copy Parsing + +```rust +// Bad: Copies each parsed field +struct ParsedBad { + name: String, + value: String, +} + +fn parse_bad(input: &str) -> ParsedBad { + let (name, value) = input.split_once('=').unwrap(); + ParsedBad { + name: name.to_string(), // Copy! + value: value.to_string(), // Copy! + } +} + +// Good: References into original string +struct Parsed<'a> { + name: &'a str, + value: &'a str, +} + +fn parse_good(input: &str) -> Parsed<'_> { + let (name, value) = input.split_once('=').unwrap(); + Parsed { name, value } // Zero-copy! +} +``` + +## Combining with Cow + +```rust +use std::borrow::Cow; + +// Zero-copy when possible, copy when needed +fn normalize<'a>(input: &'a str) -> Cow<'a, str> { + if input.contains('\t') { + // Must copy to modify + Cow::Owned(input.replace('\t', " ")) + } else { + // Zero-copy reference + Cow::Borrowed(input) + } +} +``` + +## memchr for Fast Searching + +```rust +use memchr::memchr; + +// Fast byte search using SIMD +fn find_newline(data: &[u8]) -> Option { + memchr(b'\n', data) // SIMD-accelerated, no allocation +} + +// Find all occurrences +use memchr::memchr_iter; + +fn count_newlines(data: &[u8]) -> usize { + memchr_iter(b'\n', data).count() +} +``` + +## When Zero-Copy Isn't Possible + +```rust +// Need to modify data - must copy +fn uppercase(s: &str) -> String { + s.to_uppercase() // Creates new String +} + +// Need data to outlive source +fn store_for_later(s: &str) -> String { + s.to_string() // Must copy for ownership +} + +// Cross-thread transfer (without Arc) +fn send_to_thread(data: &[u8]) { + let owned = data.to_vec(); // Must copy + std::thread::spawn(move || { + process(&owned); + }); +} +``` + +## See Also + +- [own-cow-conditional](own-cow-conditional.md) - Use Cow for conditional ownership +- [own-borrow-over-clone](own-borrow-over-clone.md) - Prefer borrowing over cloning +- [mem-arena-allocator](mem-arena-allocator.md) - Arena allocators for batch operations diff --git a/.opencode/skills/rust-skills/rules/name-acronym-word.md b/.opencode/skills/rust-skills/rules/name-acronym-word.md new file mode 100644 index 0000000..b65ac70 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-acronym-word.md @@ -0,0 +1,99 @@ +# name-acronym-word + +> Treat acronyms as words in identifiers: `HttpServer`, not `HTTPServer` + +## Why It Matters + +When acronyms are written in ALL CAPS within identifiers, word boundaries become unclear: is `HTTPSHandler` "HTTPS Handler" or "HTTP SHandler"? Treating acronyms as words (`HttpsHandler`) maintains clear word boundaries and follows Rust convention. The standard library uses this consistently. + +## Bad + +```rust +// ALL CAPS acronyms - unclear word boundaries +struct HTTPServer { ... } // HTTP + Server or H + TTP + Server? +struct TCPIPConnection { ... } // TCP + IP? Or other splits? +struct JSONParser { ... } +struct XMLHTTPRequest { ... } // Very confusing + +fn parseJSON(input: &str) { ... } +fn connectTCP(addr: &str) { ... } +``` + +## Good + +```rust +// Acronyms as words - clear boundaries +struct HttpServer { ... } // Http + Server +struct TcpIpConnection { ... } // Tcp + Ip + Connection +struct JsonParser { ... } +struct XmlHttpRequest { ... } + +fn parse_json(input: &str) { ... } +fn connect_tcp(addr: &str) { ... } + +// More examples +struct Uuid { ... } // Not UUID +struct Uri { ... } // Not URI +struct Url { ... } // Not URL +struct Html { ... } // Not HTML +struct Css { ... } // Not CSS +struct Api { ... } // Not API +``` + +## Standard Library Examples + +```rust +// std uses acronyms as words +std::net::TcpStream // Not TCPStream +std::net::TcpListener // Not TCPListener +std::net::UdpSocket // Not UDPSocket +std::net::IpAddr // Not IPAddr +std::io::IoError // Not IOError (though Io is acceptable too) +``` + +## Two-Letter Acronyms + +```rust +// Two-letter acronyms can go either way +struct Io { ... } // or IO - both acceptable +struct Id { ... } // or ID - both acceptable + +// Preference: treat as word for consistency +struct IoHandler { ... } // Preferred +struct IdGenerator { ... } // Preferred +``` + +## In snake_case + +```rust +// Acronyms become lowercase in snake_case +fn parse_json() { ... } +fn connect_tcp() { ... } +fn generate_uuid() { ... } +fn fetch_http() { ... } +fn encode_url() { ... } + +// Variables +let json_response = fetch_json(); +let tcp_connection = connect_tcp(); +let user_id = generate_uuid(); +``` + +## Mixed Cases + +```rust +// When acronym is part of compound +struct HttpsConnection { ... } // Https (not HTTPS) +struct Utf8String { ... } // Utf8 (not UTF8) +struct Base64Encoder { ... } // Base64 as word + +// Multiple acronyms +struct JsonApiClient { ... } // Json + Api + Client +struct RestApiHandler { ... } // Rest + Api + Handler +``` + +## See Also + +- [name-types-camel](./name-types-camel.md) - Type naming conventions +- [name-funcs-snake](./name-funcs-snake.md) - Function naming conventions +- [name-consts-screaming](./name-consts-screaming.md) - Constant naming diff --git a/.opencode/skills/rust-skills/rules/name-as-free.md b/.opencode/skills/rust-skills/rules/name-as-free.md new file mode 100644 index 0000000..d4ddc42 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-as-free.md @@ -0,0 +1,104 @@ +# name-as-free + +> `as_` prefix: free reference conversion + +## Why It Matters + +Consistent naming helps users understand API cost. `as_` prefix signals a free (O(1), no allocation) conversion that returns a reference. This convention is used throughout the standard library. + +## The Convention + +| Prefix | Cost | Ownership | Example | +|--------|------|-----------|---------| +| `as_` | Free | `&T -> &U` | `str::as_bytes()` | +| `to_` | Expensive | `&T -> U` | `str::to_lowercase()` | +| `into_` | Variable | `T -> U` | `String::into_bytes()` | + +## Examples + +```rust +impl MyString { + // as_ - free reference conversion + pub fn as_str(&self) -> &str { + &self.inner + } + + pub fn as_bytes(&self) -> &[u8] { + self.inner.as_bytes() + } +} + +impl Wrapper { + // as_ - returns reference to inner + pub fn as_inner(&self) -> &T { + &self.inner + } + + pub fn as_inner_mut(&mut self) -> &mut T { + &mut self.inner + } +} +``` + +## Standard Library Examples + +```rust +// String +let s = String::from("hello"); +let bytes: &[u8] = s.as_bytes(); // Free, returns &[u8] +let str_ref: &str = s.as_str(); // Free, returns &str + +// Vec +let v = vec![1, 2, 3]; +let slice: &[i32] = v.as_slice(); // Free, returns &[i32] + +// Path +let p = PathBuf::from("/home"); +let path: &Path = p.as_path(); // Free, returns &Path + +// OsString +let os = OsString::from("hello"); +let os_str: &OsStr = os.as_os_str(); // Free, returns &OsStr +``` + +## Bad + +```rust +impl MyType { + // BAD: as_ but allocates + pub fn as_string(&self) -> String { + format!("{}", self.value) // Allocates! Should be to_string() + } + + // BAD: as_ but expensive + pub fn as_processed(&self) -> &ProcessedData { + // Actually does expensive computation + } +} +``` + +## Good + +```rust +impl MyType { + // GOOD: Free reference + pub fn as_str(&self) -> &str { + &self.inner + } + + // GOOD: to_ signals allocation + pub fn to_string(&self) -> String { + format!("{}", self.value) + } + + // GOOD: into_ signals ownership transfer + pub fn into_inner(self) -> Inner { + self.inner + } +} +``` + +## See Also + +- [name-to-expensive](name-to-expensive.md) - `to_` prefix for expensive conversions +- [name-into-ownership](name-into-ownership.md) - `into_` prefix for ownership transfer diff --git a/.opencode/skills/rust-skills/rules/name-consts-screaming.md b/.opencode/skills/rust-skills/rules/name-consts-screaming.md new file mode 100644 index 0000000..dcb2cfa --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-consts-screaming.md @@ -0,0 +1,94 @@ +# name-consts-screaming + +> Use `SCREAMING_SNAKE_CASE` for constants and statics + +## Why It Matters + +Constants and statics are special—they're known at compile time and have program-wide lifetime. `SCREAMING_SNAKE_CASE` makes them visually distinct from runtime variables. This convention is enforced by the compiler and universally expected. + +## Bad + +```rust +// lowercase/camelCase constants - compiler warns +const maxConnections: u32 = 100; // warning +const default_timeout: u64 = 30; // warning +static globalCounter: AtomicU64 = AtomicU64::new(0); // warning +``` + +## Good + +```rust +// SCREAMING_SNAKE_CASE for constants +const MAX_CONNECTIONS: u32 = 100; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const BUFFER_SIZE: usize = 4096; + +// SCREAMING_SNAKE_CASE for statics +static GLOBAL_COUNTER: AtomicU64 = AtomicU64::new(0); +static CONFIG: OnceLock = OnceLock::new(); + +// Type-level constants in impl blocks +impl Buffer { + const INITIAL_CAPACITY: usize = 1024; + const MAX_CAPACITY: usize = 1024 * 1024; +} +``` + +## Associated Constants + +```rust +trait Limit { + const MAX: usize; + const MIN: usize; +} + +impl Limit for SmallBuffer { + const MAX: usize = 256; + const MIN: usize = 16; +} + +// Generic associated constants +struct Container { + data: Vec, +} + +impl Container { + const EMPTY: Self = Self { data: Vec::new() }; +} +``` + +## Environment and Config + +```rust +// Environment variable names +const ENV_DATABASE_URL: &str = "DATABASE_URL"; +const ENV_LOG_LEVEL: &str = "LOG_LEVEL"; + +// Configuration keys +const CONFIG_TIMEOUT_SECONDS: &str = "timeout_seconds"; +const CONFIG_MAX_RETRIES: &str = "max_retries"; +``` + +## Lazy Static / OnceLock + +```rust +use std::sync::OnceLock; + +// Global configuration +static CONFIG: OnceLock = OnceLock::new(); + +// Compiled regex +static EMAIL_REGEX: OnceLock = OnceLock::new(); + +fn get_email_regex() -> &'static Regex { + EMAIL_REGEX.get_or_init(|| { + Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap() + }) +} +``` + +## See Also + +- [name-funcs-snake](./name-funcs-snake.md) - Function/variable naming +- [name-types-camel](./name-types-camel.md) - Type naming +- [type-newtype-ids](./type-newtype-ids.md) - Type-safe constants diff --git a/.opencode/skills/rust-skills/rules/name-crate-no-rs.md b/.opencode/skills/rust-skills/rules/name-crate-no-rs.md new file mode 100644 index 0000000..28c2ba0 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-crate-no-rs.md @@ -0,0 +1,78 @@ +# name-crate-no-rs + +> Don't suffix crate names with `-rs` or `-rust` + +## Why It Matters + +Adding `-rs` or `-rust` to crate names is redundant—you're already on crates.io, it's obviously Rust. These suffixes waste characters, clutter the namespace, and can make crate names harder to type. The Rust community discourages this pattern. + +## Bad + +```toml +# Cargo.toml +[package] +name = "json-parser-rs" # Redundant -rs +name = "my-lib-rust" # Redundant -rust +name = "http-client-rs" # We know it's Rust +name = "rust-sqlite" # rust- prefix equally bad +``` + +## Good + +```toml +# Cargo.toml +[package] +name = "json-parser" +name = "my-lib" +name = "http-client" +name = "sqlite-wrapper" + +# Real crate examples (no -rs): +# serde (not serde-rs) +# tokio (not tokio-rs) +# reqwest (not reqwest-rs) +# clap (not clap-rs) +``` + +## When Context Is Needed + +```toml +# If you're porting a library from another language: +name = "python-ast" # Describes what it's for, not what it's written in + +# If you're providing bindings: +name = "openssl" # The Rust crate IS the Rust interface + +# Platform-specific: +name = "windows-sys" # Platform, not language +``` + +## Repository Naming + +``` +# GitHub repos don't need -rs either +github.com/user/my-library # Good +github.com/user/my-library-rs # Unnecessary + +# Though some do for disambiguation from other language versions +github.com/rust-lang/rust # The rust repo itself uses "rust" +``` + +## Exceptions + +```toml +# Rare cases where disambiguation matters: +# - If there's a widely-known non-Rust project with the same name +# - Official Rust project repositories (rust-lang org) + +# But even then, consider alternatives: +name = "fancy-lib" # Instead of fancy-rs +name = "better-json" # Instead of json-rust +name = "my-serde-impl" # Instead of serde-rs-fork +``` + +## See Also + +- [proj-workspace-deps](./proj-workspace-deps.md) - Cargo configuration +- [doc-cargo-metadata](./doc-cargo-metadata.md) - Package metadata +- [name-funcs-snake](./name-funcs-snake.md) - Naming conventions diff --git a/.opencode/skills/rust-skills/rules/name-funcs-snake.md b/.opencode/skills/rust-skills/rules/name-funcs-snake.md new file mode 100644 index 0000000..e2d4226 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-funcs-snake.md @@ -0,0 +1,76 @@ +# name-funcs-snake + +> Use `snake_case` for functions, methods, variables, and modules + +## Why It Matters + +Rust uses `snake_case` for "value-level" names—functions, methods, variables, modules. This convention is enforced by the compiler and distinguishes runtime entities from types. Consistent naming makes code scannable and predictable. + +## Bad + +```rust +// CamelCase functions - compiler warns +fn calculateTotal() -> f64 { ... } // warning: function `calculateTotal` should have a snake case name +fn getUserName() -> String { ... } // warning + +// Inconsistent naming +fn get_user() -> User { ... } +fn fetchOrder() -> Order { ... } // Mixed conventions +``` + +## Good + +```rust +// snake_case for functions +fn calculate_total() -> f64 { ... } +fn get_user_name() -> String { ... } +fn fetch_order() -> Order { ... } + +// snake_case for methods +impl User { + fn full_name(&self) -> String { ... } + fn is_active(&self) -> bool { ... } + fn set_email(&mut self, email: &str) { ... } +} + +// snake_case for variables +let user_count = 42; +let max_connections = 100; +let is_valid = true; + +// snake_case for modules +mod user_service; +mod http_client; +mod json_parser; +``` + +## Acronyms in snake_case + +```rust +// Lowercase acronyms in snake_case +fn parse_json() -> Json { ... } // Not parse_JSON +fn connect_tcp() -> TcpStream { ... } // Not connect_TCP +fn generate_uuid() -> Uuid { ... } // Not generate_UUID + +let http_response = fetch(); +let json_data = parse(); +``` + +## Local Variables + +```rust +fn process_data(input_data: &[u8]) -> Result { + let raw_bytes = input_data; + let decoded_string = decode(raw_bytes)?; + let parsed_value = parse(&decoded_string)?; + let final_result = transform(parsed_value)?; + + Ok(final_result) +} +``` + +## See Also + +- [name-types-camel](./name-types-camel.md) - Type naming +- [name-consts-screaming](./name-consts-screaming.md) - Constant naming +- [name-lifetime-short](./name-lifetime-short.md) - Lifetime naming diff --git a/.opencode/skills/rust-skills/rules/name-into-ownership.md b/.opencode/skills/rust-skills/rules/name-into-ownership.md new file mode 100644 index 0000000..320003a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-into-ownership.md @@ -0,0 +1,123 @@ +# name-into-ownership + +> Use `into_` prefix for ownership-consuming conversions + +## Why It Matters + +The `into_` prefix signals "this method consumes self and returns something else." The original value is moved and no longer usable. This ownership transfer is usually cheap (no allocation), but the caller loses access to the original. Clear naming prevents "use after move" confusion. + +## Bad + +```rust +impl Wrapper { + // Misleading: doesn't indicate ownership transfer + fn get_inner(self) -> Inner { + self.inner + } + + // Misleading: suggests borrowing + fn as_inner(self) -> Inner { // Takes self by value! + self.inner + } +} +``` + +## Good + +```rust +impl Wrapper { + // into_ clearly shows ownership transfer + fn into_inner(self) -> Inner { + self.inner + } +} + +// Usage is clear +let wrapper = Wrapper::new(inner); +let inner = wrapper.into_inner(); // wrapper is consumed +// wrapper.foo(); // Error: use of moved value +``` + +## Standard Library Examples + +```rust +// All consume self and return owned data +let string: String = "hello".to_string(); +let bytes: Vec = string.into_bytes(); // String consumed + +let path = PathBuf::from("/foo"); +let os_string: OsString = path.into_os_string(); // PathBuf consumed + +let boxed: Box<[i32]> = vec![1, 2, 3].into_boxed_slice(); // Vec consumed + +let vec: Vec = boxed.into_vec(); // Box consumed +``` + +## into_iter() Pattern + +```rust +let vec = vec![1, 2, 3]; + +// into_iter consumes the collection +for item in vec.into_iter() { // or just: for item in vec + // item is i32, not &i32 +} +// vec is consumed, can't use anymore + +// Contrast with iter() which borrows +let vec = vec![1, 2, 3]; +for item in vec.iter() { + // item is &i32 +} +// vec still usable +``` + +## IntoIterator Trait + +```rust +impl IntoIterator for MyCollection { + type Item = Element; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.elements.into_iter() // Consumes self + } +} +``` + +## Conversion Prefix Summary + +```rust +struct Buffer { + data: Vec, + name: String, +} + +impl Buffer { + // as_ : free borrow, returns reference + fn as_slice(&self) -> &[u8] { + &self.data + } + + // to_ : allocates, creates new value + fn to_vec(&self) -> Vec { + self.data.clone() + } + + // into_ : consumes self, usually cheap + fn into_inner(self) -> Vec { + self.data + } + + // into_ : can destructure into parts + fn into_parts(self) -> (Vec, String) { + (self.data, self.name) + } +} +``` + +## See Also + +- [name-as-free](./name-as-free.md) - Borrowing conversions +- [name-to-expensive](./name-to-expensive.md) - Allocating conversions +- [api-from-not-into](./api-from-not-into.md) - From trait implementation diff --git a/.opencode/skills/rust-skills/rules/name-is-has-bool.md b/.opencode/skills/rust-skills/rules/name-is-has-bool.md new file mode 100644 index 0000000..4cc267f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-is-has-bool.md @@ -0,0 +1,127 @@ +# name-is-has-bool + +> Use `is_`, `has_`, `can_`, `should_` prefixes for boolean-returning methods + +## Why It Matters + +Boolean methods answer yes/no questions. Prefixes like `is_`, `has_`, `can_` make the question explicit, so code reads naturally: `if user.is_active()`, `if buffer.has_remaining()`. Without prefixes, boolean methods are ambiguous and require reading documentation. + +## Bad + +```rust +impl User { + // Unclear: does this check or set? + fn active(&self) -> bool { ... } + + // Unclear: does this delete or check? + fn deleted(&self) -> bool { ... } + + // Unclear return type + fn admin(&self) -> bool { ... } +} + +// Reading code is confusing +if user.active() { ... } // Is this checking or activating? +``` + +## Good + +```rust +impl User { + // Clear: answers "is the user active?" + fn is_active(&self) -> bool { ... } + + // Clear: answers "is the user deleted?" + fn is_deleted(&self) -> bool { ... } + + // Clear: answers "is the user an admin?" + fn is_admin(&self) -> bool { ... } + + // Clear: answers "does the user have permission X?" + fn has_permission(&self, perm: Permission) -> bool { ... } + + // Clear: answers "can the user edit?" + fn can_edit(&self) -> bool { ... } +} + +// Reads naturally +if user.is_active() && user.has_permission(Permission::Write) { + // ... +} +``` + +## Common Prefixes + +| Prefix | Use For | Example | +|--------|---------|---------| +| `is_` | State/property check | `is_empty()`, `is_valid()`, `is_some()` | +| `has_` | Possession/containment | `has_key()`, `has_children()`, `has_remaining()` | +| `can_` | Capability/permission | `can_read()`, `can_write()`, `can_execute()` | +| `should_` | Recommendation/policy | `should_retry()`, `should_cache()` | +| `needs_` | Requirement | `needs_update()`, `needs_auth()` | +| `will_` | Future action | `will_block()`, `will_overflow()` | + +## Standard Library Examples + +```rust +// is_ prefix +vec.is_empty() +option.is_some() +option.is_none() +result.is_ok() +result.is_err() +char.is_alphabetic() +str.is_ascii() +path.is_file() +path.is_dir() + +// has_ prefix (less common in std) +iterator.has_next() // conceptual + +// Checking methods +str.contains("foo") // Not is_ because takes argument +str.starts_with("bar") // Descriptive verb phrase +str.ends_with("baz") +``` + +## Negation + +```rust +// Prefer positive form with caller negation +if !user.is_active() { ... } + +// Rather than negative method +if user.is_inactive() { ... } // Avoid double negatives: !is_inactive() + +// Exception: when negative is the common case +fn is_empty(&self) -> bool { ... } // Checking for empty is common +fn is_not_empty(&self) -> bool { ... } // Rarely needed, use !is_empty() +``` + +## Boolean Fields + +```rust +struct Config { + // Field names can omit prefix + enabled: bool, + verbose: bool, + debug: bool, +} + +impl Config { + // But methods should have prefix + fn is_enabled(&self) -> bool { + self.enabled + } + + fn is_verbose(&self) -> bool { + self.verbose + } +} +``` + +## See Also + +- [name-no-get-prefix](./name-no-get-prefix.md) - Getter naming +- [name-funcs-snake](./name-funcs-snake.md) - Function naming +- [api-must-use](./api-must-use.md) - Boolean functions should be checked diff --git a/.opencode/skills/rust-skills/rules/name-iter-convention.md b/.opencode/skills/rust-skills/rules/name-iter-convention.md new file mode 100644 index 0000000..b86194f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-iter-convention.md @@ -0,0 +1,129 @@ +# name-iter-convention + +> Use iter/iter_mut/into_iter for iterator methods + +## Why It Matters + +Rust has a standard convention for iterator method names that signals ownership semantics. Following this convention makes APIs predictable and enables the `for item in collection` syntax to work correctly. + +## The Three Iterator Methods + +| Method | Returns | Ownership | +|--------|---------|-----------| +| `iter()` | `impl Iterator` | Borrows collection | +| `iter_mut()` | `impl Iterator` | Mutably borrows | +| `into_iter()` | `impl Iterator` | Consumes collection | + +## Implementation + +```rust +struct MyCollection { + items: Vec, +} + +impl MyCollection { + /// Returns an iterator over references. + fn iter(&self) -> impl Iterator { + self.items.iter() + } + + /// Returns an iterator over mutable references. + fn iter_mut(&mut self) -> impl Iterator { + self.items.iter_mut() + } +} + +// IntoIterator trait for into_iter() +impl IntoIterator for MyCollection { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.items.into_iter() + } +} + +// Also implement for references +impl<'a, T> IntoIterator for &'a MyCollection { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter() + } +} + +impl<'a, T> IntoIterator for &'a mut MyCollection { + type Item = &'a mut T; + type IntoIter = std::slice::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter_mut() + } +} +``` + +## Usage + +```rust +let collection = MyCollection { items: vec![1, 2, 3] }; + +// Explicit methods +for x in collection.iter() { } // Borrows +for x in collection.iter_mut() { } // Mutably borrows + +// IntoIterator enables for loop syntax +for x in &collection { } // Calls (&collection).into_iter() +for x in &mut collection { } // Calls (&mut collection).into_iter() +for x in collection { } // Consumes, calls collection.into_iter() +``` + +## Bad + +```rust +impl MyCollection { + // Non-standard names + fn elements(&self) -> impl Iterator { } // Should be iter() + fn get_items(&self) -> impl Iterator { } // Should be iter() + fn iterate(&self) -> impl Iterator { } // Should be iter() + fn as_iter(&self) -> impl Iterator { } // Should be iter() +} +``` + +## Additional Iterator Methods + +```rust +impl MyCollection { + // Filter by predicate + fn iter_valid(&self) -> impl Iterator { + self.iter().filter(|x| x.is_valid()) + } + + // Specific slice + fn iter_range(&self, start: usize, end: usize) -> impl Iterator { + self.items[start..end].iter() + } +} +``` + +## Standard Library Examples + +```rust +// Vec, slice, arrays +vec.iter() // &T +vec.iter_mut() // &mut T +vec.into_iter() // T + +// HashMap +map.iter() // (&K, &V) +map.iter_mut() // (&K, &mut V) +map.into_iter() // (K, V) +map.keys() // &K +map.values() // &V +``` + +## See Also + +- [name-iter-type-match](./name-iter-type-match.md) - Iterator type naming +- [name-iter-method](./name-iter-method.md) - Iterator method names +- [perf-iter-over-index](./perf-iter-over-index.md) - Prefer iterators diff --git a/.opencode/skills/rust-skills/rules/name-iter-method.md b/.opencode/skills/rust-skills/rules/name-iter-method.md new file mode 100644 index 0000000..e7befe2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-iter-method.md @@ -0,0 +1,131 @@ +# name-iter-method + +> Name iterator methods `iter()`, `iter_mut()`, and `into_iter()` consistently + +## Why It Matters + +Rust has a strong convention for iterator method names. Following these conventions makes your types work predictably with `for` loops and iterator adapters. Users expect `iter()` for shared references, `iter_mut()` for mutable references, and `into_iter()` for owned iteration. + +## Bad + +```rust +struct Collection { + items: Vec, +} + +impl Collection { + // Non-standard names - confusing + fn elements(&self) -> impl Iterator { + self.items.iter() + } + + fn get_iterator(&self) -> impl Iterator { + self.items.iter() + } + + fn to_iter(self) -> impl Iterator { + self.items.into_iter() + } +} +``` + +## Good + +```rust +struct Collection { + items: Vec, +} + +impl Collection { + /// Returns an iterator over references. + fn iter(&self) -> impl Iterator { + self.items.iter() + } + + /// Returns an iterator over mutable references. + fn iter_mut(&mut self) -> impl Iterator { + self.items.iter_mut() + } +} + +// Implement IntoIterator for for-loop support +impl IntoIterator for Collection { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.items.into_iter() + } +} + +impl<'a, T> IntoIterator for &'a Collection { + type Item = &'a T; + type IntoIter = std::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter() + } +} + +impl<'a, T> IntoIterator for &'a mut Collection { + type Item = &'a mut T; + type IntoIter = std::slice::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.items.iter_mut() + } +} +``` + +## Iterator Convention Summary + +| Method | Receiver | Yields | Use Case | +|--------|----------|--------|----------| +| `iter()` | `&self` | `&T` | Read-only iteration | +| `iter_mut()` | `&mut self` | `&mut T` | In-place modification | +| `into_iter()` | `self` | `T` | Consuming iteration | + +## For Loop Integration + +```rust +let col = Collection { items: vec![1, 2, 3] }; + +// These all work with proper IntoIterator impls +for item in &col { // Calls (&col).into_iter() -> iter() + println!("{}", item); // &i32 +} + +for item in &mut col { // Calls (&mut col).into_iter() -> iter_mut() + *item += 1; // &mut i32 +} + +for item in col { // Calls col.into_iter() + process(item); // i32, consumes col +} +``` + +## Additional Iterator Methods + +```rust +impl Collection { + // Domain-specific iterators follow similar patterns + + /// Iterates over keys (for map-like structures). + fn keys(&self) -> impl Iterator { ... } + + /// Iterates over values. + fn values(&self) -> impl Iterator { ... } + + /// Iterates over mutable values. + fn values_mut(&mut self) -> impl Iterator { ... } + + /// Drains elements, leaving container empty. + fn drain(&mut self) -> impl Iterator { ... } +} +``` + +## See Also + +- [name-as-free](./name-as-free.md) - Conversion naming conventions +- [api-extension-trait](./api-extension-trait.md) - Iterator extensions +- [api-common-traits](./api-common-traits.md) - Standard trait implementations diff --git a/.opencode/skills/rust-skills/rules/name-iter-type-match.md b/.opencode/skills/rust-skills/rules/name-iter-type-match.md new file mode 100644 index 0000000..b6db3be --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-iter-type-match.md @@ -0,0 +1,142 @@ +# name-iter-type-match + +> Name iterator types after their source method + +## Why It Matters + +Iterator types should match the method that creates them. `iter()` returns `Iter`, `into_iter()` returns `IntoIter`, `keys()` returns `Keys`. This naming pattern is established by the standard library and makes types predictable. + +## Standard Library Pattern + +```rust +// Vec +impl Vec { + fn iter(&self) -> Iter<'_, T> { } // Returns Iter + fn iter_mut(&mut self) -> IterMut<'_, T> { } // Returns IterMut +} + +impl IntoIterator for Vec { + type IntoIter = IntoIter; // Returns IntoIter +} + +// HashMap +impl HashMap { + fn iter(&self) -> Iter<'_, K, V> { } + fn keys(&self) -> Keys<'_, K, V> { } // Returns Keys + fn values(&self) -> Values<'_, K, V> { } // Returns Values + fn drain(&mut self) -> Drain<'_, K, V> { } // Returns Drain +} +``` + +## Implementation + +```rust +mod my_collection { + pub struct MyCollection { + items: Vec, + } + + // Iterator types in same module + pub struct Iter<'a, T> { + inner: std::slice::Iter<'a, T>, + } + + pub struct IterMut<'a, T> { + inner: std::slice::IterMut<'a, T>, + } + + pub struct IntoIter { + inner: std::vec::IntoIter, + } + + impl MyCollection { + pub fn iter(&self) -> Iter<'_, T> { + Iter { inner: self.items.iter() } + } + + pub fn iter_mut(&mut self) -> IterMut<'_, T> { + IterMut { inner: self.items.iter_mut() } + } + } + + impl IntoIterator for MyCollection { + type Item = T; + type IntoIter = IntoIter; + + fn into_iter(self) -> IntoIter { + IntoIter { inner: self.items.into_iter() } + } + } + + // Implement Iterator for each type + impl<'a, T> Iterator for Iter<'a, T> { + type Item = &'a T; + fn next(&mut self) -> Option { + self.inner.next() + } + } + + impl<'a, T> Iterator for IterMut<'a, T> { + type Item = &'a mut T; + fn next(&mut self) -> Option { + self.inner.next() + } + } + + impl Iterator for IntoIter { + type Item = T; + fn next(&mut self) -> Option { + self.inner.next() + } + } +} +``` + +## Naming Convention + +| Method | Iterator Type | +|--------|---------------| +| `iter()` | `Iter` | +| `iter_mut()` | `IterMut` | +| `into_iter()` | `IntoIter` | +| `keys()` | `Keys` | +| `values()` | `Values` | +| `values_mut()` | `ValuesMut` | +| `drain()` | `Drain` | +| `chunks()` | `Chunks` | +| `windows()` | `Windows` | + +## Custom Iterator Methods + +```rust +impl Graph { + // Method name -> Type name + fn nodes(&self) -> Nodes<'_> { } // Custom: Nodes + fn edges(&self) -> Edges<'_> { } // Custom: Edges + fn neighbors(&self, node: NodeId) -> Neighbors<'_> { } // Custom: Neighbors +} + +pub struct Nodes<'a> { /* ... */ } +pub struct Edges<'a> { /* ... */ } +pub struct Neighbors<'a> { /* ... */ } +``` + +## Bad + +```rust +// Mismatched names +impl MyCollection { + fn iter(&self) -> MyCollectionIterator<'_, T> { } // Should be Iter + fn keys(&self) -> KeyIterator<'_, K> { } // Should be Keys +} + +// Generic names that don't match method +pub struct Iterator; // Conflicts with std::iter::Iterator +pub struct I; // Too cryptic +``` + +## See Also + +- [name-iter-convention](./name-iter-convention.md) - iter/iter_mut/into_iter +- [name-iter-method](./name-iter-method.md) - Iterator method names +- [api-common-traits](./api-common-traits.md) - Implementing common traits diff --git a/.opencode/skills/rust-skills/rules/name-lifetime-short.md b/.opencode/skills/rust-skills/rules/name-lifetime-short.md new file mode 100644 index 0000000..e65d281 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-lifetime-short.md @@ -0,0 +1,86 @@ +# name-lifetime-short + +> Use short, conventional lifetime names: `'a`, `'b`, `'de`, `'src` + +## Why It Matters + +Lifetime parameters are ubiquitous in Rust signatures. Short names like `'a` keep signatures readable. For domain-specific lifetimes, descriptive but short names like `'src` or `'de` communicate intent without clutter. The Rust community has established conventions that aid recognition. + +## Bad + +```rust +// Overly verbose lifetimes +fn parse<'input_lifetime, 'output_lifetime>( + input: &'input_lifetime str +) -> Result<&'output_lifetime str, Error> { ... } + +// Meaningless long names +struct Parser<'parser_instance_lifetime> { + source: &'parser_instance_lifetime str, +} +``` + +## Good + +```rust +// Standard short lifetimes +fn parse<'a>(input: &'a str) -> Result<&'a str, Error> { ... } + +struct Parser<'a> { + source: &'a str, +} + +// Multiple lifetimes: 'a, 'b, 'c +fn merge<'a, 'b>(first: &'a str, second: &'b str) -> String { ... } + +// Descriptive when clarity helps +fn deserialize<'de>(input: &'de [u8]) -> Result, Error> { ... } +``` + +## Common Lifetime Conventions + +| Lifetime | Convention | Example | +|----------|------------|---------| +| `'a` | Generic, first lifetime | `fn foo<'a>(x: &'a str)` | +| `'b` | Generic, second lifetime | `fn bar<'a, 'b>(x: &'a T, y: &'b U)` | +| `'de` | Deserialization | serde's `Deserialize<'de>` | +| `'src` | Source code/input | `struct Lexer<'src>` | +| `'ctx` | Context | `struct Query<'ctx>` | +| `'input` | Input data | `struct Parser<'input>` | +| `'static` | Static lifetime | `&'static str` | + +## Elision Preferred + +```rust +// Let elision work when possible +fn first_word(s: &str) -> &str { // Not fn first_word<'a>(s: &'a str) -> &'a str + s.split_whitespace().next().unwrap_or("") +} + +impl User { + fn name(&self) -> &str { // Elision handles this + &self.name + } +} +``` + +## Serde Convention + +```rust +use serde::{Deserialize, Serialize}; + +// 'de is the standard serde lifetime for borrowed data +#[derive(Deserialize)] +struct Request<'de> { + #[serde(borrow)] + name: &'de str, + #[serde(borrow)] + tags: Vec<&'de str>, +} +``` + +## See Also + +- [own-lifetime-elision](./own-lifetime-elision.md) - When to omit lifetimes +- [name-type-param-single](./name-type-param-single.md) - Type parameter naming +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Borrowing patterns diff --git a/.opencode/skills/rust-skills/rules/name-no-get-prefix.md b/.opencode/skills/rust-skills/rules/name-no-get-prefix.md new file mode 100644 index 0000000..c2835c6 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-no-get-prefix.md @@ -0,0 +1,154 @@ +# name-no-get-prefix + +> Omit get_ prefix for simple getters + +## Why It Matters + +Rust convention omits the `get_` prefix for simple field access. Methods like `len()`, `name()`, `value()` are cleaner than `get_len()`, `get_name()`, `get_value()`. This follows the principle of making the common case concise. + +The `get` prefix is reserved for methods that DO something beyond simple field access. + +## Bad + +```rust +struct User { + name: String, + age: u32, +} + +impl User { + fn get_name(&self) -> &str { // Verbose + &self.name + } + + fn get_age(&self) -> u32 { // Verbose + self.age + } + + fn get_is_adult(&self) -> bool { // Doubly verbose + self.age >= 18 + } +} + +let name = user.get_name(); +let age = user.get_age(); +``` + +## Good + +```rust +struct User { + name: String, + age: u32, +} + +impl User { + fn name(&self) -> &str { // Clean + &self.name + } + + fn age(&self) -> u32 { // Clean + self.age + } + + fn is_adult(&self) -> bool { // Boolean uses is_ prefix + self.age >= 18 + } +} + +let name = user.name(); +let age = user.age(); +``` + +## When get_ IS Appropriate + +Use `get` when the method does more than simple access: + +```rust +impl HashMap { + // Returns Option - not just field access + fn get(&self, key: &K) -> Option<&V> { } + + // Mutable variant + fn get_mut(&mut self, key: &K) -> Option<&mut V> { } +} + +impl Vec { + // Returns Option - bounds checked + fn get(&self, index: usize) -> Option<&T> { } +} + +impl Context { + // Does computation/lookup, not just field access + fn get_config(&self) -> Config { + self.configs.get(&self.current_env).cloned().unwrap_or_default() + } +} +``` + +## Standard Library Examples + +```rust +// No get_ prefix +String::len() +Vec::len() +Vec::capacity() +Vec::is_empty() +Path::file_name() +Option::is_some() +Result::is_ok() + +// With get - returns Option or does lookup +Vec::get(index) +HashMap::get(key) +BTreeMap::get(key) +``` + +## Pattern: Getter/Setter Pairs + +```rust +impl Config { + // Getter: no prefix + fn timeout(&self) -> Duration { + self.timeout + } + + // Setter: use set_ prefix + fn set_timeout(&mut self, timeout: Duration) { + self.timeout = timeout; + } +} +``` + +## Pattern: Builder Methods + +```rust +impl ConfigBuilder { + // Builder methods: no get_, no set_ + fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + fn retries(mut self, retries: u32) -> Self { + self.retries = retries; + self + } +} +``` + +## Decision Guide + +| Pattern | Naming | +|---------|--------| +| Simple field access | `name()`, `value()`, `len()` | +| Boolean property | `is_valid()`, `has_items()` | +| Fallible access | `get()`, `get_mut()` | +| Setter | `set_name()`, `set_value()` | +| Builder | `name()`, `value()` (consuming self) | + +## See Also + +- [name-is-has-bool](./name-is-has-bool.md) - Boolean naming +- [name-is-has-bool](./name-is-has-bool.md) - Boolean naming +- [api-builder-pattern](./api-builder-pattern.md) - Builder pattern diff --git a/.opencode/skills/rust-skills/rules/name-to-expensive.md b/.opencode/skills/rust-skills/rules/name-to-expensive.md new file mode 100644 index 0000000..66287cb --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-to-expensive.md @@ -0,0 +1,118 @@ +# name-to-expensive + +> Use `to_` prefix for expensive conversions that allocate or compute + +## Why It Matters + +The `to_` prefix signals "this conversion has a cost"—typically allocation, cloning, or computation. Callers know to consider caching the result or avoiding repeated calls. This contrasts with `as_` (free reference conversion) and `into_` (ownership transfer). + +## Bad + +```rust +impl Name { + // Misleading: suggests expensive operation + fn as_uppercase(&self) -> String { + self.0.to_uppercase() // Allocates! + } + + // Misleading: suggests cheap reference + fn get_string(&self) -> String { + self.0.clone() // Allocates! + } +} +``` + +## Good + +```rust +impl Name { + // to_ = allocates/computes + fn to_uppercase(&self) -> String { + self.0.to_uppercase() + } + + // to_ = creates new value + fn to_string(&self) -> String { + self.0.clone() + } + + // as_ = free reference (cheap) + fn as_str(&self) -> &str { + &self.0 + } +} +``` + +## Standard Library Examples + +```rust +// to_ methods - all allocate or compute +let s: String = slice.to_vec(); // Allocates Vec +let s: String = "hello".to_string(); // Allocates String +let s: String = "HELLO".to_lowercase(); // Allocates new String +let s: String = path.to_string_lossy().into_owned(); // May allocate + +// Contrast with as_ methods - all are free +let slice: &[u8] = s.as_bytes(); // Just reinterpret +let str_ref: &str = string.as_str(); // Just reference +let path: &Path = Path::new("foo"); // Just reference +``` + +## Conversion Method Prefixes + +| Prefix | Cost | Ownership | Example | +|--------|------|-----------|---------| +| `as_` | Free (O(1)) | Borrows `&T` | `as_str()`, `as_bytes()` | +| `to_` | Allocates/Computes | Creates new | `to_string()`, `to_vec()` | +| `into_` | Usually free | Takes ownership | `into_inner()`, `into_vec()` | + +## Custom Types + +```rust +struct Email(String); + +impl Email { + // Cheap: just returns reference + fn as_str(&self) -> &str { + &self.0 + } + + // Expensive: allocates + fn to_lowercase(&self) -> Email { + Email(self.0.to_lowercase()) + } + + // Expensive: allocates + fn to_display_format(&self) -> String { + format!("<{}>", self.0) + } + + // Ownership transfer: usually cheap + fn into_string(self) -> String { + self.0 + } +} +``` + +## to_owned() Pattern + +```rust +// to_owned() for getting owned version of borrowed data +let borrowed: &str = "hello"; +let owned: String = borrowed.to_owned(); // Allocates + +let borrowed: &[i32] = &[1, 2, 3]; +let owned: Vec = borrowed.to_owned(); // Allocates + +// ToOwned trait +trait ToOwned { + type Owned; + fn to_owned(&self) -> Self::Owned; +} +``` + +## See Also + +- [name-as-free](./name-as-free.md) - Free reference conversions +- [name-into-ownership](./name-into-ownership.md) - Ownership transfer +- [own-cow-conditional](./own-cow-conditional.md) - Avoiding unnecessary allocations diff --git a/.opencode/skills/rust-skills/rules/name-type-param-single.md b/.opencode/skills/rust-skills/rules/name-type-param-single.md new file mode 100644 index 0000000..6d12502 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-type-param-single.md @@ -0,0 +1,92 @@ +# name-type-param-single + +> Use single uppercase letters for type parameters: `T`, `E`, `K`, `V` + +## Why It Matters + +Generic type parameters conventionally use single uppercase letters. This keeps signatures concise and follows established conventions that readers instantly recognize. `T` for "type", `E` for "error", `K` for "key", `V` for "value" are universal in Rust. + +## Bad + +```rust +// Verbose type parameters +struct Container { + items: Vec, +} + +fn process(input: InputType) -> OutputType { ... } + +// Lowercase - looks like lifetime +struct Wrapper { ... } // Confusing +``` + +## Good + +```rust +// Single uppercase letters +struct Container { + items: Vec, +} + +fn process(input: I) -> O { ... } + +// Standard conventions +struct HashMap { ... } // K=Key, V=Value +enum Result { ... } // T=Type, E=Error +enum Option { ... } // T=Type +struct Ref<'a, T> { ... } // Lifetime + Type +``` + +## Standard Type Parameter Names + +| Parameter | Meaning | Example | +|-----------|---------|---------| +| `T` | Type (generic) | `Vec` | +| `E` | Error | `Result` | +| `K` | Key | `HashMap` | +| `V` | Value | `HashMap` | +| `I` | Input / Item | `Iterator` | +| `O` | Output | `Fn(I) -> O` | +| `R` | Return / Result | `fn() -> R` | +| `S` | State | `StateMachine` | +| `A` | Allocator | `Vec` | +| `F` | Function | `map(f: F)` | + +## Multiple Type Parameters + +```rust +// Use related letters +fn transform(input: I) -> Result +where + I: Input, + O: Output, + E: Error, +{ ... } + +// Or sequential: T, U, V +fn combine(a: T, b: U) -> V { ... } + +// Descriptive only when many parameters need clarity +struct Query { ... } +``` + +## Trait Bounds + +```rust +// Keep type params short, move complexity to where clause +fn process(value: T) -> Result +where + T: Clone + Debug + Send + Sync, + E: Error + From, +{ ... } + +// Not inline +fn process>(value: T) -> Result +// Too long! +``` + +## See Also + +- [name-lifetime-short](./name-lifetime-short.md) - Lifetime parameter naming +- [name-types-camel](./name-types-camel.md) - Concrete type naming +- [type-generic-bounds](./type-generic-bounds.md) - Trait bounds diff --git a/.opencode/skills/rust-skills/rules/name-types-camel.md b/.opencode/skills/rust-skills/rules/name-types-camel.md new file mode 100644 index 0000000..acbb70c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-types-camel.md @@ -0,0 +1,65 @@ +# name-types-camel + +> Use `UpperCamelCase` for types, traits, and enum names + +## Why It Matters + +Rust's naming conventions are enforced by the compiler and linter. Consistent naming makes code immediately recognizable—you know `HttpClient` is a type, `send_request` is a function. Violating conventions triggers warnings and makes code harder to read. + +## Bad + +```rust +// Lowercase types - compiler warns +struct http_client { ... } // warning: type `http_client` should have an upper camel case name +trait serializable { ... } // warning +enum response_type { ... } // warning + +// Screaming case for types +struct HTTP_CLIENT { ... } // Not idiomatic +``` + +## Good + +```rust +// UpperCamelCase for all types +struct HttpClient { ... } +trait Serializable { ... } +enum ResponseType { ... } + +// Compound words +struct TcpConnection { ... } +struct IoError { ... } +struct FileReader { ... } + +// Generic types +struct HashMap { ... } +struct Result { ... } +``` + +## Acronyms + +```rust +// Treat acronyms as words (capitalize first letter only) +struct HttpServer { ... } // Not HTTPServer +struct JsonParser { ... } // Not JSONParser +struct Uuid { ... } // Not UUID +struct TcpStream { ... } // Not TCPStream + +// Exception: Two-letter acronyms can be all caps +struct IOError { ... } // Acceptable +struct IoError { ... } // Also acceptable (preferred) +``` + +## Type Aliases + +```rust +// Type aliases also use UpperCamelCase +type Result = std::result::Result; +type BoxedFuture<'a, T> = Pin + Send + 'a>>; +``` + +## See Also + +- [name-variants-camel](./name-variants-camel.md) - Enum variant naming +- [name-funcs-snake](./name-funcs-snake.md) - Function naming +- [name-acronym-word](./name-acronym-word.md) - Acronym handling diff --git a/.opencode/skills/rust-skills/rules/name-variants-camel.md b/.opencode/skills/rust-skills/rules/name-variants-camel.md new file mode 100644 index 0000000..4b471ef --- /dev/null +++ b/.opencode/skills/rust-skills/rules/name-variants-camel.md @@ -0,0 +1,101 @@ +# name-variants-camel + +> Use `UpperCamelCase` for enum variants + +## Why It Matters + +Enum variants follow the same naming convention as types—`UpperCamelCase`. This distinguishes them from fields, variables, and functions. The compiler warns on violations, and consistent naming helps readers instantly recognize variant names. + +## Bad + +```rust +enum Status { + pending, // warning: variant `pending` should have an upper camel case name + in_progress, // warning + COMPLETED, // Not idiomatic +} + +enum Color { + RED, // Screaming case - not Rust style + GREEN, + BLUE, +} +``` + +## Good + +```rust +enum Status { + Pending, + InProgress, + Completed, + Failed, +} + +enum Color { + Red, + Green, + Blue, + Custom(u8, u8, u8), +} + +enum HttpMethod { + Get, + Post, + Put, + Delete, + Patch, +} +``` + +## Variants with Data + +```rust +enum Message { + // Unit variant + Quit, + + // Tuple variant + Move(i32, i32), + + // Struct variant + Write { text: String }, + + // Named fields + ChangeColor { + red: u8, + green: u8, + blue: u8, + }, +} +``` + +## Variant Naming Tips + +```rust +// Be specific +enum Error { + NotFound, // Good: specific + PermissionDenied, // Good: specific + Error, // Bad: vague +} + +// Avoid redundant type name in variant +enum ConnectionState { + Connected, // Good + Disconnected, // Good + ConnectionError, // Bad: redundant "Connection" +} + +// Use None/Some pattern for Option-like enums +enum MaybeValue { + Some(T), + None, +} +``` + +## See Also + +- [name-types-camel](./name-types-camel.md) - Type naming +- [api-non-exhaustive](./api-non-exhaustive.md) - Forward-compatible enums +- [type-enum-states](./type-enum-states.md) - State machine enums diff --git a/.opencode/skills/rust-skills/rules/num-cast-try-from.md b/.opencode/skills/rust-skills/rules/num-cast-try-from.md new file mode 100644 index 0000000..fc94d89 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/num-cast-try-from.md @@ -0,0 +1,95 @@ +# num-cast-try-from + +> Avoid `as` for narrowing casts; use `From` for widening and `TryFrom` for narrowing + +## Why It Matters + +The `as` cast silently truncates or wraps on narrowing (`300u32 as u8 == 44`) and produces surprising results on float-to-integer conversion (values outside range saturate to the type's min/max since Rust 1.45, but `NaN` becomes `0`). These behaviors are easy to miss during code review and impossible to catch at runtime without tests. `From`/`Into` are lossless by design and will not compile for lossy conversions; `TryFrom`/`TryInto` return `Result` and make the fallibility explicit. + +## Bad + +```rust +fn narrow(x: u32) -> u8 { + x as u8 // silently truncates: 300 becomes 44 +} + +fn to_index(f: f64) -> usize { + f as usize // NaN becomes 0, negatives become 0, may truncate +} + +fn widen(x: u8) -> u32 { + x as u32 // works, but hides that this is always safe +} +``` + +## Good + +```rust +use std::convert::TryFrom; + +// widening: From for u32 is always lossless — won't compile if lossy +fn widen(x: u8) -> u32 { + u32::from(x) + // or: x.into() +} + +// narrowing: TryFrom makes the failure case explicit +fn narrow(x: u32) -> Result>::Error> { + u8::try_from(x) + // or: x.try_into() +} + +// float → integer: validate the range manually before casting +fn float_to_index(f: f64, len: usize) -> Option { + if f.is_nan() || f < 0.0 || f >= len as f64 { + return None; + } + Some(f as usize) // `as` is acceptable here: range is verified above +} + +#[cfg(test)] +mod tests { + use super::*; + use std::convert::TryFrom; + + #[test] + fn widen_is_lossless() { + assert_eq!(widen(255), 255u32); + } + + #[test] + fn narrow_errors_on_overflow() { + assert!(narrow(300).is_err()); + assert_eq!(narrow(200), Ok(200u8)); + } + + #[test] + fn float_to_index_rejects_nan_and_negative() { + assert_eq!(float_to_index(f64::NAN, 10), None); + assert_eq!(float_to_index(-1.0, 10), None); + assert_eq!(float_to_index(3.9, 10), Some(3)); + } + + #[test] + fn as_cast_truncation_footgun() { + // demonstrating why `as` is dangerous for narrowing: + let x: u32 = 300; + assert_eq!(x as u8, 44); // 300 % 256 == 44 — silently wrong + } +} +``` + +## Key Points + +- `From for B` compiles only when the conversion is always lossless. Attempting `u8::from(300u32)` is a compile error. +- `TryFrom` returns `Result` from the standard library — no external crates needed. +- Reserve `as` for: + - Pointer casts (e.g., `*const u8 as *mut u8`) that are intentional. + - Float-to-integer when you have verified the range and documented the intent. + - `usize` ↔ pointer-sized integer when exact semantics are required. +- When using `.try_into()`, the turbofish or type annotation is often needed to help inference: `let n: u8 = x.try_into()?;` + +## See Also + +- [conv-tryfrom-fallible](conv-tryfrom-fallible.md) - implement `TryFrom` for your own fallible conversions +- [num-overflow-explicit](num-overflow-explicit.md) - handle integer overflow explicitly with `checked_`/`saturating_`/`wrapping_` diff --git a/.opencode/skills/rust-skills/rules/num-float-compare.md b/.opencode/skills/rust-skills/rules/num-float-compare.md new file mode 100644 index 0000000..34b5cf5 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/num-float-compare.md @@ -0,0 +1,91 @@ +# num-float-compare + +> Don't compare floats with `==`; use a tolerance, and `total_cmp` for ordering + +## Why It Matters + +Floating-point arithmetic is not exact: `0.1 + 0.2 == 0.3` evaluates to `false` in Rust (and every IEEE 754 language) because neither value is representable exactly in binary. Additionally, `NaN != NaN` by the IEEE 754 standard, so equality comparisons involving `NaN` always return `false`. For sorting, `f64::partial_cmp` returns `None` on `NaN`, which makes `sort_by` panic with an inconsistent-order error. Use an epsilon tolerance for approximate equality and `f64::total_cmp` (stable since Rust 1.62) for total ordering. + +## Bad + +```rust +fn is_unit_length(x: f64, y: f64) -> bool { + (x * x + y * y).sqrt() == 1.0 // almost always false due to rounding +} + +fn sort_scores(scores: &mut Vec) { + scores.sort_by(|a, b| a.partial_cmp(b).unwrap()); + // panics (unwrap on None) if any score is NaN +} +``` + +## Good + +```rust +// approximate equality with an absolute epsilon +fn approx_eq(a: f64, b: f64, epsilon: f64) -> bool { + (a - b).abs() < epsilon +} + +fn is_unit_length(x: f64, y: f64) -> bool { + approx_eq((x * x + y * y).sqrt(), 1.0, 1e-9) +} + +// total ordering: NaN sorts after everything else (consistent, never panics) +fn sort_scores(scores: &mut Vec) { + scores.sort_by(|a, b| a.total_cmp(b)); +} + +// direct NaN check when needed +fn safe_reciprocal(x: f64) -> Option { + if x == 0.0 || x.is_nan() { + None + } else { + Some(1.0 / x) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn float_addition_is_not_exact() { + assert_ne!(0.1_f64 + 0.2, 0.3); // IEEE 754 rounding + assert!(approx_eq(0.1 + 0.2, 0.3, 1e-10)); + } + + #[test] + fn nan_is_not_equal_to_itself() { + let nan = f64::NAN; + assert_ne!(nan, nan); // NaN != NaN by IEEE 754 + } + + #[test] + fn total_cmp_handles_nan() { + let mut v = vec![3.0_f64, f64::NAN, 1.0, f64::NAN, 2.0]; + sort_scores(&mut v); + // NaN values sort to the end; finite values are in order + assert_eq!(&v[..3], &[1.0, 2.0, 3.0]); + assert!(v[3].is_nan()); + assert!(v[4].is_nan()); + } + + #[test] + fn unit_length_uses_tolerance() { + assert!(is_unit_length(1.0, 0.0)); + assert!(is_unit_length(0.6, 0.8)); // 3-4-5 right triangle scaled + } +} +``` + +## Key Points + +- **Epsilon choice**: an absolute epsilon (`1e-9`) is simple but wrong for very large or very small values. For general-purpose code, a relative epsilon `(a - b).abs() / a.abs().max(b.abs()) < epsilon` is more robust — but requires handling the zero case. +- **`f64::total_cmp`** defines a strict total order: `-NaN < -∞ < … < -0.0 < +0.0 < … < +∞ < NaN`. It never panics and is available on `f32` and `f64`. +- **`is_nan` / `is_infinite` / `is_finite`**: use these predicates before arithmetic on untrusted floats. +- **Equality on `f32`/`f64` with `==`** is intentionally kept in the language for cases where you need bit-exact comparison (e.g., checking whether a value changed since last write). Document such uses explicitly. + +## See Also + +- [num-overflow-explicit](num-overflow-explicit.md) - handle integer overflow explicitly diff --git a/.opencode/skills/rust-skills/rules/num-nonzero.md b/.opencode/skills/rust-skills/rules/num-nonzero.md new file mode 100644 index 0000000..2b74211 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/num-nonzero.md @@ -0,0 +1,94 @@ +# num-nonzero + +> Use `NonZero*` types to forbid zero and unlock the niche optimization + +## Why It Matters + +`NonZeroU32`, `NonZeroI64`, and their siblings (available for all integer primitives in `std::num`) make zero unrepresentable at the type level — you cannot construct one without going through `NonZeroU32::new(n)`, which returns `Option`. This pushes the zero-check to the construction site and eliminates defensive zero-checks throughout the rest of the code. As a bonus, the compiler uses the zero bit-pattern as a *niche*, so `Option` is exactly the same size as `u32` — no overhead for the `Option` tag. + +## Bad + +```rust +// caller must remember never to pass 0, but nothing enforces it +fn divide(numerator: u32, denominator: u32) -> u32 { + assert!(denominator != 0, "denominator must not be zero"); + numerator / denominator +} + +// ID of 0 is "invalid" by convention — not enforced +struct Widget { + id: u32, // 0 means "not yet assigned" — stringly-typed convention +} +``` + +## Good + +```rust +use std::num::NonZeroU32; +use std::mem::size_of; + +// zero is rejected at construction; division is always safe +fn divide(numerator: u32, denominator: NonZeroU32) -> u32 { + numerator / denominator.get() +} + +// ID is guaranteed non-zero; Option is free +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct WidgetId(NonZeroU32); + +impl WidgetId { + /// Returns `None` if `id` is zero. + pub fn new(id: u32) -> Option { + NonZeroU32::new(id).map(WidgetId) + } + + pub fn get(self) -> u32 { + self.0.get() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::num::NonZeroU32; + + #[test] + fn nonzero_new_returns_none_for_zero() { + assert!(NonZeroU32::new(0).is_none()); + assert!(NonZeroU32::new(1).is_some()); + } + + #[test] + fn option_nonzero_is_same_size_as_u32() { + // niche optimization: no space overhead for Option + assert_eq!(size_of::>(), size_of::()); + assert_eq!(size_of::>(), 4); + } + + #[test] + fn widget_id_rejects_zero() { + assert!(WidgetId::new(0).is_none()); + let id = WidgetId::new(42).unwrap(); + assert_eq!(id.get(), 42); + } + + #[test] + fn divide_uses_nonzero_denominator() { + let denom = NonZeroU32::new(3).unwrap(); + assert_eq!(divide(12, denom), 4); + } +} +``` + +## Key Points + +- All `NonZero*` types live in `std::num`: `NonZeroU8`, `NonZeroU16`, `NonZeroU32`, `NonZeroU64`, `NonZeroU128`, `NonZeroUsize`, and their signed counterparts. +- **Construction**: `NonZeroU32::new(n) -> Option`. Use `NonZeroU32::new(n).expect("n must be non-zero")` only at well-verified program boundaries, not in production fallible paths. +- **Access**: `.get()` returns the inner primitive value. +- **Arithmetic**: `NonZeroU32` does not implement `Add`/`Sub` directly (the result could be zero). Extract with `.get()`, do arithmetic, and reconstruct with `NonZeroU32::new(result)?`. +- **Niche optimization** applies to `Option` and `Result`: the compiler stores the `None`/`Err` discriminant in the zero bit-pattern, so no extra word is needed. This also applies to custom newtypes that wrap `NonZero*`. + +## See Also + +- [type-newtype-ids](type-newtype-ids.md) - wrap IDs in newtypes for type-safe distinctions +- [mem-smaller-integers](mem-smaller-integers.md) - use the smallest integer type that fits diff --git a/.opencode/skills/rust-skills/rules/num-overflow-explicit.md b/.opencode/skills/rust-skills/rules/num-overflow-explicit.md new file mode 100644 index 0000000..db5e951 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/num-overflow-explicit.md @@ -0,0 +1,87 @@ +# num-overflow-explicit + +> Handle integer overflow explicitly: `checked_`/`saturating_`/`wrapping_`/`overflowing_` + +## Why It Matters + +Integer overflow panics in debug builds and silently wraps (two's complement) in release builds. Relying on either default behavior is a latent bug — the release build can produce wrong results without any diagnostic. Choosing an explicit variant makes intent unmistakable to both the compiler and future readers. + +## Bad + +```rust +fn add_score(current: u32, delta: u32) -> u32 { + current + delta // panics in debug, wraps silently in release +} + +fn increment_counter(c: u8) -> u8 { + c + 1 // wraps to 0 in release when c == 255 +} +``` + +## Good + +```rust +// checked_add: returns None on overflow — propagate or handle the error +fn add_score(current: u32, delta: u32) -> Option { + current.checked_add(delta) +} + +// saturating_add: clamps at the type's upper bound (u8::MAX == 255) +fn increment_saturating(c: u8) -> u8 { + c.saturating_add(1) +} + +// wrapping_add: intentional modular (two's complement) arithmetic +fn wrapping_sequence(n: u8) -> u8 { + n.wrapping_add(1) +} + +// overflowing_add: returns (result, did_overflow) — useful for carry detection +fn add_with_carry(a: u32, b: u32) -> (u32, bool) { + a.overflowing_add(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_returns_none_on_overflow() { + assert_eq!(add_score(u32::MAX, 1), None); + assert_eq!(add_score(10, 5), Some(15)); + } + + #[test] + fn saturating_clamps_at_max() { + assert_eq!(increment_saturating(255), 255); + assert_eq!(increment_saturating(10), 11); + } + + #[test] + fn wrapping_rolls_over() { + assert_eq!(wrapping_sequence(255), 0); + } + + #[test] + fn overflowing_reports_carry() { + assert_eq!(add_with_carry(u32::MAX, 1), (0, true)); + assert_eq!(add_with_carry(1, 2), (3, false)); + } +} +``` + +## Key Points + +| Method family | Returns | Use when | +|---|---|---| +| `checked_*` | `Option` | overflow is an error your caller must handle | +| `saturating_*` | `T` | clamping at the type's bounds is correct behavior | +| `wrapping_*` | `T` | modular arithmetic is intentional (e.g., checksums, ring buffers) | +| `overflowing_*` | `(T, bool)` | you need the result *and* know whether it overflowed | + +These methods are available on all primitive integer types in the standard library (`u8` through `u128`, `i8` through `i128`, `usize`, `isize`). Equivalent sub/mul/div/shl/shr variants exist for every arithmetic operation. + +## See Also + +- [num-saturating-clamp](num-saturating-clamp.md) - bound values with `clamp` and saturating arithmetic +- [num-cast-try-from](num-cast-try-from.md) - avoid `as` for narrowing casts; prefer `TryFrom` diff --git a/.opencode/skills/rust-skills/rules/num-saturating-clamp.md b/.opencode/skills/rust-skills/rules/num-saturating-clamp.md new file mode 100644 index 0000000..fee5571 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/num-saturating-clamp.md @@ -0,0 +1,91 @@ +# num-saturating-clamp + +> Bound values with `clamp` and saturating arithmetic + +## Why It Matters + +Constraining a numeric value to a range is a common operation, but hand-rolled `if`/`min`/`max` chains are verbose and easy to get wrong (especially when combining signed and unsigned types). `Ord::clamp(min, max)` expresses the intent in a single call and is available on all types that implement `Ord` (all integer primitives, and `f32`/`f64` via their own `clamp` since Rust 1.50). When you additionally want arithmetic that stops at the type's limits rather than panicking or wrapping, combine `clamp` with `saturating_*` methods. + +## Bad + +```rust +fn apply_damage(health: i32, damage: i32) -> i32 { + let result = health - damage; + if result < 0 { 0 } else { result } // verbose, easy to mis-order +} + +fn clamp_volume(vol: u8, min: u8, max: u8) -> u8 { + if vol < min { + min + } else if vol > max { + max + } else { + vol + } +} +``` + +## Good + +```rust +fn apply_damage(health: i32, damage: i32) -> i32 { + // saturating_sub stops at i32::MIN — then clamp ensures non-negative + health.saturating_sub(damage).clamp(0, i32::MAX) +} + +fn clamp_volume(vol: u8, min: u8, max: u8) -> u8 { + vol.clamp(min, max) +} + +// integer clamp: any Ord type +fn clamp_score(score: i64) -> i64 { + score.clamp(0, 100) +} + +// float clamp: available on f32/f64 since Rust 1.50 +fn normalize_alpha(a: f32) -> f32 { + a.clamp(0.0, 1.0) // NaN propagates: NaN.clamp(0.0, 1.0) == NaN +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn damage_does_not_go_below_zero() { + assert_eq!(apply_damage(10, 5), 5); + assert_eq!(apply_damage(3, 100), 0); + } + + #[test] + fn volume_is_bounded() { + assert_eq!(clamp_volume(50, 10, 90), 50); + assert_eq!(clamp_volume(5, 10, 90), 10); + assert_eq!(clamp_volume(200, 10, 90), 90); + } + + #[test] + fn score_is_clamped() { + assert_eq!(clamp_score(-10), 0); + assert_eq!(clamp_score(150), 100); + assert_eq!(clamp_score(75), 75); + } + + #[test] + fn float_nan_propagates_through_clamp() { + assert!(f32::NAN.clamp(0.0, 1.0).is_nan()); + } +} +``` + +## Key Points + +- **`clamp` panics if `min > max`** — this is a programming error and is caught in debug builds. If `min`/`max` come from user input, validate their order first. +- **`f32`/`f64` `clamp`** propagates `NaN`: if `self` is `NaN`, the result is `NaN`. If `min` or `max` is `NaN`, the result is unspecified. Filter `NaN` before calling `clamp` on untrusted floats. +- **Saturating vs. clamping**: `saturating_*` applies to a single arithmetic operation and stops at the type's absolute limits (`i32::MIN`/`i32::MAX`). `clamp` applies to the final value and accepts arbitrary bounds. They compose well, as shown in `apply_damage`. +- The standard library docs for `clamp` are at `std::cmp::Ord::clamp` and `f32::clamp`. + +## See Also + +- [num-overflow-explicit](num-overflow-explicit.md) - handle overflow with `checked_`/`saturating_`/`wrapping_`/`overflowing_` +- [num-cast-try-from](num-cast-try-from.md) - avoid `as` for narrowing casts; prefer `TryFrom` diff --git a/.opencode/skills/rust-skills/rules/obs-error-chain.md b/.opencode/skills/rust-skills/rules/obs-error-chain.md new file mode 100644 index 0000000..e90bd56 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/obs-error-chain.md @@ -0,0 +1,92 @@ +# obs-error-chain + +> Log errors with their full source chain, and log each error exactly once + +## Why It Matters + +Logging only the top-level `Display` of an error silently drops the underlying cause chain — you see "request failed" but not *why*. The two common fixes are: use `?` format (`error = ?err`) to capture `Debug` output including the chain, or use `{:#}` on an `anyhow::Error` which formats the full cause chain. The second hazard is the log-and-return anti-pattern: logging the error at every propagation layer records the same failure multiple times with different amounts of context, polluting aggregators. Log once, at the boundary that *handles* the error; everywhere else, propagate with `?` and optionally add context. + +## Bad + +```rust +use tracing::error; + +async fn fetch_data(id: u64) -> Result, Box> { + let data = read_from_db(id).await.map_err(|e| { + error!("{}", e); // BAD: drops source chain, logs too early + e + })?; + Ok(data) +} + +async fn handle(id: u64) -> Result<(), Box> { + let data = fetch_data(id).await.map_err(|e| { + error!("{}", e); // BAD: logged again at every layer + e + })?; + process(data); + Ok(()) +} + +async fn read_from_db(_id: u64) -> Result, std::io::Error> { + Err(std::io::Error::other("connection refused")) +} +fn process(_data: Vec) {} +``` + +## Good + +```rust +use anyhow::{Context, Result}; +use tracing::{error, instrument, warn}; + +// Propagate with context; do NOT log here +#[instrument] +async fn read_from_db(id: u64) -> Result> { + inner_db_call(id) + .await + .with_context(|| format!("failed to read record {id} from database")) + // No logging — just add context and propagate +} + +// Also just propagates +#[instrument] +async fn fetch_data(id: u64) -> Result> { + read_from_db(id).await.context("fetch_data failed") +} + +// The handler boundary: this is where the error is HANDLED, so log it once +#[instrument] +async fn handle_request(id: u64) -> Result<(), String> { + match fetch_data(id).await { + Ok(data) => { + process(data); + Ok(()) + } + Err(err) => { + // {:#} on anyhow::Error prints the full cause chain + error!(error = %format!("{err:#}"), "request failed"); + Err("internal error".to_string()) + } + } +} + +async fn inner_db_call(_id: u64) -> Result> { + Err(anyhow::anyhow!("connection refused")) +} +fn process(_data: Vec) {} +``` + +## Key Points + +- **`error = ?err`**: uses `Debug` — prints the error and its `source()` chain for types that implement it. +- **`format!("{err:#}")`** or `%format!(...)`: `anyhow::Error`'s alternate Display walks the full chain with `: ` separators. +- **Propagate, don't log**: use `?` and `.context()` / `.with_context()` at intermediate layers; log at the single handling boundary. +- If you *must* log at a non-handling layer (e.g., background task that discards the error), use `warn!` not `error!` to signal it was absorbed. +- The `tracing-error` crate provides `SpanTrace` to capture the span context at the error site and attach it to the error type. + +## See Also + +- [err-context-chain](err-context-chain.md) - add context with `.context()` / `.with_context()` +- [err-source-chain](err-source-chain.md) - chain underlying errors with `#[source]` +- [anti-empty-catch](anti-empty-catch.md) - avoid silently swallowing errors diff --git a/.opencode/skills/rust-skills/rules/obs-instrument-spans.md b/.opencode/skills/rust-skills/rules/obs-instrument-spans.md new file mode 100644 index 0000000..9b6d8e1 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/obs-instrument-spans.md @@ -0,0 +1,81 @@ +# obs-instrument-spans + +> Use `#[tracing::instrument]` and spans to attach context to async tasks and requests + +## Why It Matters + +A span groups all events emitted during a logical operation (an HTTP request, a database call, a background job) and attaches structured context to every event within it. Without spans, log lines from concurrent async tasks interleave with no way to correlate them. The `#[tracing::instrument]` attribute creates a span automatically from the function's arguments as fields. There is one critical async pitfall: holding a span *entry guard* (`let _g = span.enter()`) across an `.await` point attaches the span to the wrong task when the executor resumes on a different thread — use `.instrument(span)` on the future instead. + +## Bad + +```rust +use tracing::{info, span, Level}; + +// BAD: holding an entry guard across .await corrupts span context +async fn fetch_user(user_id: u64) -> Result { + let span = span!(Level::INFO, "fetch_user", user_id); + let _guard = span.enter(); // guard held here... + + let result = some_async_db_call(user_id).await; // ...across this await — wrong! + info!("fetched user"); + result +} + +async fn some_async_db_call(_id: u64) -> Result { + Ok("alice".to_string()) +} +``` + +## Good + +```rust +use tracing::{info, instrument, Instrument, info_span}; + +// GOOD: #[instrument] handles async correctly; skip large/sensitive args +#[instrument(skip(db), fields(user.id = user_id))] +async fn fetch_user(user_id: u64, db: &DbPool) -> Result { + info!("fetching user from database"); + let user = db.query_user(user_id).await?; + info!(username = %user.name, "user fetched"); + Ok(user.name) +} + +// GOOD: manual span + .instrument() for dynamic span names +async fn process_job(job_id: &str) { + let span = info_span!("process_job", job.id = job_id); + async move { + info!("job started"); + do_work().await; + info!("job complete"); + } + .instrument(span) + .await; +} + +async fn do_work() {} + +// Placeholder types for the example +struct DbPool; +#[derive(Debug)] struct DbUser { name: String } +#[derive(Debug)] struct DbError; + +impl DbPool { + async fn query_user(&self, _id: u64) -> Result { + Ok(DbUser { name: "alice".to_string() }) + } +} +``` + +## Key Points + +- **`#[instrument]`** is the preferred way to instrument async functions — it wraps the whole future in `.instrument(span)` under the hood. +- Use `skip(arg)` or `skip_all` to exclude large types (e.g., database pools, byte buffers) and sensitive values from auto-captured fields. +- Use `fields(key = value)` inside `#[instrument]` to add or rename fields beyond the auto-captured args. +- For manual spans, always attach with `.instrument(span).await`, never hold a guard across `.await`. +- Spans nest automatically: entering a child span inside a parent records the parent's context in traces, enabling waterfall views in tools like Jaeger or Tempo. + +## See Also + +- [obs-structured-fields](obs-structured-fields.md) - structured fields within span events +- [obs-no-sensitive-data](obs-no-sensitive-data.md) - use `skip` to prevent secrets leaking into spans +- [async-no-lock-await](async-no-lock-await.md) - same problem pattern: do not hold guards across `.await` diff --git a/.opencode/skills/rust-skills/rules/obs-levels-filter.md b/.opencode/skills/rust-skills/rules/obs-levels-filter.md new file mode 100644 index 0000000..9f64fb3 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/obs-levels-filter.md @@ -0,0 +1,83 @@ +# obs-levels-filter + +> Use log levels meaningfully and filter with `EnvFilter` / `RUST_LOG` + +## Why It Matters + +Log levels exist to communicate urgency and to let operators tune verbosity without recompiling. Misusing them — emitting everything at `info!`, or leaving `debug!` output in hot paths in production — overwhelms aggregators and hides real signals. `tracing_subscriber::EnvFilter` reads the `RUST_LOG` environment variable and supports per-crate, per-target, and per-span directives, giving operators fine-grained control at runtime. For release builds, tracing's `max_level_*` Cargo features can compile out verbose levels entirely, eliminating even the call-site overhead. + +## Bad + +```rust +use tracing::info; + +fn handle_request(path: &str, body: &[u8]) { + // BAD: debug-level detail emitted at info — always noisy in production + info!(path, body_len = body.len(), raw = ?body, "handling request"); + info!("entered handle_request"); // trace-level lifecycle noise + info!("about to parse body"); // also trace-level + // ... actual logic ... + info!("done handling request"); +} +``` + +## Good + +```rust +use tracing::{debug, error, info, instrument, trace, warn}; + +#[instrument(skip(body))] +fn handle_request(path: &str, body: &[u8]) { + trace!("entered handler"); // very verbose — trace + debug!(body_len = body.len(), "parsing body"); // diagnostic — debug + info!(path, "request received"); // lifecycle — info + + match parse_body(body) { + Ok(parsed) => { + info!(items = parsed.len(), "request processed"); + } + Err(e) if is_client_error(&e) => { + warn!(error = ?e, "malformed request from client"); // recoverable — warn + } + Err(e) => { + error!(error = ?e, "unexpected parse failure"); // needs attention — error + } + } +} + +fn parse_body(_body: &[u8]) -> Result, String> { Ok(vec![]) } +fn is_client_error(_e: &str) -> bool { false } +``` + +```rust +// In main: configure EnvFilter from RUST_LOG +fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,myapp=debug,hyper=warn".into()), + ) + .init(); +} +``` + +## Level Guidelines + +| Level | Use for | Example | +|---|---|---| +| `error!` | Failures that need immediate attention | database connection lost | +| `warn!` | Recoverable anomalies, degraded state | retrying after timeout | +| `info!` | High-level lifecycle events | server started, request complete | +| `debug!` | Diagnostic detail for development | query parameters, cache status | +| `trace!` | Very verbose, per-iteration detail | loop counters, raw bytes | + +## Key Points + +- `RUST_LOG=info,mycrate=debug,hyper=warn` — comma-separated target=level pairs; the first token sets the global default. +- Compile out verbose levels in release with Cargo features: `tracing = { version = "0.1", features = ["max_level_debug", "release_max_level_info"] }`. +- Prefer `try_from_default_env()` with a fallback string over `from_default_env()` so the binary still starts when `RUST_LOG` is unset or malformed. + +## See Also + +- [obs-tracing-over-log](obs-tracing-over-log.md) - foundational `tracing` setup +- [obs-library-facade](obs-library-facade.md) - libraries emit events; binaries configure filtering diff --git a/.opencode/skills/rust-skills/rules/obs-library-facade.md b/.opencode/skills/rust-skills/rules/obs-library-facade.md new file mode 100644 index 0000000..80480a4 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/obs-library-facade.md @@ -0,0 +1,73 @@ +# obs-library-facade + +> Libraries emit through the tracing/log facade and never install a subscriber + +## Why It Matters + +Installing a global subscriber or logger is a one-time, process-wide operation. If a library calls `tracing_subscriber::fmt::init()` or `env_logger::init()`, it silently conflicts with any other library or the application binary that does the same — the second call panics or is silently ignored, and the caller loses all control over log format, destination, and level filtering. Libraries must only *emit* events and spans; the binary that owns `main` decides how to handle them. This is the same contract as `log` has always enforced and `tracing` carries forward. + +## Bad + +```rust +// In a library crate: mylib/src/lib.rs +use tracing::info; + +pub fn connect(url: &str) { + // BAD: library installs a subscriber — conflicts with the application + tracing_subscriber::fmt::init(); + info!(url, "connecting"); +} +``` + +```rust +// Also bad: using env_logger in a library +pub fn init_logging() { + env_logger::init(); // steals the global logger from the application +} +``` + +## Good + +```rust +// In a library crate: mylib/src/lib.rs +use tracing::info; + +pub fn connect(url: &str) { + // Good: just emit; the application owns subscriber setup + info!(url, "connecting"); +} +``` + +```rust +// In the binary: src/main.rs +fn main() { + // The application initializes once, with full control + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + mylib::connect("postgres://localhost/app"); +} +``` + +## Key Points + +- **Library `Cargo.toml`**: depend on `tracing = "0.1"` only. Do **not** add `tracing-subscriber` or `env_logger` as non-`dev` dependencies. +- **Binary `Cargo.toml`**: add `tracing-subscriber = { version = "0.3", features = ["env-filter"] }` for the subscriber. +- If you need a subscriber in library tests, add it to `[dev-dependencies]` and call it inside `#[test]` functions using `tracing_subscriber::fmt::try_init()` (the `try_` variant does not panic on re-init). +- The `log` crate follows the same rule: libraries call `log::info!(...)`; applications call `env_logger::init()` or bridge via `tracing_log::LogTracer`. + +```toml +# library Cargo.toml +[dependencies] +tracing = "0.1" + +[dev-dependencies] +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +``` + +## See Also + +- [obs-tracing-over-log](obs-tracing-over-log.md) - why to use `tracing` over `println!` or bare `log` +- [obs-levels-filter](obs-levels-filter.md) - configure level filtering with `EnvFilter` in the binary +- [api-serde-optional](api-serde-optional.md) - pattern for gating heavy dependencies behind feature flags diff --git a/.opencode/skills/rust-skills/rules/obs-no-sensitive-data.md b/.opencode/skills/rust-skills/rules/obs-no-sensitive-data.md new file mode 100644 index 0000000..438c1db --- /dev/null +++ b/.opencode/skills/rust-skills/rules/obs-no-sensitive-data.md @@ -0,0 +1,87 @@ +# obs-no-sensitive-data + +> Never log secrets or PII; redact or skip them + +## Why It Matters + +Logs and traces are routinely shipped to cloud aggregators (Datadog, Grafana Loki, Splunk) and retained for weeks or months. If a password, API token, session cookie, or piece of PII (email, SSN, health data) appears in a log line or span field, it leaks into systems with weaker access controls than your secrets manager, appears in support exports, and may violate GDPR/HIPAA/PCI-DSS. The fix is cheap: use `#[instrument(skip(...))]` or `skip_all` to exclude sensitive arguments, and wrap sensitive types in a redacting newtype or use the `secrecy` crate's `Secret` which prints `[redacted]` from both `Debug` and `Display`. + +## Bad + +```rust +use tracing::instrument; + +struct Credentials { + username: String, + password: String, // secret + api_key: String, // secret +} + +// BAD: instrument auto-captures all args as fields — password becomes a span field +#[instrument] +async fn authenticate(credentials: &Credentials) -> bool { + // Also BAD: manual logging of the whole struct + tracing::info!(?credentials, "authenticating user"); + true +} +``` + +## Good + +```rust +use tracing::{info, instrument}; + +// A simple redacting newtype — implement for any sensitive type +#[derive(Clone)] +struct Secret(String); + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[redacted]") + } +} + +impl std::fmt::Display for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[redacted]") + } +} + +struct Credentials { + username: String, + password: Secret, // redacts in Debug/Display + api_key: Secret, // redacts in Debug/Display +} + +// GOOD: skip sensitive args by name +#[instrument(skip(credentials), fields(username = %credentials.username))] +async fn authenticate(credentials: &Credentials) -> bool { + info!("authenticating user"); + // password and api_key never appear in any span field or log line + verify_password(&credentials.username, &credentials.password) +} + +fn verify_password(_username: &str, _password: &Secret) -> bool { true } +``` + +```rust +// Alternative: use the `secrecy` crate (Secret implements Debug as "[redacted]") +// use secrecy::{Secret, ExposeSecret}; +// struct Credentials { username: String, password: Secret } +// credentials.password.expose_secret() // only call site that reveals value +``` + +## Key Points + +- **`skip(arg)`**: exclude a single argument from `#[instrument]` auto-captured fields. +- **`skip_all`**: exclude all arguments; then add only safe fields with `fields(key = value)`. +- **Redacting newtypes**: override `Debug` and `Display` to emit `"[redacted]"` — this protects against accidental `?arg` or `%arg` elsewhere in the codebase. +- **`secrecy` crate**: provides `Secret` with a `[redacted]` `Debug` impl and an explicit `.expose_secret()` API so you know exactly where the value is revealed. +- **Do not log full request bodies** in production: they may contain tokens, credentials, or PII embedded in JSON payloads. Log only metadata (size, content-type, path). +- Audit existing spans with `RUST_LOG=trace` in a staging environment before shipping. + +## See Also + +- [obs-instrument-spans](obs-instrument-spans.md) - how to use `#[instrument]` and spans correctly +- [obs-structured-fields](obs-structured-fields.md) - structured fields must be safe to emit +- [err-thiserror-lib](err-thiserror-lib.md) - defining error types that don't accidentally expose secrets diff --git a/.opencode/skills/rust-skills/rules/obs-structured-fields.md b/.opencode/skills/rust-skills/rules/obs-structured-fields.md new file mode 100644 index 0000000..a01bf78 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/obs-structured-fields.md @@ -0,0 +1,71 @@ +# obs-structured-fields + +> Record structured key-value fields, not values interpolated into the message string + +## Why It Matters + +When values are interpolated directly into the message string (e.g., `"processed 42 items for user 7 in 120ms"`), they become opaque text. Log aggregators (Loki, Elasticsearch, OpenTelemetry) cannot filter on `items = 42` or group by `user.id` because those values no longer exist as discrete fields. Structured fields keep data machine-parseable, filterable, and chart-able without regex post-processing. `tracing` supports three field sigils: `%expr` for `Display`, `?expr` for `Debug`, and bare `field = value` for typed primitives — the message string should only contain a stable, human-readable description. + +## Bad + +```rust +use tracing::info; + +fn process_batch(user_id: u64, items: usize, elapsed_ms: u64) { + // Values buried in the message string — unqueryable in aggregators + info!("processed {} items for user {} in {}ms", items, user_id, elapsed_ms); +} +``` + +## Good + +```rust +use tracing::info; + +fn process_batch(user_id: u64, items: usize, elapsed_ms: u64) { + // Structured: each value is a discrete, queryable field + info!( + user.id = user_id, + items, + elapsed_ms, + "batch processed" + ); +} + +#[derive(Debug)] +struct Request { + path: String, + method: String, +} + +fn handle_request(req: &Request, status: u16) { + // %req uses Display; ?req uses Debug; status is a primitive + info!( + request = ?req, // Debug format for the whole struct + status, + "request complete" + ); +} +``` + +## Field Sigil Reference + +| Syntax | Trait used | When to use | +|---|---|---| +| `field = value` | native (primitive) | integers, bools, floats | +| `field = %expr` | `Display` | strings, IDs, URLs, types with clean `Display` | +| `field = ?expr` | `Debug` | structs, enums, vecs — for diagnostics | +| `field` (shorthand) | same as `field = field` | when name matches variable | + +## Key Points + +- Keep the message string short, stable, and human-readable. It should make sense *without* the fields. +- Prefer `%` over `?` for values that have a clean `Display` (e.g., `%id`, `%path`) — JSON backends quote Debug output inconsistently. +- Use namespaced field names like `user.id`, `http.status`, `db.query` when aligning to OpenTelemetry semantic conventions. +- Avoid placing the same data in both the message and a field (redundant and noisy). + +## See Also + +- [obs-tracing-over-log](obs-tracing-over-log.md) - foundational setup for `tracing` +- [obs-no-sensitive-data](obs-no-sensitive-data.md) - never put secrets or PII in structured fields +- [obs-error-chain](obs-error-chain.md) - log errors as structured fields with full source chain diff --git a/.opencode/skills/rust-skills/rules/obs-tracing-over-log.md b/.opencode/skills/rust-skills/rules/obs-tracing-over-log.md new file mode 100644 index 0000000..0a3a292 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/obs-tracing-over-log.md @@ -0,0 +1,55 @@ +# obs-tracing-over-log + +> Use `tracing` for structured, span-aware diagnostics instead of `println!` or bare `log` + +## Why It Matters + +`println!` and `eprintln!` have no concept of log levels, targets, or structured data — they cannot be silenced, filtered, or parsed by observability pipelines. The `log` facade improves this but emits only flat strings and has no notion of spans. `tracing` records both *events* (point-in-time observations) and *spans* (contextual scopes that automatically follow execution across `.await` points and threads), with structured key-value fields, level filtering, and target routing. It is also interoperable with the `log` ecosystem via `tracing`'s `log` feature flag. + +## Bad + +```rust +fn handle_login(id: u64) { + println!("user {} logged in", id); + // No level, no structure, no filtering, goes to stdout unconditionally +} + +fn main() { + handle_login(42); +} +``` + +## Good + +```rust +use tracing::info; + +fn handle_login(id: u64) { + // Structured field: user.id is queryable in JSON/OpenTelemetry backends + info!(user.id = %id, "user logged in"); +} + +fn main() { + // One-time subscriber init belongs in the binary, not in libraries + tracing_subscriber::fmt::init(); + handle_login(42); +} +``` + +## Key Points + +| Approach | Levels | Structured | Async-aware spans | `log` compat | +|---|---|---|---|---| +| `println!` | No | No | No | No | +| `log` facade | Yes | No | No | Yes | +| `tracing` | Yes | Yes | Yes | Yes (via feature) | + +- Add to `Cargo.toml`: `tracing = "0.1"` for all crates; `tracing-subscriber = { version = "0.3", features = ["env-filter"] }` for binaries only. +- The `%expr` sigil uses `Display`; `?expr` uses `Debug`; bare `field = value` records typed primitives. +- `tracing` ships a `log` compatibility bridge: set `tracing-subscriber`'s `log` feature or call `tracing_log::LogTracer::init()` to capture existing `log`-emitting dependencies. + +## See Also + +- [obs-structured-fields](obs-structured-fields.md) - record key-value fields, not interpolated strings +- [obs-instrument-spans](obs-instrument-spans.md) - attach context to async tasks with spans +- [async-tokio-runtime](async-tokio-runtime.md) - production async runtime setup diff --git a/.opencode/skills/rust-skills/rules/opt-bounds-check.md b/.opencode/skills/rust-skills/rules/opt-bounds-check.md new file mode 100644 index 0000000..a482ae4 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-bounds-check.md @@ -0,0 +1,163 @@ +# opt-bounds-check + +> Use iterators and patterns that eliminate bounds checks in hot paths + +## Why It Matters + +Rust's safety guarantees require bounds checking on array/slice indexing. In tight loops, these checks can cause measurable overhead (branch mispredictions, preventing vectorization). Patterns like iterators, `get_unchecked`, and index splitting can eliminate these checks while maintaining safety. + +## Bad + +```rust +fn sum_products(a: &[f64], b: &[f64]) -> f64 { + let mut sum = 0.0; + for i in 0..a.len() { + sum += a[i] * b[i]; // Two bounds checks per iteration + } + sum +} + +fn apply_filter(data: &mut [u8], kernel: &[u8; 3]) { + for i in 1..data.len() - 1 { + // Three bounds checks per iteration + data[i] = (data[i - 1] + data[i] + data[i + 1]) / 3; + } +} +``` + +## Good + +```rust +fn sum_products(a: &[f64], b: &[f64]) -> f64 { + // Iterator zips - no bounds checks, vectorizes well + a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() +} + +fn apply_filter(data: &mut [u8]) { + // Windows pattern - no bounds checks + for window in data.windows(3) { + // window[0], window[1], window[2] are all valid + } + + // Or use chunks + for chunk in data.chunks_exact(4) { + process_simd(chunk); + } +} +``` + +## Iterator Patterns + +```rust +// These give the compiler much better opportunities to eliminate bounds checks +// (and often do in practice), but elimination is not guaranteed — verify hot +// code with generated assembly (e.g. cargo-show-asm): + +// zip - parallel iteration +for (a, b) in xs.iter().zip(ys.iter()) { ... } + +// enumerate - index + value +for (i, x) in data.iter().enumerate() { ... } + +// windows - sliding window +for window in data.windows(3) { ... } + +// chunks - fixed-size groups +for chunk in data.chunks(4) { ... } +for chunk in data.chunks_exact(4) { ... } // Guarantees exact size + +// split_at - divide slice +let (left, right) = data.split_at(mid); +``` + +## Split for Parallel Access + +```rust +fn parallel_sum(data: &[i32]) -> i32 { + // Split into independent chunks + let (left, right) = data.split_at(data.len() / 2); + + // Process chunks without bounds checks + let sum_left: i32 = left.iter().sum(); + let sum_right: i32 = right.iter().sum(); + + sum_left + sum_right +} +``` + +## get_unchecked for Proven Safety + +```rust +fn matrix_multiply(a: &[f64], b: &[f64], c: &mut [f64], n: usize) { + assert!(a.len() >= n * n); + assert!(b.len() >= n * n); + assert!(c.len() >= n * n); + + for i in 0..n { + for j in 0..n { + let mut sum = 0.0; + for k in 0..n { + // SAFETY: bounds verified by asserts above + unsafe { + sum += a.get_unchecked(i * n + k) + * b.get_unchecked(k * n + j); + } + } + // SAFETY: bounds verified by asserts above + unsafe { + *c.get_unchecked_mut(i * n + j) = sum; + } + } + } +} +``` + +## Slice Patterns + +```rust +fn process_header(data: &[u8]) -> Option
    { + // Slice pattern - single length check, no per-field checks + let [a, b, c, d, rest @ ..] = data else { + return None; + }; + + Some(Header { + magic: *a, + version: *b, + flags: u16::from_le_bytes([*c, *d]), + payload: rest, + }) +} +``` + +## Verify Bounds Check Elimination + +```bash +# Check generated assembly +cargo asm --release my_crate::hot_function + +# Look for 'cmp' and 'ja'/'jbe' instructions near array access +# If eliminated, you'll see direct memory access +``` + +## When to Accept Bounds Checks + +```rust +// Random access patterns - checks unavoidable +fn random_lookup(data: &[u8], indices: &[usize]) -> Vec { + indices.iter() + .filter_map(|&i| data.get(i).copied()) // Checked, but necessary + .collect() +} + +// Infrequent access - overhead negligible +fn get_config(&self, key: &str) -> Option<&Value> { + self.config.get(key) // Fine, not hot path +} +``` + +## See Also + +- [opt-simd-portable](./opt-simd-portable.md) - SIMD requires unchecked access +- [opt-cache-friendly](./opt-cache-friendly.md) - Cache-efficient patterns +- [perf-profile-first](./perf-profile-first.md) - Identify actual hot paths diff --git a/.opencode/skills/rust-skills/rules/opt-cache-friendly.md b/.opencode/skills/rust-skills/rules/opt-cache-friendly.md new file mode 100644 index 0000000..fbfb199 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-cache-friendly.md @@ -0,0 +1,187 @@ +# opt-cache-friendly + +> Organize data for cache-efficient access patterns + +## Why It Matters + +Cache misses are expensive—a L3 cache miss costs ~100+ cycles vs ~4 cycles for L1 hit. Data layout and access patterns determine cache efficiency. Arrays of structs (AoS) vs structs of arrays (SoA), memory locality, and access patterns can make order-of-magnitude performance differences. + +## Bad + +```rust +// Array of Structs (AoS) - poor cache use when accessing one field +struct Particle { + position: [f32; 3], // 12 bytes + velocity: [f32; 3], // 12 bytes + mass: f32, // 4 bytes + id: u64, // 8 bytes + flags: u8, // 1 byte + padding + // Total: 40 bytes per particle +} + +fn update_positions(particles: &mut [Particle], dt: f32) { + for p in particles { + // Access position and velocity - 24 bytes + // But loads 40-byte struct per particle + // 16 bytes wasted per cache line load + p.position[0] += p.velocity[0] * dt; + p.position[1] += p.velocity[1] * dt; + p.position[2] += p.velocity[2] * dt; + } +} +``` + +## Good + +```rust +// Struct of Arrays (SoA) - cache-efficient for field access +struct Particles { + positions_x: Vec, + positions_y: Vec, + positions_z: Vec, + velocities_x: Vec, + velocities_y: Vec, + velocities_z: Vec, + masses: Vec, + ids: Vec, + flags: Vec, +} + +fn update_positions(p: &mut Particles, dt: f32) { + // Access contiguous memory - perfect cache utilization + for (px, vx) in p.positions_x.iter_mut().zip(&p.velocities_x) { + *px += vx * dt; + } + for (py, vy) in p.positions_y.iter_mut().zip(&p.velocities_y) { + *py += vy * dt; + } + for (pz, vz) in p.positions_z.iter_mut().zip(&p.velocities_z) { + *pz += vz * dt; + } +} +``` + +## Hot/Cold Splitting + +```rust +// Separate frequently and rarely accessed fields +struct EntityHot { + position: [f32; 3], + velocity: [f32; 3], + // Hot data - accessed every frame +} + +struct EntityCold { + name: String, + creation_time: Instant, + metadata: HashMap, + // Cold data - rarely accessed +} + +struct Entities { + hot: Vec, + cold: Vec, +} + +// Hot loop touches only hot data +fn update(entities: &mut Entities, dt: f32) { + for e in &mut entities.hot { + e.position[0] += e.velocity[0] * dt; + // Cold data stays out of cache + } +} +``` + +## Prefetching + +```rust +// Process in cache-line-sized chunks +const CACHE_LINE: usize = 64; + +fn process_with_prefetch(data: &mut [u8]) { + for chunk in data.chunks_mut(CACHE_LINE) { + // Prefetch next chunk while processing current + // (automatic in many cases, manual for complex patterns) + process_chunk(chunk); + } +} + +// Matrix multiplication - block for cache +fn matmul_blocked(a: &[f64], b: &[f64], c: &mut [f64], n: usize) { + const BLOCK: usize = 32; // Fits in L1 cache + + for i0 in (0..n).step_by(BLOCK) { + for j0 in (0..n).step_by(BLOCK) { + for k0 in (0..n).step_by(BLOCK) { + // Process BLOCK x BLOCK tile + for i in i0..min(i0 + BLOCK, n) { + for j in j0..min(j0 + BLOCK, n) { + // Inner loop operates on cached data + } + } + } + } + } +} +``` + +## Avoid Pointer Chasing + +```rust +// Bad: linked list - random memory access +struct Node { + value: i32, + next: Option>, +} + +fn sum_linked(head: &Node) -> i32 { + // Each node is a cache miss +} + +// Good: contiguous vector +fn sum_vector(data: &[i32]) -> i32 { + data.iter().sum() // Sequential access, prefetcher happy +} + +// Good: if graph needed, use indices +struct Graph { + values: Vec, + edges: Vec, // Indices into values +} +``` + +## Memory Layout Attributes + +```rust +// Ensure cache-line alignment +#[repr(C, align(64))] +struct CacheAligned { + data: [u8; 64], +} + +// Prevent false sharing in concurrent code +#[repr(C, align(64))] +struct PaddedCounter { + value: AtomicU64, + _pad: [u8; 56], +} +``` + +## Measuring Cache Performance + +```bash +# Linux perf +perf stat -e cache-references,cache-misses ./my_program + +# Detailed cache analysis +perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-loads,LLC-load-misses ./my_program + +# Cachegrind +valgrind --tool=cachegrind ./my_program +``` + +## See Also + +- [mem-smaller-integers](./mem-smaller-integers.md) - Smaller data fits more in cache +- [mem-box-large-variant](./mem-box-large-variant.md) - Keep enum sizes small +- [opt-bounds-check](./opt-bounds-check.md) - Sequential access patterns diff --git a/.opencode/skills/rust-skills/rules/opt-codegen-units.md b/.opencode/skills/rust-skills/rules/opt-codegen-units.md new file mode 100644 index 0000000..eedda68 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-codegen-units.md @@ -0,0 +1,142 @@ +# opt-codegen-units + +> Set `codegen-units = 1` for maximum optimization in release builds + +## Why It Matters + +By default, Cargo splits code into multiple codegen units for parallel compilation. This speeds up builds but prevents some cross-unit optimizations. Setting `codegen-units = 1` allows LLVM to optimize across the entire crate, potentially improving runtime performance by 5-20% at the cost of slower builds. + +## Bad + +```toml +# Cargo.toml - default settings +[profile.release] +# codegen-units defaults to 16 +# Fast to compile, but misses optimization opportunities +``` + +## Good + +```toml +# Cargo.toml - optimized for runtime performance +[profile.release] +codegen-units = 1 # Single unit = better optimization +lto = true # Link-time optimization +opt-level = 3 # Maximum optimization +``` + +## What codegen-units Affects + +| Codegen Units | Compile Time | Runtime Performance | Memory Use | +|---------------|--------------|---------------------|------------| +| 16 (default) | Faster | Baseline | Lower | +| 4-8 | Moderate | Slightly better | Moderate | +| 1 | Slower | Best | Higher | + +## How It Works + +```rust +// With codegen-units = 16: +// - Crate split into 16 independent compilation units +// - Compiled in parallel +// - Limited visibility between units for optimization + +// With codegen-units = 1: +// - Entire crate in single unit +// - LLVM sees all code at once +// - Can inline across module boundaries +// - Better dead code elimination +// - Better constant propagation +``` + +## Full Release Profile + +```toml +[profile.release] +# Maximum runtime performance +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" # Smaller binary, slight perf gain +strip = true # Smaller binary + +[profile.release-with-debug] +# Performance with debugging ability +inherits = "release" +debug = true # Keep debug symbols +strip = false + +[profile.bench] +# For benchmarking +inherits = "release" +``` + +## Build Time Trade-offs + +```bash +# Default release build (fast compile) +cargo build --release +# Time: ~30s + +# Optimized release build (slow compile, fast runtime) +# With codegen-units = 1, lto = "fat" +cargo build --release +# Time: ~2-5min, but potentially 10-20% faster binary +``` + +## Per-Profile Configuration + +```toml +# Fast debug builds +[profile.dev] +codegen-units = 256 # Maximum parallelism + +# Fast CI builds +[profile.ci] +inherits = "release" +codegen-units = 16 # Balance compile time vs runtime +lto = "thin" # Faster than "fat" + +# Production release +[profile.production] +inherits = "release" +codegen-units = 1 +lto = "fat" +``` + +## When to Use What + +```rust +// codegen-units = 16 (default) +// - Development builds +// - CI where compile time matters +// - When runtime performance isn't critical + +// codegen-units = 1 +// - Production deployments +// - Performance-critical applications +// - Final releases +// - Benchmarking +``` + +## Measuring Impact + +```bash +# Build with different settings +cargo build --release + +# Benchmark +cargo bench + +# Compare binary sizes +ls -lh target/release/my_binary + +# Profile runtime +perf stat ./target/release/my_binary +``` + +## See Also + +- [opt-lto-release](./opt-lto-release.md) - Link-time optimization +- [opt-pgo-profile](./opt-pgo-profile.md) - Profile-guided optimization +- [opt-target-cpu](./opt-target-cpu.md) - CPU-specific optimization diff --git a/.opencode/skills/rust-skills/rules/opt-cold-unlikely.md b/.opencode/skills/rust-skills/rules/opt-cold-unlikely.md new file mode 100644 index 0000000..85128e7 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-cold-unlikely.md @@ -0,0 +1,152 @@ +# opt-cold-unlikely + +> Mark unlikely code paths with `#[cold]` to help compiler optimization + +## Why It Matters + +The `#[cold]` attribute tells the compiler that a function is rarely called. The compiler uses this to optimize code layout—keeping cold code away from hot code improves instruction cache utilization. Combined with branch layout optimization, this can measurably improve performance. + +## Bad + +```rust +// All branches treated equally +fn validate(input: &str) -> Result { + if input.is_empty() { + return Err(ValidationError::Empty); // Rare + } + + if input.len() > 1000 { + return Err(ValidationError::TooLong); // Rare + } + + if !input.is_ascii() { + return Err(ValidationError::NonAscii); // Rare + } + + // This is the common case + Ok(parse_data(input)) +} +``` + +## Good + +```rust +fn validate(input: &str) -> Result { + if input.is_empty() { + return cold_empty_error(); + } + + if input.len() > 1000 { + return cold_too_long_error(); + } + + if !input.is_ascii() { + return cold_non_ascii_error(); + } + + Ok(parse_data(input)) +} + +#[cold] +fn cold_empty_error() -> Result { + Err(ValidationError::Empty) +} + +#[cold] +fn cold_too_long_error() -> Result { + Err(ValidationError::TooLong) +} + +#[cold] +fn cold_non_ascii_error() -> Result { + Err(ValidationError::NonAscii) +} +``` + +## What #[cold] Does + +1. **Code placement**: Cold functions are placed in separate code sections, away from hot code +2. **Branch prediction**: Compiler generates branch hints favoring the non-cold path +3. **Inlining decisions**: Cold functions are not inlined into hot paths +4. **Optimization budget**: Compiler spends less effort optimizing cold code + +## Common Cold Patterns + +```rust +// Error handling +#[cold] +fn handle_error(e: E) -> ! { + eprintln!("Fatal error: {}", e); + std::process::exit(1); +} + +// Logging rare events +#[cold] +fn log_rare_event(event: &Event) { + log::warn!("Rare event occurred: {:?}", event); +} + +// Fallback paths +#[cold] +fn slow_fallback(data: &Data) -> Output { + // This path should rarely be taken + compute_slowly(data) +} + +// Panic handlers +#[cold] +fn panic_invalid_state(state: &State) -> ! { + panic!("Invalid state: {:?}", state); +} +``` + +## Assertions and Invariants + +```rust +fn get_unchecked(&self, index: usize) -> &T { + if index >= self.len { + cold_bounds_panic(index, self.len); + } + unsafe { &*self.ptr.add(index) } +} + +#[cold] +#[inline(never)] +fn cold_bounds_panic(index: usize, len: usize) -> ! { + panic!("index out of bounds: the len is {} but the index is {}", len, index); +} +``` + +## Combining with #[inline(never)] + +```rust +// Usually combine both for maximum effect +#[cold] +#[inline(never)] +fn error_path() -> Error { + // Complex error construction stays out of hot code + Error { + backtrace: Backtrace::capture(), + context: gather_context(), + } +} +``` + +## Measuring Impact + +```rust +// Check code layout with objdump +// objdump -d target/release/binary | less + +// Look for .cold sections +// nm target/release/binary | grep cold + +// Profile to verify improvement +// perf stat -e cache-misses,cache-references ./binary +``` + +## See Also + +- [opt-inline-never-cold](./opt-inline-never-cold.md) - Combining with inline(never) +- [opt-likely-hint](./opt-likely-hint.md) - Branch prediction hints +- [err-result-over-panic](./err-result-over-panic.md) - Error handling diff --git a/.opencode/skills/rust-skills/rules/opt-inline-always-rare.md b/.opencode/skills/rust-skills/rules/opt-inline-always-rare.md new file mode 100644 index 0000000..981cc06 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-inline-always-rare.md @@ -0,0 +1,141 @@ +# opt-inline-always-rare + +> Use `#[inline(always)]` sparingly—only for critical hot paths proven by profiling + +## Why It Matters + +`#[inline(always)]` forces the compiler to inline a function regardless of heuristics. Overuse increases binary size, hurts instruction cache, and can slow down code. The compiler is usually smarter about inlining than humans. Reserve this for measured hot paths where benchmarks prove a benefit. + +## Bad + +```rust +// Annotating everything - trusting intuition over data +#[inline(always)] +pub fn get_name(&self) -> &str { + &self.name +} + +#[inline(always)] +pub fn calculate_tax(amount: f64) -> f64 { + amount * 0.1 +} + +#[inline(always)] +fn helper(x: i32) -> i32 { + x + 1 +} + +// Result: bloated binary, poor cache utilization +``` + +## Good + +```rust +// Let compiler decide for most functions +pub fn get_name(&self) -> &str { + &self.name +} + +pub fn calculate_tax(amount: f64) -> f64 { + amount * 0.1 +} + +// Only force inline for proven hot paths +impl Hasher for MyHasher { + // Hasher::write is called millions of times in tight loops + // Profiling showed 15% improvement from forced inlining + #[inline(always)] + fn write(&mut self, bytes: &[u8]) { + // Very small, very hot + self.state = self.state.wrapping_add(bytes.len() as u64); + } +} +``` + +## When #[inline(always)] Helps + +```rust +// ✅ Tiny functions in hot inner loops +#[inline(always)] +fn fast_hash(a: u64, b: u64) -> u64 { + a.wrapping_mul(b).wrapping_add(a) +} + +// ✅ Generic functions that benefit from monomorphization +#[inline(always)] +fn swap(a: &mut T, b: &mut T) { + std::mem::swap(a, b); +} + +// ✅ Iterator adapters and closures +#[inline(always)] +fn apply T>(f: F, x: T) -> T { + f(x) +} + +// ✅ SIMD/vectorization helpers +#[inline(always)] +fn add_simd(a: &[f32], b: &[f32], out: &mut [f32]) { + // ... +} +``` + +## Inline Variants + +```rust +// #[inline] - hint to inline, compiler may ignore +#[inline] +fn suggested_inline(x: i32) -> i32 { x + 1 } + +// #[inline(always)] - force inline (almost always) +#[inline(always)] +fn force_inline(x: i32) -> i32 { x + 1 } + +// #[inline(never)] - prevent inlining (for profiling, code size) +#[inline(never)] +fn no_inline(x: i32) -> i32 { x + 1 } + +// No annotation - compiler decides based on heuristics +fn compiler_decides(x: i32) -> i32 { x + 1 } +``` + +## Measuring Inline Impact + +```rust +// Use criterion to benchmark +use criterion::{criterion_group, criterion_main, Criterion}; + +fn bench_with_inline(c: &mut Criterion) { + c.bench_function("hot_path_inline", |b| { + b.iter(|| hot_loop()) + }); +} + +// Compare binary sizes +// cargo bloat --release --crates + +// Check if function was inlined +// cargo asm --rust my_crate::hot_function +``` + +## Generic Functions + +```rust +// Generic functions across crate boundaries often need #[inline] +// Because the generic code is compiled in the calling crate + +// In library crate: +#[inline] // Allow inlining in downstream crates +pub fn generic_function(x: T) { + println!("{}", x); +} + +// Without #[inline], the generic function can't be inlined +// across crate boundaries even if beneficial +``` + +## See Also + +- [opt-inline-small](./opt-inline-small.md) - Regular inline for small functions +- [opt-inline-never-cold](./opt-inline-never-cold.md) - Preventing inlining +- [perf-profile-first](./perf-profile-first.md) - Profile before optimizing diff --git a/.opencode/skills/rust-skills/rules/opt-inline-never-cold.md b/.opencode/skills/rust-skills/rules/opt-inline-never-cold.md new file mode 100644 index 0000000..9861cea --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-inline-never-cold.md @@ -0,0 +1,182 @@ +# opt-inline-never-cold + +> Use `#[inline(never)]` and `#[cold]` for error paths and rarely-executed code + +## Why It Matters + +Inlining error handling code into hot paths wastes instruction cache space and can prevent other optimizations. `#[inline(never)]` keeps cold code out of the hot path. `#[cold]` tells the compiler this branch is unlikely, enabling better branch prediction hints and code layout. + +## Bad + +```rust +fn process_data(data: &[u8]) -> Result { + if data.is_empty() { + // Error path inlined into hot function + return Err(Error::Empty { + context: format!("Expected data, got empty slice"), + suggestions: vec!["Check input", "Validate before calling"], + }); + } + + // Hot path - now polluted with error construction code + do_processing(data) +} +``` + +## Good + +```rust +fn process_data(data: &[u8]) -> Result { + if data.is_empty() { + return Err(empty_data_error()); // Cold path stays small + } + + do_processing(data) +} + +#[cold] +#[inline(never)] +fn empty_data_error() -> Error { + Error::Empty { + context: format!("Expected data, got empty slice"), + suggestions: vec!["Check input", "Validate before calling"], + } +} +``` + +## #[cold] for Unlikely Branches + +```rust +fn parse_value(input: &str) -> Result { + match input.parse() { + Ok(n) => Ok(n), + Err(e) => cold_parse_error(input, e), + } +} + +#[cold] +fn cold_parse_error(input: &str, e: std::num::ParseIntError) -> Result { + Err(ParseError { + input: input.to_string(), + source: e, + }) +} +``` + +## Panic Paths + +```rust +fn get_index(&self, idx: usize) -> &T { + if idx >= self.len { + cold_out_of_bounds(idx, self.len); + } + unsafe { self.ptr.add(idx).as_ref().unwrap() } +} + +#[cold] +#[inline(never)] +fn cold_out_of_bounds(idx: usize, len: usize) -> ! { + panic!("index {} out of bounds for length {}", idx, len); +} +``` + +## Error Construction Functions + +```rust +// Keep error construction out of hot path +impl MyError { + #[cold] + pub fn io_error(source: std::io::Error, path: &Path) -> Self { + MyError::Io { + source, + path: path.to_path_buf(), + context: get_context(), + } + } + + #[cold] + pub fn validation_error(msg: &str, field: &str) -> Self { + MyError::Validation { + message: msg.to_string(), + field: field.to_string(), + } + } +} + +fn read_config(path: &Path) -> Result { + std::fs::read_to_string(path) + .map_err(|e| MyError::io_error(e, path))? + .parse() + .map_err(|e| MyError::parse_error(e)) +} +``` + +## likely/unlikely Hints + +```rust +// Nightly: std::hint likely/unlikely branch hints (still unstable as of Rust 1.96) +// (std::hint::cold_path() is stable since 1.95 for marking the rare branch) +#![feature(likely_unlikely)] +use std::hint::{likely, unlikely}; + +fn process(data: Option<&Data>) -> Result { + if unlikely(data.is_none()) { + return cold_none_error(); + } + + let data = data.unwrap(); + + if likely(data.is_valid()) { + fast_process(data) + } else { + slow_validate_and_process(data) + } +} + +// Stable alternative: structure code so hot path is "fall through" +fn process(data: Option<&Data>) -> Result { + let data = match data { + Some(d) => d, + None => return cold_none_error(), // Early return = unlikely hint + }; + + // Compiler assumes code after early returns is "hot" + fast_process(data) +} +``` + +## Pattern: Extract Cold Code + +```rust +// Before: cold code inline +fn hot_function(x: i32) -> i32 { + if x < 0 { + log::error!("Negative value: {}", x); + eprintln!("Debug info: {:?}", std::backtrace::Backtrace::capture()); + return 0; + } + x * 2 +} + +// After: cold code extracted +fn hot_function(x: i32) -> i32 { + if x < 0 { + return handle_negative(x); + } + x * 2 +} + +#[cold] +#[inline(never)] +fn handle_negative(x: i32) -> i32 { + log::error!("Negative value: {}", x); + eprintln!("Debug info: {:?}", std::backtrace::Backtrace::capture()); + 0 +} +``` + +## See Also + +- [opt-inline-small](./opt-inline-small.md) - Inlining for hot code +- [opt-inline-always-rare](./opt-inline-always-rare.md) - Forced inlining +- [err-result-over-panic](./err-result-over-panic.md) - Error handling patterns diff --git a/.opencode/skills/rust-skills/rules/opt-inline-small.md b/.opencode/skills/rust-skills/rules/opt-inline-small.md new file mode 100644 index 0000000..9be800f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-inline-small.md @@ -0,0 +1,164 @@ +# opt-inline-small + +> Use `#[inline]` for small hot functions + +## Why It Matters + +Function call overhead (stack frame setup, register saves, jumps) can dominate small functions. Inlining eliminates this overhead and enables further optimizations by the compiler. The compiler often inlines automatically, but hints help for cross-crate calls. + +## Bad + +```rust +// Small hot function without inline hint +// May not be inlined across crate boundaries +fn is_ascii_digit(b: u8) -> bool { + b >= b'0' && b <= b'9' +} + +// Called millions of times +for byte in data { + if is_ascii_digit(*byte) { // Function call overhead + count += 1; + } +} +``` + +## Good + +```rust +#[inline] +fn is_ascii_digit(b: u8) -> bool { + b >= b'0' && b <= b'9' +} + +// Now the compiler will inline this +for byte in data { + if is_ascii_digit(*byte) { // Inlined, no call overhead + count += 1; + } +} +``` + +## Inline Attributes + +```rust +// No attribute - compiler decides (usually good for same-crate) +fn auto_decide() { } + +// Suggest inlining - helps cross-crate +#[inline] +fn suggest_inline() { } + +// Strongly suggest inlining - almost always inlined +#[inline(always)] +fn force_inline() { } + +// Strongly suggest NOT inlining - for large/cold code +#[inline(never)] +fn prevent_inline() { } +``` + +## When to Use Each + +```rust +// #[inline] - Small functions, especially in libraries +#[inline] +pub fn len(&self) -> usize { + self.inner.len() +} + +// #[inline(always)] - Critical hot path, verified by profiling +#[inline(always)] +fn hot_inner_loop_helper(x: u32) -> u32 { + x.wrapping_mul(0x9E3779B9) +} + +// #[inline(never)] - Error handlers, cold paths +#[inline(never)] +fn handle_error(err: Error) -> ! { + eprintln!("Fatal: {}", err); + std::process::exit(1); +} + +// No attribute - large functions, infrequent calls +fn complex_processing(data: &mut Data) { + // Many lines of code... +} +``` + +## Evidence from ripgrep + +```rust +// https://github.com/BurntSushi/ripgrep/blob/master/crates/printer/src/standard.rs + +#[inline(always)] +fn write_prelude( + &self, + absolute_byte_offset: u64, + line_number: Option, + column: Option, +) -> io::Result<()> { + // Hot path in printing matches +} + +#[inline(always)] +fn write_line(&self, line: &[u8]) -> io::Result<()> { + // Called for every line +} +``` + +## Generic Functions + +```rust +// Generic functions are already candidates for per-monomorphization inlining +// But #[inline] helps ensure it across crates + +#[inline] +pub fn min(a: T, b: T) -> T { + if a < b { a } else { b } +} +``` + +## Cautions + +```rust +// DON'T inline large functions - hurts instruction cache +#[inline(always)] // BAD for large function +fn large_complex_function(data: &mut [u8]) { + // 100+ lines of code + // Inlining bloats every call site +} + +// DON'T assume inlining always helps - measure! +// Sometimes the compiler makes better decisions + +// Cross-crate inlining requires #[inline] on each function +// Without LTO, a function body is not available to other crates unless +// it carries #[inline]. Within a single crate (or with LTO enabled), +// the compiler may still inline `inner` transitively after inlining +// `outer`, but this is not guaranteed — verify hot code with assembly. +#[inline] +fn outer() { + inner(); +} + +fn inner() { } // May not be inlined at outer's call sites across crate boundaries without #[inline] +``` + +## Verifying Inlining + +```bash +# Check if function was inlined using Cachegrind +# Non-inlined functions show entry/exit counts + +# Or examine assembly +cargo rustc --release -- --emit=asm +# Look for call instructions vs inlined code +``` + +## See Also + +- [opt-inline-always-rare](opt-inline-always-rare.md) - Use #[inline(always)] sparingly +- [opt-inline-never-cold](opt-inline-never-cold.md) - Use #[inline(never)] for cold paths +- [opt-cold-unlikely](opt-cold-unlikely.md) - Use #[cold] for unlikely paths +- [opt-lto-release](opt-lto-release.md) - LTO enables cross-crate inlining diff --git a/.opencode/skills/rust-skills/rules/opt-likely-hint.md b/.opencode/skills/rust-skills/rules/opt-likely-hint.md new file mode 100644 index 0000000..4a8a662 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-likely-hint.md @@ -0,0 +1,187 @@ +# opt-likely-hint + +> Use code structure to hint at likely branches; use intrinsics on nightly + +## Why It Matters + +Modern CPUs predict branches to speculatively execute code. Mispredictions cause pipeline stalls (10-20 cycles). Helping the compiler understand which branches are likely allows it to generate optimal code layout and branch hints, improving performance in hot paths. + +## Stable Rust: Code Structure Hints + +```rust +// Pattern 1: Early returns for unlikely cases +fn process(data: Option<&Data>) -> i32 { + // Compiler assumes early return is "unlikely" + let data = match data { + None => return 0, // Unlikely + Some(d) => d, + }; + + // Hot path continues here + complex_processing(data) +} + +// Pattern 2: if-else ordering +fn calculate(x: i32) -> i32 { + if x >= 0 { + // Put likely case in "if" branch + x * 2 + } else { + // Unlikely case in "else" + handle_negative(x) + } +} + +// Pattern 3: Cold function extraction +fn hot_path(data: &[u8]) -> Result<(), Error> { + if data.is_empty() { + return cold_empty_error(); // Extracted = unlikely + } + + process_fast(data) +} + +#[cold] +fn cold_empty_error() -> Result<(), Error> { + Err(Error::EmptyInput) +} +``` + +## Nightly: std::hint + +```rust +// Requires nightly; still unstable as of Rust 1.96 +#![feature(likely_unlikely)] +use std::hint::{likely, unlikely}; + +fn process(data: &Data) -> i32 { + if unlikely(data.is_corrupted()) { + return handle_corruption(data); + } + + if likely(data.is_cached()) { + return fast_cached_path(data); + } + + slow_uncached_path(data) +} +``` + +## Boolean Likely Wrapper (Nightly) + +```rust +// Requires nightly; still unstable as of Rust 1.96 +#![feature(likely_unlikely)] + +#[inline(always)] +fn likely(b: bool) -> bool { + std::hint::likely(b) +} + +#[inline(always)] +fn unlikely(b: bool) -> bool { + std::hint::unlikely(b) +} + +// Usage +if likely(x > 0) { + hot_path(x) +} else { + cold_path(x) +} +``` + +## Stable: std::hint::cold_path + +`std::hint::cold_path()` (stable since Rust 1.95) marks the enclosing code path as unlikely so the optimizer can deprioritize it — a stable substitute for nightly `unlikely`. Call it inside the rarely-taken branch: + +```rust +fn process(data: &Data) -> i32 { + if data.is_corrupted() { + std::hint::cold_path(); // tell the optimizer this branch is rare + return handle_corruption(data); + } + fast_path(data) +} +``` + +## Stable: likely-stable Crate + +```rust +use likely_stable::{likely, unlikely}; + +fn check(value: i32) -> bool { + if unlikely(value < 0) { + handle_negative() + } else if likely(value < 1000) { + handle_common() + } else { + handle_large() + } +} +``` + +## Loop Optimization + +```rust +fn search(data: &[i32], target: i32) -> Option { + for (i, &item) in data.iter().enumerate() { + // Assume most iterations DON'T find the target + if unlikely(item == target) { + return Some(i); + } + } + None +} + +// Alternative: structure for likely case +fn search_common(data: &[i32], target: i32) -> Option { + // If target is usually found + for (i, &item) in data.iter().enumerate() { + if likely(item == target) { + return Some(i); + } + } + None +} +``` + +## Match Arm Ordering + +```rust +// Put most common variants first +fn process_message(msg: Message) { + match msg { + // Most common - listed first + Message::Data(d) => handle_data(d), + Message::Heartbeat => (), // Second most common + + // Rare cases last + Message::Error(e) => handle_error(e), + Message::Shutdown => shutdown(), + } +} +``` + +## Benchmark-Driven Hints + +```rust +// Profile first to know which branches are actually likely! +fn speculative(x: i32) -> i32 { + // DON'T GUESS - measure with profiling + // perf record / perf report + // cargo flamegraph + + if x > threshold { // Is this actually common? + path_a(x) + } else { + path_b(x) + } +} +``` + +## See Also + +- [opt-cold-unlikely](./opt-cold-unlikely.md) - #[cold] for unlikely functions +- [opt-inline-never-cold](./opt-inline-never-cold.md) - Keeping cold code separate +- [perf-profile-first](./perf-profile-first.md) - Profile to know what's likely diff --git a/.opencode/skills/rust-skills/rules/opt-lto-release.md b/.opencode/skills/rust-skills/rules/opt-lto-release.md new file mode 100644 index 0000000..01f6e03 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-lto-release.md @@ -0,0 +1,135 @@ +# opt-lto-release + +> Enable LTO in release builds + +## Why It Matters + +Link-Time Optimization (LTO) enables optimizations across crate boundaries that aren't possible during normal compilation. This includes cross-crate inlining, dead code elimination, and devirtualization. Typically provides 5-20% performance improvement. + +## Bad + +```toml +# Cargo.toml - default release profile +[profile.release] +opt-level = 3 +# No LTO = missed optimization opportunities +``` + +## Good + +```toml +# Cargo.toml - optimized release profile +[profile.release] +opt-level = 3 +lto = "fat" # Maximum optimization +codegen-units = 1 # Better optimization (single codegen unit) +panic = "abort" # Smaller binary, no unwind tables +strip = true # Remove symbols for smaller binary +``` + +## LTO Options Explained + +```toml +# No LTO (default) +lto = false + +# Thin LTO - fast compilation, most benefits +lto = "thin" + +# Fat LTO - slowest compilation, maximum optimization +lto = "fat" +# Equivalent to: +lto = true + +# Thin-local - LTO within each crate only +lto = "off" +``` + +## Trade-offs + +| Setting | Compile Time | Binary Size | Performance | +|---------|--------------|-------------|-------------| +| `lto = false` | Fast | Larger | Baseline | +| `lto = "thin"` | Medium | Smaller | +5-15% | +| `lto = "fat"` | Slow | Smallest | +10-20% | + +## Evidence from Production + +Many production crates enable fat LTO and `codegen-units = 1` in their release +profiles for maximum performance. For example, ripgrep ships a `release-lto` +profile (see the Cargo Book for profile documentation: +): + +```toml +# A common pattern in performance-critical crates +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 + +# Named profile for explicit LTO builds (e.g. ripgrep's release-lto) +[profile.release-lto] +inherits = "release" +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" +``` + +## Complete Optimized Profile + +```toml +[profile.release] +opt-level = 3 # Maximum optimization +lto = "fat" # Link-time optimization +codegen-units = 1 # Single codegen unit for better optimization +panic = "abort" # Remove panic unwinding code +strip = true # Strip symbols +debug = false # No debug info + +# For benchmarking (need some debug info for profiling) +[profile.bench] +inherits = "release" +debug = true +strip = false + +# Fast dev builds with optimized dependencies +[profile.dev] +opt-level = 0 +debug = true + +[profile.dev.package."*"] +opt-level = 3 # Optimize dependencies even in dev +``` + +## When to Use Each + +| Situation | LTO Setting | +|-----------|-------------| +| Development | `false` (fast compiles) | +| CI builds | `"thin"` (balance) | +| Release binaries | `"fat"` (max perf) | +| Libraries (crates.io) | `false` (users choose) | + +## Measuring Impact + +```bash +# Build without LTO +cargo build --release +hyperfine ./target/release/myapp + +# Build with LTO +# (after adding lto = "fat" to Cargo.toml) +cargo build --release +hyperfine ./target/release/myapp + +# Compare binary sizes +ls -la target/release/myapp +``` + +## See Also + +- [opt-codegen-units](opt-codegen-units.md) - Use codegen-units = 1 +- [opt-pgo-profile](opt-pgo-profile.md) - Profile-guided optimization +- [perf-release-profile](perf-release-profile.md) - Full release profile settings diff --git a/.opencode/skills/rust-skills/rules/opt-pgo-profile.md b/.opencode/skills/rust-skills/rules/opt-pgo-profile.md new file mode 100644 index 0000000..b8001ca --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-pgo-profile.md @@ -0,0 +1,167 @@ +# opt-pgo-profile + +> Use Profile-Guided Optimization (PGO) for maximum performance + +## Why It Matters + +PGO uses real runtime behavior to guide compiler optimization decisions. By profiling actual workloads, the compiler learns which code paths are hot, optimizing them aggressively while deprioritizing cold paths. This can yield 10-30% performance improvements beyond standard optimizations. + +## The PGO Process + +1. **Instrument**: Build with profiling instrumentation +2. **Profile**: Run representative workloads +3. **Optimize**: Rebuild using collected profile data + +## Step-by-Step + +```bash +# Step 1: Build instrumented binary +RUSTFLAGS="-Cprofile-generate=/tmp/pgo-data" \ + cargo build --release + +# Step 2: Run representative workloads +./target/release/my_app < test_data_1.txt +./target/release/my_app < test_data_2.txt +./target/release/my_app < typical_workload.txt + +# Step 3: Merge profile data +llvm-profdata merge -o /tmp/pgo-data/merged.profdata /tmp/pgo-data + +# Step 4: Build optimized binary using profile +RUSTFLAGS="-Cprofile-use=/tmp/pgo-data/merged.profdata" \ + cargo build --release +``` + +## Cargo Configuration + +```toml +# Cargo.toml +[profile.release] +lto = "fat" +codegen-units = 1 +opt-level = 3 + +# PGO flags set via RUSTFLAGS environment variable +``` + +## Build Script + +```bash +#!/bin/bash +set -e + +PGO_DIR=/tmp/pgo-$(date +%s) + +# Clean +cargo clean + +# Instrumented build +echo "Building instrumented binary..." +RUSTFLAGS="-Cprofile-generate=$PGO_DIR" cargo build --release + +# Run workloads +echo "Collecting profile data..." +./target/release/my_app --benchmark-mode +./target/release/my_app < test_fixtures/typical.txt +./target/release/my_app < test_fixtures/stress.txt + +# Merge profiles +echo "Merging profile data..." +llvm-profdata merge -o $PGO_DIR/merged.profdata $PGO_DIR + +# Optimized build +echo "Building optimized binary..." +RUSTFLAGS="-Cprofile-use=$PGO_DIR/merged.profdata" cargo build --release + +echo "Done! Optimized binary at target/release/my_app" +``` + +## Representative Workloads + +```rust +// Create benchmarks that match real usage patterns + +// Good: actual data samples +fn profile_workload() { + for file in real_customer_data_samples() { + process_file(&file); + } +} + +// Good: synthetic but realistic +fn profile_synthetic() { + for _ in 0..10000 { + let data = generate_realistic_data(); + process(&data); + } +} + +// Bad: artificial microbenchmarks +fn profile_bad() { + for _ in 0..1000000 { + small_operation(); // Doesn't reflect real hot paths + } +} +``` + +## BOLT Post-Link Optimization + +For even more gains, combine PGO with BOLT: + +```bash +# After PGO build, apply BOLT +llvm-bolt target/release/my_app \ + -o target/release/my_app.bolt \ + -data=perf.data \ + -reorder-blocks=ext-tsp \ + -reorder-functions=hfsort + +# BOLT can add another 5-15% on top of PGO +``` + +## CI/CD Integration + +```yaml +# GitHub Actions example +jobs: + pgo-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install LLVM tools + run: sudo apt-get install llvm + + - name: Instrumented build + run: RUSTFLAGS="-Cprofile-generate=/tmp/pgo" cargo build --release + + - name: Run profiling workloads + run: ./scripts/run_profiling_workloads.sh + + - name: Merge profiles + run: llvm-profdata merge -o /tmp/pgo/merged.profdata /tmp/pgo + + - name: Optimized build + run: RUSTFLAGS="-Cprofile-use=/tmp/pgo/merged.profdata" cargo build --release + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: optimized-binary + path: target/release/my_app +``` + +## When to Use PGO + +| Use PGO | Skip PGO | +|---------|----------| +| Production deployments | Development builds | +| Performance-critical apps | Libraries (users can PGO) | +| Stable workload patterns | Highly variable workloads | +| Sufficient profiling data | Quick iteration cycles | + +## See Also + +- [opt-lto-release](./opt-lto-release.md) - LTO works well with PGO +- [opt-codegen-units](./opt-codegen-units.md) - Single codegen unit for PGO +- [perf-profile-first](./perf-profile-first.md) - Profiling basics diff --git a/.opencode/skills/rust-skills/rules/opt-simd-portable.md b/.opencode/skills/rust-skills/rules/opt-simd-portable.md new file mode 100644 index 0000000..96c0c7f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-simd-portable.md @@ -0,0 +1,143 @@ +# opt-simd-portable + +> Use portable SIMD for vectorized operations across architectures + +## Why It Matters + +SIMD (Single Instruction, Multiple Data) processes multiple values per instruction—4x, 8x, or more speedup for suitable algorithms. Rust's portable SIMD (nightly) and crates like `wide` provide cross-platform vectorization without architecture-specific intrinsics. For stable Rust, let LLVM auto-vectorize or use platform-specific crates. + +## Autovectorization (Stable) + +```rust +// LLVM often vectorizes simple patterns automatically +fn sum(data: &[f32]) -> f32 { + data.iter().sum() // May vectorize to SIMD +} + +fn add_arrays(a: &[f32], b: &[f32], out: &mut [f32]) { + for ((x, y), o) in a.iter().zip(b).zip(out.iter_mut()) { + *o = x + y; // Often vectorizes + } +} + +// Help autovectorization: +// 1. Use iterators over indexing +// 2. Avoid early exits in loops +// 3. Use chunks_exact for aligned access +``` + +## Portable SIMD (Nightly) + +```rust +#![feature(portable_simd)] +use std::simd::*; + +fn sum_simd(data: &[f32]) -> f32 { + let (prefix, middle, suffix) = data.as_simd::<8>(); + + // Handle unaligned prefix + let mut sum = prefix.iter().sum::(); + + // SIMD loop - 8 floats at a time + let mut simd_sum = f32x8::splat(0.0); + for chunk in middle { + simd_sum += *chunk; + } + sum += simd_sum.reduce_sum(); + + // Handle unaligned suffix + sum += suffix.iter().sum::(); + + sum +} + +fn dot_product(a: &[f32], b: &[f32]) -> f32 { + assert_eq!(a.len(), b.len()); + + let (a_pre, a_mid, a_suf) = a.as_simd::<8>(); + let (b_pre, b_mid, b_suf) = b.as_simd::<8>(); + + let scalar: f32 = a_pre.iter().zip(b_pre).map(|(x, y)| x * y).sum(); + + let mut simd_sum = f32x8::splat(0.0); + for (av, bv) in a_mid.iter().zip(b_mid) { + simd_sum += *av * *bv; + } + + let suffix: f32 = a_suf.iter().zip(b_suf).map(|(x, y)| x * y).sum(); + + scalar + simd_sum.reduce_sum() + suffix +} +``` + +## wide Crate (Stable) + +```rust +use wide::*; + +fn process_simd(data: &mut [f32]) { + // Process 8 floats at a time + for chunk in data.chunks_exact_mut(8) { + let v = f32x8::from(chunk); + let result = v * f32x8::splat(2.0) + f32x8::splat(1.0); + chunk.copy_from_slice(&result.to_array()); + } +} + +fn blend_images(a: &[u8], b: &[u8], alpha: f32, out: &mut [u8]) { + let alpha_v = f32x8::splat(alpha); + let one_minus = f32x8::splat(1.0 - alpha); + + for ((a_chunk, b_chunk), out_chunk) in + a.chunks_exact(8).zip(b.chunks_exact(8)).zip(out.chunks_exact_mut(8)) + { + let av = f32x8::from([ + a_chunk[0] as f32, a_chunk[1] as f32, /* ... */ + ]); + let bv = f32x8::from([ + b_chunk[0] as f32, b_chunk[1] as f32, /* ... */ + ]); + + let result = av * one_minus + bv * alpha_v; + // Convert back to u8... + } +} +``` + +## Platform-Specific (When Needed) + +```rust +#[cfg(target_arch = "x86_64")] +use std::arch::x86_64::*; + +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn sum_avx2(data: &[f32]) -> f32 { + let mut acc = _mm256_setzero_ps(); + let mut chunks = data.chunks_exact(8); + for chunk in &mut chunks { + let v = _mm256_loadu_ps(chunk.as_ptr()); + acc = _mm256_add_ps(acc, v); + } + + // store the 8 lanes, then finish the reduction (and the remainder) in scalar + let mut lanes = [0.0f32; 8]; + _mm256_storeu_ps(lanes.as_mut_ptr(), acc); + lanes.iter().sum::() + chunks.remainder().iter().sum::() +} +``` + +## Choosing an Approach + +| Approach | Stability | Portability | Control | +|----------|-----------|-------------|---------| +| Autovectorization | Stable | Excellent | Low | +| `wide` crate | Stable | Good | Medium | +| Portable SIMD | Nightly | Excellent | High | +| Intrinsics | Stable | None | Maximum | + +## See Also + +- [opt-target-cpu](./opt-target-cpu.md) - Enable SIMD features +- [opt-bounds-check](./opt-bounds-check.md) - Unchecked access for SIMD +- [perf-profile-first](./perf-profile-first.md) - Identify vectorization opportunities diff --git a/.opencode/skills/rust-skills/rules/opt-target-cpu.md b/.opencode/skills/rust-skills/rules/opt-target-cpu.md new file mode 100644 index 0000000..8513fa8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/opt-target-cpu.md @@ -0,0 +1,159 @@ +# opt-target-cpu + +> Use `target-cpu=native` for maximum performance on known deployment targets + +## Why It Matters + +By default, Rust compiles for a generic x86-64 baseline (roughly Sandy Bridge era). Modern CPUs have SIMD extensions (AVX2, AVX-512), improved instructions, and micro-architectural optimizations that go unused. `target-cpu=native` enables all features of your current CPU, potentially unlocking significant speedups. + +## Bad + +```toml +# Cargo.toml - compiles for generic x86-64 +[profile.release] +# No target-cpu specified +# Binary works everywhere but uses only SSE2 +``` + +## Good + +```toml +# .cargo/config.toml - for known deployment target +[build] +rustflags = ["-C", "target-cpu=native"] + +# Or specific CPU for cross-compilation +# rustflags = ["-C", "target-cpu=skylake"] +``` + +## Via Environment + +```bash +# Build with native optimizations +RUSTFLAGS="-C target-cpu=native" cargo build --release + +# Check what features are enabled +rustc --print cfg -C target-cpu=native | grep target_feature +``` + +## Common Target CPUs + +```bash +# x86-64 targets +target-cpu=native # Current machine +target-cpu=x86-64 # Baseline (SSE2) +target-cpu=x86-64-v2 # SSE4.2, POPCNT +target-cpu=x86-64-v3 # AVX2, BMI2 +target-cpu=x86-64-v4 # AVX-512 + +# Intel specific +target-cpu=skylake # 6th gen Core +target-cpu=alderlake # 12th gen Core + +# AMD specific +target-cpu=znver3 # Zen 3 +target-cpu=znver4 # Zen 4 + +# ARM +target-cpu=apple-m1 # Apple Silicon +target-cpu=neoverse-n1 # AWS Graviton2 +``` + +## Feature Detection at Runtime + +```rust +// For portable binaries that use native features when available +#[cfg(target_arch = "x86_64")] +fn process_fast(data: &[u8]) -> u64 { + if is_x86_feature_detected!("avx2") { + // SAFETY: only reached after avx2 is detected at runtime + unsafe { process_avx2(data) } + } else { + process_generic(data) + } +} + +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn process_avx2(data: &[u8]) -> u64 { + // an AVX2-optimized path would go here; delegate to the scalar version + process_generic(data) +} + +fn process_generic(data: &[u8]) -> u64 { + data.iter().map(|&b| u64::from(b)).sum() +} +``` + +## Multi-Architecture Builds + +```bash +# Build multiple binaries +RUSTFLAGS="-C target-cpu=x86-64" cargo build --release +mv target/release/app target/release/app-generic + +RUSTFLAGS="-C target-cpu=x86-64-v3" cargo build --release +mv target/release/app target/release/app-avx2 + +# Select at runtime +if supports_avx2; then + ./app-avx2 +else + ./app-generic +fi +``` + +## Cargo Configuration + +```toml +# .cargo/config.toml + +# Native builds for development +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=native"] + +# AWS deployment (Graviton2) +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=neoverse-n1"] + +# Intel server deployment +[target.x86_64-unknown-linux-gnu.deployment] +rustflags = ["-C", "target-cpu=skylake-avx512"] +``` + +## What Changes + +```rust +// With AVX2 enabled: +// - 256-bit SIMD operations +// - Better autovectorization +// - FMA (fused multiply-add) +// - BMI (bit manipulation) + +// Example: sum of squares +fn sum_squares(data: &[f64]) -> f64 { + data.iter().map(|x| x * x).sum() +} +// Generic: scalar loop +// AVX2: processes 4 f64s per iteration +``` + +## Checking Enabled Features + +```bash +# What's enabled for native? +rustc --print cfg -C target-cpu=native | grep feature + +# Compare generic vs native +rustc --print cfg -C target-cpu=x86-64 | grep feature +rustc --print cfg -C target-cpu=native | grep feature + +# View generated assembly +cargo asm --rust --release my_crate::hot_function +``` + +## See Also + +- [opt-lto-release](./opt-lto-release.md) - Combine with LTO +- [opt-simd-portable](./opt-simd-portable.md) - Portable SIMD +- [opt-codegen-units](./opt-codegen-units.md) - Single codegen unit diff --git a/.opencode/skills/rust-skills/rules/own-arc-shared.md b/.opencode/skills/rust-skills/rules/own-arc-shared.md new file mode 100644 index 0000000..fbb1cfc --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-arc-shared.md @@ -0,0 +1,143 @@ +# own-arc-shared + +> Use `Arc` for thread-safe shared ownership + +## Why It Matters + +`Arc` (Atomic Reference Counted) provides shared ownership across threads. Unlike `Rc`, its reference count is updated atomically, making it safe for concurrent access. Use it when multiple threads need to read the same data. + +## Bad + +```rust +use std::rc::Rc; +use std::thread; + +let data = Rc::new(vec![1, 2, 3]); +let data_clone = Rc::clone(&data); + +// ERROR: Rc cannot be sent between threads safely +thread::spawn(move || { + println!("{:?}", data_clone); +}); +``` + +## Good + +```rust +use std::sync::Arc; +use std::thread; + +let data = Arc::new(vec![1, 2, 3]); +let data_clone = Arc::clone(&data); + +thread::spawn(move || { + println!("{:?}", data_clone); // Safe! +}); + +println!("{:?}", data); // Original still accessible +``` + +## Arc with Mutex for Mutable Shared State + +```rust +use std::sync::{Arc, Mutex}; +use std::thread; + +let counter = Arc::new(Mutex::new(0)); +let mut handles = vec![]; + +for _ in 0..10 { + let counter = Arc::clone(&counter); + let handle = thread::spawn(move || { + let mut num = counter.lock().unwrap(); + *num += 1; + }); + handles.push(handle); +} + +for handle in handles { + handle.join().unwrap(); +} + +println!("Result: {}", *counter.lock().unwrap()); +``` + +## Arc vs Rc Decision Tree + +``` +Need shared ownership? +├── No → Use owned value or references +└── Yes → Will it cross thread boundaries? + ├── No → Use Rc (cheaper, no atomic ops) + └── Yes → Use Arc + └── Need mutation? + ├── No → Arc is enough + └── Yes → Arc> or Arc> +``` + +## Common Patterns + +```rust +use std::sync::Arc; + +// Shared configuration (read-only) +struct AppConfig { + database_url: String, + max_connections: u32, +} + +fn setup_workers(config: Arc) { + for i in 0..4 { + let config = Arc::clone(&config); + std::thread::spawn(move || { + println!("Worker {} using db: {}", i, config.database_url); + }); + } +} + +// Shared cache with interior mutability +use std::sync::RwLock; +use std::collections::HashMap; + +type Cache = Arc>>; + +fn get_cached(cache: &Cache, key: &str) -> Option { + cache.read().unwrap().get(key).cloned() +} + +fn set_cached(cache: &Cache, key: String, value: String) { + cache.write().unwrap().insert(key, value); +} +``` + +## Performance Considerations + +```rust +// Arc::clone is cheap - just increments atomic counter +let a = Arc::new(large_data); +let b = Arc::clone(&a); // Fast! No data copied + +// But atomic operations have overhead vs Rc +// Use Rc in single-threaded contexts for better performance + +// Avoid cloning Arc in hot loops if possible +// Bad: +for item in items { + let arc = Arc::clone(&shared); // Atomic op each iteration + process(arc, item); +} + +// Better: Clone once outside loop if possible +let arc = Arc::clone(&shared); +for item in items { + process(&arc, item); // Pass reference +} +``` + +## See Also + +- [own-rc-single-thread](own-rc-single-thread.md) - Use Rc for single-threaded sharing +- [own-mutex-interior](own-mutex-interior.md) - Use Mutex for interior mutability +- [async-clone-before-await](async-clone-before-await.md) - Clone Arc before await points +- [conc-scoped-threads](conc-scoped-threads.md) - Borrow stack data instead of Arc +- [unsafe-send-sync-manual](unsafe-send-sync-manual.md) - Document manual Send/Sync impls diff --git a/.opencode/skills/rust-skills/rules/own-borrow-over-clone.md b/.opencode/skills/rust-skills/rules/own-borrow-over-clone.md new file mode 100644 index 0000000..67d0a3b --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-borrow-over-clone.md @@ -0,0 +1,96 @@ +# own-borrow-over-clone + +> Prefer `&T` borrowing over `.clone()` + +## Why It Matters + +Cloning allocates new memory and copies data, while borrowing is free. Unnecessary clones can significantly impact performance, especially in hot paths or with large data structures. + +## Bad + +```rust +fn process(data: &String) { + let local = data.clone(); // Unnecessary allocation! + println!("{}", local); +} + +fn count_words(text: &String) -> usize { + let owned = text.clone(); // Why clone just to read? + owned.split_whitespace().count() +} + +// Clone in a loop - multiplied cost +fn process_all(items: &[String]) { + for item in items { + let copy = item.clone(); // N allocations! + handle(©); + } +} +``` + +## Good + +```rust +fn process(data: &str) { // Accept &str, more flexible + println!("{}", data); // No allocation needed +} + +fn count_words(text: &str) -> usize { + text.split_whitespace().count() // Just borrow +} + +// Borrow in a loop - zero allocations +fn process_all(items: &[String]) { + for item in items { + handle(item); // Pass reference + } +} +``` + +## When Clone Is Acceptable + +```rust +// 1. Need owned data for storage +struct Cache { + data: HashMap, +} + +impl Cache { + fn insert(&mut self, key: &str, value: &str) { + // Clone needed - we're storing owned data + self.data.insert(key.to_string(), value.to_string()); + } +} + +// 2. Need to send across threads +fn spawn_worker(data: &Config) { + let owned = data.clone(); // Clone needed for 'static + std::thread::spawn(move || { + use_config(owned); + }); +} + +// 3. Copy types (no heap allocation) +let x: i32 = 42; +let y = x; // Copy, not clone - this is fine +``` + +## Evidence + +From ripgrep's codebase - uses `Cow` to avoid clones: +```rust +// https://github.com/BurntSushi/ripgrep/blob/master/crates/globset/src/pathutil.rs +pub(crate) fn file_name<'a>(path: &Cow<'a, [u8]>) -> Option> { + match *path { + Cow::Borrowed(path) => Cow::Borrowed(&path[last_slash..]), + Cow::Owned(ref path) => Cow::Owned(path.clone()), + } +} +``` + +## See Also + +- [own-slice-over-vec](own-slice-over-vec.md) - Accept slices instead of references to collections +- [own-cow-conditional](own-cow-conditional.md) - Use Cow for conditional ownership +- [mem-clone-from](mem-clone-from.md) - Reuse allocations when cloning +- [mem-take-replace](mem-take-replace.md) - Move out of &mut without cloning diff --git a/.opencode/skills/rust-skills/rules/own-clone-explicit.md b/.opencode/skills/rust-skills/rules/own-clone-explicit.md new file mode 100644 index 0000000..a347730 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-clone-explicit.md @@ -0,0 +1,135 @@ +# own-clone-explicit + +> Use explicit `Clone` for types where copying has meaningful cost + +## Why It Matters + +Unlike `Copy` which is implicit and "free," `Clone` requires an explicit `.clone()` call, signaling that duplication has a cost. This makes heap allocations and deep copies visible in code, helping developers reason about performance. Types with heap data (`String`, `Vec`, `Box`) should implement `Clone` but not `Copy`. + +## Bad + +```rust +// Hiding expensive operations +fn process_data(data: Vec) -> Vec { + let backup = data; // Moved, not copied - but unclear at call site + transform(backup) +} + +let my_data = vec![1, 2, 3, 4, 5]; +let result = process_data(my_data); +// my_data is moved - surprise if you expected it to still exist +``` + +## Good + +```rust +fn process_data(data: Vec) -> Vec { + let backup = data; + transform(backup) +} + +let my_data = vec![1, 2, 3, 4, 5]; +let result = process_data(my_data.clone()); // Explicit: "I know this allocates" +// my_data still available + +// Or better - take reference if you don't need ownership +fn process_data_ref(data: &[u32]) -> Vec { + transform(data) +} +let result = process_data_ref(&my_data); // No clone needed +``` + +## Custom Clone Implementation + +For types with mixed cheap/expensive fields, implement `Clone` manually: + +```rust +#[derive(Debug)] +struct Document { + id: u64, // Cheap to copy + content: String, // Expensive to clone + metadata: Metadata, // Moderate cost +} + +impl Clone for Document { + fn clone(&self) -> Self { + Self { + id: self.id, + content: self.content.clone(), + metadata: self.metadata.clone(), + } + } + + // Optimization: reuse existing allocations + fn clone_from(&mut self, source: &Self) { + self.id = source.id; + self.content.clone_from(&source.content); // Reuses capacity + self.metadata.clone_from(&source.metadata); + } +} +``` + +## clone_from Optimization + +`clone_from` can reuse existing allocations: + +```rust +let mut buffer = String::with_capacity(1000); + +// Bad: drops old allocation, creates new one +buffer = source.clone(); + +// Good: reuses existing capacity if sufficient +buffer.clone_from(&source); +``` + +## Derive vs Manual Clone + +```rust +// Derive when all fields need cloning +#[derive(Clone)] +struct Simple { + data: Vec, + name: String, +} + +// Manual when you need special behavior +struct CachedValue { + value: i32, + cache: RefCell>, +} + +impl Clone for CachedValue { + fn clone(&self) -> Self { + Self { + value: self.value, + cache: RefCell::new(None), // Don't clone cache, let it rebuild + } + } +} +``` + +## When to Avoid Clone + +```rust +// Instead of cloning, consider: + +// 1. References +fn process(data: &MyType) { } // Borrow instead of clone + +// 2. Cow for conditional cloning +fn process(data: Cow<'_, str>) { } // Clone only if mutation needed + +// 3. Arc for shared ownership +let shared = Arc::new(expensive_data); +let handle = shared.clone(); // Cheap: just increments counter + +// 4. Passing by value when caller is done with it +fn consume(data: MyType) { } // Caller moves, no clone +``` + +## See Also + +- [own-copy-small](./own-copy-small.md) - When implicit Copy is appropriate +- [own-cow-conditional](./own-cow-conditional.md) - Avoiding clones with Cow +- [mem-clone-from](./mem-clone-from.md) - Optimizing repeated clones diff --git a/.opencode/skills/rust-skills/rules/own-copy-small.md b/.opencode/skills/rust-skills/rules/own-copy-small.md new file mode 100644 index 0000000..de7e06c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-copy-small.md @@ -0,0 +1,124 @@ +# own-copy-small + +> Implement `Copy` for small, simple types + +## Why It Matters + +Types that implement `Copy` are implicitly duplicated on assignment instead of moved. This eliminates the need for explicit `.clone()` calls and makes the code more ergonomic. For small types (generally ≤16 bytes), copying is as fast or faster than moving a pointer. + +## Bad + +```rust +// Small type without Copy - requires explicit clone +#[derive(Clone, Debug)] +struct Point { + x: f64, + y: f64, +} + +fn distance(p1: Point, p2: Point) -> f64 { + ((p2.x - p1.x).powi(2) + (p2.y - p1.y).powi(2)).sqrt() +} + +let origin = Point { x: 0.0, y: 0.0 }; +let target = Point { x: 3.0, y: 4.0 }; + +let d1 = distance(origin.clone(), target.clone()); // Tedious +let d2 = distance(origin.clone(), target.clone()); // Every use needs clone +// origin and target still usable but verbose +``` + +## Good + +```rust +// Small type with Copy - implicit duplication +#[derive(Clone, Copy, Debug)] +struct Point { + x: f64, + y: f64, +} + +fn distance(p1: Point, p2: Point) -> f64 { + ((p2.x - p1.x).powi(2) + (p2.y - p1.y).powi(2)).sqrt() +} + +let origin = Point { x: 0.0, y: 0.0 }; +let target = Point { x: 3.0, y: 4.0 }; + +let d1 = distance(origin, target); // Implicitly copied +let d2 = distance(origin, target); // Still works! +// origin and target remain valid +``` + +## Copy Requirements + +A type can implement `Copy` only if: +1. All fields implement `Copy` +2. No custom `Drop` implementation +3. No heap-allocated data (`String`, `Vec`, `Box`, etc.) + +```rust +// ✅ Can be Copy +#[derive(Clone, Copy)] +struct Color { + r: u8, + g: u8, + b: u8, + a: u8, +} + +// ❌ Cannot be Copy - contains String +#[derive(Clone)] +struct Person { + name: String, // String is not Copy + age: u32, +} + +// ❌ Cannot be Copy - has Drop +struct FileHandle { + fd: i32, +} +impl Drop for FileHandle { + fn drop(&mut self) { /* close file */ } +} +``` + +## Size Guidelines + +| Size | Recommendation | +|------|----------------| +| ≤ 16 bytes | Implement `Copy` | +| 17-64 bytes | Consider `Copy`, benchmark if critical | +| > 64 bytes | Probably don't, prefer references | + +```rust +use std::mem::size_of; + +#[derive(Clone, Copy)] +struct SmallId(u64); // 8 bytes ✅ + +#[derive(Clone, Copy)] +struct Rect { x: f32, y: f32, w: f32, h: f32 } // 16 bytes ✅ + +#[derive(Clone)] // No Copy - 72 bytes +struct Transform { + matrix: [[f64; 3]; 3], // 72 bytes, too large +} +``` + +## Common Copy Types + +Standard library types that are `Copy`: +- All primitives: `i32`, `f64`, `bool`, `char`, etc. +- Shared references: `&T` (note: `&mut T` is NOT `Copy` — copying a mutable reference would alias it, so it is reborrowed instead) +- Raw pointers: `*const T`, `*mut T` +- Function pointers: `fn(T) -> U` +- Tuples of `Copy` types: `(i32, f64)` +- Arrays of `Copy` types: `[u8; 32]` +- `Option` where `T: Copy` +- `PhantomData` + +## See Also + +- [own-clone-explicit](./own-clone-explicit.md) - When Clone without Copy is appropriate +- [type-newtype-ids](./type-newtype-ids.md) - Newtype pattern often uses Copy diff --git a/.opencode/skills/rust-skills/rules/own-cow-conditional.md b/.opencode/skills/rust-skills/rules/own-cow-conditional.md new file mode 100644 index 0000000..0955e29 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-cow-conditional.md @@ -0,0 +1,135 @@ +# own-cow-conditional + +> Use `Cow<'a, T>` for conditional ownership + +## Why It Matters + +`Cow` (Clone-on-Write) lets you avoid allocations when you *might* need to own data but usually don't. It holds either a borrowed reference or an owned value, cloning only when mutation is needed. + +## Bad + +```rust +// Always allocates, even when input doesn't need modification +fn normalize_path(path: &str) -> String { + if path.contains("//") { + path.replace("//", "/") // Allocation needed + } else { + path.to_string() // Unnecessary allocation! + } +} + +// Always clones the error message +fn format_error(code: u32) -> String { + match code { + 404 => "Not Found".to_string(), // Unnecessary! + 500 => "Internal Error".to_string(), // Unnecessary! + _ => format!("Error {}", code), // This one needs allocation + } +} +``` + +## Good + +```rust +use std::borrow::Cow; + +// Only allocates when needed +fn normalize_path(path: &str) -> Cow<'_, str> { + if path.contains("//") { + Cow::Owned(path.replace("//", "/")) // Allocate + } else { + Cow::Borrowed(path) // Zero-cost borrow + } +} + +// Static strings stay borrowed +fn format_error(code: u32) -> Cow<'static, str> { + match code { + 404 => Cow::Borrowed("Not Found"), // No allocation + 500 => Cow::Borrowed("Internal Error"), // No allocation + _ => Cow::Owned(format!("Error {}", code)), // Allocate only for unknown + } +} +``` + +## Real-World Example from ripgrep + +```rust +// https://github.com/BurntSushi/ripgrep/blob/master/crates/globset/src/pathutil.rs +pub(crate) fn file_name<'a>(path: &Cow<'a, [u8]>) -> Option> { + let last_slash = path.rfind_byte(b'/').map(|i| i + 1).unwrap_or(0); + match *path { + Cow::Borrowed(path) => Some(Cow::Borrowed(&path[last_slash..])), + Cow::Owned(ref path) => { + let mut path = path.clone(); + path.drain_bytes(..last_slash); + Some(Cow::Owned(path)) + } + } +} +``` + +## Clone-on-Write Pattern + +```rust +use std::borrow::Cow; + +fn process_text(text: Cow<'_, str>) -> Cow<'_, str> { + if text.contains("bad_word") { + // to_mut() clones if borrowed, returns &mut if owned + let mut owned = text.into_owned(); + owned = owned.replace("bad_word", "***"); + Cow::Owned(owned) + } else { + text // Pass through unchanged + } +} + +// Usage +let borrowed: Cow = Cow::Borrowed("hello world"); +let result = process_text(borrowed); // No allocation! + +let with_bad: Cow = Cow::Borrowed("hello bad_word"); +let result = process_text(with_bad); // Allocates only here +``` + +## Cow with Collections + +```rust +use std::borrow::Cow; + +// Mixed borrowed/owned in a collection +fn collect_errors<'a>( + static_errors: &[&'static str], + dynamic_errors: Vec, +) -> Vec> { + let mut errors: Vec> = Vec::new(); + + // Static strings - no allocation + for &e in static_errors { + errors.push(Cow::Borrowed(e)); + } + + // Dynamic strings - take ownership + for e in dynamic_errors { + errors.push(Cow::Owned(e)); + } + + errors +} +``` + +## When to Use Cow + +| Situation | Use Cow? | +|-----------|----------| +| Usually borrow, sometimes own | Yes | +| Always need owned data | No, just use owned type | +| Always borrow | No, just use reference | +| Hot path, avoiding all allocations | Yes | +| Returning static strings or formatted | Yes | + +## See Also + +- [own-borrow-over-clone](own-borrow-over-clone.md) - Prefer borrowing over cloning +- [mem-avoid-format](mem-avoid-format.md) - Avoid format! when possible diff --git a/.opencode/skills/rust-skills/rules/own-lifetime-elision.md b/.opencode/skills/rust-skills/rules/own-lifetime-elision.md new file mode 100644 index 0000000..ec75d5e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-lifetime-elision.md @@ -0,0 +1,134 @@ +# own-lifetime-elision + +> Rely on lifetime elision rules; add explicit lifetimes only when required + +## Why It Matters + +Rust's lifetime elision rules handle most common borrowing patterns automatically. Adding explicit lifetimes where they're not needed clutters code without adding clarity. However, understanding when elision applies helps you know when explicit lifetimes are truly necessary. + +## Bad + +```rust +// Unnecessary explicit lifetimes - elision handles these +fn first_word<'a>(s: &'a str) -> &'a str { + s.split_whitespace().next().unwrap_or("") +} + +fn get_name<'a>(person: &'a Person) -> &'a str { + &person.name +} + +impl<'a> Display for Wrapper<'a> { + fn fmt<'b>(&'b self, f: &'b mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} +``` + +## Good + +```rust +// Let elision do its job +fn first_word(s: &str) -> &str { + s.split_whitespace().next().unwrap_or("") +} + +fn get_name(person: &Person) -> &str { + &person.name +} + +impl Display for Wrapper<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} +``` + +## The Three Elision Rules + +1. **Each input reference gets its own lifetime:** + ```rust + fn foo(x: &str, y: &str) + // becomes + fn foo<'a, 'b>(x: &'a str, y: &'b str) + ``` + +2. **One input reference → output gets same lifetime:** + ```rust + fn foo(x: &str) -> &str + // becomes + fn foo<'a>(x: &'a str) -> &'a str + ``` + +3. **Method with `&self`/`&mut self` → output gets self's lifetime:** + ```rust + fn foo(&self, x: &str) -> &str + // becomes + fn foo<'a, 'b>(&'a self, x: &'b str) -> &'a str + ``` + +## When Explicit Lifetimes ARE Required + +```rust +// Multiple input references, output could come from either +fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { + if x.len() > y.len() { x } else { y } +} + +// Struct holding references +struct Parser<'input> { + source: &'input str, + position: usize, +} + +// Multiple distinct lifetimes needed +struct Context<'s, 'c> { + source: &'s str, + cache: &'c mut Cache, +} + +// Static lifetime for constants +fn get_default() -> &'static str { + "default" +} +``` + +## Anonymous Lifetime `'_` + +Use `'_` to let the compiler infer while being explicit about the presence of a lifetime: + +```rust +// In struct definitions +impl Iterator for Parser<'_> { + type Item = Token; + fn next(&mut self) -> Option { ... } +} + +// In function signatures where it adds clarity +fn parse(input: &str) -> Result, Error> { ... } + +// Especially useful in trait bounds +fn process(data: &impl AsRef) -> Cow<'_, str> { ... } +``` + +## Common Patterns + +```rust +// ✅ Elision works +fn trim(s: &str) -> &str { s.trim() } +fn first(v: &[i32]) -> Option<&i32> { v.first() } +fn name(&self) -> &str { &self.name } + +// ❌ Elision fails - multiple inputs, ambiguous output +fn pick(a: &str, b: &str, first: bool) -> &str // Error! + +// ✅ Fixed with explicit lifetime +fn pick<'a>(a: &'a str, b: &'a str, first: bool) -> &'a str { + if first { a } else { b } +} +``` + +## See Also + +- [own-borrow-over-clone](./own-borrow-over-clone.md) - Prefer borrowing to avoid ownership issues +- [api-impl-asref](./api-impl-asref.md) - Generic borrowing with AsRef diff --git a/.opencode/skills/rust-skills/rules/own-move-large.md b/.opencode/skills/rust-skills/rules/own-move-large.md new file mode 100644 index 0000000..6fc0b86 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-move-large.md @@ -0,0 +1,134 @@ +# own-move-large + +> Move large types instead of copying; use `Box` if moves are expensive + +## Why It Matters + +In Rust, "moving" a value means copying its bytes to a new location and invalidating the old one. For large types (hundreds of bytes), this memcpy can be expensive. Boxing large types reduces move cost to copying a single pointer (8 bytes), making moves cheap regardless of the actual data size. + +## Bad + +```rust +// Large struct moved repeatedly = expensive memcpy each time +struct GameState { + board: [[Cell; 100]; 100], // 10,000 cells + history: [Move; 1000], // 1,000 moves + players: [Player; 4], // Player data + // Total: potentially tens of KB +} + +fn process_state(state: GameState) -> GameState { + // Moving ~40KB+ of data + let mut new_state = state; // Memcpy here + new_state.apply_rules(); + new_state // Memcpy on return +} + +let state = GameState::new(); +let state = process_state(state); // Two large memcpys +``` + +## Good + +```rust +// Box reduces move cost to 8 bytes +struct GameState { + board: Box<[[Cell; 100]; 100]>, // Pointer to heap + history: Vec, // Already heap-allocated + players: [Player; 4], +} + +fn process_state(mut state: GameState) -> GameState { + // Moving just pointers + small inline data + state.apply_rules(); + state // Cheap move +} + +// Or use Box at call site for one-off cases +fn process_large(state: Box) -> Box { + // 8-byte move regardless of LargeStruct size + state +} +``` + +## When to Box + +| Type Size | Move Frequency | Recommendation | +|-----------|----------------|----------------| +| < 128 bytes | Any | Don't box | +| 128-512 bytes | Rare | Probably don't box | +| 128-512 bytes | Frequent | Consider boxing | +| > 512 bytes | Any | Box or use references | +| > 4KB | Any | Definitely box | + +## Stack vs Heap Tradeoffs + +```rust +// Stack: fast allocation, limited size, moves copy bytes +struct StackHeavy { + data: [u8; 4096], // 4KB on stack +} + +// Heap: allocation cost, unlimited size, moves copy pointer +struct HeapLight { + data: Box<[u8; 4096]>, // 8 bytes on stack, 4KB on heap +} + +// Measure with size_of +use std::mem::size_of; +assert_eq!(size_of::(), 4096); +assert_eq!(size_of::(), 8); +``` + +## Alternative: References + +When you don't need ownership transfer, use references: + +```rust +// Best: no move at all +fn analyze_state(state: &GameState) -> Analysis { + // Borrows state, no copying + compute_analysis(state) +} + +// Mutable borrow for in-place modification +fn update_state(state: &mut GameState) { + state.tick(); +} +``` + +## Pattern: Builder Returns Boxed + +```rust +impl LargeConfig { + pub fn builder() -> ConfigBuilder { + ConfigBuilder::default() + } +} + +impl ConfigBuilder { + // Return boxed to avoid large move + pub fn build(self) -> Box { + Box::new(LargeConfig { + // ... fields from builder + }) + } +} +``` + +## Profile First + +Don't prematurely optimize. Use tools to identify if moves are actually a bottleneck: + +```rust +// Check type sizes +println!("Size of GameState: {}", std::mem::size_of::()); + +// Profile with cargo flamegraph or perf to find hot memcpys +``` + +## See Also + +- [own-copy-small](./own-copy-small.md) - Cheap types should be Copy +- [mem-box-large-variant](./mem-box-large-variant.md) - Boxing enum variants +- [perf-profile-first](./perf-profile-first.md) - Measure before optimizing diff --git a/.opencode/skills/rust-skills/rules/own-mutex-interior.md b/.opencode/skills/rust-skills/rules/own-mutex-interior.md new file mode 100644 index 0000000..48948da --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-mutex-interior.md @@ -0,0 +1,106 @@ +# own-mutex-interior + +> Use `Mutex` for interior mutability across threads + +## Why It Matters + +When you need shared mutable state across threads, `Mutex` provides safe interior mutability with synchronization. Unlike `RefCell`, `Mutex` is `Send + Sync` and uses OS-level locking to ensure only one thread can access the data at a time. + +## Bad + +```rust +use std::cell::RefCell; +use std::sync::Arc; + +// RefCell is !Sync - this won't compile +let shared = Arc::new(RefCell::new(vec![])); + +// ERROR: RefCell cannot be shared between threads safely +std::thread::spawn({ + let shared = shared.clone(); + move || shared.borrow_mut().push(1) +}); +``` + +## Good + +```rust +use std::sync::{Arc, Mutex}; + +let shared = Arc::new(Mutex::new(vec![])); + +let handles: Vec<_> = (0..10).map(|i| { + let shared = shared.clone(); + std::thread::spawn(move || { + let mut data = shared.lock().unwrap(); + data.push(i); + }) +}).collect(); + +for handle in handles { + handle.join().unwrap(); +} + +println!("{:?}", shared.lock().unwrap()); // All values present +``` + +## Mutex Poisoning + +If a thread panics while holding a lock, the mutex becomes "poisoned": + +```rust +use std::sync::{Arc, Mutex}; + +let mutex = Arc::new(Mutex::new(0)); + +// Handle poisoning gracefully +match mutex.lock() { + Ok(guard) => println!("Value: {}", *guard), + Err(poisoned) => { + // Recover the data anyway + let guard = poisoned.into_inner(); + println!("Recovered value: {}", *guard); + } +} + +// Or ignore poisoning (use with caution) +let guard = mutex.lock().unwrap_or_else(|e| e.into_inner()); +``` + +## Prefer parking_lot::Mutex + +For better performance, consider `parking_lot::Mutex`: + +```rust +use parking_lot::Mutex; +use std::sync::Arc; + +let shared = Arc::new(Mutex::new(vec![])); + +// No poisoning, no Result to unwrap +let mut data = shared.lock(); +data.push(42); +// Lock automatically released when guard drops +``` + +Benefits of `parking_lot`: +- No poisoning (returns guard directly) +- Smaller size (1 byte vs 40+ bytes) +- Better performance under contention +- Fair locking option available + +## When to Use What + +| Type | Threading | Overhead | Use Case | +|------|-----------|----------|----------| +| `RefCell` | Single | Minimal | Interior mutability, same thread | +| `Mutex` | Multi | Locking | Shared mutable state across threads | +| `RwLock` | Multi | Locking | Many readers, few writers | +| `parking_lot::Mutex` | Multi | Less | Drop-in std::Mutex replacement | + +## See Also + +- [own-rwlock-readers](./own-rwlock-readers.md) - When reads dominate writes +- [own-refcell-interior](./own-refcell-interior.md) - Single-threaded alternative +- [async-no-lock-await](./async-no-lock-await.md) - Avoiding locks across await points +- [conc-atomic-ordering](./conc-atomic-ordering.md) - Lock-free alternative for simple state diff --git a/.opencode/skills/rust-skills/rules/own-rc-single-thread.md b/.opencode/skills/rust-skills/rules/own-rc-single-thread.md new file mode 100644 index 0000000..8307c93 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-rc-single-thread.md @@ -0,0 +1,97 @@ +# own-rc-single-thread + +> Use `Rc` for shared ownership in single-threaded contexts + +## Why It Matters + +`Rc` (Reference Counted) provides shared ownership without the atomic overhead of `Arc`. In single-threaded code, `Rc` is faster because it uses non-atomic reference counting. Using `Arc` when you don't need thread-safety wastes CPU cycles on unnecessary synchronization. + +## Bad + +```rust +use std::sync::Arc; + +// Single-threaded application using Arc unnecessarily +fn build_tree() -> Arc { + let root = Arc::new(Node::new("root")); + let child1 = Arc::new(Node::new("child1")); + let child2 = Arc::new(Node::new("child2")); + + // All in same thread, but paying atomic overhead + root.add_child(child1.clone()); + root.add_child(child2.clone()); + root +} +``` + +Atomic operations have measurable overhead even without contention. + +## Good + +```rust +use std::rc::Rc; + +// Single-threaded: use Rc for zero atomic overhead +fn build_tree() -> Rc { + let root = Rc::new(Node::new("root")); + let child1 = Rc::new(Node::new("child1")); + let child2 = Rc::new(Node::new("child2")); + + root.add_child(child1.clone()); + root.add_child(child2.clone()); + root +} + +// Compiler enforces single-thread: Rc is !Send + !Sync +// Attempting to send across threads = compile error +``` + +## Decision Guide + +| Scenario | Use | +|----------|-----| +| Single-threaded, shared ownership | `Rc` | +| Multi-threaded, shared ownership | `Arc` | +| Single owner, might need multiple later | Start with `Rc`, upgrade if needed | +| Library code, unknown threading model | `Arc` (safer default) | + +## Breaking Cycles with Weak + +`Rc` never frees a value caught in a reference cycle — the strong count never reaches zero, so it leaks. Use `Weak` for back-references (a child pointing at its parent): a `Weak` does not keep the value alive and is upgraded to an `Rc` only when needed. + +```rust +use std::rc::{Rc, Weak}; +use std::cell::RefCell; + +struct Node { + parent: RefCell>, // back-reference: does not own the parent + children: RefCell>>, +} + +let parent = Rc::new(Node { + parent: RefCell::new(Weak::new()), + children: RefCell::new(vec![]), +}); +let child = Rc::new(Node { + parent: RefCell::new(Rc::downgrade(&parent)), + children: RefCell::new(vec![]), +}); +parent.children.borrow_mut().push(Rc::clone(&child)); + +// upgrade() yields Option> — None once the parent is dropped +let _maybe_parent: Option> = child.parent.borrow().upgrade(); +``` + +## Key Points + +- Prefer `Rc::clone(&x)` to `x.clone()`: it makes the cheap refcount bump explicit and visually distinct from a deep clone. +- `Rc` gives shared *immutable* access; pair it with `RefCell` (`Rc>`) for shared mutability in single-threaded code. +- `Rc::strong_count(&x)` / `Rc::weak_count(&x)` inspect the counts — handy in tests. +- `Rc` is `!Send` and `!Sync`, so the compiler rejects sending it across threads; switch to `Arc` (with `Mutex`/`RwLock`) at a thread boundary. + +## See Also + +- [own-arc-shared](./own-arc-shared.md) - When you need thread-safe sharing +- [own-refcell-interior](./own-refcell-interior.md) - Combining Rc with interior mutability +- [conc-thread-local](./conc-thread-local.md) - Per-thread state in single-threaded-style code +- [mem-drop-order](./mem-drop-order.md) - Drop order matters for cyclic/`Weak` structures diff --git a/.opencode/skills/rust-skills/rules/own-refcell-interior.md b/.opencode/skills/rust-skills/rules/own-refcell-interior.md new file mode 100644 index 0000000..b6c0b27 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-refcell-interior.md @@ -0,0 +1,116 @@ +# own-refcell-interior + +> Use `RefCell` for interior mutability in single-threaded code + +## Why It Matters + +Rust's borrow checker enforces rules at compile time, but sometimes you need to mutate data through a shared reference. `RefCell` moves borrow checking to runtime, allowing mutation through `&self`. This is essential for patterns like caches, lazy initialization, and observer patterns where compile-time borrowing is too restrictive. + +## Bad + +```rust +struct Cache { + // Requires &mut self to update, breaking shared reference patterns + data: HashMap, +} + +impl Cache { + fn get_or_compute(&mut self, key: &str) -> &str { + // Caller needs &mut Cache, can't share cache reference + if !self.data.contains_key(key) { + self.data.insert(key.to_string(), expensive_compute(key)); + } + &self.data[key] + } +} +``` + +This forces exclusive access even for logically shared operations. + +## Good + +```rust +use std::cell::RefCell; +use std::collections::HashMap; + +struct Cache { + data: RefCell>, +} + +impl Cache { + fn get_or_compute(&self, key: &str) -> String { + // Can mutate through &self + let mut data = self.data.borrow_mut(); + if !data.contains_key(key) { + data.insert(key.to_string(), expensive_compute(key)); + } + data[key].clone() + } +} + +// Multiple references can coexist +let cache = Cache::new(); +let ref1 = &cache; +let ref2 = &cache; +ref1.get_or_compute("key1"); +ref2.get_or_compute("key2"); +``` + +## Common Pattern: Rc> + +```rust +use std::rc::Rc; +use std::cell::RefCell; + +// Shared mutable state in single-threaded code +type SharedState = Rc>; + +fn create_handlers(state: SharedState) -> Vec> { + vec![ + Box::new({ + let state = state.clone(); + move || state.borrow_mut().increment() + }), + Box::new({ + let state = state.clone(); + move || state.borrow_mut().decrement() + }), + ] +} +``` + +## Runtime Panics + +`RefCell` panics if you violate borrowing rules at runtime: + +```rust +let cell = RefCell::new(5); +let borrow1 = cell.borrow(); +let borrow2 = cell.borrow_mut(); // PANIC: already borrowed +``` + +Use `try_borrow()` and `try_borrow_mut()` for fallible borrowing. + +## Cell for Copy Types + +For simple `Copy` values, `Cell` is lighter than `RefCell` — no runtime borrow flags, no panics. You `get()`/`set()`/`replace()` the value instead of borrowing it: + +```rust +use std::cell::Cell; + +struct Counter { + count: Cell, +} + +impl Counter { + fn bump(&self) { + self.count.set(self.count.get() + 1); // mutate through &self, never panics + } +} +``` + +## See Also + +- [own-rc-single-thread](./own-rc-single-thread.md) - Combining with Rc for shared ownership +- [own-mutex-interior](./own-mutex-interior.md) - Thread-safe alternative +- [conc-thread-local](./conc-thread-local.md) - `thread_local!` with `Cell`/`RefCell` for per-thread state diff --git a/.opencode/skills/rust-skills/rules/own-rwlock-readers.md b/.opencode/skills/rust-skills/rules/own-rwlock-readers.md new file mode 100644 index 0000000..f37ac78 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-rwlock-readers.md @@ -0,0 +1,122 @@ +# own-rwlock-readers + +> Use `RwLock` when reads significantly outnumber writes + +## Why It Matters + +`Mutex` allows only one thread to access data at a time, even for reads. `RwLock` allows multiple concurrent readers OR one exclusive writer. For read-heavy workloads, this dramatically improves throughput by eliminating unnecessary serialization of read operations. + +## Bad + +```rust +use std::sync::{Arc, Mutex}; + +// Configuration rarely changes but is read constantly +let config = Arc::new(Mutex::new(Config::load())); + +// Every read blocks other reads unnecessarily +fn get_setting(config: &Mutex, key: &str) -> String { + let guard = config.lock().unwrap(); + guard.get(key).to_string() +} + +// 100 threads reading = serialized, one at a time +``` + +## Good + +```rust +use std::sync::{Arc, RwLock}; + +// Multiple readers can proceed concurrently +let config = Arc::new(RwLock::new(Config::load())); + +fn get_setting(config: &RwLock, key: &str) -> String { + let guard = config.read().unwrap(); // Multiple threads can hold read lock + guard.get(key).to_string() +} + +fn update_setting(config: &RwLock, key: &str, value: &str) { + let mut guard = config.write().unwrap(); // Exclusive access for writes + guard.set(key, value); +} + +// 100 threads reading = parallel execution +``` + +## parking_lot::RwLock + +Prefer `parking_lot::RwLock` for better performance: + +```rust +use parking_lot::RwLock; +use std::sync::Arc; + +let data = Arc::new(RwLock::new(HashMap::new())); + +// Read - no unwrap needed +let value = data.read().get("key").cloned(); + +// Write +data.write().insert("key".to_string(), "value".to_string()); + +// Upgradeable read lock (unique to parking_lot) +let upgradeable = data.upgradable_read(); +if upgradeable.get("key").is_none() { + let mut write = parking_lot::RwLockUpgradableReadGuard::upgrade(upgradeable); + write.insert("key".to_string(), "default".to_string()); +} +``` + +## When RwLock Hurts + +RwLock has overhead for tracking readers. It can be slower than Mutex when: + +| Scenario | Better Choice | +|----------|---------------| +| Writes are frequent (>20% of operations) | `Mutex` | +| Lock held very briefly | `Mutex` | +| Single-threaded | `RefCell` | +| Reads dominate, lock held longer | `RwLock` | + +## Write Starvation + +Standard `RwLock` may starve writers if readers are continuous. `parking_lot::RwLock` is fair by default. + +```rust +// parking_lot is writer-fair, preventing starvation +use parking_lot::RwLock; + +// Or use std with explicit fairness (nightly) +// #![feature(rwlock_downgrade)] +``` + +## Real-World Pattern: Cached Computation + +```rust +use parking_lot::RwLock; +use std::sync::Arc; + +struct CachedData { + cache: RwLock>, +} + +impl CachedData { + fn get(&self) -> ExpensiveResult { + // Fast path: read lock + if let Some(cached) = self.cache.read().as_ref() { + return cached.clone(); + } + + // Slow path: compute and cache + let result = compute_expensive(); + *self.cache.write() = Some(result.clone()); + result + } +} +``` + +## See Also + +- [own-mutex-interior](./own-mutex-interior.md) - When writes are frequent +- [async-no-lock-await](./async-no-lock-await.md) - RwLock in async contexts diff --git a/.opencode/skills/rust-skills/rules/own-slice-over-vec.md b/.opencode/skills/rust-skills/rules/own-slice-over-vec.md new file mode 100644 index 0000000..d34f828 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/own-slice-over-vec.md @@ -0,0 +1,119 @@ +# own-slice-over-vec + +> Accept `&[T]` not `&Vec`, `&str` not `&String` + +## Why It Matters + +Accepting `&[T]` instead of `&Vec` makes your function more flexible - it can accept slices from arrays, vectors, or other sources. Similarly, `&str` accepts string slices from `String`, `&'static str`, or substrings. + +## Bad + +```rust +// Overly restrictive - only accepts &Vec +fn sum(numbers: &Vec) -> i32 { + numbers.iter().sum() +} + +// Overly restrictive - only accepts &String +fn greet(name: &String) { + println!("Hello, {}", name); +} + +// Can't call with arrays or slices +let arr = [1, 2, 3]; +// sum(&arr); // ERROR: expected &Vec + +let literal = "world"; +// greet(&literal); // ERROR: expected &String +``` + +## Good + +```rust +// Flexible - accepts any slice-like thing +fn sum(numbers: &[i32]) -> i32 { + numbers.iter().sum() +} + +// Flexible - accepts any string-like thing +fn greet(name: &str) { + println!("Hello, {}", name); +} + +// Now all of these work: +let vec = vec![1, 2, 3]; +let arr = [4, 5, 6]; +let slice = &vec[0..2]; + +sum(&vec); // Vec coerces to slice +sum(&arr); // Array coerces to slice +sum(slice); // Slice works directly + +let string = String::from("Alice"); +let literal = "Bob"; + +greet(&string); // String coerces to &str +greet(literal); // &str works directly +``` + +## The Deref Coercion Chain + +```rust +// These coercions happen automatically: +// Vec -> &[T] (via Deref) +// String -> &str (via Deref) +// Box -> &T (via Deref) +// Arc -> &T (via Deref) + +fn process(data: &[u8]) { /* ... */ } + +let vec: Vec = vec![1, 2, 3]; +let boxed: Box<[u8]> = vec.into_boxed_slice(); +let arc: Arc<[u8]> = Arc::from(&[1, 2, 3][..]); + +process(&vec); // Works +process(&boxed); // Works +process(&arc); // Works +``` + +## Path Types Too + +```rust +// Bad +fn read_config(path: &PathBuf) -> Config { /* ... */ } + +// Good - accepts &Path, &PathBuf, &str, &String +fn read_config(path: &Path) -> Config { /* ... */ } + +// Even better - accept anything path-like +fn read_config(path: impl AsRef) -> Config { + let path = path.as_ref(); + // ... +} +``` + +## When to Accept Owned Types + +```rust +// Accept owned when you need to store it +struct Logger { + prefix: String, // Needs to own the string +} + +impl Logger { + // Take ownership - caller decides to clone or move + fn new(prefix: String) -> Self { + Self { prefix } + } + + // Or use Into for flexibility + fn with_prefix(prefix: impl Into) -> Self { + Self { prefix: prefix.into() } + } +} +``` + +## See Also + +- [api-impl-asref](api-impl-asref.md) - Accept `impl AsRef` for maximum flexibility +- [own-borrow-over-clone](own-borrow-over-clone.md) - Prefer borrowing over cloning diff --git a/.opencode/skills/rust-skills/rules/pat-at-bindings.md b/.opencode/skills/rust-skills/rules/pat-at-bindings.md new file mode 100644 index 0000000..4eecd21 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/pat-at-bindings.md @@ -0,0 +1,99 @@ +# pat-at-bindings + +> Use `@` bindings to capture a value while matching it against a pattern + +## Why It Matters + +The `name @ pattern` syntax binds the matched value to `name` and simultaneously tests it against `pattern` in a single arm. Without `@`, you either re-access the original expression (verbose) or add a guard that repeats the condition and re-extracts the value (redundant). `@` bindings make the constraint and the binding a single, readable unit. + +## Bad + +```rust +fn classify(n: u32) -> String { + match n { + 1..=9 => format!("single digit: {n}"), // fine here — n is Copy and in scope + 10..=99 => { + let tens = n; // no real benefit; contrived but shows the pattern + format!("two digits: {tens}") + } + _ => String::from("large"), + } +} + +// More revealing: nested struct field — must re-access after matching range +#[derive(Debug)] +enum Command { + Move { x: i32, y: i32 }, +} + +fn validate_move(cmd: &Command) { + match cmd { + Command::Move { x, y } if *x >= 0 && *x <= 100 => { + // x is already bound, so this is fine, but the guard duplicates the range + println!("valid move to x={x}, y={y}"); + } + _ => println!("invalid command"), + } +} +``` + +## Good + +```rust +fn classify(n: u32) -> String { + match n { + id @ 1..=9 => format!("single digit: {id}"), + id @ 10..=99 => format!("two digits: {id}"), + _ => String::from("large"), + } +} + +// Nested struct field with @ binding +#[derive(Debug)] +enum Command { + Move { x: i32, y: i32 }, +} + +fn validate_move(cmd: &Command) { + match cmd { + Command::Move { x: x_pos @ 0..=100, y } => { + println!("valid move to x={x_pos}, y={y}"); + } + _ => println!("invalid command"), + } +} +``` + +`x: x_pos @ 0..=100` destructures the `x` field, checks that it falls in `0..=100`, and binds the value to `x_pos` — all in one expression. + +## Binding an Enum Variant While Inspecting Its Payload + +```rust +#[derive(Debug, Clone)] +enum Packet { + Data(Vec), + Control(u8), +} + +fn log_data(packet: &Packet) { + match packet { + whole @ Packet::Data(bytes) if !bytes.is_empty() => { + println!("non-empty packet: {whole:?}"); + } + Packet::Data(_) => println!("empty data packet"), + Packet::Control(code) => println!("control: {code}"), + } +} +``` + +`whole` captures the entire `Packet::Data(...)` value while the guard checks the payload — no need to reconstruct the variant for logging. + +## Notes + +- `@` bindings work in all pattern positions: `match`, `if let`, `while let`, `let ... else`, and function parameters. +- Clippy lint `clippy::bind_instead_of_map` is unrelated but similarly reduces redundant re-access patterns. + +## See Also + +- [pat-exhaustive-enum](pat-exhaustive-enum.md) - match enums exhaustively to catch new variants +- [type-enum-states](type-enum-states.md) - use enums for mutually exclusive states diff --git a/.opencode/skills/rust-skills/rules/pat-exhaustive-enum.md b/.opencode/skills/rust-skills/rules/pat-exhaustive-enum.md new file mode 100644 index 0000000..25a7ca4 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/pat-exhaustive-enum.md @@ -0,0 +1,89 @@ +# pat-exhaustive-enum + +> Match owned enums exhaustively; avoid catch-all `_` that hides new variants + +## Why It Matters + +A `_ =>` wildcard arm silently absorbs any variant added to an enum you own, converting what should be a compile-time error into a silent runtime no-op. Exhaustive matches let the compiler act as a checklist: add a variant, get a build failure everywhere it is unhandled. Reserve `_` and `..` for **foreign** `#[non_exhaustive]` enums, where the language requires a catch-all, and document why it is necessary. + +## Bad + +```rust +#[derive(Debug)] +enum Status { + Active, + Pending, + Closed, +} + +fn describe(s: &Status) -> &'static str { + match s { + Status::Active => "active", + _ => "inactive", // hides Status::Pending silently; adding a new variant goes unnoticed + } +} +``` + +If `Status::Suspended` is later added, `describe` compiles and silently returns `"inactive"` for it — a logic bug the compiler never catches. + +## Good + +```rust +#[derive(Debug)] +enum Status { + Active, + Pending, + Closed, +} + +fn describe(s: &Status) -> &'static str { + match s { + Status::Active => "active", + Status::Pending => "pending", + Status::Closed => "closed", + // Adding Status::Suspended now causes a compile error here — intended. + } +} +``` + +## Grouping Variants with `|` + +When several variants share the same handling, list them explicitly rather than falling back to `_`: + +```rust +fn is_terminal(s: &Status) -> bool { + match s { + Status::Closed | Status::Pending => true, + Status::Active => false, + } +} +``` + +## When `_` Is Required: Foreign `#[non_exhaustive]` Enums + +External crates may mark enums `#[non_exhaustive]`, which means the compiler *forces* a wildcard. Document the intent: + +```rust +// From a hypothetical external crate: +// #[non_exhaustive] +// pub enum TheirEvent { Click, Hover, /* ... future variants */ } + +fn handle_event(event: &some_crate::TheirEvent) { + match event { + some_crate::TheirEvent::Click => { /* ... */ } + some_crate::TheirEvent::Hover => { /* ... */ } + // required by #[non_exhaustive]; intentionally a no-op for unknown variants + _ => {} + } +} +``` + +## Clippy Lint + +`clippy::wildcard_enum_match_arm` (part of `clippy::restriction`) warns when a wildcard arm in a match on a non-`#[non_exhaustive]` enum could be replaced with explicit variants. Enabling it catches drift over time. + +## See Also + +- [api-non-exhaustive](api-non-exhaustive.md) - use `#[non_exhaustive]` for future-proof enums in public APIs +- [type-enum-states](type-enum-states.md) - use enums for mutually exclusive states +- [pat-matches-macro](pat-matches-macro.md) - boolean pattern tests with `matches!()` diff --git a/.opencode/skills/rust-skills/rules/pat-if-let-chains.md b/.opencode/skills/rust-skills/rules/pat-if-let-chains.md new file mode 100644 index 0000000..694b93e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/pat-if-let-chains.md @@ -0,0 +1,81 @@ +# pat-if-let-chains + +> Use `if let` chains to combine pattern bindings and conditions + +## Why It Matters + +If-let chains (stabilized in Rust 1.88 under the 2024 edition) let you write a single `if` header that binds multiple patterns and tests arbitrary boolean conditions, all with `&&`. Without chains, each additional binding requires another level of nesting, pushing the happy-path body further right and forcing the reader to track multiple scopes. With chains, all preconditions read left-to-right at the same indentation level. + +## Prerequisite + +If-let chains require the **2024 edition**. Set it in `Cargo.toml`: + +```toml +[package] +edition = "2024" +``` + +## Bad + +```rust +fn handle(input: Option, limit: Option) -> Option { + if let Some(s) = input { + if let Ok(n) = s.trim().parse::() { + if let Some(max) = limit { + if n <= max { + return Some(format!("valid: {n}")); + } + } + } + } + None +} +``` + +## Good + +```rust +fn handle(input: Option, limit: Option) -> Option { + if let Some(s) = input + && let Ok(n) = s.trim().parse::() + && let Some(max) = limit + && n <= max + { + return Some(format!("valid: {n}")); + } + None +} +``` + +Each `&&` clause is evaluated in order; short-circuit semantics still apply, so later clauses only run if earlier ones succeed. + +## Mixing Patterns and Boolean Guards + +Chains can freely interleave `let` bindings with plain boolean expressions: + +```rust +struct Config { + debug: bool, + timeout: Option, +} + +fn effective_timeout(cfg: &Config) -> Option { + if cfg.debug + && let Some(t) = cfg.timeout + && t > 0 + { + Some(t) + } else { + None + } +} +``` + +## When to Prefer `let ... else` + +Use `let ... else` when the goal is early return on failure; use if-let chains when you have a richer condition that combines multiple optional bindings with a positive body to execute. + +## See Also + +- [pat-let-else](pat-let-else.md) - early-return pattern extraction without nesting +- [pat-matches-macro](pat-matches-macro.md) - boolean pattern tests with `matches!()` diff --git a/.opencode/skills/rust-skills/rules/pat-let-else.md b/.opencode/skills/rust-skills/rules/pat-let-else.md new file mode 100644 index 0000000..878486e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/pat-let-else.md @@ -0,0 +1,69 @@ +# pat-let-else + +> Use `let ... else` for early-return pattern extraction + +## Why It Matters + +`let ... else` (stable since Rust 1.65) binds a pattern in the success path or diverges in the `else` branch. It keeps the happy path at the top indentation level and eliminates rightward drift that accumulates when nesting multiple `if let` blocks. The `else` block must diverge via `return`, `continue`, `break`, or a macro like `panic!` or `bail!`. + +## Bad + +```rust +fn process(input: Option) -> Option { + if let Some(s) = input { + if let Ok(n) = s.trim().parse::() { + if n > 0 { + return Some(n * 2); + } else { + return None; + } + } else { + return None; + } + } else { + return None; + } +} +``` + +## Good + +```rust +fn process(input: Option) -> Option { + let Some(s) = input else { return None; }; + let Ok(n) = s.trim().parse::() else { return None; }; + if n == 0 { + return None; + } + Some(n * 2) +} +``` + +Multiple extractions stay flat, each guarding against one failure mode before the next line runs. + +## Using with `anyhow` / `bail!` + +The `else` block can use any diverging expression, including macros: + +```rust +use anyhow::{bail, Result}; + +fn get_id(map: &std::collections::HashMap, key: &str) -> Result { + let Some(&id) = map.get(key) else { + bail!("key '{}' not found", key); + }; + Ok(id) +} +``` + +## Notes + +- The bound variable is in scope **after** the `let` statement, not inside the `else` block. +- Prefer `?` when the `else` branch would just propagate an error; `let ... else` shines when the divergence is a `return`, `continue`, or `break`. +- Clippy lint `clippy::manual_let_else` flags patterns that can be converted. + +## See Also + +- [err-question-mark](err-question-mark.md) - use `?` for error propagation +- [anti-unwrap-abuse](anti-unwrap-abuse.md) - avoid `.unwrap()` in production code +- [pat-exhaustive-enum](pat-exhaustive-enum.md) - match enums exhaustively diff --git a/.opencode/skills/rust-skills/rules/pat-matches-macro.md b/.opencode/skills/rust-skills/rules/pat-matches-macro.md new file mode 100644 index 0000000..e49f2f4 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/pat-matches-macro.md @@ -0,0 +1,107 @@ +# pat-matches-macro + +> Use `matches!()` for boolean pattern tests + +## Why It Matters + +`matches!(value, Pattern)` is the idiomatic way to ask "does this value match a pattern?" in a boolean context. Spelling it out as a `match` that returns `true`/`false` is noisy and distracts the reader from the intent. Clippy flags the verbose form with `clippy::match_like_matches_macro`. The macro also supports optional guards, making it useful for range checks and filtered option tests without introducing a binding. + +## Bad + +```rust +enum Status { + Active, + Pending, + Closed, +} + +fn is_active(s: &Status) -> bool { + match s { + Status::Active => true, + _ => false, + } +} + +fn is_small_digit(n: u32) -> bool { + match n { + 1..=9 => true, + _ => false, + } +} + +fn is_positive(opt: Option) -> bool { + match opt { + Some(v) if v > 0 => true, + _ => false, + } +} +``` + +## Good + +```rust +enum Status { + Active, + Pending, + Closed, +} + +fn is_active(s: &Status) -> bool { + matches!(s, Status::Active) +} + +fn is_small_digit(n: u32) -> bool { + matches!(n, 1..=9) +} + +fn is_positive(opt: Option) -> bool { + matches!(opt, Some(v) if v > 0) +} +``` + +## Combining Multiple Variants + +`matches!` accepts alternation with `|`, making multi-variant checks concise: + +```rust +fn is_terminal(s: &Status) -> bool { + matches!(s, Status::Closed | Status::Pending) +} +``` + +## Good for `is_*` Helper Methods + +`matches!` pairs especially well with the `is_`/`has_`/`can_` naming convention for boolean predicate methods: + +```rust +impl Status { + pub fn is_active(&self) -> bool { + matches!(self, Self::Active) + } + + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Closed | Self::Pending) + } +} +``` + +## Asserting a Match in Tests + +For tests, `assert_matches!` (and `debug_assert_matches!`) — stabilized in Rust 1.96 — checks a value against a pattern and panics with the value's `Debug` output on failure, which is more informative than `assert!(matches!(...))`. They are not in the prelude, so import them: + +```rust +use std::assert_matches::assert_matches; + +let result = parse("42"); +assert_matches!(result, Ok(n) if n == 42); +``` + +## Notes + +- Enable `#![warn(clippy::match_like_matches_macro)]` (included in `clippy::style`) to catch existing verbose patterns automatically. +- When you need to bind the matched value or branch on it further, a real `match` or `if let` is still the right tool. + +## See Also + +- [pat-exhaustive-enum](pat-exhaustive-enum.md) - match enums exhaustively instead of using catch-all arms +- [name-is-has-bool](name-is-has-bool.md) - use `is_`, `has_`, `can_` prefixes for boolean methods diff --git a/.opencode/skills/rust-skills/rules/perf-ahash.md b/.opencode/skills/rust-skills/rules/perf-ahash.md new file mode 100644 index 0000000..2cf72c2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-ahash.md @@ -0,0 +1,79 @@ +# perf-ahash + +> Use a faster hasher (`ahash` / `FxHashMap`) when DoS resistance is not needed + +## Why It Matters + +Rust's default `HashMap` uses SipHash-1-3, which is DoS-resistant (hash flooding attacks on external input are not viable) but roughly 2–4× slower than non-cryptographic hashers on typical integer and short-string keys. For internal maps keyed by compiler-generated IDs, integer handles, or other trusted data, switching to a faster hasher can meaningfully reduce CPU time in hot map-heavy code. The wrong choice here is a security bug, not just a performance one — never use a non-DoS-resistant hasher for maps keyed by untrusted external input (user-supplied strings, network data, file paths from untrusted sources). + +## Bad + +```rust +use std::collections::HashMap; + +// Using the default SipHash hasher for compiler-internal integer keys — +// DoS resistance is wasted cost here. +fn build_id_map(ids: &[(u32, String)]) -> HashMap { + ids.iter().cloned().collect() +} +``` + +## Good + +```rust +// ahash: randomized seed per process, DoS-resistant, ~2x faster than SipHash. +// Good default replacement for most use cases. +use ahash::AHashMap; + +fn build_id_map_ahash(ids: &[(u32, String)]) -> AHashMap { + ids.iter().cloned().collect() +} + +// FxHashMap (rustc-hash): fastest option, but uses a predictable hash function. +// Only for trusted integer or pointer keys where hash flooding is not a concern +// (e.g., compiler internals, in-process caches keyed by integer IDs). +use rustc_hash::FxHashMap; + +type NodeMap = FxHashMap; + +fn build_node_map(nodes: &[(u32, String)]) -> NodeMap { + let mut map = NodeMap::with_capacity_and_hasher( + nodes.len(), + Default::default(), + ); + map.extend(nodes.iter().cloned()); + map +} + +// Convenient type aliases to avoid repeating the hasher parameter +use std::collections::HashMap; +use rustc_hash::FxBuildHasher; + +type FastMap = HashMap; + +fn fast_map_example() -> FastMap { + FastMap::with_capacity_and_hasher(64, FxBuildHasher) +} +``` + +## Hasher Selection Guide + +| Hasher | Crate | DoS-resistant | Speed | Use when | +|--------|-------|--------------|-------|----------| +| `SipHash-1-3` | std (default) | Yes | Baseline | Keys from untrusted external input | +| `ahash` | `ahash` | Yes (randomized) | ~2× faster | General-purpose replacement; safe default upgrade | +| `FxHash` | `rustc-hash` | No | Fastest | Trusted integer/pointer keys, compiler internals | +| `gxhash` | `gxhash` | Optional | Very fast (SIMD) | Throughput-critical, homogeneous key types | + +## Key Points + +- **Profile first**: switch hashers only after confirming map operations appear in profiler output. +- `ahash::AHashMap` is a drop-in replacement for `HashMap` and is the safest upgrade — it uses a random per-process seed. +- `FxHashMap` is what rustc uses internally; it is predictable, so never expose it to externally-supplied keys. +- Pass `with_capacity` when the final size is known — it applies regardless of hasher choice. + +## See Also + +- [perf-entry-api](perf-entry-api.md) - avoid redundant lookups with the entry API +- [perf-profile-first](perf-profile-first.md) - profile before optimizing +- [mem-with-capacity](mem-with-capacity.md) - pre-allocate collections when size is known diff --git a/.opencode/skills/rust-skills/rules/perf-black-box-bench.md b/.opencode/skills/rust-skills/rules/perf-black-box-bench.md new file mode 100644 index 0000000..f82cc1b --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-black-box-bench.md @@ -0,0 +1,153 @@ +# perf-black-box-bench + +> Use black_box in benchmarks + +## Why It Matters + +The compiler aggressively optimizes code, potentially eliminating computations whose results aren't used. In benchmarks, this can lead to measuring nothing instead of the actual code. `std::hint::black_box()` prevents the compiler from optimizing away values, ensuring accurate measurements. + +## Bad + +```rust +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn benchmark_bad(c: &mut Criterion) { + c.bench_function("compute", |b| { + b.iter(|| { + let result = expensive_computation(42); + // Result unused - compiler may eliminate the call! + }); + }); +} + +fn benchmark_also_bad(c: &mut Criterion) { + let input = 42; // Constant - compiler may precompute + + c.bench_function("compute", |b| { + b.iter(|| { + expensive_computation(input) + // Return value may still be optimized away + }); + }); +} +``` + +## Good + +```rust +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn benchmark_good(c: &mut Criterion) { + c.bench_function("compute", |b| { + b.iter(|| { + // black_box on input prevents constant folding + let result = expensive_computation(black_box(42)); + // black_box on output prevents dead code elimination + black_box(result) + }); + }); +} + +// Or simpler with Criterion's built-in support +fn benchmark_simpler(c: &mut Criterion) { + c.bench_function("compute", |b| { + b.iter(|| expensive_computation(black_box(42))) + }); +} +``` + +## What black_box Does + +| Without black_box | With black_box | +|-------------------|----------------| +| Input may be constant-folded | Input treated as unknown | +| Result may be eliminated | Result must be computed | +| Loops may be optimized away | Each iteration runs | +| Functions may be inlined | Call semantics preserved | + +## Standard Library Usage + +```rust +use std::hint::black_box; + +fn main() { + // In std since Rust 1.66 + let result = black_box(compute_something(black_box(input))); +} +``` + +## Criterion's black_box + +Criterion re-exports `std::hint::black_box`: + +```rust +use criterion::black_box; + +// Equivalent to std::hint::black_box +``` + +## Pattern: Benchmark with Setup + +```rust +fn benchmark_with_setup(c: &mut Criterion) { + c.bench_function("process_data", |b| { + // Setup outside iter - not measured + let data = generate_test_data(1000); + + b.iter(|| { + // black_box the input reference + let result = process(black_box(&data)); + black_box(result) + }); + }); +} +``` + +## Pattern: Benchmark Multiple Inputs + +```rust +fn benchmark_sizes(c: &mut Criterion) { + let mut group = c.benchmark_group("scaling"); + + for size in [100, 1000, 10000] { + let data = generate_data(size); + + group.bench_with_input( + BenchmarkId::from_parameter(size), + &data, + |b, data| { + b.iter(|| process(black_box(data))) + }, + ); + } + group.finish(); +} +``` + +## Common Mistakes + +```rust +// WRONG: black_box inside loop does nothing useful +for _ in 0..1000 { + black_box(()); // Doesn't help + compute(); +} + +// RIGHT: black_box the computation result +for _ in 0..1000 { + black_box(compute()); +} + +// WRONG: Only blocking output, not input +let x = 42; // Constant, may be optimized +black_box(expensive(x)); + +// RIGHT: Block both +black_box(expensive(black_box(42))); +``` + +## See Also + +- [test-criterion-bench](./test-criterion-bench.md) - Using Criterion +- [perf-profile-first](./perf-profile-first.md) - Profile before optimize +- [perf-release-profile](./perf-release-profile.md) - Release settings diff --git a/.opencode/skills/rust-skills/rules/perf-chain-avoid.md b/.opencode/skills/rust-skills/rules/perf-chain-avoid.md new file mode 100644 index 0000000..2cb9cdc --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-chain-avoid.md @@ -0,0 +1,136 @@ +# perf-chain-avoid + +> Avoid chain in hot loops + +## Why It Matters + +`Iterator::chain()` adds overhead for checking which iterator is active on every `.next()` call. In hot loops, this branch prediction overhead can impact performance. For performance-critical code, prefer single iterators or pre-combined collections. + +## Bad + +```rust +// Chain in hot inner loop +fn process_hot_path(a: &[i32], b: &[i32]) -> i64 { + let mut sum = 0i64; + + // Called millions of times + for _ in 0..1_000_000 { + for x in a.iter().chain(b.iter()) { // Branch every iteration + sum += *x as i64; + } + } + sum +} + +// Chaining multiple small slices in tight loop +fn combine_results(parts: &[&[u8]]) -> Vec { + let mut result = Vec::new(); + for part in parts { + for byte in std::iter::once(&0u8).chain(part.iter()) { + result.push(*byte); + } + } + result +} +``` + +## Good + +```rust +// Separate loops - branch-free inner loops +fn process_hot_path(a: &[i32], b: &[i32]) -> i64 { + let mut sum = 0i64; + + for _ in 0..1_000_000 { + for x in a { + sum += *x as i64; + } + for x in b { + sum += *x as i64; + } + } + sum +} + +// Pre-combine outside hot loop +fn combine_results(parts: &[&[u8]]) -> Vec { + let mut result = Vec::new(); + for part in parts { + result.push(0u8); + result.extend_from_slice(part); + } + result +} +``` + +## When Chain Is Fine + +Chain is perfectly acceptable when: + +```rust +// One-time iteration, not in hot path +fn collect_all(a: Vec, b: Vec) -> Vec { + a.into_iter().chain(b).collect() +} + +// Lazy evaluation with short-circuit +fn find_in_either(a: &[Item], b: &[Item], target: i32) -> Option<&Item> { + a.iter().chain(b.iter()).find(|x| x.id == target) +} + +// Small number of elements +fn get_prefixes() -> impl Iterator { + ["Mr.", "Mrs.", "Dr."].iter().copied() + .chain(["Prof."].iter().copied()) +} +``` + +## Alternative Patterns + +### Pre-allocate and Extend + +```rust +fn merge_slices(slices: &[&[i32]]) -> Vec { + let total: usize = slices.iter().map(|s| s.len()).sum(); + let mut result = Vec::with_capacity(total); + for slice in slices { + result.extend_from_slice(slice); + } + result +} +``` + +### Use append for Vecs + +```rust +fn combine_vecs(mut a: Vec, mut b: Vec) -> Vec { + a.append(&mut b); // Moves elements, no reallocation if a has capacity + a +} +``` + +### Flatten Instead of Chain + +```rust +// Instead of: a.iter().chain(b.iter()).chain(c.iter()) +let all = [a, b, c]; +for item in all.iter().flat_map(|slice| slice.iter()) { + process(item); +} +``` + +## Performance Impact + +| Pattern | Per-Item Overhead | +|---------|-------------------| +| Single iterator | None | +| `chain(a, b)` | 1 branch per item | +| `chain(a, b, c)` | 2 branches per item | +| Nested chains | Compounds | +| Separate loops | None (but code duplication) | + +## See Also + +- [perf-iter-over-index](./perf-iter-over-index.md) - Prefer iterators +- [perf-extend-batch](./perf-extend-batch.md) - Batch insertions +- [opt-cache-friendly](./opt-cache-friendly.md) - Cache-friendly patterns diff --git a/.opencode/skills/rust-skills/rules/perf-collect-into.md b/.opencode/skills/rust-skills/rules/perf-collect-into.md new file mode 100644 index 0000000..2508b80 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-collect-into.md @@ -0,0 +1,142 @@ +# perf-collect-into + +> Use collect_into for reusing containers + +## Why It Matters + +`collect_into()` allows collecting iterator results into an existing collection, reusing its allocation. This avoids the allocation that `collect()` would make for a new collection. + +> **Note:** `collect_into` is currently **nightly-only** (requires `#![feature(iter_collect_into)]`, tracking issue [#94780](https://github.com/rust-lang/rust/issues/94780)). On stable Rust, use `extend()` instead — see the Stable Alternative section below. + +## Bad + +```rust +// Allocates new Vec each time +fn process_batches(batches: Vec>) -> Vec> { + batches.into_iter() + .map(|batch| { + batch.into_iter() + .filter(|x| *x > 0) + .collect::>() // New allocation per batch + }) + .collect() +} + +// Can't reuse cleared buffer +fn filter_loop(data: &[Vec]) { + for batch in data { + let filtered: Vec<_> = batch.iter() + .filter(|&&x| x > 0) + .copied() + .collect(); // New allocation each iteration + process(&filtered); + } +} +``` + +## Good (Stable: extend) + +```rust +// Stable approach: reuse buffer with extend +fn filter_loop(data: &[Vec]) { + let mut buffer = Vec::new(); + + for batch in data { + buffer.clear(); // Keep allocation + buffer.extend( + batch.iter() + .filter(|&&x| x > 0) + .copied() + ); + process(&buffer); + } +} +``` + +## Nightly: collect_into + +```rust +#![feature(iter_collect_into)] + +// Reuse buffer with collect_into (nightly only) +fn filter_loop_nightly(data: &[Vec]) { + let mut buffer = Vec::new(); + + for batch in data { + buffer.clear(); // Keep allocation + batch.iter() + .filter(|&&x| x > 0) + .copied() + .collect_into(&mut buffer); + process(&buffer); + } +} + +``` + +## Stable Alternative: extend + +On stable Rust, `extend()` is equivalent and idiomatic: + +```rust +fn reuse_buffer(data: &[Vec]) { + let mut buffer = Vec::new(); + + for batch in data { + buffer.clear(); + buffer.extend(batch.iter().filter(|&&x| x > 0).copied()); + process(&buffer); + } +} +``` + +## Pattern: Transform and Reuse + +```rust +fn transform_batches(batches: &[Vec]) -> Vec { + let mut temp = Vec::new(); + let mut all_results = Vec::new(); + + for batch in batches { + temp.clear(); + batch.iter() + .map(ProcessedData::from) + .collect_into(&mut temp); + + // Process temp, append to results + all_results.extend(temp.drain(..).filter(|p| p.is_valid())); + } + + all_results +} +``` + +## Supported Collections + +`collect_into()` works with any type implementing `Extend`: + +```rust +use std::collections::{HashSet, HashMap, VecDeque}; + +let mut vec = Vec::new(); +let mut set = HashSet::new(); +let mut deque = VecDeque::new(); + +(0..10).collect_into(&mut vec); +(0..10).collect_into(&mut set); +(0..10).collect_into(&mut deque); +``` + +## Comparison + +| Method | Allocation | Buffer Reuse | +|--------|------------|--------------| +| `.collect()` | New each time | No | +| `.collect_into(&mut buf)` | Reuses buffer | Yes | +| `buf.extend(iter)` | Reuses buffer | Yes | + +## See Also + +- [perf-drain-reuse](./perf-drain-reuse.md) - Drain for reuse +- [mem-reuse-collections](./mem-reuse-collections.md) - Collection reuse +- [perf-extend-batch](./perf-extend-batch.md) - Batch extensions diff --git a/.opencode/skills/rust-skills/rules/perf-collect-once.md b/.opencode/skills/rust-skills/rules/perf-collect-once.md new file mode 100644 index 0000000..8829bd8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-collect-once.md @@ -0,0 +1,120 @@ +# perf-collect-once + +> Don't collect intermediate iterators + +## Why It Matters + +Each `.collect()` allocates a new collection. Chaining multiple operations with intermediate collections wastes memory and CPU cycles. Keep iterator chains lazy and collect only once at the end. + +## Bad + +```rust +// Three allocations, three passes +fn process_users(users: Vec) -> Vec { + let active: Vec<_> = users.into_iter() + .filter(|u| u.is_active) + .collect(); + + let verified: Vec<_> = active.into_iter() + .filter(|u| u.is_verified) + .collect(); + + verified.into_iter() + .map(|u| u.name) + .collect() +} + +// Collecting to count +fn count_valid(items: &[Item]) -> usize { + items.iter() + .filter(|i| i.is_valid()) + .collect::>() // Unnecessary! + .len() +} +``` + +## Good + +```rust +// One allocation, one pass +fn process_users(users: Vec) -> Vec { + users.into_iter() + .filter(|u| u.is_active) + .filter(|u| u.is_verified) + .map(|u| u.name) + .collect() +} + +// No allocation needed +fn count_valid(items: &[Item]) -> usize { + items.iter() + .filter(|i| i.is_valid()) + .count() +} +``` + +## Pattern: Deferred Collection + +```rust +// Create the iterator chain +fn prepare_data(raw: Vec) -> impl Iterator { + raw.into_iter() + .filter(|d| d.is_valid()) + .map(ProcessedData::from) +} + +// Collect only when needed +let data: Vec<_> = prepare_data(input).collect(); + +// Or consume without collecting +prepare_data(input).for_each(|d| process(d)); +``` + +## When Intermediate Collection Is Needed + +```rust +// Need to iterate multiple times +let items: Vec<_> = data.iter() + .filter(|x| x.is_valid()) + .collect(); + +let count = items.len(); +let first = items.first(); +for item in &items { + process(item); +} + +// Need to sort (requires concrete collection) +let mut sorted: Vec<_> = data.iter() + .filter(|x| x.is_active) + .collect(); +sorted.sort_by_key(|x| x.priority); +``` + +## Comparison + +| Approach | Allocations | Passes | Memory | +|----------|-------------|--------|--------| +| Multiple `.collect()` | N | N | O(N × data) | +| Single chain + `.collect()` | 1 | 1 | O(data) | +| No `.collect()` (streaming) | 0 | 1 | O(1) | + +## Pattern: Collect with Capacity + +When you must collect, pre-allocate: + +```rust +// With estimated capacity +let mut result = Vec::with_capacity(items.len()); +result.extend( + items.iter() + .filter(|x| x.is_valid()) + .map(|x| x.clone()) +); +``` + +## See Also + +- [perf-iter-lazy](./perf-iter-lazy.md) - Keep iterators lazy +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocate collections +- [anti-collect-intermediate](./anti-collect-intermediate.md) - Anti-pattern diff --git a/.opencode/skills/rust-skills/rules/perf-drain-reuse.md b/.opencode/skills/rust-skills/rules/perf-drain-reuse.md new file mode 100644 index 0000000..2016076 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-drain-reuse.md @@ -0,0 +1,137 @@ +# perf-drain-reuse + +> Use drain to reuse allocations + +## Why It Matters + +`drain()` removes elements from a collection while keeping its allocated capacity. This allows reusing the same allocation across iterations, avoiding repeated allocate/deallocate cycles in loops. + +## Bad + +```rust +// Allocates new Vec every iteration +fn process_batches(data: Vec) { + let mut remaining = data; + + while !remaining.is_empty() { + let batch: Vec<_> = remaining.drain(..100.min(remaining.len())).collect(); + process_batch(batch); + // remaining keeps its capacity - good + // but batch allocates new every time - bad + } +} + +// Clears and reallocates +fn reuse_buffer() { + for _ in 0..1000 { + let mut buffer = Vec::new(); // Allocates each iteration + fill_buffer(&mut buffer); + process(&buffer); + } +} +``` + +## Good + +```rust +// Reuses allocation with drain +fn process_batches(mut data: Vec) { + let mut batch = Vec::with_capacity(100); + + while !data.is_empty() { + batch.extend(data.drain(..100.min(data.len()))); + process_batch(&batch); + batch.clear(); // Keeps capacity + } +} + +// Reuses buffer across iterations +fn reuse_buffer() { + let mut buffer = Vec::new(); + + for _ in 0..1000 { + buffer.clear(); // Keeps capacity + fill_buffer(&mut buffer); + process(&buffer); + } +} +``` + +## Drain Methods + +| Collection | Method | Behavior | +|------------|--------|----------| +| `Vec` | `.drain(range)` | Remove range, shift remaining | +| `Vec` | `.drain(..)` | Remove all (like clear) | +| `VecDeque` | `.drain(range)` | Remove range | +| `String` | `.drain(range)` | Remove char range | +| `HashMap` | `.drain()` | Remove all entries | +| `HashSet` | `.drain()` | Remove all elements | + +## Pattern: Batch Processing + +```rust +fn process_in_chunks(mut items: Vec, chunk_size: usize) { + while !items.is_empty() { + let chunk: Vec<_> = items.drain(..chunk_size.min(items.len())).collect(); + process_chunk(chunk); + } +} +``` + +## Pattern: Transfer Between Collections + +```rust +// Move all elements without reallocation +fn transfer_all(src: &mut Vec, dst: &mut Vec) { + dst.extend(src.drain(..)); + // src is now empty but keeps capacity +} + +// Move matching elements +fn transfer_matching(src: &mut Vec, dst: &mut Vec, predicate: impl Fn(&Item) -> bool) { + let matching: Vec<_> = src.drain(..).filter(predicate).collect(); + dst.extend(matching); +} +``` + +## Pattern: HashMap Drain + +```rust +use std::collections::HashMap; + +fn process_and_clear(map: &mut HashMap) { + // Process all entries, clearing the map + for (key, value) in map.drain() { + process(key, value); + } + // map is now empty but keeps capacity +} +``` + +## drain vs clear vs take + +| Operation | Elements | Capacity | Returns | +|-----------|----------|----------|---------| +| `.clear()` | Removed | Kept | Nothing | +| `.drain(..)` | Removed | Kept | Iterator | +| `std::mem::take()` | Moved out | Reset to 0 | Owned collection | + +```rust +// clear: just empty +vec.clear(); + +// drain: empty and iterate +for item in vec.drain(..) { + process(item); +} + +// take: swap with empty, get ownership +let old_vec = std::mem::take(&mut vec); +``` + +## See Also + +- [mem-reuse-collections](./mem-reuse-collections.md) - Reusing collections +- [perf-extend-batch](./perf-extend-batch.md) - Batch insertions +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation diff --git a/.opencode/skills/rust-skills/rules/perf-entry-api.md b/.opencode/skills/rust-skills/rules/perf-entry-api.md new file mode 100644 index 0000000..ce60eaf --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-entry-api.md @@ -0,0 +1,135 @@ +# perf-entry-api + +> Use entry API for map insert-or-update + +## Why It Matters + +The entry API performs a single lookup for insert-or-update operations. Without it, you lookup twice: once to check existence, once to insert. For `HashMap` and `BTreeMap`, the entry API is both faster and more idiomatic. + +## Bad + +```rust +use std::collections::HashMap; + +// Double lookup: contains_key + insert +fn increment(map: &mut HashMap, key: String) { + if map.contains_key(&key) { + *map.get_mut(&key).unwrap() += 1; + } else { + map.insert(key, 1); + } +} + +// Double lookup with get + insert +fn get_or_insert(map: &mut HashMap>, key: String) -> &mut Vec { + if !map.contains_key(&key) { + map.insert(key.clone(), Vec::new()); + } + map.get_mut(&key).unwrap() +} + +// Triple lookup pattern +fn update_or_default(map: &mut HashMap, key: &str, value: i32) { + match map.get(key) { + Some(config) => { + let mut new_config = config.clone(); + new_config.value = value; + map.insert(key.to_string(), new_config); + } + None => { + map.insert(key.to_string(), Config::default()); + } + } +} +``` + +## Good + +```rust +use std::collections::HashMap; +use std::collections::hash_map::Entry; + +// Single lookup with entry +fn increment(map: &mut HashMap, key: String) { + *map.entry(key).or_insert(0) += 1; +} + +// Single lookup, returns mutable reference +fn get_or_insert(map: &mut HashMap>, key: String) -> &mut Vec { + map.entry(key).or_insert_with(Vec::new) +} + +// Single lookup with and_modify +fn update_or_default(map: &mut HashMap, key: String, value: i32) { + map.entry(key) + .and_modify(|config| config.value = value) + .or_insert_with(Config::default); +} +``` + +## Entry API Methods + +| Method | Behavior | +|--------|----------| +| `.or_insert(val)` | Insert `val` if empty | +| `.or_insert_with(f)` | Insert `f()` if empty (lazy) | +| `.or_default()` | Insert `Default::default()` if empty | +| `.and_modify(f)` | Apply `f` if occupied | +| `.or_insert_with_key(f)` | Insert `f(&key)` if empty | + +## Pattern: Count Occurrences + +```rust +fn word_count(text: &str) -> HashMap<&str, usize> { + let mut counts = HashMap::new(); + for word in text.split_whitespace() { + *counts.entry(word).or_insert(0) += 1; + } + counts +} +``` + +## Pattern: Group By + +```rust +fn group_by_category(items: Vec) -> HashMap> { + let mut groups: HashMap> = HashMap::new(); + for item in items { + groups.entry(item.category.clone()) + .or_default() + .push(item); + } + groups +} +``` + +## Pattern: Complex Entry Logic + +```rust +match map.entry(key) { + Entry::Occupied(mut entry) => { + let value = entry.get_mut(); + if should_update(value) { + *value = new_value; + } + } + Entry::Vacant(entry) => { + entry.insert(default_value); + } +} +``` + +## Performance + +| Pattern | Lookups | Hash Computations | +|---------|---------|-------------------| +| `contains_key` + `insert` | 2 | 2 | +| `get` + `insert` | 2 | 2 | +| `entry().or_insert()` | 1 | 1 | + +## See Also + +- [perf-extend-batch](./perf-extend-batch.md) - Batch insertions +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocate maps +- [perf-drain-reuse](./perf-drain-reuse.md) - Reuse map allocations +- [coll-map-choice](./coll-map-choice.md) - Pick the right map type diff --git a/.opencode/skills/rust-skills/rules/perf-extend-batch.md b/.opencode/skills/rust-skills/rules/perf-extend-batch.md new file mode 100644 index 0000000..489f041 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-extend-batch.md @@ -0,0 +1,150 @@ +# perf-extend-batch + +> Use extend for batch insertions + +## Why It Matters + +`extend()` can pre-allocate capacity for the incoming elements and insert them in a single operation. Individual `push()` calls may trigger multiple reallocations as the collection grows. For adding multiple elements, `extend()` is both faster and clearer. + +## Bad + +```rust +// Multiple potential reallocations +fn collect_results(sources: Vec) -> Vec { + let mut results = Vec::new(); + + for source in sources { + for result in source.get_results() { + results.push(result); // May reallocate + } + } + results +} + +// Loop with push for known data +fn build_list() -> Vec { + let mut list = Vec::new(); + for i in 0..1000 { + list.push(i); // Many reallocations + } + list +} + +// Appending another collection +fn combine(mut a: Vec, b: Vec) -> Vec { + for item in b { + a.push(item); + } + a +} +``` + +## Good + +```rust +// Single extend with size hint +fn collect_results(sources: Vec) -> Vec { + let mut results = Vec::new(); + + for source in sources { + results.extend(source.get_results()); + } + results +} + +// Direct collection from iterator +fn build_list() -> Vec { + (0..1000).collect() +} + +// Extend for combining +fn combine(mut a: Vec, b: Vec) -> Vec { + a.extend(b); + a +} +``` + +## Extend with Capacity + +For best performance, combine with `reserve()`: + +```rust +fn merge_all(chunks: Vec>) -> Vec { + // Calculate total size + let total: usize = chunks.iter().map(|c| c.len()).sum(); + + let mut result = Vec::with_capacity(total); + for chunk in chunks { + result.extend(chunk); + } + result +} +``` + +## Extend Methods + +| Method | Description | +|--------|-------------| +| `.extend(iter)` | Add all elements from iterator | +| `.extend_from_slice(&[T])` | Add from slice (for `Copy` types) | +| `.append(&mut Vec)` | Move all from another Vec | + +## Pattern: Building Strings + +```rust +// Bad: multiple allocations +fn build_message(parts: &[&str]) -> String { + let mut result = String::new(); + for part in parts { + result.push_str(part); // May reallocate + } + result +} + +// Good: extend with known parts +fn build_message(parts: &[&str]) -> String { + let total_len: usize = parts.iter().map(|s| s.len()).sum(); + let mut result = String::with_capacity(total_len); + for part in parts { + result.push_str(part); + } + result +} + +// Better: collect/join +fn build_message(parts: &[&str]) -> String { + parts.concat() // or parts.join("") +} +``` + +## HashMap/HashSet Extend + +```rust +use std::collections::HashMap; + +// Extend from iterator of tuples +fn merge_maps(mut base: HashMap, other: HashMap) -> HashMap { + base.extend(other); // Moves entries from other + base +} + +// Extend from iterator +let mut set = HashSet::new(); +set.extend(items.iter().map(|i| i.id)); +``` + +## Performance + +| Operation | Allocations | Complexity | +|-----------|-------------|------------| +| N × `push()` | O(log N) | O(N) amortized | +| `extend(iter)` | O(1)* | O(N) | +| `with_capacity` + `extend` | 1 | O(N) | + +*When iterator provides accurate `size_hint()` + +## See Also + +- [mem-with-capacity](./mem-with-capacity.md) - Pre-allocation +- [perf-drain-reuse](./perf-drain-reuse.md) - Reusing allocations +- [mem-reuse-collections](./mem-reuse-collections.md) - Collection reuse diff --git a/.opencode/skills/rust-skills/rules/perf-io-buffering.md b/.opencode/skills/rust-skills/rules/perf-io-buffering.md new file mode 100644 index 0000000..9ad3774 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-io-buffering.md @@ -0,0 +1,98 @@ +# perf-io-buffering + +> Wrap `Read`/`Write` in `BufReader`/`BufWriter` for many small operations + +## Why It Matters + +Every unbuffered read or write to a file or socket is a syscall. Calling `read()` byte-by-byte or line-by-line without buffering can issue millions of syscalls per second, each with kernel-transition overhead that dwarfs the actual data transfer. `BufReader` and `BufWriter` batch those operations into large internal buffer reads and writes, cutting syscall count by orders of magnitude. This is one of the highest-impact, lowest-effort performance fixes available for IO-heavy code. + +## Bad + +```rust +use std::fs::File; +use std::io::{Read, Write}; + +// Every read call goes to the OS — catastrophic for line-by-line processing +fn count_lines_slow(path: &str) -> std::io::Result { + let file = File::open(path)?; + let mut count = 0usize; + let mut byte = [0u8; 1]; + loop { + match file.read(&mut byte) { // one syscall per byte + Ok(0) => break, + Ok(_) => { + if byte[0] == b'\n' { + count += 1; + } + } + Err(e) => return Err(e), + } + } + Ok(count) +} + +// Writing many small records without buffering — each write is a syscall +fn write_records_slow(path: &str, records: &[String]) -> std::io::Result<()> { + let mut file = File::create(path)?; + for record in records { + file.write_all(record.as_bytes())?; // one syscall per record + file.write_all(b"\n")?; // another syscall + } + Ok(()) +} +``` + +## Good + +```rust +use std::fs::File; +use std::io::{self, BufRead, BufReader, BufWriter, Write}; + +// BufReader batches OS reads; lines() iterates safely without extra allocation per line +fn count_lines_fast(path: &str) -> io::Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut count = 0usize; + for line in reader.lines() { + line?; // propagate IO errors + count += 1; + } + Ok(count) +} + +// BufWriter batches writes; explicit flush() surfaces errors that drop() would swallow +fn write_records_fast(path: &str, records: &[String]) -> io::Result<()> { + let file = File::create(path)?; + let mut writer = BufWriter::new(file); + for record in records { + writer.write_all(record.as_bytes())?; + writer.write_all(b"\n")?; + } + writer.flush()?; // MUST flush explicitly — drop() swallows flush errors + Ok(()) +} + +// Custom buffer size when the default 8 KiB isn't optimal +fn process_large_file(path: &str) -> io::Result<()> { + let file = File::open(path)?; + let reader = BufReader::with_capacity(64 * 1024, file); // 64 KiB buffer + for line in reader.lines() { + let _line = line?; + // process... + } + Ok(()) +} +``` + +## Key Points + +- `BufWriter::flush()?` must be called explicitly. When a `BufWriter` is dropped, it attempts to flush, but any resulting error is **silently discarded**. Always flush before the writer goes out of scope. +- The default buffer size for both `BufReader` and `BufWriter` is 8 KiB. For sequential reads of large files, a larger buffer (32–512 KiB) can improve throughput by reducing the number of `read` syscalls further. +- `BufReader` implements `BufRead`, which provides `lines()`, `read_line()`, and `read_until()` — use these instead of reading bytes manually. +- Network streams (`TcpStream`, etc.) benefit equally from buffering, since each `write` on an unbuffered stream may send a tiny TCP segment. +- If you wrap a type that is already internally buffered (e.g., `tokio::io::BufWriter` in async code), adding another layer is redundant. + +## See Also + +- [mem-with-capacity](mem-with-capacity.md) - pre-size buffers when the final size is known +- [perf-profile-first](perf-profile-first.md) - confirm IO is the bottleneck before tuning diff --git a/.opencode/skills/rust-skills/rules/perf-iter-lazy.md b/.opencode/skills/rust-skills/rules/perf-iter-lazy.md new file mode 100644 index 0000000..3b98302 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-iter-lazy.md @@ -0,0 +1,123 @@ +# perf-iter-lazy + +> Keep iterators lazy, collect only when needed + +## Why It Matters + +Rust iterators are lazy—they compute values on demand. This enables single-pass processing, avoids intermediate allocations, and allows short-circuiting. Calling `.collect()` too early forces evaluation and allocates unnecessarily. + +## Bad + +```rust +// Collects intermediate results unnecessarily +fn process(data: Vec) -> Vec { + let filtered: Vec<_> = data.into_iter() + .filter(|x| *x > 0) + .collect(); // Unnecessary allocation + + let mapped: Vec<_> = filtered.into_iter() + .map(|x| x * 2) + .collect(); // Another unnecessary allocation + + mapped.into_iter() + .take(10) + .collect() +} + +// Collects before checking existence +fn has_positive(data: &[i32]) -> bool { + let positives: Vec<_> = data.iter() + .filter(|&&x| x > 0) + .collect(); // Allocates entire filtered result + + !positives.is_empty() +} +``` + +## Good + +```rust +// Single chain, single collect +fn process(data: Vec) -> Vec { + data.into_iter() + .filter(|x| *x > 0) + .map(|x| x * 2) + .take(10) + .collect() +} + +// Short-circuits on first match +fn has_positive(data: &[i32]) -> bool { + data.iter().any(|&x| x > 0) +} +``` + +## Lazy Iterator Methods + +These methods return iterators (lazy): + +| Method | Description | +|--------|-------------| +| `.filter()` | Keep matching elements | +| `.map()` | Transform elements | +| `.take(n)` | Limit to n elements | +| `.skip(n)` | Skip first n elements | +| `.zip()` | Pair with another iterator | +| `.chain()` | Concatenate iterators | +| `.flat_map()` | Map and flatten | +| `.enumerate()` | Add index | + +## Consuming Methods + +These methods consume the iterator (evaluate immediately): + +| Method | Description | +|--------|-------------| +| `.collect()` | Gather into collection | +| `.for_each()` | Execute side effect | +| `.count()` | Count elements | +| `.sum()` | Sum elements | +| `.fold()` | Accumulate value | +| `.any()` | Check if any match | +| `.all()` | Check if all match | +| `.find()` | Find first match | + +## Short-Circuit Benefits + +```rust +// Without lazy: processes ALL items +let found: Vec<_> = items.iter() + .filter(|x| expensive_check(x)) + .collect(); +let result = found.first(); + +// With lazy: stops at first match +let result = items.iter() + .find(|x| expensive_check(x)); +``` + +## Pattern: Process Without Collecting + +```rust +// Print all matches without allocating +data.iter() + .filter(|x| x.is_valid()) + .for_each(|x| println!("{}", x)); + +// Count without collecting +let count = data.iter() + .filter(|x| x.is_valid()) + .count(); + +// Sum without intermediate collection +let total: i64 = data.iter() + .filter(|x| x.is_valid()) + .map(|x| x.value as i64) + .sum(); +``` + +## See Also + +- [perf-collect-once](./perf-collect-once.md) - Single collect +- [perf-iter-over-index](./perf-iter-over-index.md) - Prefer iterators +- [anti-collect-intermediate](./anti-collect-intermediate.md) - Anti-pattern diff --git a/.opencode/skills/rust-skills/rules/perf-iter-over-index.md b/.opencode/skills/rust-skills/rules/perf-iter-over-index.md new file mode 100644 index 0000000..b80d4fa --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-iter-over-index.md @@ -0,0 +1,114 @@ +# perf-iter-over-index + +> Prefer iterators over manual indexing + +## Why It Matters + +Iterators are the idiomatic way to traverse collections in Rust. They enable bounds check elimination, SIMD auto-vectorization, and cleaner code. Manual indexing (`for i in 0..len`) often prevents these optimizations and introduces off-by-one error risks. + +## Bad + +```rust +// Manual indexing - bounds checked every iteration +fn sum_squares(data: &[i32]) -> i64 { + let mut sum = 0i64; + for i in 0..data.len() { + sum += (data[i] as i64) * (data[i] as i64); + } + sum +} + +// Index-based iteration with multiple collections +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + let mut sum = 0.0; + for i in 0..a.len().min(b.len()) { + sum += a[i] * b[i]; + } + sum +} + +// Mutating with indices +fn double_values(data: &mut [i32]) { + for i in 0..data.len() { + data[i] *= 2; + } +} +``` + +## Good + +```rust +// Iterator - bounds checks eliminated, SIMD-friendly +fn sum_squares(data: &[i32]) -> i64 { + data.iter() + .map(|&x| (x as i64) * (x as i64)) + .sum() +} + +// Zip iterators - no manual length handling +fn dot_product(a: &[f64], b: &[f64]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| x * y) + .sum() +} + +// Mutable iteration +fn double_values(data: &mut [i32]) { + for x in data.iter_mut() { + *x *= 2; + } +} +``` + +## When Indexing Is Needed + +Sometimes you genuinely need indices: + +```rust +// Need the index for output or processing +for (i, value) in data.iter().enumerate() { + println!("Index {}: {}", i, value); +} + +// Non-sequential access patterns +fn interleave(data: &mut [i32]) { + let mid = data.len() / 2; + for i in 0..mid { + data.swap(i * 2, mid + i); + } +} +``` + +## Performance Comparison + +| Pattern | Bounds Checks | SIMD Potential | Clarity | +|---------|---------------|----------------|---------| +| `for i in 0..len` | Every access | Limited | Medium | +| `for &x in slice` | None | High | High | +| `.iter().enumerate()` | None | Medium | High | +| `get_unchecked` | None (unsafe) | High | Low | + +## Iterator Advantages + +```rust +// Chaining operations - single pass +let result: Vec<_> = data.iter() + .filter(|x| **x > 0) + .map(|x| x * 2) + .collect(); + +// Early termination optimized +let found = data.iter().any(|&x| x == target); + +// Parallel iteration (with rayon) +use rayon::prelude::*; +let sum: i64 = data.par_iter().map(|&x| x as i64).sum(); +``` + +## See Also + +- [perf-iter-lazy](./perf-iter-lazy.md) - Keep iterators lazy +- [opt-bounds-check](./opt-bounds-check.md) - Bounds check elimination +- [anti-index-over-iter](./anti-index-over-iter.md) - Anti-pattern +- [conc-rayon-par-iter](./conc-rayon-par-iter.md) - Parallelize data-parallel loops diff --git a/.opencode/skills/rust-skills/rules/perf-profile-first.md b/.opencode/skills/rust-skills/rules/perf-profile-first.md new file mode 100644 index 0000000..c4410d6 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-profile-first.md @@ -0,0 +1,175 @@ +# perf-profile-first + +> Profile before optimizing + +## Why It Matters + +Intuition about performance is often wrong. The code you think is slow frequently isn't, while actual bottlenecks hide in unexpected places. Profiling shows you exactly where time is spent, preventing wasted effort on optimizations that don't matter. + +## Bad + +```rust +// Optimizing without measuring +fn process(data: &[Item]) -> Vec { + // "I bet this clone is slow..." + let cloned: Vec<_> = data.iter().cloned().collect(); + + // Actually, 99% of time is spent here: + cloned.iter().map(|x| expensive_computation(x)).collect() +} + +// Over-engineering rarely-called code +#[inline(always)] +fn rarely_called() { + // This runs once at startup... +} +``` + +## Good + +```rust +// 1. Profile first +// cargo flamegraph --bin myapp +// cargo instruments -t time --bin myapp (macOS) + +// 2. Find the actual bottleneck +// Flamegraph shows expensive_computation takes 95% of time + +// 3. Optimize the hot spot +fn process(data: &[Item]) -> Vec { + // Clone is fine - only 1% of time + let cloned: Vec<_> = data.iter().cloned().collect(); + + // Focus optimization HERE + cloned.par_iter() // Parallelize the expensive part + .map(|x| expensive_computation(x)) + .collect() +} +``` + +## Profiling Tools + +### Flamegraphs (Recommended Start) + +```bash +# Install +cargo install flamegraph + +# Profile +cargo flamegraph --bin myapp -- + +# Opens flamegraph.svg showing call stacks by time +``` + +### perf (Linux) + +```bash +# Record +perf record -g cargo run --release + +# Report +perf report + +# Or generate flamegraph +perf script | inferno-collapse-perf | inferno-flamegraph > flamegraph.svg +``` + +### Instruments (macOS) + +```bash +# Install cargo-instruments +cargo install cargo-instruments + +# Time profiler +cargo instruments -t time --release + +# Allocations profiler +cargo instruments -t alloc --release +``` + +### DHAT (Heap Profiling) + +```bash +# In your code +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +fn main() { + let _profiler = dhat::Profiler::new_heap(); + // ... your code +} + +# Run and get allocation report +cargo run --release +``` + +### criterion (Micro-benchmarks) + +```rust +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn bench_my_function(c: &mut Criterion) { + c.bench_function("my_function", |b| { + b.iter(|| my_function(black_box(input))) + }); +} + +criterion_group!(benches, bench_my_function); +criterion_main!(benches); +``` + +## What to Look For + +``` +Flamegraph Reading: +├── Width = time spent +├── Height = call stack depth +└── Look for: + ├── Wide bars (time hogs) + ├── malloc/free (allocation heavy) + ├── memcpy (copying data) + └── Unexpected functions taking time +``` + +## Common Findings + +```rust +// Finding: HashMap operations are slow +// Fix: Use FxHashMap or AHashMap for non-crypto hashing + +// Finding: String allocation in hot loop +// Fix: Pre-allocate with capacity, use &str + +// Finding: Clone in hot path +// Fix: Use references or Cow + +// Finding: Bounds checks visible in profile +// Fix: Use iterators instead of indexing + +// Finding: Lock contention +// Fix: Reduce critical section, use RwLock, or partition data +``` + +## Optimization Workflow + +``` +1. Write correct code first +2. Write benchmarks for hot paths +3. Profile under realistic load +4. Identify actual bottlenecks +5. Optimize ONE thing +6. Measure improvement +7. Repeat if needed +``` + +## Evidence: Rust Performance Book + +> "The biggest performance improvements often come from changes to algorithms or data structures, rather than low-level optimizations." + +> "It is worth understanding which Rust data structures and operations cause allocations, because avoiding them can greatly improve performance." + +## See Also + +- [opt-lto-release](opt-lto-release.md) - Enable LTO for release builds +- [test-criterion-bench](test-criterion-bench.md) - Use criterion for benchmarking +- [anti-premature-optimize](anti-premature-optimize.md) - Don't optimize without data diff --git a/.opencode/skills/rust-skills/rules/perf-release-profile.md b/.opencode/skills/rust-skills/rules/perf-release-profile.md new file mode 100644 index 0000000..d8e65fa --- /dev/null +++ b/.opencode/skills/rust-skills/rules/perf-release-profile.md @@ -0,0 +1,149 @@ +# perf-release-profile + +> Optimize release profile settings + +## Why It Matters + +The default release profile prioritizes compile speed over runtime performance. For production binaries, tuning the release profile can yield significant performance improvements (10-40% in some cases) at the cost of longer compile times. + +## Default Profile + +```toml +[profile.release] +opt-level = 3 +debug = false +lto = false +codegen-units = 16 +``` + +## Optimized Profile + +```toml +[profile.release] +opt-level = 3 # Maximum optimization +lto = "fat" # Full link-time optimization +codegen-units = 1 # Better optimization, slower compile +panic = "abort" # Smaller binary, no unwinding +strip = true # Remove symbols + +[profile.release.package."*"] +# Keep dependencies optimized even if main crate changes +opt-level = 3 +``` + +## Profile Options + +| Option | Values | Effect | +|--------|--------|--------| +| `opt-level` | 0-3, "s", "z" | Optimization level | +| `lto` | false, "thin", "fat" | Link-time optimization | +| `codegen-units` | 1-256 | Parallel compilation units | +| `panic` | "unwind", "abort" | Panic behavior | +| `strip` | true, false, "symbols", "debuginfo" | Binary stripping | +| `debug` | true, false, 0-2 | Debug info level | + +## Optimization Levels + +| Level | Description | Use Case | +|-------|-------------|----------| +| `0` | No optimization | Debug builds | +| `1` | Basic optimization | Fast compile | +| `2` | Most optimizations | Balanced | +| `3` | All optimizations | Maximum performance | +| `"s"` | Optimize for size | Embedded | +| `"z"` | Minimize size | Smallest binary | + +## LTO Options + +| Option | Compile Time | Performance | Binary Size | +|--------|--------------|-------------|-------------| +| `false` | Fast | Baseline | Larger | +| `"thin"` | Medium | Good | Smaller | +| `"fat"` | Slow | Best | Smallest | + +## Custom Profiles + +```toml +# Fast release builds for development +[profile.release-dev] +inherits = "release" +lto = false +codegen-units = 16 + +# Maximum performance for production +[profile.release-prod] +inherits = "release" +lto = "fat" +codegen-units = 1 +strip = true + +# Profiling with symbols +[profile.profiling] +inherits = "release" +debug = true +strip = false +``` + +Use with: `cargo build --profile release-prod` + +## Dev Dependencies Optimization + +Speed up tests and dev builds: + +```toml +[profile.dev] +opt-level = 0 + +# Optimize dependencies even in dev +[profile.dev.package."*"] +opt-level = 3 +``` + +## Benchmarking Profile + +```toml +[profile.bench] +inherits = "release" +debug = true # For profiling +strip = false # Keep symbols for flamegraphs +lto = "fat" # Consistent with release-prod +``` + +## Size vs Speed Trade-offs + +```toml +# Smallest binary +[profile.min-size] +inherits = "release" +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +# Balance size and speed +[profile.balanced] +inherits = "release" +opt-level = "s" +lto = "thin" +``` + +## Workspace Configuration + +```toml +# In workspace Cargo.toml +[profile.release] +lto = "fat" +codegen-units = 1 + +# Override for specific package +[profile.release.package.fast-compile-lib] +lto = false +codegen-units = 16 +``` + +## See Also + +- [opt-lto-release](./opt-lto-release.md) - LTO details +- [opt-codegen-units](./opt-codegen-units.md) - Codegen units +- [opt-pgo-profile](./opt-pgo-profile.md) - Profile-guided optimization diff --git a/.opencode/skills/rust-skills/rules/proj-bin-dir.md b/.opencode/skills/rust-skills/rules/proj-bin-dir.md new file mode 100644 index 0000000..d606fbd --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-bin-dir.md @@ -0,0 +1,142 @@ +# proj-bin-dir + +> Put multiple binaries in src/bin/ + +## Why It Matters + +When a crate produces multiple binaries, placing them in `src/bin/` keeps the project organized. Each file becomes a separate binary target automatically, without manual `Cargo.toml` configuration. + +## Bad + +``` +my-project/ +├── Cargo.toml # Complex [[bin]] sections for each binary +├── src/ +│ ├── main.rs # Which binary is this? +│ ├── server.rs # Is this a module or binary? +│ ├── cli.rs # Unclear +│ └── lib.rs +``` + +```toml +# Cargo.toml - verbose and error-prone +[[bin]] +name = "server" +path = "src/server.rs" + +[[bin]] +name = "cli" +path = "src/cli.rs" +``` + +## Good + +``` +my-project/ +├── Cargo.toml # Clean, no [[bin]] needed +├── src/ +│ ├── lib.rs # Shared library code +│ └── bin/ +│ ├── server.rs # Binary: my-project-server (or just server) +│ └── cli.rs # Binary: my-project-cli (or just cli) +``` + +Each file in `src/bin/` automatically becomes a binary named after the file. + +## Running Binaries + +```bash +# Run specific binary +cargo run --bin server +cargo run --bin cli + +# Build specific binary +cargo build --bin server + +# Build all binaries +cargo build --bins +``` + +## Pattern: Binary with Multiple Files + +For complex binaries, use directories: + +``` +src/ +├── lib.rs +└── bin/ + ├── server/ + │ ├── main.rs # Entry point + │ ├── config.rs # Server-specific module + │ └── handlers.rs + └── cli/ + ├── main.rs + └── commands.rs +``` + +## Pattern: Shared Library Code + +```rust +// src/lib.rs - Shared code +pub mod config; +pub mod database; +pub mod models; + +// src/bin/server.rs - Server binary +use my_project::{config, database, models}; + +fn main() { + let config = config::load(); + let db = database::connect(&config); + // ... +} + +// src/bin/cli.rs - CLI binary +use my_project::{config, models}; + +fn main() { + let config = config::load(); + // CLI logic using shared code +} +``` + +## Binary Naming + +| File Path | Binary Name | +|-----------|-------------| +| `src/main.rs` | `my-project` (crate name) | +| `src/bin/server.rs` | `server` | +| `src/bin/my-cli.rs` | `my-cli` | +| `src/bin/server/main.rs` | `server` | + +## Explicit Configuration + +When you need custom settings: + +```toml +[[bin]] +name = "my-server" +path = "src/bin/server.rs" +required-features = ["server"] + +[[bin]] +name = "my-cli" +path = "src/bin/cli.rs" +``` + +## Pattern: Default Binary + +```toml +# src/main.rs is the default binary +# Additional binaries in src/bin/ + +[package] +name = "my-tool" +default-run = "my-tool" # Or specify another +``` + +## See Also + +- [proj-lib-main-split](./proj-lib-main-split.md) - Keep main.rs minimal +- [proj-workspace-large](./proj-workspace-large.md) - Workspace for larger projects +- [proj-flat-small](./proj-flat-small.md) - Simple project structure diff --git a/.opencode/skills/rust-skills/rules/proj-build-rs-minimal.md b/.opencode/skills/rust-skills/rules/proj-build-rs-minimal.md new file mode 100644 index 0000000..4f44590 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-build-rs-minimal.md @@ -0,0 +1,70 @@ +# proj-build-rs-minimal + +> Keep `build.rs` minimal, deterministic, and idempotent + +## Why It Matters + +Build scripts run on every Cargo invocation that touches their crate. Cargo decides whether to re-run a build script based on `cargo::rerun-if-changed` and `cargo::rerun-if-env-changed` directives: if you emit none, Cargo re-runs the script on every build; if you emit overly broad ones, you force unnecessary recompiles. Non-determinism (timestamps, UUIDs, network fetches) breaks reproducible builds, caching, and offline workflows. Parsing `rustc --version` strings to detect capabilities is fragile; the `autocfg` crate performs capability probes safely by compiling small snippets against the actual toolchain. + +## Bad + +```rust +// build.rs — overly broad, non-deterministic, network-dependent +use std::process::Command; + +fn main() { + // re-runs on every build because no rerun directives are emitted + // (Cargo default: re-run if ANY file changes) + + // fragile: parses version string instead of probing capability + let output = Command::new("rustc").arg("--version").output().unwrap(); + let version = String::from_utf8(output.stdout).unwrap(); + if version.contains("1.8") { + println!("cargo::rustc-cfg=has_feature"); + } + + // network access breaks offline/reproducible builds + let _resp = reqwest::blocking::get("https://example.com/schema.json").unwrap(); +} +``` + +## Good + +```rust +// build.rs — narrow directives, capability probe via autocfg, no network +fn main() { + // Only re-run when these specific files change + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rerun-if-changed=src/generated.rs"); + println!("cargo::rerun-if-env-changed=MY_BUILD_FLAG"); + + // Probe actual compiler capability instead of parsing version strings + let ac = autocfg::new(); + // Emit cfg if the compiler supports the feature we need + ac.emit_has_type("std::collections::BTreeMap"); + + // Conditional cfg from env var + if std::env::var("MY_BUILD_FLAG").is_ok() { + println!("cargo::rustc-cfg=my_feature"); + } +} +``` + +```toml +[build-dependencies] +autocfg = "1" +``` + +## Guidelines + +- Always emit at least `println!("cargo::rerun-if-changed=build.rs")` so the script only re-runs when it or its tracked inputs change. +- List every file the script reads: source files, schema files, code-gen templates. Missing a file means stale output; listing too many causes spurious rebuilds. +- Never make network calls. Download assets in advance and check them into the repo or use a `vendored` feature that ships them. +- Prefer `autocfg` or `rustversion` crates over parsing `rustc --version` for conditional compilation. +- Keep build scripts short. If codegen is complex, extract it into a library crate used by `build.rs`, not into `build.rs` itself. +- Avoid writing outside `OUT_DIR` (the directory Cargo provides for build output); writing elsewhere breaks sandboxed build environments. + +## See Also + +- [proj-feature-additive](proj-feature-additive.md) - design features to be strictly additive +- [lint-cfg-check](lint-cfg-check.md) - catch cfg typos with unexpected_cfgs diff --git a/.opencode/skills/rust-skills/rules/proj-feature-additive.md b/.opencode/skills/rust-skills/rules/proj-feature-additive.md new file mode 100644 index 0000000..81bf540 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-feature-additive.md @@ -0,0 +1,70 @@ +# proj-feature-additive + +> Design Cargo features to be strictly additive + +## Why It Matters + +Cargo unifies features across the dependency graph: if any crate in the build enables a feature, every consumer of that crate gets it. A feature that removes or changes existing behavior will break crates that depend on the baseline behavior the moment a third dependency enables it. Features must only add capability — new trait impls, additional dependencies, optional integrations — never subtract. Mutually exclusive features are an anti-pattern in the Cargo model. + +## Bad + +```toml +[features] +# "no_std" disables std — enabling it REMOVES behavior +no_std = [] + +[dependencies] +# and somewhere in lib.rs: +# #[cfg(not(feature = "no_std"))] +# use std::collections::HashMap; +``` + +```rust +// lib.rs — toggling off std via a feature is non-additive +#[cfg(not(feature = "no_std"))] +use std::vec::Vec; + +#[cfg(feature = "no_std")] +use alloc::vec::Vec; +``` + +## Good + +```toml +[features] +# "std" ADDS std support; no_std is the baseline +default = ["std"] +std = [] + +# Optional integrations — purely additive +serde = ["dep:serde"] +tokio = ["dep:tokio"] + +[dependencies] +serde = { version = "1", optional = true } +tokio = { version = "1", optional = true } +``` + +```rust +// lib.rs — std is opt-in, no_std is the default baseline +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(feature = "std")] +use std::vec::Vec; + +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +``` + +## Rules for Additive Features + +- A feature may add new items, trait impls, or dependencies — never gate-away existing ones. +- If you ship a `no_std` crate, make `std` a feature in `default`, not the other way around. +- Mutually exclusive features (e.g. `backend-a` vs `backend-b`) cannot be enforced by Cargo; emit a compile-time error via `compile_error!` if both are set, and document the limitation clearly. +- Use `dep:` syntax (`dep:serde`) to keep optional dependency names out of the feature namespace. + +## See Also + +- [api-serde-optional](api-serde-optional.md) - gate Serialize/Deserialize behind a feature flag +- [proj-workspace-deps](proj-workspace-deps.md) - use workspace dependency inheritance +- [lint-cfg-check](lint-cfg-check.md) - catch feature-gate typos with unexpected_cfgs diff --git a/.opencode/skills/rust-skills/rules/proj-flat-small.md b/.opencode/skills/rust-skills/rules/proj-flat-small.md new file mode 100644 index 0000000..a579894 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-flat-small.md @@ -0,0 +1,133 @@ +# proj-flat-small + +> Keep small projects flat + +## Why It Matters + +Over-organizing small projects adds navigation overhead without benefit. A project with 5-10 files doesn't need nested directories. Start flat, add structure only when complexity demands it. + +## Bad + +``` +src/ +├── core/ +│ └── mod.rs # Just re-exports +├── domain/ +│ ├── mod.rs +│ └── models/ +│ ├── mod.rs +│ └── user.rs # 50 lines +├── infrastructure/ +│ ├── mod.rs +│ └── database/ +│ ├── mod.rs +│ └── connection.rs # 30 lines +├── application/ +│ ├── mod.rs +│ └── services/ +│ └── mod.rs # Empty +└── main.rs +``` + +## Good + +``` +src/ +├── main.rs +├── lib.rs +├── config.rs +├── database.rs +├── user.rs +└── error.rs +``` + +## When to Add Structure + +| File Count | Structure | +|------------|-----------| +| < 10 files | Flat in `src/` | +| 10-20 files | Group by feature | +| 20+ files | Feature folders with submodules | + +## Progressive Structuring + +### Stage 1: Flat + +``` +src/ +├── main.rs +├── config.rs +├── user.rs +└── database.rs +``` + +### Stage 2: Logical Groups + +``` +src/ +├── main.rs +├── config.rs +├── user.rs +├── order.rs # Getting bigger +├── order_item.rs # Related to order +└── database.rs +``` + +### Stage 3: Feature Folders + +``` +src/ +├── main.rs +├── config.rs +├── user.rs +├── order/ # Now complex enough +│ ├── mod.rs +│ ├── model.rs +│ └── item.rs +└── database.rs +``` + +## Signs You Need More Structure + +- Files exceed 300-500 lines +- Related files are hard to identify +- You're adding `_` prefixes for grouping (`user_model.rs`, `user_service.rs`) +- New team members get lost +- Same concepts repeated in file names + +## Signs of Over-Structure + +- Folders with 1-2 files +- `mod.rs` files that only re-export +- Deep nesting for simple concepts +- More lines in module declarations than code + +## Example: CLI Tool + +``` +src/ +├── main.rs # Argument parsing, entry point +├── commands.rs # CLI subcommands +├── config.rs # Configuration loading +└── output.rs # Formatting, printing +``` + +Not: + +``` +src/ +├── cli/ +│ └── commands/ +│ └── mod.rs +├── config/ +│ └── mod.rs +└── presentation/ + └── output/ + └── mod.rs +``` + +## See Also + +- [proj-mod-by-feature](./proj-mod-by-feature.md) - Feature organization +- [proj-lib-main-split](./proj-lib-main-split.md) - Lib/main separation +- [proj-mod-rs-dir](./proj-mod-rs-dir.md) - Multi-file modules diff --git a/.opencode/skills/rust-skills/rules/proj-lib-main-split.md b/.opencode/skills/rust-skills/rules/proj-lib-main-split.md new file mode 100644 index 0000000..278799f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-lib-main-split.md @@ -0,0 +1,148 @@ +# proj-lib-main-split + +> Keep `main.rs` minimal, logic in `lib.rs` + +## Why It Matters + +Putting your logic in `lib.rs` makes it testable, reusable, and keeps `main.rs` as a thin entry point. Integration tests can only access your library crate, not binary code in `main.rs`. + +## Bad + +```rust +// src/main.rs - everything here +fn main() { + let args = parse_args(); + let config = load_config(&args.config_path).unwrap(); + let db = connect_database(&config.db_url).unwrap(); + + // Hundreds of lines of application logic... + // All untestable from integration tests! +} + +fn parse_args() -> Args { /* ... */ } +fn load_config(path: &str) -> Result { /* ... */ } +fn connect_database(url: &str) -> Result { /* ... */ } +// ... more functions that can't be tested +``` + +## Good + +```rust +// src/main.rs - thin entry point +use my_app::{run, Config}; + +fn main() -> anyhow::Result<()> { + let config = Config::from_env()?; + run(config) +} + +// src/lib.rs - all the logic +pub mod config; +pub mod database; +pub mod handlers; + +pub use config::Config; + +pub fn run(config: Config) -> anyhow::Result<()> { + let db = database::connect(&config.db_url)?; + let app = handlers::build_app(db); + app.run() +} +``` + +## With CLI Arguments + +```rust +// src/main.rs +use clap::Parser; +use my_app::{run, Args}; + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + run(args) +} + +// src/lib.rs +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(name = "myapp", version, about)] +pub struct Args { + #[arg(short, long)] + pub config: PathBuf, + + #[arg(short, long, default_value = "info")] + pub log_level: String, +} + +pub fn run(args: Args) -> anyhow::Result<()> { + // All application logic here - testable! +} +``` + +## Project Structure + +``` +my_app/ +├── Cargo.toml +├── src/ +│ ├── main.rs # Entry point only +│ ├── lib.rs # Library root, re-exports +│ ├── config.rs # Configuration +│ ├── database.rs # Database connection +│ └── handlers/ # Request handlers +│ ├── mod.rs +│ └── users.rs +└── tests/ + └── integration.rs # Can access lib.rs! +``` + +## Testing Benefits + +```rust +// tests/integration.rs - can test everything! +use my_app::{Config, run, database}; + +#[test] +fn test_database_connection() { + let config = Config::test_config(); + let db = database::connect(&config.db_url).unwrap(); + assert!(db.is_connected()); +} + +#[test] +fn test_full_workflow() { + let config = Config::test_config(); + // Test the actual run function + assert!(my_app::run(config).is_ok()); +} +``` + +## Multiple Binaries + +```rust +// src/lib.rs - shared code +pub mod core; +pub mod utils; + +// src/bin/server.rs +use my_app::core::Server; + +fn main() -> anyhow::Result<()> { + Server::new()?.run() +} + +// src/bin/cli.rs +use my_app::core::Client; + +fn main() -> anyhow::Result<()> { + let client = Client::new()?; + client.execute_command() +} +``` + +## See Also + +- [proj-bin-dir](proj-bin-dir.md) - Put multiple binaries in src/bin/ +- [proj-mod-by-feature](proj-mod-by-feature.md) - Organize modules by feature +- [test-integration-dir](test-integration-dir.md) - Integration tests in tests/ diff --git a/.opencode/skills/rust-skills/rules/proj-mod-by-feature.md b/.opencode/skills/rust-skills/rules/proj-mod-by-feature.md new file mode 100644 index 0000000..c203c57 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-mod-by-feature.md @@ -0,0 +1,130 @@ +# proj-mod-by-feature + +> Organize modules by feature, not type + +## Why It Matters + +Feature-based organization keeps related code together, making navigation intuitive and changes localized. Type-based organization (all handlers in one folder, all models in another) scatters related code across the codebase, making features harder to understand and modify. + +## Bad + +``` +src/ +├── controllers/ +│ ├── user_controller.rs +│ ├── order_controller.rs +│ └── product_controller.rs +├── models/ +│ ├── user.rs +│ ├── order.rs +│ └── product.rs +├── services/ +│ ├── user_service.rs +│ ├── order_service.rs +│ └── product_service.rs +└── repositories/ + ├── user_repository.rs + ├── order_repository.rs + └── product_repository.rs +``` + +## Good + +``` +src/ +├── user/ +│ ├── mod.rs # Re-exports public items +│ ├── model.rs # User struct, types +│ ├── repository.rs # Database operations +│ ├── service.rs # Business logic +│ └── handler.rs # HTTP handlers +├── order/ +│ ├── mod.rs +│ ├── model.rs +│ ├── repository.rs +│ ├── service.rs +│ └── handler.rs +├── product/ +│ ├── mod.rs +│ ├── model.rs +│ ├── repository.rs +│ └── handler.rs +└── lib.rs +``` + +## Benefits + +| Aspect | Type-Based | Feature-Based | +|--------|------------|---------------| +| Finding code | Search across folders | One folder per feature | +| Adding feature | Touch 4+ folders | Create one folder | +| Understanding feature | Jump between folders | Everything in one place | +| Deleting feature | Hunt through codebase | Delete one folder | +| Code ownership | Unclear | Clear feature owners | + +## Module Structure + +```rust +// src/user/mod.rs +mod model; +mod repository; +mod service; +mod handler; + +// Re-export public API +pub use model::{User, UserId, CreateUserRequest}; +pub use handler::router; +pub(crate) use service::UserService; +``` + +## Shared Code + +``` +src/ +├── user/ +├── order/ +├── shared/ # Cross-cutting concerns +│ ├── mod.rs +│ ├── database.rs # Connection pool +│ ├── error.rs # Common error types +│ └── middleware.rs # Auth, logging +└── lib.rs +``` + +## When to Flatten + +Small modules don't need deep nesting: + +``` +src/ +├── user/ +│ ├── mod.rs # Contains User struct + simple functions +│ └── repository.rs # Only if complex enough +├── config.rs # Simple enough for single file +└── lib.rs +``` + +## Hybrid Approach + +For larger features, nest further by concern: + +``` +src/ +├── billing/ +│ ├── mod.rs +│ ├── invoice/ +│ │ ├── mod.rs +│ │ ├── model.rs +│ │ └── service.rs +│ ├── payment/ +│ │ ├── mod.rs +│ │ ├── model.rs +│ │ └── processor.rs +│ └── shared.rs +``` + +## See Also + +- [proj-flat-small](./proj-flat-small.md) - Keep small projects flat +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Clean public API +- [proj-lib-main-split](./proj-lib-main-split.md) - Lib/main separation diff --git a/.opencode/skills/rust-skills/rules/proj-mod-rs-dir.md b/.opencode/skills/rust-skills/rules/proj-mod-rs-dir.md new file mode 100644 index 0000000..63a2bb3 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-mod-rs-dir.md @@ -0,0 +1,120 @@ +# proj-mod-rs-dir + +> Use mod.rs for multi-file modules + +## Why It Matters + +Rust offers two styles for multi-file modules. The `mod.rs` style is clearer for larger modules and aligns with how most Rust projects are structured. Choose one style consistently. + +## Two Styles + +### Style 1: mod.rs (Recommended for larger modules) + +``` +src/ +├── user/ +│ ├── mod.rs # Module root +│ ├── model.rs +│ └── repository.rs +└── lib.rs +``` + +```rust +// src/lib.rs +mod user; // Looks for user/mod.rs or user.rs + +// src/user/mod.rs +mod model; +mod repository; +pub use model::User; +``` + +### Style 2: Adjacent file (Recommended for smaller modules) + +``` +src/ +├── user.rs # Module root +├── user/ +│ ├── model.rs +│ └── repository.rs +└── lib.rs +``` + +```rust +// src/lib.rs +mod user; // Looks for user.rs, then user/ for submodules + +// src/user.rs +mod model; +mod repository; +pub use model::User; +``` + +## When to Use Each + +| Scenario | Recommendation | +|----------|----------------| +| Simple module (1-3 submodules) | Adjacent file (`user.rs` + `user/`) | +| Complex module (4+ submodules) | `mod.rs` style (`user/mod.rs`) | +| Deep nesting | `mod.rs` at each level | +| Library with public modules | Consistent style throughout | + +## mod.rs Benefits + +- Clear that `user/` is a module directory +- All module code inside the folder +- Easier to move/rename entire modules +- Common in large codebases (tokio, serde) + +## Adjacent File Benefits + +- Module declaration outside directory +- Can see module's interface without entering folder +- Matches Rust 2018+ default lint preference +- Good for small modules with few submodules + +## Example: Complex Module + +``` +src/ +├── database/ +│ ├── mod.rs # Main module, re-exports +│ ├── connection.rs # Connection pool +│ ├── migrations.rs # Schema migrations +│ ├── queries/ # Sub-module for queries +│ │ ├── mod.rs +│ │ ├── user.rs +│ │ └── order.rs +│ └── error.rs +└── lib.rs +``` + +```rust +// src/database/mod.rs +mod connection; +mod migrations; +mod queries; +mod error; + +pub use connection::Pool; +pub use error::DatabaseError; +pub use queries::{UserQueries, OrderQueries}; +``` + +## Consistency Rule + +Pick one style for your project and stick with it: + +```rust +// Cargo.toml or clippy.toml +[lints.clippy] +mod_module_files = "warn" # Enforces mod.rs style +# OR +self_named_module_files = "warn" # Enforces adjacent style +``` + +## See Also + +- [proj-flat-small](./proj-flat-small.md) - Keep small projects flat +- [proj-mod-by-feature](./proj-mod-by-feature.md) - Feature organization +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Re-export patterns diff --git a/.opencode/skills/rust-skills/rules/proj-msrv-declare.md b/.opencode/skills/rust-skills/rules/proj-msrv-declare.md new file mode 100644 index 0000000..c3733f8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-msrv-declare.md @@ -0,0 +1,57 @@ +# proj-msrv-declare + +> Declare `rust-version` (MSRV) in Cargo.toml and test it in CI + +## Why It Matters + +Setting `package.rust-version` causes Cargo to emit a clear, actionable error when the installed toolchain is too old, instead of a cryptic type or feature error deep inside your code. The 2024-edition resolver (resolver = "3", the default for edition 2024) is MSRV-aware: it will avoid selecting dependency versions whose own `rust-version` field exceeds yours, preventing accidental MSRV breakage from transitive upgrades. Without a declared MSRV, you have no contract with downstream users and no CI gate to catch regressions. + +## Bad + +```toml +[package] +name = "my-crate" +version = "0.1.0" +edition = "2021" +# no rust-version — users get cryptic errors on old toolchains, +# and nothing prevents a dep bump from silently raising the floor +``` + +## Good + +```toml +[package] +name = "my-crate" +version = "0.1.0" +edition = "2024" +rust-version = "1.80" # oldest toolchain you commit to supporting + +[workspace] +resolver = "3" # default for edition 2024; enables MSRV-aware dep resolution +``` + +CI job pinning the MSRV toolchain (GitHub Actions example): + +```yaml +# .github/workflows/msrv.yml +- name: Install MSRV toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.80" + +- name: Check MSRV + run: cargo check --all-features +``` + +## Choosing and Maintaining MSRV + +- Pick the oldest stable toolchain your users are realistically running (check distro packages, embedded targets, corporate freeze windows). +- Do not go lower than needed — a lower MSRV widens the set of eligible dependency versions and can force you onto older, buggier releases. +- When you bump MSRV, treat it as a semver-minor change (for libraries) and document it in your changelog. +- Run `cargo msrv` (the `cargo-msrv` tool) to find the actual floor automatically. + +## See Also + +- [proj-workspace-deps](proj-workspace-deps.md) - use workspace dependency inheritance +- [lint-cargo-metadata](lint-cargo-metadata.md) - warn on missing Cargo.toml metadata +- [doc-cargo-metadata](doc-cargo-metadata.md) - fill Cargo.toml metadata fields diff --git a/.opencode/skills/rust-skills/rules/proj-prelude-module.md b/.opencode/skills/rust-skills/rules/proj-prelude-module.md new file mode 100644 index 0000000..5b75d11 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-prelude-module.md @@ -0,0 +1,155 @@ +# proj-prelude-module + +> Create prelude module for common imports + +## Why It Matters + +A `prelude` module collects the most commonly used types and traits for glob import. Users write `use my_crate::prelude::*` instead of many individual imports. This follows the pattern established by `std::prelude`. + +## Bad + +```rust +// Users must import everything individually +use my_crate::Client; +use my_crate::Config; +use my_crate::Error; +use my_crate::Request; +use my_crate::Response; +use my_crate::traits::Handler; +use my_crate::traits::Middleware; +use my_crate::types::Method; +``` + +## Good + +```rust +// src/lib.rs +pub mod prelude { + pub use crate::{ + Client, + Config, + Error, + Request, + Response, + }; + pub use crate::traits::{Handler, Middleware}; + pub use crate::types::Method; +} + +// Users write: +use my_crate::prelude::*; +``` + +## What to Include + +| Include | Don't Include | +|---------|---------------| +| Core types users always need | Rarely-used types | +| Common traits | Implementation details | +| Error types | Internal helpers | +| Extension traits | Feature-gated items (usually) | +| Type aliases | Everything | + +## Example: Web Framework Prelude + +```rust +pub mod prelude { + // Core request/response + pub use crate::{Request, Response, Body}; + + // Error handling + pub use crate::Error; + + // Common traits + pub use crate::traits::{FromRequest, IntoResponse}; + + // Routing + pub use crate::Router; + + // HTTP types + pub use crate::http::{Method, StatusCode}; +} +``` + +## Example: Database Library Prelude + +```rust +pub mod prelude { + // Connection and pool + pub use crate::{Connection, Pool}; + + // Query building + pub use crate::query::{Query, Select, Insert, Update, Delete}; + + // Traits for custom types + pub use crate::traits::{FromRow, ToSql}; + + // Error type + pub use crate::Error; +} +``` + +## Pattern: Tiered Preludes + +```rust +// Minimal prelude +pub mod prelude { + pub use crate::{Client, Config, Error}; +} + +// Full prelude for power users +pub mod full_prelude { + pub use crate::prelude::*; + pub use crate::advanced::*; + pub use crate::extensions::*; +} +``` + +## Pattern: Feature-Gated Prelude Items + +```rust +pub mod prelude { + pub use crate::{Client, Error}; + + #[cfg(feature = "async")] + pub use crate::async_client::AsyncClient; + + #[cfg(feature = "serde")] + pub use crate::serde::{Serialize, Deserialize}; +} +``` + +## Guidelines + +1. **Be conservative** - Only include truly common items +2. **Avoid conflicts** - Don't include names that might clash (e.g., `Error`) +3. **Document it** - List what's included in module docs +4. **Stay stable** - Removing items is breaking change + +## Documenting the Prelude + +```rust +//! Common imports for convenient glob importing. +//! +//! # Usage +//! +//! ``` +//! use my_crate::prelude::*; +//! ``` +//! +//! # Contents +//! +//! This prelude re-exports: +//! - [`Client`] - The main API client +//! - [`Config`] - Client configuration +//! - [`Error`] - Error type +pub mod prelude { + // ... +} +``` + +## See Also + +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Re-export patterns +- [api-extension-trait](./api-extension-trait.md) - Extension traits +- [doc-module-inner](./doc-module-inner.md) - Module documentation diff --git a/.opencode/skills/rust-skills/rules/proj-pub-crate-internal.md b/.opencode/skills/rust-skills/rules/proj-pub-crate-internal.md new file mode 100644 index 0000000..d83bc36 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-pub-crate-internal.md @@ -0,0 +1,139 @@ +# proj-pub-crate-internal + +> Use pub(crate) for internal APIs + +## Why It Matters + +`pub(crate)` exposes items within the crate but hides them from external users. This creates clear boundaries between public API and internal implementation, preventing accidental breakage and reducing public API surface. + +## Bad + +```rust +// Everything public - users depend on internals +pub mod internal { + pub struct InternalState { + pub buffer: Vec, // Implementation detail exposed + pub dirty: bool, + } + + pub fn process_internal(state: &mut InternalState) { + // Users can call this, creating coupling + } +} + +pub struct Widget { + pub state: internal::InternalState, // Exposed! +} +``` + +## Good + +```rust +// Internal module with crate visibility +pub(crate) mod internal { + pub(crate) struct InternalState { + pub(crate) buffer: Vec, + pub(crate) dirty: bool, + } + + pub(crate) fn process_internal(state: &mut InternalState) { + // Only callable within crate + } +} + +pub struct Widget { + state: internal::InternalState, // Private field +} + +impl Widget { + pub fn new() -> Self { + Self { + state: internal::InternalState { + buffer: Vec::new(), + dirty: false, + } + } + } + + pub fn do_something(&mut self) { + internal::process_internal(&mut self.state); + } +} +``` + +## Visibility Levels + +| Visibility | Accessible From | +|------------|-----------------| +| `pub` | Everywhere | +| `pub(crate)` | Current crate only | +| `pub(super)` | Parent module only | +| `pub(in path)` | Specific module path | +| (private) | Current module only | + +## Pattern: Internal Module + +```rust +// src/lib.rs +mod internal; // Private module +pub mod api; // Public API + +// src/internal.rs +pub(crate) struct Helper; +pub(crate) fn helper_function() -> Helper { Helper } + +// src/api.rs +use crate::internal::{Helper, helper_function}; + +pub struct PublicType { + helper: Helper, // Uses internal type, but field is private +} +``` + +## Pattern: Test Visibility + +```rust +pub struct Parser { + // Private implementation + state: ParserState, +} + +// Expose for testing but not public API +#[cfg(test)] +pub(crate) fn debug_state(&self) -> &ParserState { + &self.state +} + +// Or use a dedicated test helper +#[doc(hidden)] +pub mod __test_helpers { + pub use super::ParserState; +} +``` + +## Pattern: Feature Module Internals + +```rust +// src/user/mod.rs +mod repository; // Private +mod service; // Private + +pub use service::UserService; // Only export the public API + +// repository and service are pub(crate) internally +// so other modules in crate can use them if needed +``` + +## Benefits + +| Approach | API Stability | Flexibility | +|----------|---------------|-------------| +| All `pub` | Any change breaks users | None | +| `pub(crate)` internals | Only `pub` items matter | Can refactor freely | +| Private | Maximum encapsulation | Limits crate flexibility | + +## See Also + +- [proj-pub-super-parent](./proj-pub-super-parent.md) - Parent-only visibility +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Clean re-exports +- [api-non-exhaustive](./api-non-exhaustive.md) - Future-proof structs diff --git a/.opencode/skills/rust-skills/rules/proj-pub-super-parent.md b/.opencode/skills/rust-skills/rules/proj-pub-super-parent.md new file mode 100644 index 0000000..dceaf66 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-pub-super-parent.md @@ -0,0 +1,135 @@ +# proj-pub-super-parent + +> Use pub(super) for parent-only visibility + +## Why It Matters + +`pub(super)` exposes items only to the immediate parent module. This is useful for helper functions and types that submodules share but shouldn't be visible to the rest of the crate. + +## Bad + +```rust +// src/parser/mod.rs +pub mod lexer; +pub mod ast; + +// src/parser/lexer.rs +pub fn internal_helper() { // Visible to entire crate! + // Helper only needed by lexer and ast +} + +pub(crate) struct Token { // Visible to entire crate + // Only parser submodules need this +} +``` + +## Good + +```rust +// src/parser/mod.rs +pub mod lexer; +pub mod ast; + +// Shared types for parser submodules only +pub(super) struct Token { + pub(super) kind: TokenKind, + pub(super) span: Span, +} + +pub(super) fn shared_helper() -> Token { + // Only visible in parser/* +} + +// src/parser/lexer.rs +use super::{Token, shared_helper}; + +pub fn lex(input: &str) -> Vec { + shared_helper(); + // ... +} + +// src/parser/ast.rs +use super::Token; + +pub fn parse(tokens: Vec) -> Ast { + // ... +} +``` + +## Visibility Hierarchy + +``` +src/ +├── lib.rs # crate root +├── parser/ +│ ├── mod.rs # pub(super) items visible here +│ ├── lexer.rs # can use pub(super) from mod.rs +│ └── ast.rs # can use pub(super) from mod.rs +└── codegen.rs # CANNOT see pub(super) parser items +``` + +## Pattern: Layered Visibility + +```rust +// src/database/mod.rs +mod connection; +mod query; +mod pool; + +// Only this module's children can see +pub(super) struct RawConnection { /* ... */ } + +// Entire crate can see +pub(crate) struct Pool { /* ... */ } + +// Everyone can see +pub struct Database { /* ... */ } +``` + +## Pattern: Test Helpers + +```rust +// src/parser/mod.rs +mod lexer; +mod ast; + +#[cfg(test)] +mod tests { + use super::*; + + // Test helper visible only to parser module's tests + pub(super) fn make_test_token() -> Token { + Token { kind: TokenKind::Test, span: Span::dummy() } + } +} + +// src/parser/lexer.rs +#[cfg(test)] +mod tests { + use super::super::tests::make_test_token; + // ... +} +``` + +## Comparison + +| Visibility | Scope | Use Case | +|------------|-------|----------| +| `pub` | Everywhere | Public API | +| `pub(crate)` | Crate-wide | Internal shared utilities | +| `pub(super)` | Parent module | Submodule helpers | +| `pub(in path)` | Specific path | Precise control | +| (private) | Current module | Implementation details | + +## When to Use pub(super) + +- Helper functions shared between sibling modules +- Types used by submodules but not the rest of crate +- Implementation details of a module group +- Test utilities for a module tree + +## See Also + +- [proj-pub-crate-internal](./proj-pub-crate-internal.md) - Crate visibility +- [proj-pub-use-reexport](./proj-pub-use-reexport.md) - Re-export patterns +- [proj-mod-by-feature](./proj-mod-by-feature.md) - Feature organization diff --git a/.opencode/skills/rust-skills/rules/proj-pub-use-reexport.md b/.opencode/skills/rust-skills/rules/proj-pub-use-reexport.md new file mode 100644 index 0000000..02c672b --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-pub-use-reexport.md @@ -0,0 +1,162 @@ +# proj-pub-use-reexport + +> Use pub use for clean public API + +## Why It Matters + +`pub use` re-exports items from submodules at the current module level. This creates a flat, ergonomic public API while keeping internal organization flexible. Users import from one place; you can reorganize internals without breaking their code. + +## Bad + +```rust +// lib.rs - Deep module paths exposed +pub mod error; +pub mod config; +pub mod client; +pub mod types; + +// Users must write: +use my_crate::error::MyError; +use my_crate::config::Config; +use my_crate::client::http::HttpClient; +use my_crate::types::request::Request; +``` + +## Good + +```rust +// lib.rs - Flat public API +mod error; +mod config; +mod client; +mod types; + +pub use error::MyError; +pub use config::Config; +pub use client::http::HttpClient; +pub use types::request::Request; + +// Users write: +use my_crate::{Config, HttpClient, MyError, Request}; +``` + +## Pattern: Selective Re-export + +```rust +// src/lib.rs +mod internal; + +// Only re-export what users need +pub use internal::{ + PublicStruct, + PublicTrait, + public_function, +}; + +// Keep implementation details hidden +// internal::helper_function is NOT exported +``` + +## Pattern: Rename on Re-export + +```rust +mod v1 { + pub struct Client { /* old implementation */ } +} + +mod v2 { + pub struct Client { /* new implementation */ } +} + +// Re-export with clear names +pub use v2::Client; +pub use v1::Client as LegacyClient; +``` + +## Pattern: Prelude Module + +```rust +// src/lib.rs +pub mod prelude { + pub use crate::{ + Config, + Client, + Error, + Request, + Response, + }; +} + +// Users can glob import common items +use my_crate::prelude::*; +``` + +## Pattern: Feature-Gated Re-exports + +```rust +// src/lib.rs +mod core; +mod serde_impl; +mod async_impl; + +pub use core::*; + +#[cfg(feature = "serde")] +pub use serde_impl::*; + +#[cfg(feature = "async")] +pub use async_impl::*; +``` + +## Comparison: Module Structure vs Public API + +```rust +// Internal structure (complex) +src/ +├── transport/ +│ ├── http/ +│ │ └── client.rs // HttpClient +│ └── grpc/ +│ └── client.rs // GrpcClient +├── auth/ +│ └── token.rs // Token +└── lib.rs + +// Public API (flat) +pub use transport::http::client::HttpClient; +pub use transport::grpc::client::GrpcClient; +pub use auth::token::Token; + +// Users see: +my_crate::HttpClient +my_crate::GrpcClient +my_crate::Token +``` + +## Re-export External Types + +```rust +// Re-export dependencies users will need +pub use bytes::Bytes; +pub use http::{Method, StatusCode}; + +// Now users don't need to depend on these crates directly +``` + +## Glob Re-exports + +Use sparingly: + +```rust +// OK for internal modules +pub use internal::*; + +// Careful with external crates - pollutes namespace +pub use serde::*; // Usually too broad +``` + +## See Also + +- [proj-prelude-module](./proj-prelude-module.md) - Prelude pattern +- [proj-pub-crate-internal](./proj-pub-crate-internal.md) - Internal visibility +- [api-non-exhaustive](./api-non-exhaustive.md) - API stability diff --git a/.opencode/skills/rust-skills/rules/proj-workspace-deps.md b/.opencode/skills/rust-skills/rules/proj-workspace-deps.md new file mode 100644 index 0000000..e005d08 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-workspace-deps.md @@ -0,0 +1,186 @@ +# proj-workspace-deps + +> Use workspace dependency inheritance for consistent versions across crates + +## Why It Matters + +Multi-crate workspaces often have dependency version drift—different crates using different versions of the same dependency. Workspace dependency inheritance (Rust 1.64+) lets you declare dependencies once in the workspace `Cargo.toml` and inherit them in member crates, ensuring consistency. + +## Bad + +```toml +# crate-a/Cargo.toml +[dependencies] +serde = "1.0.150" +tokio = "1.25" + +# crate-b/Cargo.toml +[dependencies] +serde = "1.0.188" # Different version! +tokio = "1.32" # Different version! + +# Version drift leads to: +# - Larger binaries (multiple versions) +# - Compilation time increase +# - Subtle behavior differences +``` + +## Good + +```toml +# Root Cargo.toml +[workspace] +members = ["crate-a", "crate-b", "crate-c"] + +[workspace.dependencies] +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.32", features = ["full"] } +thiserror = "1.0" +anyhow = "1.0" +tracing = "0.1" + +# crate-a/Cargo.toml +[dependencies] +serde.workspace = true +tokio.workspace = true + +# crate-b/Cargo.toml +[dependencies] +serde.workspace = true +tokio.workspace = true +thiserror.workspace = true +``` + +## Override Features + +```toml +# Root Cargo.toml +[workspace.dependencies] +tokio = { version = "1.32", features = ["rt-multi-thread"] } + +# crate-a/Cargo.toml - add extra features +[dependencies] +tokio = { workspace = true, features = ["net", "io-util"] } +# Gets both workspace features AND local features + +# crate-b/Cargo.toml - minimal features +[dependencies] +tokio = { workspace = true } # Just workspace features +``` + +## Dev and Build Dependencies + +```toml +# Root Cargo.toml +[workspace.dependencies] +criterion = "0.5" +proptest = "1.0" +trybuild = "1.0" +cc = "1.0" + +# crate-a/Cargo.toml +[dev-dependencies] +criterion.workspace = true +proptest.workspace = true + +[build-dependencies] +cc.workspace = true +``` + +## Internal Crate Dependencies + +```toml +# Root Cargo.toml +[workspace.dependencies] +# Internal crates +my-core = { path = "crates/core" } +my-utils = { path = "crates/utils" } +my-derive = { path = "crates/derive" } + +# External crates +serde = "1.0" + +# crate-a/Cargo.toml +[dependencies] +my-core.workspace = true +my-utils.workspace = true +serde.workspace = true +``` + +## Optional Dependencies + +```toml +# Root Cargo.toml +[workspace.dependencies] +serde = { version = "1.0", optional = true } # Won't work! + +# Optional must be set in member, not workspace +[workspace.dependencies] +serde = "1.0" + +# crate-a/Cargo.toml +[dependencies] +serde = { workspace = true, optional = true } + +[features] +serde = ["dep:serde"] +``` + +## Complete Workspace Example + +```toml +# Root Cargo.toml +[workspace] +members = ["crates/*"] +resolver = "3" # default for the 2024 edition; use "2" for 2021 + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/user/repo" + +[workspace.dependencies] +# Internal +my-core = { path = "crates/core", version = "0.1" } + +# Async +tokio = { version = "1.32", features = ["full"] } +futures = "0.3" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling +thiserror = "1.0" +anyhow = "1.0" + +# Logging +tracing = "0.1" +tracing-subscriber = "0.3" + +# Testing +proptest = "1.0" +criterion = { version = "0.5", features = ["html_reports"] } + +# crates/core/Cargo.toml +[package] +name = "my-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true +``` + +## See Also + +- [proj-lib-main-split](./proj-lib-main-split.md) - Workspace structure +- [api-serde-optional](./api-serde-optional.md) - Optional dependencies +- [lint-deny-correctness](./lint-deny-correctness.md) - Workspace lints diff --git a/.opencode/skills/rust-skills/rules/proj-workspace-large.md b/.opencode/skills/rust-skills/rules/proj-workspace-large.md new file mode 100644 index 0000000..d6587d2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/proj-workspace-large.md @@ -0,0 +1,162 @@ +# proj-workspace-large + +> Use workspaces for large projects + +## Why It Matters + +Cargo workspaces manage multiple related crates under one repository. They share a single `Cargo.lock`, build cache, and can be versioned together. For large projects, workspaces improve build times, enforce modularity, and simplify dependency management. + +## Bad + +``` +# Separate repositories for each crate +my-app-core/ +my-app-cli/ +my-app-server/ +my-app-common/ + +# Each has its own Cargo.lock +# Dependencies may drift +# Cross-crate development is painful +``` + +## Good + +``` +my-app/ +├── Cargo.toml # Workspace root +├── Cargo.lock # Shared lock file +├── crates/ +│ ├── core/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ ├── cli/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ ├── server/ +│ │ ├── Cargo.toml +│ │ └── src/ +│ └── common/ +│ ├── Cargo.toml +│ └── src/ +└── README.md +``` + +## Workspace Cargo.toml + +```toml +# Root Cargo.toml +[workspace] +resolver = "3" # default for the 2024 edition; use "2" for 2021 +members = [ + "crates/core", + "crates/cli", + "crates/server", + "crates/common", +] + +# Shared dependencies - all crates use same versions +[workspace.dependencies] +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +tracing = "0.1" +anyhow = "1.0" + +# Shared lints +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "warn" +``` + +## Member Crate Cargo.toml + +```toml +# crates/core/Cargo.toml +[package] +name = "my-app-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Inherit from workspace +tokio = { workspace = true } +serde = { workspace = true } + +# Crate-specific dependencies +uuid = "1.0" + +# Internal dependency +my-app-common = { path = "../common" } + +[lints] +workspace = true # Inherit workspace lints +``` + +## When to Use Workspaces + +| Scenario | Recommendation | +|----------|----------------| +| Single binary/library | No workspace needed | +| Library + CLI | Maybe, depends on size | +| Multiple related crates | Yes | +| Shared internal libraries | Yes | +| Microservices mono-repo | Yes | +| Plugin architecture | Yes | + +## Benefits + +| Aspect | Single Crate | Workspace | +|--------|--------------|-----------| +| Build cache | Crate only | Shared across all | +| Dependency versions | Per-crate | Synchronized | +| Compile times | Full rebuild | Incremental | +| Modularity | Files/modules | Crate boundaries | +| Publishing | Single crate | Independent | + +## Commands + +```bash +# Build all crates +cargo build --workspace + +# Build specific crate +cargo build -p my-app-core + +# Test all crates +cargo test --workspace + +# Run specific binary +cargo run -p my-app-cli + +# Check all +cargo check --workspace +``` + +## Pattern: Virtual Workspace + +Root Cargo.toml is workspace-only (no `[package]`): + +```toml +[workspace] +members = ["crates/*"] + +[workspace.dependencies] +# ... +``` + +## Pattern: Crate Interdependencies + +```toml +# crates/server/Cargo.toml +[dependencies] +my-app-core = { path = "../core" } +my-app-common = { path = "../common" } +``` + +## See Also + +- [proj-workspace-deps](./proj-workspace-deps.md) - Workspace dependencies +- [proj-bin-dir](./proj-bin-dir.md) - Multiple binaries +- [proj-lib-main-split](./proj-lib-main-split.md) - Lib/main separation diff --git a/.opencode/skills/rust-skills/rules/serde-custom-with.md b/.opencode/skills/rust-skills/rules/serde-custom-with.md new file mode 100644 index 0000000..7fe6563 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/serde-custom-with.md @@ -0,0 +1,80 @@ +# serde-custom-with + +> Customize a field's (de)serialization with `with` / `serialize_with` / `deserialize_with` + +## Why It Matters + +Some types have a natural Rust representation that differs from what the wire format expects: a `Duration` stored as whole seconds, raw bytes encoded as base64, a timestamp as an ISO-8601 string. Changing the field type just to satisfy serde pollutes the domain model. A `#[serde(with = "module")]` (or the one-sided `serialize_with`/`deserialize_with`) attributes point serde at custom conversion functions without touching the field type. + +## Bad + +```rust +use serde::{Serialize, Deserialize}; + +// Forces a u64 "seconds" field instead of the natural Duration type +#[derive(Serialize, Deserialize, Debug)] +struct Task { + name: String, + timeout_secs: u64, // callers must manually convert to/from Duration +} +``` + +## Good + +```rust +use serde::{Serialize, Deserialize, Serializer, Deserializer}; +use std::time::Duration; + +mod duration_secs { + use super::*; + + pub fn serialize(duration: &Duration, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u64(duration.as_secs()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let secs = u64::deserialize(deserializer)?; + Ok(Duration::from_secs(secs)) + } +} + +#[derive(Serialize, Deserialize, Debug)] +struct Task { + name: String, + // wire format: {"name":"...", "timeout": 30} + // Rust type: Duration — no manual conversion needed at call sites + #[serde(with = "duration_secs", rename = "timeout")] + timeout: Duration, +} + +// One-sided variants when you only need to customize one direction: +#[derive(Serialize, Deserialize, Debug)] +struct Report { + title: String, + #[serde(serialize_with = "duration_secs::serialize")] + elapsed: Duration, + // deserialize_with leaves the deserialize direction at its default +} +``` + +## Key Points + +- `#[serde(with = "module")]` requires the module to expose both `pub fn serialize(&T, S) -> Result` and `pub fn deserialize<'de, D>(D) -> Result`. +- Use `serialize_with = "path"` or `deserialize_with = "path"` to customize only one direction, leaving the other at its derived default. +- The module approach (`with`) is more reusable: define it once, apply it anywhere. Some crates (e.g. `time`, `chrono`, `uuid`) ship ready-made `with` modules in their serde feature. +- For widespread custom representations, a newtype wrapper with its own `Serialize`/`Deserialize` impl is often cleaner than repeating `#[serde(with = "...")]` everywhere. + +## Caveats + +The `with` module functions must match the exact signatures serde expects. The `serialize` function receives `&T` (a reference), not `T`. + +## See Also + +- [serde-try-from-validate](serde-try-from-validate.md) - validate while deserializing with TryFrom +- [type-newtype-validated](type-newtype-validated.md) - newtypes for validated data diff --git a/.opencode/skills/rust-skills/rules/serde-default-compat.md b/.opencode/skills/rust-skills/rules/serde-default-compat.md new file mode 100644 index 0000000..d2b8ed2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/serde-default-compat.md @@ -0,0 +1,71 @@ +# serde-default-compat + +> Use `#[serde(default)]` for optional and backward-compatible fields + +## Why It Matters + +Without `#[serde(default)]`, any field missing from an incoming payload causes deserialization to fail with a "missing field" error. When you add new fields to a struct over time, older payloads that predate those fields will suddenly break. Marking fields (or the whole container) with `#[serde(default)]` fills missing keys from the type's `Default` implementation, enabling graceful forward compatibility. + +## Bad + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug)] +struct Config { + host: String, + port: u16, + timeout_secs: u64, // newly added — old configs don't have this, so they fail + retries: u32, // newly added — same problem +} +``` + +## Good + +```rust +use serde::{Serialize, Deserialize}; + +fn default_timeout() -> u64 { 30 } +fn default_retries() -> u32 { 3 } + +#[derive(Serialize, Deserialize, Debug)] +struct Config { + host: String, + port: u16, + // fills from Default::default() (0u64) if missing + #[serde(default)] + timeout_secs: u64, + // fills from the named function if missing + #[serde(default = "default_retries")] + retries: u32, + // fills from Default (None) if missing + #[serde(default)] + tls_cert_path: Option, +} + +// Alternatively, annotate the whole container so every field uses its Default: +#[derive(Serialize, Deserialize, Debug, Default)] +#[serde(default)] +struct FeatureFlags { + enable_caching: bool, // false + enable_metrics: bool, // false + max_connections: u32, // 0 +} +``` + +## Key Points + +- **Field-level** `#[serde(default)]` — fills only that field; other required fields still fail if absent. +- **Field-level** `#[serde(default = "path")]` — calls a user-supplied function `fn() -> T`; use this when `Default` would produce the wrong value (e.g. `timeout = 30` rather than `0`). +- **Container-level** `#[serde(default)]` — applies to every field; the struct must implement `Default` (or `#[derive(Default)]`). Convenient for all-optional structs like feature-flag configs. +- The default function signature must be `fn() -> T` with no arguments and no generics. +- `#[serde(default)]` only affects **deserialization**. It has no effect on serialization; pair with `#[serde(skip_serializing_if = "...")]` if you also want to omit defaults on the way out. + +## Caveats + +Using container-level `#[serde(default)]` makes every field optional to deserializers, which can hide typos in field names. Prefer field-level annotation when only some fields are backward-compatible additions. + +## See Also + +- [serde-skip-empty](serde-skip-empty.md) - omit None/empty values during serialization +- [api-default-impl](api-default-impl.md) - implement `Default` for sensible defaults diff --git a/.opencode/skills/rust-skills/rules/serde-deny-unknown-fields.md b/.opencode/skills/rust-skills/rules/serde-deny-unknown-fields.md new file mode 100644 index 0000000..27c749f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/serde-deny-unknown-fields.md @@ -0,0 +1,74 @@ +# serde-deny-unknown-fields + +> Reject unexpected keys with `#[serde(deny_unknown_fields)]` + +## Why It Matters + +By default, serde silently discards any key in the input that doesn't match a struct field. For user-facing config files and strict API contracts this is dangerous: a typo like `"timout_secs"` passes validation without error, and the intended field is simply never set. `#[serde(deny_unknown_fields)]` turns unrecognized keys into hard errors, surfacing mistakes immediately. + +## Bad + +```rust +use serde::{Serialize, Deserialize}; +use serde_json; + +#[derive(Serialize, Deserialize, Debug)] +struct ServerConfig { + host: String, + port: u16, + timeout_secs: u64, +} + +fn main() { + // "timout_secs" is a typo — serde silently ignores it, timeout stays 0 + let json = r#"{"host":"localhost","port":8080,"timout_secs":30}"#; + let cfg: ServerConfig = serde_json::from_str(json).unwrap(); + println!("{:?}", cfg); // timeout_secs is 0, not 30 +} +``` + +## Good + +```rust +use serde::{Serialize, Deserialize}; +use serde_json; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(deny_unknown_fields)] +struct ServerConfig { + host: String, + port: u16, + timeout_secs: u64, +} + +fn parse_config(json: &str) -> Result { + serde_json::from_str(json) +} + +fn main() { + // Typo is now a hard error + let bad = r#"{"host":"localhost","port":8080,"timout_secs":30}"#; + assert!(parse_config(bad).is_err()); + + // Correct input still works + let good = r#"{"host":"localhost","port":8080,"timeout_secs":30}"#; + let cfg = parse_config(good).unwrap(); + println!("{:?}", cfg); +} +``` + +## Key Points + +- Apply `deny_unknown_fields` to **config-file structs**, **request/response DTOs**, and any struct that forms a public API contract where typos must be caught. +- Skip it for **flexible or extensible** structs where callers are expected to pass extra metadata. Use `#[serde(flatten)]` with a `HashMap` catch-all instead. +- The attribute works with JSON, TOML, YAML, and most self-describing formats. +- Error messages name the unexpected field, making them actionable for end users. + +## Caveats + +`#[serde(deny_unknown_fields)]` is **incompatible with `#[serde(flatten)]`** on the same struct. `flatten` needs to pass unmatched keys down to the flattened field; `deny_unknown_fields` intercepts them first. If you need both behaviors, split the struct or use a two-pass approach (deserialize into a `serde_json::Value`, then convert). + +## See Also + +- [serde-flatten](serde-flatten.md) - inline nested structs or collect extra keys (incompatible with deny_unknown_fields) +- [api-parse-dont-validate](api-parse-dont-validate.md) - parse into validated types at boundaries diff --git a/.opencode/skills/rust-skills/rules/serde-enum-representation.md b/.opencode/skills/rust-skills/rules/serde-enum-representation.md new file mode 100644 index 0000000..4b7f3f2 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/serde-enum-representation.md @@ -0,0 +1,85 @@ +# serde-enum-representation + +> Choose enum tagging deliberately: externally, internally, adjacently tagged, or untagged + +## Why It Matters + +Serde's default enum representation (externally tagged) wraps every variant in an object keyed by variant name. That format can clash with external APIs, event systems, or config schemas that use a discriminator field. Picking the wrong tagging strategy produces a mismatch between your wire format and the expected schema, leading to silent parse failures or round-trip data loss. + +## Bad + +```rust +use serde::{Serialize, Deserialize}; + +// Default: externally tagged. Serializes as {"Circle":{"radius":5.0}} +// Most REST APIs expect {"type":"circle","radius":5.0} instead. +#[derive(Serialize, Deserialize, Debug)] +enum Shape { + Circle { radius: f64 }, + Rectangle { width: f64, height: f64 }, +} +``` + +## Good + +```rust +use serde::{Serialize, Deserialize}; + +// Externally tagged (default) — {"Circle":{"radius":5.0}} +// Good for: Rust-to-Rust, when the variant name IS the key +#[derive(Serialize, Deserialize, Debug)] +enum ShapeExternal { + Circle { radius: f64 }, + Rectangle { width: f64, height: f64 }, +} + +// Internally tagged — {"type":"Circle","radius":5.0} +// Good for: REST APIs with a discriminator field; all variants must be structs/maps +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +enum ShapeInternal { + Circle { radius: f64 }, + Rectangle { width: f64, height: f64 }, +} + +// Adjacently tagged — {"t":"Circle","c":{"radius":5.0}} +// Good for: when variants may contain primitives or vecs (internally tagged can't handle those) +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "t", content = "c")] +enum ShapeAdjacent { + Circle { radius: f64 }, + Rectangle { width: f64, height: f64 }, + Count(u32), // tuple variant — works here, but NOT with internally tagged +} + +// Untagged — {"radius":5.0} +// Good for: wrapping a small number of clearly distinct types; avoid otherwise +#[derive(Serialize, Deserialize, Debug)] +#[serde(untagged)] +enum Value { + Integer(i64), + Float(f64), + Text(String), +} +``` + +## Comparison Table + +| Strategy | Attribute | Wire form (Circle) | Tuple variant | +|---|---|---|---| +| Externally tagged | (default) | `{"Circle":{"radius":5}}` | yes | +| Internally tagged | `#[serde(tag = "type")]` | `{"type":"Circle","radius":5}` | no | +| Adjacently tagged | `#[serde(tag="t", content="c")]` | `{"t":"Circle","c":{"radius":5}}` | yes | +| Untagged | `#[serde(untagged)]` | `{"radius":5}` | yes | + +## Caveats + +- **Untagged** deserializes by trying each variant in declaration order; it is slower, can silently pick the wrong variant, and produces generic error messages. Reserve it for small, structurally distinct sets (numbers vs strings). +- **Internally tagged** cannot represent tuple variants or newtype variants wrapping primitives/vecs — use adjacently tagged instead. +- All variants in an internally tagged enum must serialize as maps (structs or `HashMap`). + +## See Also + +- [type-enum-states](type-enum-states.md) - use enums for mutually exclusive states +- [api-non-exhaustive](api-non-exhaustive.md) - use `#[non_exhaustive]` for future-proof enums +- [serde-flatten](serde-flatten.md) - inline nested struct fields into parent diff --git a/.opencode/skills/rust-skills/rules/serde-flatten.md b/.opencode/skills/rust-skills/rules/serde-flatten.md new file mode 100644 index 0000000..f854c3e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/serde-flatten.md @@ -0,0 +1,86 @@ +# serde-flatten + +> Inline nested structs or capture extra keys with `#[serde(flatten)]` + +## Why It Matters + +APIs frequently share a common set of fields across multiple message types (pagination metadata, audit timestamps, error envelopes). Duplicating those fields in every struct is fragile. `#[serde(flatten)]` merges a nested struct's fields directly into the parent's wire representation, so the JSON looks flat while the Rust code stays modular. The same attribute can also collect all unknown keys into a `HashMap`, giving you a "catch-all" bucket. + +## Bad + +```rust +use serde::{Serialize, Deserialize}; + +// Duplicated pagination fields in every list response +#[derive(Serialize, Deserialize, Debug)] +struct UserListResponse { + users: Vec, + page: u32, + per_page: u32, + total: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +struct PostListResponse { + posts: Vec, + page: u32, // copy-paste + per_page: u32, // copy-paste + total: u64, // copy-paste +} +``` + +## Good + +```rust +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize, Debug)] +struct Pagination { + page: u32, + per_page: u32, + total: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +struct UserListResponse { + users: Vec, + #[serde(flatten)] + pagination: Pagination, +} + +#[derive(Serialize, Deserialize, Debug)] +struct PostListResponse { + posts: Vec, + #[serde(flatten)] + pagination: Pagination, +} + +// Capture unknown/dynamic keys into a map +#[derive(Serialize, Deserialize, Debug)] +struct FlexibleConfig { + name: String, + version: u32, + #[serde(flatten)] + extra: HashMap, +} +``` + +`UserListResponse` serializes to `{"users":[...],"page":1,"per_page":20,"total":100}` — the `Pagination` fields appear at the top level, not nested under a `"pagination"` key. + +## Key Points + +- Works with both structs and maps. The flattened type must implement `Serialize`/`Deserialize`. +- Multiple `#[serde(flatten)]` fields are allowed on the same struct, as long as the key sets don't overlap at runtime. +- `HashMap` (or any map type) as a flattened field acts as a catch-all for keys not matched by other fields — useful for extensible configs and pass-through proxies. + +## Caveats + +- `#[serde(flatten)]` is **incompatible with `#[serde(deny_unknown_fields)]`** on the same struct. The two attributes conflict because `flatten` needs to forward unrecognized keys. +- Flattening uses a less efficient internal deserialization path (content buffering) compared to normal struct deserialization. Avoid it on extremely hot deserialization paths. +- `flatten` does not work with non-self-describing formats like `bincode` that don't support maps with arbitrary keys. + +## See Also + +- [serde-deny-unknown-fields](serde-deny-unknown-fields.md) - reject unexpected keys (incompatible with flatten) +- [serde-enum-representation](serde-enum-representation.md) - choose enum tagging strategy diff --git a/.opencode/skills/rust-skills/rules/serde-rename-all.md b/.opencode/skills/rust-skills/rules/serde-rename-all.md new file mode 100644 index 0000000..9f890ac --- /dev/null +++ b/.opencode/skills/rust-skills/rules/serde-rename-all.md @@ -0,0 +1,72 @@ +# serde-rename-all + +> Match the external naming convention with `#[serde(rename_all = ...)]` + +## Why It Matters + +Rust fields are `snake_case` by convention, but JSON APIs, GraphQL responses, and config formats often use `camelCase`, `kebab-case`, or `SCREAMING_SNAKE_CASE`. Renaming every field individually with `#[serde(rename = "...")]` is noisy and error-prone. A single `#[serde(rename_all = "camelCase")]` on the container keeps Rust idiomatic and the wire format correct in one declaration. + +## Bad + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize)] +struct UserProfile { + #[serde(rename = "firstName")] + first_name: String, + #[serde(rename = "lastName")] + last_name: String, + #[serde(rename = "emailAddress")] + email_address: String, + #[serde(rename = "isActive")] + is_active: bool, +} +``` + +## Good + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UserProfile { + first_name: String, + last_name: String, + email_address: String, + is_active: bool, + // per-field override: "type" is a keyword in Rust, so we rename it explicitly + #[serde(rename = "type")] + user_type: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum Status { + Active, + Inactive, + PendingVerification, +} +``` + +## Key Points + +`rename_all` applies to both serialization and deserialization. Supported values: + +| Value | Example field `foo_bar` becomes | +|---|---| +| `"camelCase"` | `fooBar` | +| `"PascalCase"` | `FooBar` | +| `"kebab-case"` | `foo-bar` | +| `"SCREAMING_SNAKE_CASE"` | `FOO_BAR` | +| `"snake_case"` | `foo_bar` (identity) | +| `"UPPERCASE"` | `FOOBAR` | +| `"lowercase"` | `foobar` | + +A field-level `#[serde(rename = "...")]` always wins over the container-level `rename_all`, so use it for exceptions like reserved words or one-off mismatches. For enums, `rename_all` applies to variant names. + +## See Also + +- [serde-default-compat](serde-default-compat.md) - add default values for backward-compatible fields +- [api-serde-optional](api-serde-optional.md) - gate serde behind a feature flag in libraries diff --git a/.opencode/skills/rust-skills/rules/serde-skip-empty.md b/.opencode/skills/rust-skills/rules/serde-skip-empty.md new file mode 100644 index 0000000..2a868f4 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/serde-skip-empty.md @@ -0,0 +1,76 @@ +# serde-skip-empty + +> Omit empty fields with `skip_serializing_if` + +## Why It Matters + +Serializing `None` values as `null` and empty collections as `[]` bloats payloads, clutters logs, and can confuse clients that distinguish between a missing key and an explicit null. `#[serde(skip_serializing_if = "predicate")]` conditionally drops a field from output when the predicate returns true, keeping the wire format lean. `#[serde(skip)]` goes further and excludes a field from both serialization and deserialization entirely. + +## Bad + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug)] +struct ApiResponse { + id: u64, + name: String, + description: Option, // serializes as null when None + tags: Vec, // serializes as [] when empty + error: Option, // serializes as null when None +} +``` + +Produces: `{"id":1,"name":"Alice","description":null,"tags":[],"error":null}` + +## Good + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug)] +struct ApiResponse { + id: u64, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + // internal field excluded entirely from the wire format + #[serde(skip)] + _cache_key: Option, +} + +impl Default for ApiResponse { + fn default() -> Self { + ApiResponse { + id: 0, + name: String::new(), + description: None, + tags: Vec::new(), + error: None, + _cache_key: None, + } + } +} +``` + +Produces: `{"id":1,"name":"Alice"}` — absent fields are simply omitted. + +## Key Points + +- `skip_serializing_if` takes any path resolving to `fn(&T) -> bool`. Common choices: + - `Option::is_none` for `Option` + - `Vec::is_empty` / `<[T]>::is_empty` for collections + - `String::is_empty` for strings + - A custom function for more complex conditions +- `#[serde(skip)]` removes the field from **both** directions. The type must implement `Default` so deserialization can still construct the struct (serde fills it with `Default::default()`). +- `#[serde(skip_serializing)]` skips only on the way out; `#[serde(skip_deserializing)]` skips only on the way in — useful when reading legacy fields you no longer write. +- Pair `skip_serializing_if` with `#[serde(default)]` so that a missing key on deserialization also produces the empty/`None` value rather than a hard error. + +## See Also + +- [serde-default-compat](serde-default-compat.md) - fill missing fields from Default on deserialization +- [serde-rename-all](serde-rename-all.md) - match external naming conventions with rename_all diff --git a/.opencode/skills/rust-skills/rules/serde-try-from-validate.md b/.opencode/skills/rust-skills/rules/serde-try-from-validate.md new file mode 100644 index 0000000..cf00424 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/serde-try-from-validate.md @@ -0,0 +1,114 @@ +# serde-try-from-validate + +> Validate while deserializing with `#[serde(try_from = "Raw")]` + +## Why It Matters + +Running validation after deserialization means you can construct an invalid value in memory, even briefly. Parse-don't-validate says to make invalid states unrepresentable. `#[serde(try_from = "Raw")]` wires the deserializer directly through a `TryFrom` conversion: serde reads the raw type, then your conversion either produces the validated value or returns an error — so an invalid instance is never constructed. + +## Bad + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct Email(String); + +impl Email { + fn new(s: String) -> Result { + if s.contains('@') { + Ok(Email(s)) + } else { + Err(format!("invalid email: {s}")) + } + } +} + +// Caller must validate after deserialization — easy to forget: +fn process(json: &str) -> Result<(), Box> { + let email: Email = serde_json::from_str(json)?; + // nothing stops "notanemail" from being deserialized and used + println!("{:?}", email); + Ok(()) +} +``` + +## Good + +```rust +use serde::{Serialize, Deserialize}; +use serde_json; + +#[derive(Debug, Clone)] +struct Email(String); + +impl TryFrom for Email { + type Error = String; + + fn try_from(s: String) -> Result { + if s.contains('@') && !s.starts_with('@') && !s.ends_with('@') { + Ok(Email(s)) + } else { + Err(format!("invalid email address: {s}")) + } + } +} + +// For the serialize direction: implement From for String, then add into = "String" +impl From for String { + fn from(e: Email) -> String { + e.0 + } +} + +// Deserialize: serde reads a String, then calls Email::try_from — error if invalid. +// Serialize: serde calls String::from(email) — converts back to the raw type. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +struct ValidatedEmail(String); + +impl TryFrom for ValidatedEmail { + type Error = String; + + fn try_from(s: String) -> Result { + if s.contains('@') && !s.starts_with('@') && !s.ends_with('@') { + Ok(ValidatedEmail(s)) + } else { + Err(format!("invalid email address: {s}")) + } + } +} + +impl From for String { + fn from(e: ValidatedEmail) -> String { + e.0 + } +} + +fn main() { + // Valid email round-trips fine + let good = serde_json::from_str::("\"user@example.com\"").unwrap(); + println!("{}", serde_json::to_string(&good).unwrap()); + + // Invalid email is rejected at parse time — never enters the program + let bad = serde_json::from_str::("\"notanemail\""); + assert!(bad.is_err()); +} +``` + +## Key Points + +- `#[serde(try_from = "Raw")]` — serde deserializes `Raw`, then calls `T::try_from(raw)?`. Any error from `TryFrom` becomes a serde deserialization error. +- `#[serde(into = "Raw")]` — serde calls `Raw::from(value)` for serialization. The type must also implement `Clone` because serde may clone before converting. +- The `Raw` type must itself implement `Deserialize` (for `try_from`) and the target must implement `Into` / `From for Raw` (for `into`). +- This pattern is composable with other rules: define the `TryFrom` logic once and reuse it in both serde and non-serde code paths (e.g. CLI argument parsing, form validation). + +## Caveats + +`#[serde(try_from)]` and `#[serde(into)]` are mutually exclusive with deriving `Serialize`/`Deserialize` via field-by-field derivation on the same struct — the attribute replaces the derived impl entirely. + +## See Also + +- [api-parse-dont-validate](api-parse-dont-validate.md) - parse into validated types at boundaries +- [type-newtype-validated](type-newtype-validated.md) - newtypes for validated data +- [serde-custom-with](serde-custom-with.md) - customize field (de)serialization with with modules diff --git a/.opencode/skills/rust-skills/rules/test-arrange-act-assert.md b/.opencode/skills/rust-skills/rules/test-arrange-act-assert.md new file mode 100644 index 0000000..0ad7cd5 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-arrange-act-assert.md @@ -0,0 +1,160 @@ +# test-arrange-act-assert + +> Structure tests with clear Arrange, Act, Assert sections + +## Why It Matters + +The AAA pattern makes tests readable and maintainable. Each section has a clear purpose: set up test data, execute the code under test, verify the results. This structure helps identify what's being tested and makes tests easier to debug when they fail. + +## Bad + +```rust +#[test] +fn test_user() { + assert_eq!(User::new("alice", "alice@example.com").unwrap().name(), "alice"); + assert!(User::new("", "email@example.com").is_err()); + let u = User::new("bob", "bob@example.com").unwrap(); + assert!(u.validate()); + assert_eq!(u.email(), "bob@example.com"); +} +// Multiple concerns, hard to understand, hard to debug +``` + +## Good + +```rust +#[test] +fn new_user_has_correct_name() { + // Arrange + let name = "alice"; + let email = "alice@example.com"; + + // Act + let user = User::new(name, email).unwrap(); + + // Assert + assert_eq!(user.name(), "alice"); +} + +#[test] +fn user_creation_fails_with_empty_name() { + // Arrange + let name = ""; + let email = "email@example.com"; + + // Act + let result = User::new(name, email); + + // Assert + assert!(result.is_err()); + assert!(matches!(result, Err(UserError::EmptyName))); +} +``` + +## With Comments + +```rust +#[test] +fn order_total_includes_tax() { + // Arrange + let mut order = Order::new(); + order.add_item(Item::new("Widget", 100.00)); + order.add_item(Item::new("Gadget", 50.00)); + let tax_rate = 0.10; + + // Act + let total = order.calculate_total(tax_rate); + + // Assert + let expected = (100.00 + 50.00) * 1.10; + assert_eq!(total, expected); +} +``` + +## Complex Arrange + +```rust +#[test] +fn search_returns_matching_documents() { + // Arrange + let mut index = SearchIndex::new(); + index.add_document(Document::new(1, "rust programming")); + index.add_document(Document::new(2, "python programming")); + index.add_document(Document::new(3, "rust web development")); + + let query = Query::new("rust"); + + // Act + let results = index.search(&query); + + // Assert + assert_eq!(results.len(), 2); + assert!(results.iter().any(|d| d.id == 1)); + assert!(results.iter().any(|d| d.id == 3)); +} +``` + +## Async Tests + +```rust +#[tokio::test] +async fn fetch_user_returns_user_data() { + // Arrange + let client = TestClient::new(); + let user_id = 42; + + // Act + let result = client.fetch_user(user_id).await; + + // Assert + assert!(result.is_ok()); + let user = result.unwrap(); + assert_eq!(user.id, user_id); +} +``` + +## Helper Functions + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Arrange helpers + fn create_test_user() -> User { + User::new("test", "test@example.com").unwrap() + } + + fn create_order_with_items(items: &[(&str, f64)]) -> Order { + let mut order = Order::new(); + for (name, price) in items { + order.add_item(Item::new(name, *price)); + } + order + } + + // Assert helpers + fn assert_order_total(order: &Order, expected: f64) { + let total = order.calculate_total(0.0); + assert!((total - expected).abs() < 0.01); + } + + #[test] + fn order_total_sums_items() { + // Arrange + let order = create_order_with_items(&[ + ("A", 10.0), + ("B", 20.0), + ]); + + // Act & Assert + assert_order_total(&order, 30.0); + } +} +``` + +## See Also + +- [test-descriptive-names](./test-descriptive-names.md) - Test naming +- [test-fixture-raii](./test-fixture-raii.md) - Test setup/teardown +- [test-mock-traits](./test-mock-traits.md) - Mocking dependencies diff --git a/.opencode/skills/rust-skills/rules/test-cfg-test-module.md b/.opencode/skills/rust-skills/rules/test-cfg-test-module.md new file mode 100644 index 0000000..53f7eb7 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-cfg-test-module.md @@ -0,0 +1,151 @@ +# test-cfg-test-module + +> Put unit tests in `#[cfg(test)] mod tests { }` within each module + +## Why It Matters + +The `#[cfg(test)]` attribute ensures test code is only compiled during `cargo test`, not in release builds. Placing tests in a `tests` submodule within the same file keeps tests close to the code they test while maintaining separation. This is Rust's idiomatic unit test pattern. + +## Bad + +```rust +// Tests without cfg(test) - compiled into release binary +mod tests { + #[test] + fn test_something() { ... } // Included in release build! +} + +// Tests in separate file without access to private items +// src/my_module.rs +fn private_helper() { ... } + +// tests/my_module_test.rs +// Can't access private_helper! +``` + +## Good + +```rust +// src/my_module.rs + +fn public_api() -> i32 { + private_helper() * 2 +} + +fn private_helper() -> i32 { + 21 +} + +#[cfg(test)] +mod tests { + use super::*; // Access to private items + + #[test] + fn test_public_api() { + assert_eq!(public_api(), 42); + } + + #[test] + fn test_private_helper() { + assert_eq!(private_helper(), 21); // Can test private! + } +} +``` + +## Module Structure + +```rust +// src/lib.rs +mod parser; +mod lexer; +mod ast; + +// src/parser.rs +pub fn parse(input: &str) -> Result { + let tokens = tokenize(input)?; + build_ast(tokens) +} + +fn tokenize(input: &str) -> Result, Error> { ... } +fn build_ast(tokens: Vec) -> Result { ... } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple() { + let ast = parse("1 + 2").unwrap(); + assert_eq!(ast.evaluate(), 3); + } + + #[test] + fn test_tokenize() { + let tokens = tokenize("1 + 2").unwrap(); + assert_eq!(tokens.len(), 3); + } +} +``` + +## Test Helpers + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Test-only helpers + fn create_test_data() -> Data { + Data { + id: 1, + name: "test".into(), + values: vec![1, 2, 3], + } + } + + fn assert_valid(data: &Data) { + assert!(data.id > 0); + assert!(!data.name.is_empty()); + } + + #[test] + fn test_processing() { + let data = create_test_data(); + let result = process(&data); + assert_valid(&result); + } +} +``` + +## Multiple Test Modules + +```rust +// For larger test suites, use submodules +#[cfg(test)] +mod tests { + use super::*; + + mod parsing { + use super::*; + + #[test] + fn test_parse_number() { ... } + + #[test] + fn test_parse_string() { ... } + } + + mod validation { + use super::*; + + #[test] + fn test_validate_range() { ... } + } +} +``` + +## See Also + +- [test-use-super](./test-use-super.md) - Importing from parent module +- [test-integration-dir](./test-integration-dir.md) - Integration tests +- [test-descriptive-names](./test-descriptive-names.md) - Test naming diff --git a/.opencode/skills/rust-skills/rules/test-criterion-bench.md b/.opencode/skills/rust-skills/rules/test-criterion-bench.md new file mode 100644 index 0000000..cdc4423 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-criterion-bench.md @@ -0,0 +1,171 @@ +# test-criterion-bench + +> Use `criterion` for benchmarking + +## Why It Matters + +Criterion provides statistically rigorous benchmarking with warmup, multiple iterations, outlier detection, and comparison between runs. It's far more reliable than simple timing with `Instant::now()`. + +## Setup + +```toml +# Cargo.toml +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "my_benchmark" +harness = false +``` + +## Basic Benchmark + +```rust +// benches/my_benchmark.rs +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn fibonacci(n: u64) -> u64 { + match n { + 0 => 0, + 1 => 1, + n => fibonacci(n - 1) + fibonacci(n - 2), + } +} + +fn bench_fibonacci(c: &mut Criterion) { + c.bench_function("fib 20", |b| { + b.iter(|| fibonacci(black_box(20))) + }); +} + +criterion_group!(benches, bench_fibonacci); +criterion_main!(benches); +``` + +## black_box is Critical + +```rust +// BAD: Compiler may optimize away the computation +b.iter(|| fibonacci(20)); // Result unused, might be eliminated + +// GOOD: black_box prevents optimization +b.iter(|| fibonacci(black_box(20))); + +// Also wrap the result if needed +b.iter(|| black_box(fibonacci(black_box(20)))); +``` + +## Comparing Implementations + +```rust +fn bench_comparison(c: &mut Criterion) { + let mut group = c.benchmark_group("String concat"); + + let data = "hello"; + + group.bench_function("format!", |b| { + b.iter(|| format!("{}{}", black_box(data), " world")) + }); + + group.bench_function("push_str", |b| { + b.iter(|| { + let mut s = String::from(black_box(data)); + s.push_str(" world"); + s + }) + }); + + group.bench_function("concat", |b| { + b.iter(|| [black_box(data), " world"].concat()) + }); + + group.finish(); +} +``` + +## Parameterized Benchmarks + +```rust +fn bench_vec_push(c: &mut Criterion) { + let mut group = c.benchmark_group("Vec::push"); + + for size in [100, 1000, 10000].iter() { + group.bench_with_input( + BenchmarkId::from_parameter(size), + size, + |b, &size| { + b.iter(|| { + let mut v = Vec::new(); + for i in 0..size { + v.push(black_box(i)); + } + v + }); + }, + ); + } + + group.finish(); +} +``` + +## Throughput Measurement + +```rust +use criterion::Throughput; + +fn bench_parse(c: &mut Criterion) { + let input = "a]ong string to parse..."; + + let mut group = c.benchmark_group("Parser"); + group.throughput(Throughput::Bytes(input.len() as u64)); + + group.bench_function("parse", |b| { + b.iter(|| parse(black_box(input))) + }); + + group.finish(); +} +``` + +## Running Benchmarks + +```bash +# Run all benchmarks +cargo bench + +# Run specific benchmark +cargo bench -- fib + +# Save baseline for comparison +cargo bench -- --save-baseline main + +# Compare against baseline +cargo bench -- --baseline main +``` + +## Evidence from tokio + +```rust +// https://github.com/tokio-rs/tokio/blob/master/benches/sync_mpsc.rs +use criterion::{criterion_group, criterion_main, Criterion}; + +fn send_data( + g: &mut BenchmarkGroup, + prefix: &str +) { + let rt = rt(); + g.bench_function(format!("{prefix}_{SIZE}"), |b| { + b.iter(|| { + let (tx, mut rx) = mpsc::channel::(SIZE); + rt.block_on(tx.send(T::default())).unwrap(); + rt.block_on(rx.recv()).unwrap(); + }) + }); +} +``` + +## See Also + +- [perf-profile-first](perf-profile-first.md) - Profile before optimizing +- [perf-black-box-bench](perf-black-box-bench.md) - Use black_box in benchmarks diff --git a/.opencode/skills/rust-skills/rules/test-descriptive-names.md b/.opencode/skills/rust-skills/rules/test-descriptive-names.md new file mode 100644 index 0000000..de77cc7 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-descriptive-names.md @@ -0,0 +1,142 @@ +# test-descriptive-names + +> Use descriptive test names that explain what is being tested + +## Why It Matters + +Test names appear in test output and serve as documentation. A good test name tells you what behavior is being verified without reading the test body. When a test fails, a descriptive name immediately tells you what broke. + +## Bad + +```rust +#[test] +fn test1() { ... } + +#[test] +fn test_parse() { ... } // Parse what? What behavior? + +#[test] +fn it_works() { ... } + +#[test] +fn test_function() { ... } + +// Failure output: "test test_parse ... FAILED" +// What failed? No idea. +``` + +## Good + +```rust +#[test] +fn parse_returns_error_for_empty_input() { ... } + +#[test] +fn parse_handles_unicode_characters() { ... } + +#[test] +fn user_creation_requires_valid_email() { ... } + +#[test] +fn expired_token_is_rejected() { ... } + +// Failure output: "test parse_returns_error_for_empty_input ... FAILED" +// Immediately know what broke! +``` + +## Naming Patterns + +```rust +// Pattern: function_condition_expected_result +#[test] +fn parse_valid_json_returns_document() { ... } + +#[test] +fn parse_invalid_json_returns_syntax_error() { ... } + +// Pattern: scenario_expectation +#[test] +fn empty_cart_has_zero_total() { ... } + +#[test] +fn adding_item_increases_cart_total() { ... } + +// Pattern: when_given_then (BDD-style) +#[test] +fn when_user_not_found_then_returns_404() { ... } +``` + +## Edge Cases + +```rust +#[test] +fn handles_empty_string() { ... } + +#[test] +fn handles_max_length_input() { ... } + +#[test] +fn handles_unicode_emoji() { ... } + +#[test] +fn handles_null_bytes() { ... } + +#[test] +fn handles_concurrent_access() { ... } +``` + +## Error Cases + +```rust +#[test] +fn rejects_negative_quantity() { ... } + +#[test] +fn returns_error_for_invalid_email_format() { ... } + +#[test] +fn panics_on_double_initialization() { ... } + +#[test] +fn timeout_returns_timeout_error() { ... } +``` + +## Module Organization + +```rust +#[cfg(test)] +mod tests { + use super::*; + + mod parsing { + use super::*; + + #[test] + fn accepts_valid_json() { ... } + + #[test] + fn rejects_trailing_comma() { ... } + } + + mod validation { + use super::*; + + #[test] + fn requires_name_field() { ... } + + #[test] + fn email_must_contain_at_symbol() { ... } + } +} + +// Test output: +// tests::parsing::accepts_valid_json +// tests::parsing::rejects_trailing_comma +// tests::validation::requires_name_field +``` + +## See Also + +- [test-arrange-act-assert](./test-arrange-act-assert.md) - Test structure +- [test-cfg-test-module](./test-cfg-test-module.md) - Test module organization +- [doc-examples-section](./doc-examples-section.md) - Documentation tests diff --git a/.opencode/skills/rust-skills/rules/test-doctest-examples.md b/.opencode/skills/rust-skills/rules/test-doctest-examples.md new file mode 100644 index 0000000..1b9428e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-doctest-examples.md @@ -0,0 +1,168 @@ +# test-doctest-examples + +> Keep documentation examples as executable doctests + +## Why It Matters + +Doctests are examples in documentation that are automatically tested. They serve dual purposes: demonstrating usage to readers and verifying the examples compile and work. When your API changes, failing doctests catch outdated documentation. + +## Bad + +```rust +/// Parses a number from a string. +/// +/// Example: +/// let n = parse("42"); // Not tested! +/// assert_eq!(n, 42); +pub fn parse(s: &str) -> i32 { + s.parse().unwrap() +} + +// Documentation can become outdated: +/// Adds two numbers. +/// +/// ``` +/// let sum = add(1, 2, 3); // Wrong number of args - not caught! +/// ``` +pub fn add(a: i32, b: i32) -> i32 { + a + b +} +``` + +## Good + +```rust +/// Parses a number from a string. +/// +/// # Examples +/// +/// ``` +/// use my_crate::parse; +/// +/// let n = parse("42"); +/// assert_eq!(n, 42); +/// ``` +pub fn parse(s: &str) -> i32 { + s.parse().unwrap() +} + +/// Adds two numbers. +/// +/// # Examples +/// +/// ``` +/// use my_crate::add; +/// +/// let sum = add(1, 2); +/// assert_eq!(sum, 3); +/// ``` +pub fn add(a: i32, b: i32) -> i32 { + a + b +} +``` + +## Hiding Setup Code + +```rust +/// Processes data from a file. +/// +/// # Examples +/// +/// ``` +/// # use std::io::Write; +/// # let mut file = tempfile::NamedTempFile::new().unwrap(); +/// # writeln!(file, "test data").unwrap(); +/// # let path = file.path(); +/// use my_crate::process_file; +/// +/// let result = process_file(path)?; +/// assert!(!result.is_empty()); +/// # Ok::<(), Box>(()) +/// ``` +pub fn process_file(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(Error::from) +} +``` + +## Showing Error Handling + +```rust +/// Parses and validates an email address. +/// +/// # Examples +/// +/// ``` +/// use my_crate::Email; +/// +/// let email = Email::parse("user@example.com")?; +/// assert_eq!(email.domain(), "example.com"); +/// # Ok::<(), my_crate::EmailError>(()) +/// ``` +/// +/// # Errors +/// +/// Returns error for invalid format: +/// +/// ``` +/// use my_crate::Email; +/// +/// assert!(Email::parse("not-an-email").is_err()); +/// ``` +pub fn parse(s: &str) -> Result { + // ... +} +``` + +## no_run and ignore + +```rust +/// Starts the server. +/// +/// ```no_run +/// use my_crate::Server; +/// +/// // This compiles but doesn't run (would block forever) +/// Server::new().run(); +/// ``` +pub fn run(&self) { ... } + +/// Platform-specific example. +/// +/// ```ignore +/// // This might not compile on all platforms +/// use windows_specific::Feature; +/// ``` +``` + +## compile_fail + +```rust +/// This type is not Clone. +/// +/// ```compile_fail +/// use my_crate::UniqueHandle; +/// +/// let a = UniqueHandle::new(); +/// let b = a.clone(); // Error: Clone not implemented +/// ``` +pub struct UniqueHandle { ... } +``` + +## Running Doctests + +```bash +# Run all tests including doctests +cargo test + +# Run only doctests +cargo test --doc + +# Run doctests for specific item +cargo test --doc my_function +``` + +## See Also + +- [doc-examples-section](./doc-examples-section.md) - Documentation structure +- [doc-hidden-setup](./doc-hidden-setup.md) - Hiding setup code +- [doc-question-mark](./doc-question-mark.md) - Error handling in examples diff --git a/.opencode/skills/rust-skills/rules/test-fixture-raii.md b/.opencode/skills/rust-skills/rules/test-fixture-raii.md new file mode 100644 index 0000000..9b565c8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-fixture-raii.md @@ -0,0 +1,154 @@ +# test-fixture-raii + +> Use RAII pattern (Drop trait) for automatic test cleanup + +## Why It Matters + +Tests often need setup and teardown—creating temp files, starting servers, setting environment variables. Using RAII (Resource Acquisition Is Initialization) with Drop ensures cleanup happens automatically, even if the test panics. This prevents test pollution and resource leaks. + +## Bad + +```rust +#[test] +fn test_with_temp_file() { + let path = "/tmp/test_file.txt"; + std::fs::write(path, "test data").unwrap(); + + let result = process_file(path); + + std::fs::remove_file(path).unwrap(); // Might not run if test panics! + assert!(result.is_ok()); +} + +#[test] +fn test_with_env_var() { + std::env::set_var("MY_VAR", "test_value"); + + let result = read_config(); + + std::env::remove_var("MY_VAR"); // Might not run if test panics! + assert!(result.is_ok()); +} +``` + +## Good + +```rust +use tempfile::NamedTempFile; + +#[test] +fn test_with_temp_file() { + // Arrange - file deleted automatically when `file` drops + let file = NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "test data").unwrap(); + + // Act + let result = process_file(file.path()); + + // Assert - file cleaned up even if assertion panics + assert!(result.is_ok()); +} + +// Custom RAII guard for environment variables +struct EnvGuard { + key: String, + original: Option, +} + +impl EnvGuard { + fn set(key: &str, value: &str) -> Self { + let original = std::env::var(key).ok(); + // SAFETY: env::set_var is unsafe since the 2024 edition (env writes are + // not thread-safe); env-touching tests should run single-threaded. + unsafe { std::env::set_var(key, value) }; + EnvGuard { + key: key.to_string(), + original, + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: see EnvGuard::set — restored on the same single-threaded test + match &self.original { + Some(v) => unsafe { std::env::set_var(&self.key, v) }, + None => unsafe { std::env::remove_var(&self.key) }, + } + } +} + +#[test] +fn test_with_env_var() { + let _guard = EnvGuard::set("MY_VAR", "test_value"); + + let result = read_config(); + + assert!(result.is_ok()); +} // MY_VAR automatically restored +``` + +## Common RAII Patterns + +```rust +// Temporary directory +use tempfile::TempDir; + +#[test] +fn test_with_temp_dir() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "data").unwrap(); + + // dir and all contents deleted on drop +} + +// Server guard +struct TestServer { + handle: std::thread::JoinHandle<()>, + shutdown: std::sync::mpsc::Sender<()>, +} + +impl Drop for TestServer { + fn drop(&mut self) { + let _ = self.shutdown.send(()); + // Wait for server to stop + } +} + +// Database transaction rollback +struct TestTransaction<'a> { + conn: &'a mut Connection, +} + +impl Drop for TestTransaction<'_> { + fn drop(&mut self) { + self.conn.execute("ROLLBACK").unwrap(); + } +} +``` + +## scopeguard Crate + +```rust +use scopeguard::defer; + +#[test] +fn test_with_defer() { + let path = "/tmp/test_file.txt"; + std::fs::write(path, "data").unwrap(); + + defer! { + std::fs::remove_file(path).ok(); + } + + // Test logic here + // File removed when scope exits +} +``` + +## See Also + +- [test-arrange-act-assert](./test-arrange-act-assert.md) - Test structure +- [test-tokio-async](./test-tokio-async.md) - Async test cleanup +- [test-mock-traits](./test-mock-traits.md) - Mocking with RAII diff --git a/.opencode/skills/rust-skills/rules/test-integration-dir.md b/.opencode/skills/rust-skills/rules/test-integration-dir.md new file mode 100644 index 0000000..0767fd0 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-integration-dir.md @@ -0,0 +1,144 @@ +# test-integration-dir + +> Put integration tests in the `tests/` directory + +## Why It Matters + +Integration tests live in `tests/` at the crate root, separate from `src/`. Each file in `tests/` is compiled as a separate crate, testing your library's public API as external users would. This separation ensures you're testing the real public interface, not implementation details. + +## Structure + +``` +my_project/ +├── Cargo.toml +├── src/ +│ ├── lib.rs +│ └── internal.rs +└── tests/ + ├── integration_test.rs # Each file is a separate test binary + ├── api_tests.rs + └── common/ # Shared test utilities + └── mod.rs +``` + +## Bad + +```rust +// src/lib.rs +// Mixing integration test logic in library code +#[test] +fn integration_test_full_workflow() { + // This is a unit test location, not integration +} +``` + +## Good + +```rust +// tests/integration_test.rs +use my_crate::{Client, Config}; // Uses public API only + +#[test] +fn test_full_workflow() { + let config = Config::default(); + let client = Client::new(config); + + let result = client.process("input"); + assert!(result.is_ok()); +} + +#[test] +fn test_error_handling() { + let client = Client::new(Config::strict()); + + let result = client.process("invalid"); + assert!(matches!(result, Err(Error::InvalidInput { .. }))); +} +``` + +## Shared Test Utilities + +```rust +// tests/common/mod.rs +use my_crate::Config; + +pub fn test_config() -> Config { + Config { + timeout: Duration::from_secs(5), + retries: 3, + debug: true, + } +} + +pub fn setup_test_environment() { + // Set up test fixtures +} + +// tests/api_tests.rs +mod common; + +use my_crate::Client; + +#[test] +fn test_with_shared_config() { + common::setup_test_environment(); + let client = Client::new(common::test_config()); + // ... +} +``` + +## Organizing Many Tests + +```rust +// tests/api/mod.rs +mod auth; +mod users; +mod orders; + +// tests/api/auth.rs +use my_crate::auth::{login, logout}; + +#[test] +fn test_login_success() { ... } + +#[test] +fn test_login_invalid_credentials() { ... } + +// tests/api/users.rs +use my_crate::users::{create_user, get_user}; + +#[test] +fn test_create_user() { ... } +``` + +## Integration vs Unit Tests + +| Unit Tests | Integration Tests | +|------------|-------------------| +| In `src/` with `#[cfg(test)]` | In `tests/` directory | +| Access private items | Public API only | +| Test individual functions | Test module interactions | +| Fast, isolated | May be slower | +| `cargo test --lib` | `cargo test --test '*'` | + +## Running Specific Tests + +```bash +# Run all tests +cargo test + +# Run only integration tests +cargo test --test '*' + +# Run specific integration test file +cargo test --test integration_test + +# Run tests matching pattern +cargo test --test api_tests test_login +``` + +## See Also + +- [test-cfg-test-module](./test-cfg-test-module.md) - Unit test modules +- [test-descriptive-names](./test-descriptive-names.md) - Test naming +- [test-tokio-async](./test-tokio-async.md) - Async integration tests diff --git a/.opencode/skills/rust-skills/rules/test-loom-concurrency.md b/.opencode/skills/rust-skills/rules/test-loom-concurrency.md new file mode 100644 index 0000000..8df5b37 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-loom-concurrency.md @@ -0,0 +1,102 @@ +# test-loom-concurrency + +> Use `loom` to exhaustively test lock-free and concurrent code + +## Why It Matters + +Probabilistic stress tests can run millions of iterations and still miss a race condition that only manifests under a specific thread interleaving. `loom` systematically explores every thread scheduling and memory-reordering permitted by the C11 memory model, turning "we ran it a lot and it seemed fine" into a proof of correctness for the interleavings that exist within the model bounds. Tokio uses loom to verify its internal synchronization primitives. + +## Bad + +```rust +// Stress test: might pass a billion times, still doesn't prove correctness +#[test] +fn stress_test_flag() { + use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; + let flag = Arc::new(AtomicBool::new(false)); + for _ in 0..1_000_000 { + let flag = Arc::clone(&flag); + std::thread::spawn(move || { + flag.store(true, Ordering::Relaxed); + }); + } + // races may never surface under the OS scheduler used here +} +``` + +## Good + +Gate concurrent primitives behind `#[cfg(loom)]` so the same code runs with loom's instrumented types during model checking and with std types in production: + +```rust +// src/flag.rs +#[cfg(loom)] +use loom::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(loom))] +use std::sync::atomic::{AtomicBool, Ordering}; + +pub struct Flag(AtomicBool); + +impl Flag { + pub const fn new() -> Self { + Self(AtomicBool::new(false)) + } + + pub fn set(&self) { + self.0.store(true, Ordering::Release); + } + + pub fn is_set(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} +``` + +```rust +// tests/loom_flag.rs (or inside a #[cfg(loom)] mod in the crate) +#[cfg(loom)] +mod tests { + use loom::sync::Arc; + use super::Flag; + + #[test] + fn flag_set_visible_to_other_thread() { + loom::model(|| { + let flag = Arc::new(Flag::new()); + + let flag2 = Arc::clone(&flag); + let writer = loom::thread::spawn(move || { + flag2.set(); + }); + + // All interleavings: either writer runs first or reader does. + // loom verifies the Acquire/Release pair holds in both cases. + let seen = flag.is_set(); + writer.join().unwrap(); + + // After join, writer must have completed; flag must be set. + assert!(flag.is_set(), "flag must be set after join"); + // 'seen' may be false if reader ran before writer — that is valid. + let _ = seen; + }); + } +} +``` + +Run loom tests with the feature flag: + +```bash +RUSTFLAGS="--cfg loom" cargo test --test loom_flag +``` + +## Key Points + +- Keep loom model closures **small**: combinatorial explosion grows with the number of atomic operations and threads. Test one primitive or algorithm at a time. +- loom replaces `std::sync::atomic`, `std::sync::Mutex`, `std::thread`, and `std::cell` with instrumented equivalents — import from `loom::` under `#[cfg(loom)]`. +- Use `loom::model(|| { ... })` as the entry point; loom runs the closure repeatedly under different schedules. +- loom checks the C11 model — it does not detect logical bugs unrelated to concurrency. + +## See Also + +- [conc-atomic-ordering](conc-atomic-ordering.md) - choose correct memory orderings +- [test-criterion-bench](test-criterion-bench.md) - benchmark concurrent code after verifying correctness diff --git a/.opencode/skills/rust-skills/rules/test-mock-traits.md b/.opencode/skills/rust-skills/rules/test-mock-traits.md new file mode 100644 index 0000000..f3da4bb --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-mock-traits.md @@ -0,0 +1,189 @@ +# test-mock-traits + +> Use traits for dependencies to enable mocking in tests + +## Why It Matters + +Concrete dependencies make testing hard—you can't easily test error paths, timeouts, or edge cases without real external systems. Extracting dependencies behind traits lets you inject test doubles (mocks, fakes, stubs), enabling isolated unit tests that run fast and cover edge cases. + +## Bad + +```rust +struct UserService { + db: PostgresConnection, // Concrete type - hard to test +} + +impl UserService { + async fn get_user(&self, id: u64) -> Result { + // Directly calls Postgres - needs real database to test + self.db.query("SELECT * FROM users WHERE id = $1", &[&id]).await + } +} + +// Test requires real Postgres instance +#[tokio::test] +async fn test_get_user() { + let db = PostgresConnection::connect("postgres://...").await?; + let service = UserService { db }; + // Slow, flaky, can't test error paths +} +``` + +## Good + +```rust +// Define trait for dependency +#[async_trait] +trait UserRepository: Send + Sync { + async fn find_by_id(&self, id: u64) -> Result, DbError>; + async fn save(&self, user: &User) -> Result<(), DbError>; +} + +// Production implementation +struct PostgresUserRepo { + pool: PgPool, +} + +#[async_trait] +impl UserRepository for PostgresUserRepo { + async fn find_by_id(&self, id: u64) -> Result, DbError> { + sqlx::query_as("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_optional(&self.pool) + .await + } + // ... +} + +// Service depends on trait, not concrete type +struct UserService { + repo: R, +} + +impl UserService { + async fn get_user(&self, id: u64) -> Result { + self.repo.find_by_id(id).await? + .ok_or(Error::NotFound) + } +} + +// Test with mock +#[cfg(test)] +mod tests { + struct MockUserRepo { + users: HashMap, + } + + #[async_trait] + impl UserRepository for MockUserRepo { + async fn find_by_id(&self, id: u64) -> Result, DbError> { + Ok(self.users.get(&id).cloned()) + } + // ... + } + + #[tokio::test] + async fn test_get_user_found() { + let mut mock = MockUserRepo { users: HashMap::new() }; + mock.users.insert(1, User { id: 1, name: "Alice".into() }); + + let service = UserService { repo: mock }; + let user = service.get_user(1).await.unwrap(); + + assert_eq!(user.name, "Alice"); + } + + #[tokio::test] + async fn test_get_user_not_found() { + let mock = MockUserRepo { users: HashMap::new() }; + let service = UserService { repo: mock }; + + let result = service.get_user(999).await; + assert!(matches!(result, Err(Error::NotFound))); + } +} +``` + +## mockall Crate + +```rust +use mockall::*; +use mockall::predicate::*; + +#[automock] +#[async_trait] +trait Database: Send + Sync { + async fn query(&self, sql: &str) -> Result, Error>; +} + +#[tokio::test] +async fn test_with_mockall() { + let mut mock = MockDatabase::new(); + + mock.expect_query() + .with(eq("SELECT 1")) + .times(1) + .returning(|_| Ok(vec![Row::new()])); + + let result = mock.query("SELECT 1").await; + assert!(result.is_ok()); +} +``` + +## Testing Error Paths + +```rust +#[async_trait] +trait HttpClient: Send + Sync { + async fn get(&self, url: &str) -> Result; +} + +struct FailingClient; + +#[async_trait] +impl HttpClient for FailingClient { + async fn get(&self, _url: &str) -> Result { + Err(HttpError::Timeout) // Always fails + } +} + +#[tokio::test] +async fn test_handles_timeout() { + let client = FailingClient; + let service = ApiService { client }; + + let result = service.fetch_data().await; + assert!(matches!(result, Err(Error::NetworkError(_)))); +} +``` + +## Dynamic Dispatch Alternative + +```rust +// When you don't want generics everywhere +struct UserService { + repo: Box, +} + +impl UserService { + fn new(repo: impl UserRepository + 'static) -> Self { + Self { repo: Box::new(repo) } + } +} + +// Slight runtime cost but cleaner API +``` + +## Cargo.toml + +```toml +[dev-dependencies] +mockall = "0.11" +async-trait = "0.1" # For async trait mocking +``` + +## See Also + +- [api-sealed-trait](./api-sealed-trait.md) - Trait design +- [test-proptest-properties](./test-proptest-properties.md) - Property-based testing +- [proj-lib-main-split](./proj-lib-main-split.md) - Testable architecture diff --git a/.opencode/skills/rust-skills/rules/test-mockall-mocking.md b/.opencode/skills/rust-skills/rules/test-mockall-mocking.md new file mode 100644 index 0000000..8499a64 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-mockall-mocking.md @@ -0,0 +1,226 @@ +# test-mockall-mocking + +> Use mockall for trait mocking + +## Why It Matters + +Unit tests should isolate the code under test from external dependencies (databases, APIs, file systems). Mockall generates mock implementations of traits, allowing you to control and verify behavior without real dependencies. + +## Setup + +```toml +# Cargo.toml +[dev-dependencies] +mockall = "0.12" +``` + +## Basic Usage + +```rust +use mockall::automock; + +#[automock] +trait Database { + fn get_user(&self, id: u64) -> Option; + fn save_user(&self, user: &User) -> Result<(), Error>; +} + +#[cfg(test)] +mod tests { + use super::*; + use mockall::predicate::*; + + #[test] + fn test_get_user() { + let mut mock = MockDatabase::new(); + + mock.expect_get_user() + .with(eq(42)) + .returning(|_| Some(User { id: 42, name: "Alice".into() })); + + let service = UserService::new(mock); + let user = service.find_user(42); + + assert_eq!(user.unwrap().name, "Alice"); + } +} +``` + +## Expectations + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_save_calls() { + let mut mock = MockDatabase::new(); + + // Expect exactly one call + mock.expect_save_user() + .times(1) + .returning(|_| Ok(())); + + // Expect call with specific argument + mock.expect_get_user() + .with(eq(42)) + .returning(|_| Some(User::default())); + + // Expect multiple calls + mock.expect_get_user() + .times(3..) // At least 3 times + .returning(|_| None); + + // Expectations are verified on drop + } +} +``` + +## Predicates + +```rust +use mockall::predicate::*; + +mock.expect_process() + .with(eq(42)) // Exact match + .returning(|_| Ok(())); + +mock.expect_validate() + .with(function(|s: &str| s.len() > 5)) // Custom predicate + .returning(|_| true); + +mock.expect_search() + .withf(|query, limit| { // Multiple args + query.len() < 100 && *limit <= 1000 + }) + .returning(|_, _| vec![]); +``` + +## Sequences + +```rust +use mockall::Sequence; + +#[test] +fn test_ordered_calls() { + let mut seq = Sequence::new(); + let mut mock = MockDatabase::new(); + + mock.expect_connect() + .times(1) + .in_sequence(&mut seq) + .returning(|| Ok(())); + + mock.expect_query() + .times(1) + .in_sequence(&mut seq) + .returning(|_| Ok(vec![])); + + mock.expect_disconnect() + .times(1) + .in_sequence(&mut seq) + .returning(|| Ok(())); +} +``` + +## Return Values + +```rust +// Fixed value +mock.expect_count().returning(|| 42); + +// Based on input +mock.expect_double().returning(|x| x * 2); + +// Different values per call +mock.expect_next() + .times(3) + .returning(|| 1) + .returning(|| 2) + .returning(|| 3); + +// Return owned values +mock.expect_get_name() + .returning(|| "Alice".to_string()); +``` + +## Mocking External Traits + +```rust +// For traits you don't own +#[cfg_attr(test, mockall::automock)] +trait HttpClient { + fn get(&self, url: &str) -> Result; +} + +// In production +struct RealHttpClient; +impl HttpClient for RealHttpClient { + fn get(&self, url: &str) -> Result { /* ... */ } +} + +// In tests +#[cfg(test)] +fn mock_client() -> MockHttpClient { + let mut mock = MockHttpClient::new(); + mock.expect_get() + .returning(|_| Ok(Response::new(200, "OK"))); + mock +} +``` + +## Async Mocking + +```rust +#[automock] +#[async_trait] +trait AsyncDatabase { + async fn fetch(&self, id: u64) -> Option; +} + +#[tokio::test] +async fn test_async() { + let mut mock = MockAsyncDatabase::new(); + + mock.expect_fetch() + .returning(|_| Some(Data::default())); + + let result = mock.fetch(1).await; + assert!(result.is_some()); +} +``` + +## Design for Testability + +```rust +// Accept trait, not concrete type +struct Service { + db: D, +} + +impl Service { + fn new(db: D) -> Self { + Self { db } + } +} + +// Tests use mock +#[test] +fn test_service() { + let mock = MockDatabase::new(); + let service = Service::new(mock); +} + +// Production uses real implementation +fn main() { + let db = PostgresDatabase::connect(); + let service = Service::new(db); +} +``` + +## See Also + +- [test-mock-traits](./test-mock-traits.md) - Mock trait design +- [test-proptest-properties](./test-proptest-properties.md) - Property testing +- [test-arrange-act-assert](./test-arrange-act-assert.md) - Test structure diff --git a/.opencode/skills/rust-skills/rules/test-proptest-properties.md b/.opencode/skills/rust-skills/rules/test-proptest-properties.md new file mode 100644 index 0000000..dee45e8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-proptest-properties.md @@ -0,0 +1,161 @@ +# test-proptest-properties + +> Use proptest for property-based testing + +## Why It Matters + +Property-based testing generates random inputs to verify that properties hold across all possible values, not just hand-picked examples. Proptest finds edge cases you wouldn't think to test manually—empty strings, integer overflows, unicode edge cases. + +## Setup + +```toml +# Cargo.toml +[dev-dependencies] +proptest = "1.0" +``` + +## Basic Usage + +```rust +use proptest::prelude::*; + +proptest! { + #[test] + fn test_reverse_reverse_is_identity(s in ".*") { + let reversed: String = s.chars().rev().collect(); + let double_reversed: String = reversed.chars().rev().collect(); + assert_eq!(s, double_reversed); + } + + #[test] + fn test_sort_is_idempotent(mut v in prop::collection::vec(any::(), 0..100)) { + v.sort(); + let sorted = v.clone(); + v.sort(); + assert_eq!(v, sorted); + } +} +``` + +## Common Strategies + +```rust +use proptest::prelude::*; + +proptest! { + // Any type implementing Arbitrary + #[test] + fn test_i32(x in any::()) { } + + // Regex-based string generation + #[test] + fn test_email(email in "[a-z]+@[a-z]+\\.[a-z]{2,3}") { } + + // Ranges + #[test] + fn test_range(x in 0..100i32) { } + + // Collections + #[test] + fn test_vec(v in prop::collection::vec(any::(), 0..10)) { } + + // Optionals + #[test] + fn test_option(opt in prop::option::of(any::())) { } +} +``` + +## Custom Strategies + +```rust +use proptest::prelude::*; + +#[derive(Debug, Clone)] +struct User { + name: String, + age: u8, +} + +fn user_strategy() -> impl Strategy { + ("[a-zA-Z]{1,20}", 0..120u8) + .prop_map(|(name, age)| User { name, age }) +} + +proptest! { + #[test] + fn test_user(user in user_strategy()) { + assert!(user.age < 150); + assert!(!user.name.is_empty()); + } +} + +// Or derive Arbitrary +use proptest_derive::Arbitrary; + +#[derive(Debug, Arbitrary)] +struct Point { + x: i32, + y: i32, +} +``` + +## Properties to Test + +| Property | Example | +|----------|---------| +| Roundtrip | `decode(encode(x)) == x` | +| Idempotence | `f(f(x)) == f(x)` | +| Commutativity | `f(a, b) == f(b, a)` | +| Associativity | `f(f(a, b), c) == f(a, f(b, c))` | +| Identity | `f(x, identity) == x` | +| Invariants | `len(push(v, x)) == len(v) + 1` | + +## Example: Parser Roundtrip + +```rust +proptest! { + #[test] + fn parse_roundtrip(config in valid_config_strategy()) { + let serialized = config.to_string(); + let parsed = Config::parse(&serialized).unwrap(); + assert_eq!(config, parsed); + } +} +``` + +## Shrinking + +Proptest automatically shrinks failing inputs to minimal cases: + +```rust +// If this fails with vec![100, 50, 75, 25, 0] +// Proptest will shrink to vec![1, 0] (minimal failing case) +proptest! { + #[test] + fn test_sorted(v in prop::collection::vec(0..1000i32, 1..100)) { + let sorted = is_sorted(&v); + // This will fail and shrink + } +} +``` + +## Configuration + +```rust +proptest! { + #![proptest_config(ProptestConfig { + cases: 1000, // More test cases + max_shrink_iters: 10000, // More shrinking + ..ProptestConfig::default() + })] + + #[test] + fn extensive_test(x in any::()) { } +} +``` + +## See Also + +- [test-criterion-bench](./test-criterion-bench.md) - Benchmarking +- [test-mockall-mocking](./test-mockall-mocking.md) - Mocking +- [test-arrange-act-assert](./test-arrange-act-assert.md) - Test structure diff --git a/.opencode/skills/rust-skills/rules/test-should-panic.md b/.opencode/skills/rust-skills/rules/test-should-panic.md new file mode 100644 index 0000000..1b4b98e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-should-panic.md @@ -0,0 +1,130 @@ +# test-should-panic + +> Use `#[should_panic]` to test that code panics as expected + +## Why It Matters + +Some code should panic on invalid inputs or invariant violations. `#[should_panic]` verifies the panic occurs, optionally checking the panic message. This ensures defensive panics work correctly and documents expected panic conditions. + +## Bad + +```rust +#[test] +fn test_panic() { + // Just calling panicking code makes test fail + divide(1, 0); // Test fails with panic +} + +// Using catch_unwind is verbose +#[test] +fn test_panic_manual() { + let result = std::panic::catch_unwind(|| divide(1, 0)); + assert!(result.is_err()); +} +``` + +## Good + +```rust +#[test] +#[should_panic] +fn divide_by_zero_panics() { + divide(1, 0); // Test passes when this panics +} + +// With expected message +#[test] +#[should_panic(expected = "division by zero")] +fn divide_by_zero_panics_with_message() { + divide(1, 0); // Panics with "division by zero" +} + +// Partial message match +#[test] +#[should_panic(expected = "index out of bounds")] +fn index_panic_contains_message() { + let v = vec![1, 2, 3]; + let _ = v[100]; // Message contains "index out of bounds" +} +``` + +## Testing Invariants + +```rust +struct NonEmpty(Vec); + +impl NonEmpty { + fn new(items: Vec) -> Self { + assert!(!items.is_empty(), "NonEmpty cannot be empty"); + NonEmpty(items) + } +} + +#[test] +#[should_panic(expected = "NonEmpty cannot be empty")] +fn non_empty_rejects_empty_vec() { + NonEmpty::new(Vec::::new()); +} + +#[test] +fn non_empty_accepts_non_empty_vec() { + let ne = NonEmpty::new(vec![1, 2, 3]); + assert_eq!(ne.0.len(), 3); +} +``` + +## With expect() Messages + +```rust +fn get_config_value(key: &str) -> String { + CONFIG.get(key) + .expect(&format!("missing required config: {}", key)) + .to_string() +} + +#[test] +#[should_panic(expected = "missing required config: DATABASE_URL")] +fn missing_config_panics_with_key() { + get_config_value("DATABASE_URL"); +} +``` + +## When NOT to Use should_panic + +```rust +// ❌ For recoverable errors - use Result +#[test] +#[should_panic] // Wrong: this should return Err, not panic +fn invalid_input_panics() { + parse_config("invalid"); // Should return Err, not panic +} + +// ✅ Return Result and test the error +#[test] +fn invalid_input_returns_error() { + let result = parse_config("invalid"); + assert!(result.is_err()); +} +``` + +## Combining with Result + +```rust +#[test] +#[should_panic] +fn test_panics() -> Result<(), Error> { + // Can combine with Result for setup + let data = setup_test_data()?; + + // This should panic + process_invalid(&data); + + Ok(()) // Never reached +} +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - Panic vs Result +- [err-expect-bugs-only](./err-expect-bugs-only.md) - When to use expect +- [test-descriptive-names](./test-descriptive-names.md) - Test naming diff --git a/.opencode/skills/rust-skills/rules/test-snapshot-testing.md b/.opencode/skills/rust-skills/rules/test-snapshot-testing.md new file mode 100644 index 0000000..1e7cac8 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-snapshot-testing.md @@ -0,0 +1,91 @@ +# test-snapshot-testing + +> Use snapshot testing (insta) for complex or serialized output + +## Why It Matters + +Asserting large structured output — pretty-printed structs, rendered error messages, JSON responses, generated code, CLI output — with hand-written `assert_eq!` is verbose, brittle, and hard to update when output intentionally changes. The `insta` crate records an approved snapshot on first run and diffs against it on subsequent runs; when output changes legitimately, `cargo insta review` presents a diff and lets you accept it in one keystroke. Snapshots are committed to the repo and reviewed in PRs, making output changes visible and deliberate rather than silent. + +## Bad + +```rust +#[test] +fn test_render_error() { + let err = AppError::NotFound { id: 42 }; + // Fragile: must manually maintain this string forever + assert_eq!( + format!("{err}"), + "resource with id 42 was not found in the database and could not be retrieved" + ); +} + +#[test] +fn test_config_serialization() { + let config = Config::default(); + let json = serde_json::to_string_pretty(&config).unwrap(); + // Hard to read, hard to update, easy to get wrong + assert_eq!(json, "{\n \"timeout\": 30,\n \"retries\": 3\n}"); +} +``` + +## Good + +```toml +[dev-dependencies] +insta = { version = "1", features = ["json", "yaml"] } +``` + +```rust +use insta::assert_debug_snapshot; +use insta::assert_json_snapshot; + +#[test] +fn test_render_error() { + let err = AppError::NotFound { id: 42 }; + // On first run: creates snapshots/test_render_error.snap + // On subsequent runs: diffs against the saved snapshot + assert_debug_snapshot!(err); +} + +#[test] +fn test_config_serialization() { + let config = Config::default(); + // Snapshot stored as pretty-printed JSON for easy review + assert_json_snapshot!(config); +} + +#[test] +fn test_cli_output() { + let output = run_cli(&["--help"]); + // Named snapshot for clarity + assert_debug_snapshot!("cli_help_output", output); +} +``` + +## Workflow + +1. Run tests for the first time: `cargo test` — insta creates `.snap.new` files. +2. Review and accept: `cargo insta review` — interactive diff; press `a` to accept. +3. Commit the `.snap` files alongside your code changes. +4. In CI, run `cargo test` and `cargo insta test --check` (or set `INSTA_UPDATE=unseen`) to fail if any snapshot is new or changed without being committed. + +```bash +# CI: fail on any unapproved snapshots +INSTA_UPDATE=no cargo test +``` + +## When to Use Snapshots vs assert_eq! + +| Situation | Prefer | +|-----------|--------| +| Short, simple values (`true`, `42`, `"ok"`) | `assert_eq!` | +| Multi-line or structured output | `assert_debug_snapshot!` | +| JSON/YAML serialization | `assert_json_snapshot!` / `assert_yaml_snapshot!` | +| Rendered error messages | `assert_snapshot!` | +| Compiler-error-style output | `assert_snapshot!` | + +## See Also + +- [test-arrange-act-assert](test-arrange-act-assert.md) - structure tests as arrange/act/assert +- [test-proptest-properties](test-proptest-properties.md) - use proptest for property-based testing +- [test-doctest-examples](test-doctest-examples.md) - keep doc examples as executable tests diff --git a/.opencode/skills/rust-skills/rules/test-tokio-async.md b/.opencode/skills/rust-skills/rules/test-tokio-async.md new file mode 100644 index 0000000..f0d970a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-tokio-async.md @@ -0,0 +1,154 @@ +# test-tokio-async + +> Use `#[tokio::test]` for async tests + +## Why It Matters + +Async functions can't be called directly—they need a runtime to drive them. `#[tokio::test]` provides a Tokio runtime for your test, handling setup automatically. This is simpler than manually creating a runtime and essential for testing async code. + +## Bad + +```rust +// Won't compile - async fn can't be called without runtime +#[test] +async fn test_async_function() { // Error! + let result = fetch_data().await; + assert!(result.is_ok()); +} + +// Manual runtime - verbose and error-prone +#[test] +fn test_async_function() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let result = fetch_data().await; + assert!(result.is_ok()); + }); +} +``` + +## Good + +```rust +#[tokio::test] +async fn test_async_function() { + let result = fetch_data().await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_concurrent_operations() { + let (a, b) = tokio::join!( + fetch_user(1), + fetch_user(2), + ); + assert!(a.is_ok()); + assert!(b.is_ok()); +} +``` + +## Runtime Configuration + +```rust +// Multi-threaded runtime (default) +#[tokio::test] +async fn test_default_runtime() { + // Uses multi-thread runtime +} + +// Single-threaded (current_thread) +#[tokio::test(flavor = "current_thread")] +async fn test_single_threaded() { + // Simpler, deterministic +} + +// With specific thread count +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_with_workers() { + // Exactly 2 worker threads +} + +// With time control +#[tokio::test(start_paused = true)] +async fn test_with_time_control() { + // Time starts paused for deterministic testing + tokio::time::advance(Duration::from_secs(60)).await; +} +``` + +## Testing Timeouts + +```rust +use tokio::time::{timeout, Duration}; + +#[tokio::test] +async fn test_operation_completes_in_time() { + let result = timeout( + Duration::from_secs(5), + slow_operation() + ).await; + + assert!(result.is_ok(), "Operation timed out"); +} + +#[tokio::test] +async fn test_timeout_triggers() { + let result = timeout( + Duration::from_millis(100), + never_completes() + ).await; + + assert!(result.is_err(), "Expected timeout"); +} +``` + +## Testing Channels + +```rust +use tokio::sync::mpsc; + +#[tokio::test] +async fn test_channel_communication() { + let (tx, mut rx) = mpsc::channel(10); + + tokio::spawn(async move { + tx.send("hello").await.unwrap(); + tx.send("world").await.unwrap(); + }); + + assert_eq!(rx.recv().await, Some("hello")); + assert_eq!(rx.recv().await, Some("world")); + assert_eq!(rx.recv().await, None); +} +``` + +## Testing with Mocks + +```rust +use mockall::*; + +#[automock] +#[async_trait::async_trait] +trait Database { + async fn get_user(&self, id: u64) -> Option; +} + +#[tokio::test] +async fn test_with_mock_database() { + let mut mock = MockDatabase::new(); + mock.expect_get_user() + .with(eq(42)) + .returning(|_| Some(User { id: 42, name: "Alice".into() })); + + let service = UserService::new(mock); + let user = service.find_user(42).await; + + assert_eq!(user.unwrap().name, "Alice"); +} +``` + +## See Also + +- [async-tokio-runtime](./async-tokio-runtime.md) - Runtime configuration +- [test-mock-traits](./test-mock-traits.md) - Mocking async traits +- [test-fixture-raii](./test-fixture-raii.md) - Async test cleanup diff --git a/.opencode/skills/rust-skills/rules/test-use-super.md b/.opencode/skills/rust-skills/rules/test-use-super.md new file mode 100644 index 0000000..f2d14df --- /dev/null +++ b/.opencode/skills/rust-skills/rules/test-use-super.md @@ -0,0 +1,127 @@ +# test-use-super + +> Use `use super::*;` in test modules to access parent module items + +## Why It Matters + +The test module is a child of the module being tested. `use super::*` imports all items from the parent module, including private ones. This gives tests access to both public API and internal implementation details for thorough testing. + +## Bad + +```rust +// Verbose imports +#[cfg(test)] +mod tests { + use crate::my_module::public_function; + use crate::my_module::MyStruct; + // Can't access private items this way! + + #[test] + fn test_function() { + let result = public_function(); + // ... + } +} +``` + +## Good + +```rust +// src/my_module.rs +pub struct PublicStruct { ... } +struct PrivateStruct { ... } // Private + +pub fn public_function() -> i32 { ... } +fn private_helper() -> i32 { ... } // Private + +#[cfg(test)] +mod tests { + use super::*; // Imports everything from parent + + #[test] + fn test_public_struct() { + let s = PublicStruct::new(); + // ... + } + + #[test] + fn test_private_struct() { + let s = PrivateStruct::new(); // Can access private! + // ... + } + + #[test] + fn test_private_helper() { + assert_eq!(private_helper(), 42); // Can test private! + } +} +``` + +## Selective Imports + +```rust +#[cfg(test)] +mod tests { + // When you want to be explicit + use super::{parse, ParseError, Token}; + + // Or import all plus test utilities + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_parse() { ... } +} +``` + +## Nested Modules + +```rust +mod outer { + pub fn outer_fn() -> i32 { 1 } + + mod inner { + pub fn inner_fn() -> i32 { 2 } + + #[cfg(test)] + mod tests { + use super::*; // Gets inner's items + use super::super::*; // Gets outer's items + + #[test] + fn test_inner() { + assert_eq!(inner_fn(), 2); + assert_eq!(outer_fn(), 1); + } + } + } +} +``` + +## With External Dependencies + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Test-only dependencies + use proptest::prelude::*; + use mockall::predicate::*; + + proptest! { + #[test] + fn test_property(s: String) { + let result = process(&s); + prop_assert!(result.is_ok()); + } + } +} +``` + +## See Also + +- [test-cfg-test-module](./test-cfg-test-module.md) - Test module structure +- [test-integration-dir](./test-integration-dir.md) - Integration tests +- [proj-pub-crate-internal](./proj-pub-crate-internal.md) - Visibility modifiers diff --git a/.opencode/skills/rust-skills/rules/trait-associated-type-vs-generic.md b/.opencode/skills/rust-skills/rules/trait-associated-type-vs-generic.md new file mode 100644 index 0000000..81c39be --- /dev/null +++ b/.opencode/skills/rust-skills/rules/trait-associated-type-vs-generic.md @@ -0,0 +1,111 @@ +# trait-associated-type-vs-generic + +> Use an associated type when each impl has exactly one output type; use a generic parameter when a type can implement the trait for many input types + +## Why It Matters + +The choice between `type Output;` and `` has two concrete consequences. First, an associated type is **part of the implementing type's identity**: callers never name it with turbofish because there is only one valid binding per impl. Second, a generic parameter allows **multiple simultaneous impls** on the same type — `impl Add for Vec2` and `impl Add for Vec2` can coexist, while `impl Iterator` can only define one `Item`. Using an associated type when multiple impls are needed makes those impls impossible; using a generic parameter when there is only one output forces every call site to write noisy turbofish or type annotations. + +The Rust API Guidelines (rust-lang.github.io/api-guidelines/future-proofing.html) capture the rule: prefer associated types when there is a single natural output per implementor. + +## Bad + +```rust +// Using a generic parameter for a trait that has exactly one output per type. +// Callers must now write Parser or face ambiguity. +trait Parser { + fn parse(&self, input: &str) -> Option; +} + +struct JsonParser; + +// Only one sensible Output ever exists for JsonParser, but the signature +// forces a type parameter that adds noise everywhere. +impl Parser for JsonParser { + fn parse(&self, input: &str) -> Option { + Some(input.to_owned()) + } +} + +fn run>(p: &P, s: &str) -> Option { + p.parse(s) +} +``` + +## Good + +```rust +// ----- Associated type: one output per implementor ----- +// Mirrors std::iter::Iterator { type Item; } + +trait Parser { + type Output; + fn parse(&self, input: &str) -> Option; +} + +struct JsonParser; +struct NumberParser; + +#[derive(Debug)] +struct JsonValue(String); + +impl Parser for JsonParser { + type Output = JsonValue; + fn parse(&self, input: &str) -> Option { + Some(JsonValue(input.to_owned())) + } +} + +impl Parser for NumberParser { + type Output = f64; + fn parse(&self, input: &str) -> Option { + input.trim().parse().ok() + } +} + +// No turbofish needed — `P::Output` is unambiguous. +fn run(p: &P, s: &str) -> Option { + p.parse(s) +} + +// ----- Generic parameter: multiple impls on the same type ----- +// Mirrors std::ops::Add and std::convert::From. + +#[derive(Debug, Clone, Copy)] +struct Vec2 { x: f64, y: f64 } + +// One type implementing the same "add" concept for two different Rhs types. +impl std::ops::Add for Vec2 { + type Output = Vec2; + fn add(self, rhs: Vec2) -> Vec2 { Vec2 { x: self.x + rhs.x, y: self.y + rhs.y } } +} + +impl std::ops::Add for Vec2 { + type Output = Vec2; + fn add(self, rhs: f64) -> Vec2 { Vec2 { x: self.x + rhs, y: self.y + rhs } } +} + +fn demo() { + let a = Vec2 { x: 1.0, y: 2.0 }; + let b = Vec2 { x: 3.0, y: 4.0 }; + let _ = a + b; // Add + let _ = a + 10.0; // Add + + let p = NumberParser; + if let Some(n) = run(&p, " 3.14 ") { + println!("{n}"); + } +} +``` + +## Key Points + +- `std::iter::Iterator`, `std::future::Future`, and `std::ops::Deref` all use associated types because there is exactly one `Item`/`Output`/`Target` per implementor. +- `std::ops::Add`, `std::convert::From`, and `std::convert::Into` use generic parameters because a single type can add to, or convert from, many others. +- When you need to constrain the associated type in a bound, write `P: Parser` — less noisy than a free generic parameter. + +## See Also + +- [type-generic-bounds](type-generic-bounds.md) - add trait bounds only where needed +- [trait-default-methods](trait-default-methods.md) - define traits with required + defaulted methods +- [api-impl-fromiterator](api-impl-fromiterator.md) - implementing `FromIterator` (associated-type pattern) diff --git a/.opencode/skills/rust-skills/rules/trait-blanket-impl.md b/.opencode/skills/rust-skills/rules/trait-blanket-impl.md new file mode 100644 index 0000000..887abbc --- /dev/null +++ b/.opencode/skills/rust-skills/rules/trait-blanket-impl.md @@ -0,0 +1,80 @@ +# trait-blanket-impl + +> Use a blanket impl `impl Trait for T` to give behaviour to every type that satisfies a bound + +## Why It Matters + +A blanket impl extends an entire class of types at once without touching each one individually. The standard library uses this pervasively: `ToString` is blanket-implemented for every `T: Display`, so any type that implements `Display` automatically gets `.to_string()`. This avoids repetitive boilerplate and keeps extension traits composable. The trade-off is coherence: Rust's orphan rules allow at most one applicable impl per type, so a blanket impl can conflict with a more specific one if not designed carefully. Adding a blanket impl is also a **semver-breaking change** if it could overlap with impls that downstream crates provide. + +## Bad + +```rust +use std::fmt; + +trait Describe { + fn describe(&self) -> String; +} + +// Manual impl for each type — tedious and doesn't scale. +impl Describe for i32 { + fn describe(&self) -> String { format!("i32: {self}") } +} +impl Describe for f64 { + fn describe(&self) -> String { format!("f64: {self}") } +} +impl Describe for bool { + fn describe(&self) -> String { format!("bool: {self}") } +} +// ... repeated for every type that happens to implement Display +``` + +## Good + +```rust +use std::fmt; + +// Extension trait that any `Display` type receives automatically. +trait Describe { + fn describe(&self) -> String; +} + +// One blanket impl covers every T: Display — mirrors how std blanket-impls ToString. +impl Describe for T { + fn describe(&self) -> String { + format!("{} ({})", self, std::any::type_name::()) + } +} + +// ----- Downstream usage: zero extra code required ----- + +fn print_described(value: &impl Describe) { + println!("{}", value.describe()); +} + +fn demo() { + print_described(&42_i32); + print_described(&3.14_f64); + print_described(&true); + print_described(&"hello"); +} + +// ----- You CANNOT also override it for one type ----- +// Writing `impl Describe for MyType` while the blanket impl exists is a +// coherence conflict (E0119): stable Rust has no specialization, so a blanket +// impl and a specific impl can never overlap. If you need a per-type override, +// don't use a blanket impl — or wrap the type in a newtype (see See Also). +``` + +## Key Points + +- The standard library blanket-impls `ToString` for all `T: Display`, `From for T` (reflexive), and `impl From for Box`. +- Blanket impls live in the crate that owns the **trait** (not the type), satisfying the orphan rule. +- A blanket impl prevents any specific impl for a type it already covers — that overlap is a coherence error (E0119), because specialization is not stable. Use a newtype when you need a per-type override. +- Treat adding a public blanket impl as a **minor semver bump at minimum** (or major if it could break downstream). +- Pair blanket impls with sealed traits (`api-sealed-trait`) when you want the blanket behaviour but need to prevent external implementations. + +## See Also + +- [api-extension-trait](api-extension-trait.md) - add methods to foreign types via extension traits +- [api-sealed-trait](api-sealed-trait.md) - prevent external implementations of a trait +- [trait-coherence-newtype](trait-coherence-newtype.md) - use a newtype to implement a foreign trait on a foreign type diff --git a/.opencode/skills/rust-skills/rules/trait-coherence-newtype.md b/.opencode/skills/rust-skills/rules/trait-coherence-newtype.md new file mode 100644 index 0000000..19c3839 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/trait-coherence-newtype.md @@ -0,0 +1,85 @@ +# trait-coherence-newtype + +> Respect the orphan rule; wrap a foreign type in a newtype to implement a foreign trait on it + +## Why It Matters + +Rust's coherence rules — enforced by the orphan rule — require that for any `impl Trait for Type`, either `Trait` or `Type` must be defined in the current crate. This prevents two crates from providing conflicting implementations for the same (trait, type) pair, which would make the compiler unable to pick one. When you need to implement a trait you didn't define (e.g., `std::fmt::Display`) on a type you didn't define (e.g., `Vec`), the compiler rejects the impl outright. The solution is to wrap the foreign type in a local newtype struct, then implement the foreign trait on the wrapper. Marking the wrapper `#[repr(transparent)]` keeps it zero-cost and allows safe pointer casts where needed. + +## Bad + +```rust +use std::fmt; + +// error[E0117]: only traits defined in the current crate can be implemented for +// types defined outside of the crate +// impl fmt::Display for Vec { ... } // both `Display` and `Vec` are foreign +``` + +## Good + +```rust +use std::fmt; + +// A local newtype wrapping the foreign type. +// `#[repr(transparent)]` guarantees the same memory layout as Vec. +#[repr(transparent)] +struct CommaSeparated(Vec); + +impl CommaSeparated { + pub fn new(v: Vec) -> Self { Self(v) } + + // Provide access to the inner value. + pub fn into_inner(self) -> Vec { self.0 } + pub fn inner(&self) -> &Vec { &self.0 } +} + +// Now both the trait (Display) is foreign and the type (CommaSeparated) is local — +// the orphan rule is satisfied because CommaSeparated is defined here. +impl fmt::Display for CommaSeparated { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut iter = self.0.iter().peekable(); + while let Some(n) = iter.next() { + write!(f, "{n}")?; + if iter.peek().is_some() { + write!(f, ", ")?; + } + } + Ok(()) + } +} + +// Implement From/Into so conversion is ergonomic. +impl From> for CommaSeparated { + fn from(v: Vec) -> Self { Self(v) } +} + +impl From for Vec { + fn from(w: CommaSeparated) -> Self { w.0 } +} + +fn demo() { + let nums = CommaSeparated::new(vec![1, 2, 3, 4, 5]); + println!("{nums}"); // "1, 2, 3, 4, 5" + + // Round-trip through the inner type. + let v: Vec = nums.into(); + let again = CommaSeparated::from(v); + println!("{again}"); +} +``` + +## Key Points + +- The orphan rule: `impl ForeignTrait for ForeignType` is always rejected, even with a type parameter. +- `#[repr(transparent)]` is mandatory for newtypes that need the same ABI as the inner type (e.g., FFI, pointer casts via `transmute`). For purely logical wrapping, it is optional but good practice. +- Provide `From`/`Into` conversions and an `inner()` / `into_inner()` accessor so callers can move in and out of the wrapper easily. +- The newtype pattern is described in the Rust API Guidelines under "Newtypes provide static distinctions" (rust-lang.github.io/api-guidelines/). +- Newtype wrappers are also the correct way to add trait impls to types from transitive dependencies that you do not control. + +## See Also + +- [api-newtype-safety](api-newtype-safety.md) - use newtypes for type-safe distinctions +- [type-repr-transparent](type-repr-transparent.md) - use `#[repr(transparent)]` for FFI newtypes +- [trait-blanket-impl](trait-blanket-impl.md) - give behaviour to every type meeting a bound +- [api-from-not-into](api-from-not-into.md) - implement `From`, not `Into` (auto-derived) diff --git a/.opencode/skills/rust-skills/rules/trait-default-methods.md b/.opencode/skills/rust-skills/rules/trait-default-methods.md new file mode 100644 index 0000000..f70d77d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/trait-default-methods.md @@ -0,0 +1,117 @@ +# trait-default-methods + +> Define a trait in terms of a few required methods plus defaulted ones built on top of them + +## Why It Matters + +A trait with only required methods places the full implementation burden on every consumer. Providing default method bodies — as `std::iter::Iterator` does, building `map`, `filter`, `fold`, and dozens more on a single required `next` — means implementors write only the essential logic and receive the rest for free. Defaults also act as documentation: they show the canonical relationship between methods. Implementors can still override a default for performance (e.g., `Iterator::count` overridden by `std::vec::IntoIter` to avoid iterating) without changing the observable contract. + +## Bad + +```rust +// Every implementor must manually implement all three methods, +// even though two of them are mechanical compositions of the first. +trait Summarise { + fn sentences(&self) -> Vec; + fn first_sentence(&self) -> Option; // always just sentences().into_iter().next() + fn word_count(&self) -> usize; // always sentences().join(" ").split_whitespace().count() +} + +struct Article { body: String } + +impl Summarise for Article { + fn sentences(&self) -> Vec { + self.body.split('.').map(str::trim).map(str::to_owned).collect() + } + // Duplicated logic — must be kept in sync across every implementor. + fn first_sentence(&self) -> Option { + self.sentences().into_iter().next() + } + fn word_count(&self) -> usize { + self.sentences().join(" ").split_whitespace().count() + } +} +``` + +## Good + +```rust +trait Summarise { + // ----- Required: the only thing implementors must provide ----- + fn sentences(&self) -> Vec; + + // ----- Defaulted: free for all implementors ----- + fn first_sentence(&self) -> Option { + self.sentences().into_iter().next() + } + + fn word_count(&self) -> usize { + self.sentences().join(" ").split_whitespace().count() + } + + fn is_empty(&self) -> bool { + self.sentences().is_empty() + } +} + +// Minimal impl — one method, three come for free. +struct Article { body: String } + +impl Summarise for Article { + fn sentences(&self) -> Vec { + self.body + .split('.') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect() + } +} + +// Override a default for performance when the default is provably slower. +struct PreSplit { parts: Vec } + +impl Summarise for PreSplit { + fn sentences(&self) -> Vec { + self.parts.clone() + } + + // Override: the parts are already split — no need to join and re-split. + fn word_count(&self) -> usize { + self.parts.iter().flat_map(|s| s.split_whitespace()).count() + } +} + +fn print_summary(item: &impl Summarise) { + if item.is_empty() { + println!("(empty)"); + return; + } + if let Some(first) = item.first_sentence() { + println!("first: {first}"); + } + println!("words: {}", item.word_count()); +} + +fn demo() { + let a = Article { body: "Rust is fast. Rust is safe. Rust is fun.".to_owned() }; + print_summary(&a); + + let p = PreSplit { parts: vec!["hello world".to_owned(), "foo bar baz".to_owned()] }; + print_summary(&p); +} +``` + +## Key Points + +- Keep required methods to the **minimum orthogonal set**: ideally one or two. +- Default implementations must only call other methods on `Self`, not external state. +- An override changes performance but must preserve the same observable semantics. +- Document which methods are required vs defaulted in the trait's module-level doc comment. +- The pattern is explicitly encouraged by the Rust API Guidelines under "Implement common traits from std" and is central to `std::iter::Iterator`, `std::io::Read`, and `std::io::Write`. + +## See Also + +- [api-extension-trait](api-extension-trait.md) - add methods to foreign types via extension traits +- [trait-associated-type-vs-generic](trait-associated-type-vs-generic.md) - choose between associated types and generic parameters +- [trait-blanket-impl](trait-blanket-impl.md) - give behaviour to every type meeting a bound diff --git a/.opencode/skills/rust-skills/rules/trait-dyn-vs-generic.md b/.opencode/skills/rust-skills/rules/trait-dyn-vs-generic.md new file mode 100644 index 0000000..308992a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/trait-dyn-vs-generic.md @@ -0,0 +1,99 @@ +# trait-dyn-vs-generic + +> Choose static dispatch (generics / `impl Trait`) vs dynamic dispatch (`dyn Trait`) deliberately + +## Why It Matters + +Generic bounds and `impl Trait` monomorphize at compile time: each concrete type gets its own specialised copy that the compiler can inline and optimise, but every distinct type adds to binary size. `dyn Trait` stores a fat pointer (data + vtable) and dispatches at runtime, producing a single code path — necessary when you must store heterogeneous values or return erased types, at the cost of one pointer indirection per call. Choosing the wrong option either leaves performance on the table or prevents heterogeneous collections entirely. Default to generics for hot, simple code; reach for `dyn` when you need flexibility, heap storage, or cross-crate plug-ins. + +## Bad + +```rust +// Using `dyn` everywhere "to be flexible" — blocks inlining and +// forces heap allocation even for single, known types. +trait Shape { + fn area(&self) -> f64; +} + +struct Circle { radius: f64 } +impl Shape for Circle { + fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } +} + +// Unnecessary boxing when only one concrete type is used. +fn total_area(shapes: &[Box]) -> f64 { + shapes.iter().map(|s| s.area()).sum() +} +``` + +## Good + +```rust +use std::fmt; + +trait Shape { + fn area(&self) -> f64; + fn name(&self) -> &str; +} + +#[derive(Clone)] +struct Circle { radius: f64 } +#[derive(Clone)] +struct Rect { w: f64, h: f64 } + +impl Shape for Circle { + fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } + fn name(&self) -> &str { "circle" } +} +impl Shape for Rect { + fn area(&self) -> f64 { self.w * self.h } + fn name(&self) -> &str { "rect" } +} + +// --- Static dispatch: use when the type is known and performance matters --- +// Monomorphized; the compiler can inline `area()`. +fn total_area_generic(shapes: &[S]) -> f64 { + shapes.iter().map(|s| s.area()).sum() +} + +// Also fine with `impl Trait` in argument position (same monomorphization). +fn print_area(shape: &impl Shape) { + println!("{}: {:.2}", shape.name(), shape.area()); +} + +// --- Dynamic dispatch: use for heterogeneous collections or plugin-like APIs --- +fn total_area_dyn(shapes: &[Box]) -> f64 { + shapes.iter().map(|s| s.area()).sum() +} + +fn demo() { + // Homogeneous slice — zero boxing, static dispatch. + let circles = [Circle { radius: 1.0 }, Circle { radius: 2.0 }]; + println!("{:.2}", total_area_generic(&circles)); + + // Heterogeneous collection — `dyn` is the right tool. + let shapes: Vec> = vec![ + Box::new(Circle { radius: 1.0 }), + Box::new(Rect { w: 3.0, h: 4.0 }), + ]; + println!("{:.2}", total_area_dyn(&shapes)); +} +``` + +## Decision Table + +| Situation | Prefer | +|---|---| +| Single known concrete type | `impl Trait` / generic | +| Hot path, inlining critical | Generic bound | +| Heterogeneous collection (`Vec>`) | `dyn Trait` | +| Storing trait objects across calls | `dyn Trait` | +| Returning erased type from `fn` | `Box` or `impl Trait` (static) | +| Binary size matters, many monomorphisations | `dyn Trait` | +| Plug-in / callback registered at runtime | `dyn Trait` | + +## See Also + +- [anti-type-erasure](anti-type-erasure.md) - don't use `Box` when `impl Trait` works +- [type-generic-bounds](type-generic-bounds.md) - add trait bounds only where needed +- [trait-object-safety](trait-object-safety.md) - keep traits dyn-compatible when you need `dyn Trait` diff --git a/.opencode/skills/rust-skills/rules/trait-object-safety.md b/.opencode/skills/rust-skills/rules/trait-object-safety.md new file mode 100644 index 0000000..1cbc664 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/trait-object-safety.md @@ -0,0 +1,109 @@ +# trait-object-safety + +> Keep a trait dyn-compatible (object-safe) when you need `dyn Trait` + +## Why It Matters + +Only dyn-compatible traits can be used as `dyn Trait`. The Rust Reference defines dyn compatibility: every method must be dispatchable through a vtable, which means no generic type parameters on methods, no bare `Self` return or value position, and no associated constants. Violating these rules produces a hard compiler error at the `dyn` use site — often far from the trait definition. If you need both generic methods and `dyn Trait`, you can gate the non-dispatchable methods with `where Self: Sized`, which excludes them from the vtable while keeping the rest of the trait object-safe. + +## Bad + +```rust +trait Transformer { + // Generic method — not dispatchable, makes the whole trait non-object-safe. + fn transform(&self, value: T) -> String; + + fn name(&self) -> &str; +} + +struct Shout; +impl Transformer for Shout { + fn transform(&self, value: T) -> String { + format!("{value:?}").to_uppercase() + } + fn name(&self) -> &str { "shout" } +} + +// This fails to compile: +// error[E0038]: the trait `Transformer` cannot be made into an object +// fn apply(t: &dyn Transformer, x: i32) { ... } +``` + +## Good + +```rust +trait Transformer { + // Core dispatchable method — always in the vtable. + fn transform_str(&self, value: &str) -> String; + + fn name(&self) -> &str; + + // Generic convenience method gated with `where Self: Sized`. + // Callers can use it via a concrete type; it is excluded from `dyn Transformer`. + fn transform_debug(&self, value: T) -> String + where + Self: Sized, + { + self.transform_str(&format!("{value:?}")) + } +} + +// ----- Implementations ----- + +struct Shout; +impl Transformer for Shout { + fn transform_str(&self, value: &str) -> String { value.to_uppercase() } + fn name(&self) -> &str { "shout" } +} + +struct Whisper; +impl Transformer for Whisper { + fn transform_str(&self, value: &str) -> String { value.to_lowercase() } + fn name(&self) -> &str { "whisper" } +} + +// ----- Object-safe usage ----- + +fn apply_all(transformers: &[Box], input: &str) { + for t in transformers { + println!("[{}] {}", t.name(), t.transform_str(input)); + } +} + +// ----- Generic (static) usage — can call the `where Self: Sized` method ----- + +fn apply_generic(t: &T, value: i32) -> String { + t.transform_debug(value) // available because T: Sized +} + +fn demo() { + let ts: Vec> = vec![ + Box::new(Shout), + Box::new(Whisper), + ]; + apply_all(&ts, "Hello World"); + + // Static dispatch path can use the generic helper. + let result = apply_generic(&Shout, 42); + println!("{result}"); +} +``` + +## Dyn-Compatibility Rules (Quick Reference) + +| Feature | Allowed in `dyn Trait`? | +|---|---| +| `&self` / `&mut self` methods | Yes | +| Methods returning `Self` by value | No — use `Box` or gate with `where Self: Sized` | +| Generic method parameters (`fn f`) | No — gate with `where Self: Sized` | +| Associated constants | No | +| Associated types | Yes (type is erased but fixed per impl) | +| `where Self: Sized` methods | Excluded from vtable, safe to have | + +See the Rust Reference — "Object Safety" — at doc.rust-lang.org/reference/items/traits.html#object-safety for the full rules. + +## See Also + +- [trait-dyn-vs-generic](trait-dyn-vs-generic.md) - choose between static and dynamic dispatch deliberately +- [anti-type-erasure](anti-type-erasure.md) - don't use `Box` when `impl Trait` works +- [api-sealed-trait](api-sealed-trait.md) - prevent external implementations of a trait diff --git a/.opencode/skills/rust-skills/rules/type-deref-coercion.md b/.opencode/skills/rust-skills/rules/type-deref-coercion.md new file mode 100644 index 0000000..564819f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-deref-coercion.md @@ -0,0 +1,91 @@ +# type-deref-coercion + +> Implement `Deref`/`DerefMut` only for smart-pointer and transparent wrapper types + +## Why It Matters + +`Deref` coercions are what make `Box`, `Arc`, `String`, and `Vec` ergonomic — they let the inner type's methods surface through the wrapper transparently. The Rust API Guidelines (C-DEREF) specify this usage precisely: implement `Deref` when your type *is* a smart pointer or a transparent container for `T`. Using it as an OOP-style inheritance mechanism pollutes method resolution, confuses readers, and makes refactoring hazardous because adding methods to `T` silently affects every wrapper that `Deref`s to it. + +## Bad + +```rust +struct User { + name: String, + email: String, +} + +struct AdminUser(User); + +// Anti-pattern: using Deref to "inherit" User methods on AdminUser +impl std::ops::Deref for AdminUser { + type Target = User; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// Now AdminUser silently exposes all User fields/methods — +// callers can't tell what AdminUser owns vs. inherits. +fn greet(admin: &AdminUser) { + println!("hello, {}", admin.name); // surprising implicit deref +} +``` + +## Good + +```rust +// Smart-pointer/transparent wrapper: correct use of Deref +struct MyBox(T); + +impl std::ops::Deref for MyBox { + type Target = T; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for MyBox { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +// Domain types: expose only the API you intend, explicitly +struct User { + pub name: String, + pub email: String, +} + +struct AdminUser(User); + +impl AdminUser { + pub fn name(&self) -> &str { + &self.0.name + } + + pub fn email(&self) -> &str { + &self.0.email + } + + pub fn can_delete_users(&self) -> bool { + true + } +} + +fn greet(admin: &AdminUser) { + println!("hello, {}", admin.name()); // explicit, readable +} +``` + +## Legitimate Uses + +- `Box`, `Rc`, `Arc` — pointer indirection +- `String` → `str`, `Vec` → `[T]` — owned-to-borrowed transparent containers +- `MutexGuard` → `T` — RAII guards that provide temporary access +- Newtype wrappers where the entire semantic purpose is "this is a `T` with additional invariants" + +## See Also + +- [api-newtype-safety](api-newtype-safety.md) - newtypes for type-safe distinctions without inheritance +- [type-newtype-ids](type-newtype-ids.md) - wrapping IDs in newtypes +- [own-borrow-over-clone](own-borrow-over-clone.md) - prefer `&T` borrowing over `.clone()` diff --git a/.opencode/skills/rust-skills/rules/type-display-vs-debug.md b/.opencode/skills/rust-skills/rules/type-display-vs-debug.md new file mode 100644 index 0000000..49cb63a --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-display-vs-debug.md @@ -0,0 +1,79 @@ +# type-display-vs-debug + +> Use `Display` for user-facing output and `Debug` for diagnostics; never swap them + +## Why It Matters + +`Debug` (`{:?}`) is for developers: logs, panic messages, test assertions, and `dbg!()`. It should always be derived and reflects internal structure. `Display` (`{}`) is for end users: CLI output, error messages surfaced to humans, and log fields meant to be read in production. `std::error::Error` requires `Display` so that error chains read naturally. Routing `Debug` output to users leaks implementation details; routing `Display` output to log frameworks loses structural information. + +## Bad + +```rust +#[derive(Debug)] +struct ParseError { + input: String, + line: u32, +} + +// Mistake 1: using Debug output in a user-facing message +fn report_error(e: &ParseError) { + eprintln!("failed: {:?}", e); // leaks internal field names +} + +// Mistake 2: implementing Display by calling debug +use std::fmt; +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) // wrong — duplicates Debug + } +} +``` + +## Good + +```rust +use std::fmt; + +#[derive(Debug)] // derive Debug for free diagnostic output +struct ParseError { + input: String, + line: u32, +} + +// Hand-write Display for a clean, human-readable message +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "parse error on line {}: {:?}", self.line, self.input) + } +} + +impl std::error::Error for ParseError {} + +fn main() { + let e = ParseError { input: "foo bar".into(), line: 42 }; + + // User-facing: clean sentence + eprintln!("error: {e}"); + + // Developer/log: structured dump + eprintln!("debug: {e:?}"); +} +``` + +## Guidelines + +| Trait | Format | Audience | How to implement | +|-------|--------|----------|-----------------| +| `Debug` | `{:?}` / `{:#?}` | Developers, logs | `#[derive(Debug)]` (almost always) | +| `Display` | `{}` | End users, error messages | Hand-write to describe the condition clearly | + +- Never derive `Display` — it must be intentionally written. +- `#[derive(Debug)]` on every public type (API Guidelines C-DEBUG). +- If your error type implements `std::error::Error`, its `Display` output becomes the human-readable error message that propagates through `anyhow::Context` and similar. +- The `{:#?}` pretty-print form is still `Debug`; use it in tests for readable assertion output, not in user-facing code. + +## See Also + +- [api-common-traits](api-common-traits.md) - implement `Debug`, `Clone`, `PartialEq` eagerly +- [err-thiserror-lib](err-thiserror-lib.md) - `thiserror` generates correct `Display` from `#[error("...")]` +- [type-numeric-fmt](type-numeric-fmt.md) - hex/octal/binary formatting for numeric newtypes diff --git a/.opencode/skills/rust-skills/rules/type-enum-states.md b/.opencode/skills/rust-skills/rules/type-enum-states.md new file mode 100644 index 0000000..ffee8e0 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-enum-states.md @@ -0,0 +1,156 @@ +# type-enum-states + +> Use enums for mutually exclusive states + +## Why It Matters + +When a value can be in exactly one of several states, an enum makes invalid states unrepresentable. The compiler ensures all states are handled. Contrast with boolean flags or optional fields that can represent impossible combinations. + +## Bad + +```rust +struct Connection { + is_connected: bool, + is_authenticated: bool, + is_disconnected: bool, // Can all three be true? False? + socket: Option, + credentials: Option, +} + +// Possible invalid states: +// - is_connected && is_disconnected (contradiction) +// - is_authenticated && !is_connected (impossible) +// - socket is None but is_connected is true (inconsistent) +``` + +## Good + +```rust +enum ConnectionState { + Disconnected, + Connecting { address: SocketAddr }, + Connected { socket: TcpStream }, + Authenticated { socket: TcpStream, session: Session }, + Failed { error: ConnectionError }, +} + +struct Connection { + state: ConnectionState, +} + +// Impossible states are unrepresentable +// Each state has exactly the data it needs +``` + +## Pattern Matching Ensures Completeness + +```rust +fn handle_connection(conn: &Connection) { + match &conn.state { + ConnectionState::Disconnected => { + println!("Not connected"); + } + ConnectionState::Connecting { address } => { + println!("Connecting to {}", address); + } + ConnectionState::Connected { socket } => { + println!("Connected, not authenticated"); + } + ConnectionState::Authenticated { socket, session } => { + println!("Authenticated as {}", session.user); + } + ConnectionState::Failed { error } => { + println!("Failed: {}", error); + } + } + // Compiler error if any state is missing +} +``` + +## State Transitions + +```rust +impl Connection { + fn connect(&mut self, addr: SocketAddr) -> Result<(), Error> { + match &self.state { + ConnectionState::Disconnected => { + self.state = ConnectionState::Connecting { address: addr }; + Ok(()) + } + _ => Err(Error::AlreadyConnected), + } + } + + fn on_connected(&mut self, socket: TcpStream) { + if let ConnectionState::Connecting { .. } = &self.state { + self.state = ConnectionState::Connected { socket }; + } + } + + fn authenticate(&mut self, creds: Credentials) -> Result<(), Error> { + match std::mem::replace(&mut self.state, ConnectionState::Disconnected) { + ConnectionState::Connected { socket } => { + let session = perform_auth(&socket, creds)?; + self.state = ConnectionState::Authenticated { socket, session }; + Ok(()) + } + other => { + self.state = other; + Err(Error::NotConnected) + } + } + } +} +``` + +## Result and Option as State Enums + +```rust +// Option is an enum for "might not exist" +enum Option { + Some(T), + None, +} + +// Result is an enum for "might have failed" +enum Result { + Ok(T), + Err(E), +} + +// Use these instead of nullable/sentinel values +fn find_user(id: u64) -> Option { ... } +fn parse_config(s: &str) -> Result { ... } +``` + +## Avoid Boolean Flags + +```rust +// Bad: boolean flags +struct Task { + is_running: bool, + is_completed: bool, + is_failed: bool, + error: Option, +} + +// Good: enum state +enum TaskState { + Pending, + Running { started_at: Instant }, + Completed { result: Output }, + Failed { error: Error }, +} + +struct Task { + state: TaskState, +} +``` + +## See Also + +- [api-typestate](./api-typestate.md) - Type-level state machines +- [api-non-exhaustive](./api-non-exhaustive.md) - Forward-compatible enums +- [type-option-nullable](./type-option-nullable.md) - Option for optional values +- [pat-exhaustive-enum](./pat-exhaustive-enum.md) - Match owned enums exhaustively +- [serde-enum-representation](./serde-enum-representation.md) - Choose enum wire tagging diff --git a/.opencode/skills/rust-skills/rules/type-generic-bounds.md b/.opencode/skills/rust-skills/rules/type-generic-bounds.md new file mode 100644 index 0000000..234a978 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-generic-bounds.md @@ -0,0 +1,144 @@ +# type-generic-bounds + +> Add trait bounds only where needed, prefer where clauses for readability + +## Why It Matters + +Trait bounds constrain what types can be used with generic code. Adding unnecessary bounds limits flexibility. Adding bounds in the right place (impl vs function vs where clause) affects usability and readability. Well-placed bounds keep APIs flexible while ensuring type safety. + +## Bad + +```rust +// Bounds on struct definition - limits all uses +struct Container { // Even storage requires Clone? + items: Vec, +} + +// Inline bounds make signature hard to read +fn process( + value: T +) -> Result { ... } + +// Redundant bounds +fn print_twice(value: T) +where + T: Clone, // Already specified above +{ ... } +``` + +## Good + +```rust +// No bounds on struct - store anything +struct Container { + items: Vec, +} + +// Bounds only on impls that need them +impl Container { + fn duplicate(&self) -> Self { + Container { items: self.items.clone() } + } +} + +impl Container { + fn debug_print(&self) { + println!("{:?}", self.items); + } +} + +// Where clause for readability +fn process(value: T) -> Result +where + T: Clone + Debug + Send + Sync + 'static, + E: Error + Send + Clone, +{ ... } +``` + +## Bound Placement + +```rust +// On struct: affects all uses of the type +struct MustBeClone { data: T } // Rarely needed + +// On impl: affects specific functionality +impl Container { ... } // Common pattern + +// On function: affects that function only +fn requires_send(value: T) { ... } + +// Recommendation: start with no bounds, add as needed +``` + +## Where Clause Benefits + +```rust +// Inline: hard to read +fn complex + Into>(t: T, u: U) { } + +// Where clause: clear and scannable +fn complex(t: T, u: U) +where + T: Clone + Debug + Send, + U: AsRef + Into, +{ } + +// Essential for complex bounds +fn foo(t: T, u: U) +where + T: Iterator, + U: Clone + Into, + Vec: Debug, // Bounds on expressions +{ } +``` + +## Implied Bounds + +```rust +// Supertrait bounds are implied +trait Foo: Clone + Debug {} + +fn process(value: T) { + // T: Clone and T: Debug are implied by T: Foo + let cloned = value.clone(); + println!("{:?}", cloned); +} + +// Associated type bounds +fn process(iter: I) +where + I: Iterator, + I::Item: Clone, // Bound on associated type +{ } +``` + +## Conditional Trait Implementation + +```rust +struct Wrapper(T); + +// Implement Clone only when T: Clone +impl Clone for Wrapper { + fn clone(&self) -> Self { + Wrapper(self.0.clone()) + } +} + +// Implement Debug only when T: Debug +impl Debug for Wrapper { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_tuple("Wrapper").field(&self.0).finish() + } +} + +// Wrapper is Clone + Debug +// Wrapper is neither +``` + +## See Also + +- [api-impl-into](./api-impl-into.md) - Using Into bounds +- [api-impl-asref](./api-impl-asref.md) - Using AsRef bounds +- [name-type-param-single](./name-type-param-single.md) - Type parameter naming +- [trait-dyn-vs-generic](./trait-dyn-vs-generic.md) - Static vs dynamic dispatch +- [trait-associated-type-vs-generic](./trait-associated-type-vs-generic.md) - Associated types vs generics diff --git a/.opencode/skills/rust-skills/rules/type-never-diverge.md b/.opencode/skills/rust-skills/rules/type-never-diverge.md new file mode 100644 index 0000000..d19d9b3 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-never-diverge.md @@ -0,0 +1,153 @@ +# type-never-diverge + +> Use `!` (never type) for functions that never return + +## Why It Matters + +The never type `!` indicates a function will never return normally—it either loops forever, panics, or exits the process. This helps the compiler understand control flow and enables `!` to coerce to any type, making it useful in match arms and expressions. + +## Bad + +```rust +// Return type doesn't indicate non-returning +fn infinite_loop() { + loop { + process_events(); + } + // Implicit () return type, but never returns +} + +// Using Option when it always panics +fn unreachable_code() -> Option<()> { + panic!("This should never be called"); +} +``` + +## Good + +```rust +// ! indicates function never returns +fn infinite_loop() -> ! { + loop { + process_events(); + } +} + +fn abort_with_error(msg: &str) -> ! { + eprintln!("Fatal error: {}", msg); + std::process::exit(1); +} + +fn panic_handler() -> ! { + panic!("Unexpected state"); +} +``` + +## Coercion to Any Type + +```rust +// ! coerces to any type +fn get_value(opt: Option) -> i32 { + match opt { + Some(v) => v, + None => panic!("No value"), // panic! returns !, coerces to i32 + } +} + +// Useful in Result handling +fn must_get_config() -> Config { + match load_config() { + Ok(c) => c, + Err(e) => { + log_error(&e); + std::process::exit(1) // Returns !, coerces to Config + } + } +} +``` + +## Standard Library Examples + +```rust +// std::process::exit +pub fn exit(code: i32) -> ! + +// panic! macro +// Expands to an expression of type ! + +// std::hint::unreachable_unchecked +pub unsafe fn unreachable_unchecked() -> ! + +// loop {} with no break +fn forever() -> ! { + loop {} +} +``` + +## In Match Expressions + +```rust +enum State { + Running, + Stopped, + Error, +} + +fn get_status(state: &State) -> &str { + match state { + State::Running => "running", + State::Stopped => "stopped", + State::Error => unreachable!(), // ! coerces to &str + } +} + +// With Result +fn process(r: Result) -> Data { + match r { + Ok(d) => d, + Err(e) => panic!("Unexpected error: {}", e), // ! coerces to Data + } +} +``` + +## Diverging Closures + +```rust +// Closures that never return +let handler: fn() -> ! = || { + panic!("Handler called"); +}; + +// In thread spawn +std::thread::spawn(|| -> ! { + loop { + process_work(); + } +}); +``` + +## Stability Notes + +`fn f() -> !` (never type as a return type) has been **stable since Rust 1.41** — no feature gate needed. + +Using `!` as an *arbitrary type argument* (e.g. `Result<(), !>`) still requires the nightly feature gate: + +```rust +// Nightly only — using ! as a type argument +#![feature(never_type)] + +type NeverResult = Result<(), !>; // Can never be Err +``` + +On stable Rust, use `std::convert::Infallible` as the conventional substitute: + +```rust +// Stable: Infallible is the standard stand-in for ! +type StableNeverResult = Result<(), std::convert::Infallible>; +``` + +## See Also + +- [err-result-over-panic](./err-result-over-panic.md) - When to panic vs return Result +- [type-result-fallible](./type-result-fallible.md) - Result for errors +- [opt-cold-unlikely](./opt-cold-unlikely.md) - Marking unlikely paths diff --git a/.opencode/skills/rust-skills/rules/type-newtype-ids.md b/.opencode/skills/rust-skills/rules/type-newtype-ids.md new file mode 100644 index 0000000..1c39f52 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-newtype-ids.md @@ -0,0 +1,161 @@ +# type-newtype-ids + +> Wrap IDs in newtypes: `UserId(u64)` + +## Why It Matters + +Using raw integers for IDs is error-prone. It's easy to accidentally pass a `user_id` where a `post_id` is expected. Newtypes make these mix-ups compile-time errors instead of runtime bugs. + +## Bad + +```rust +fn get_user_posts(user_id: u64, post_id: u64) -> Vec { + // Which is which? Easy to swap by accident +} + +// Oops! Arguments swapped - compiles fine, wrong at runtime +let posts = get_user_posts(post_id, user_id); + +// Even worse with multiple IDs +fn transfer(from: u64, to: u64, amount: u64) { + // from/to can easily be swapped +} +``` + +## Good + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct UserId(pub u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PostId(pub u64); + +fn get_user_posts(user_id: UserId, post_id: PostId) -> Vec { + // Types are distinct +} + +// This won't compile - types don't match +// let posts = get_user_posts(post_id, user_id); // ERROR! + +// Correct usage +let posts = get_user_posts(UserId(1), PostId(42)); +``` + +## Derive Common Traits + +```rust +#[derive( + Debug, // For printing + Clone, // For copying + Copy, // For implicit copies (if small) + PartialEq, // For == comparison + Eq, // For HashMap keys + Hash, // For HashMap keys + PartialOrd, // For sorting (optional) + Ord, // For BTreeMap keys (optional) +)] +pub struct UserId(pub u64); +``` + +## Add Useful Methods + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct UserId(u64); + +impl UserId { + pub const fn new(id: u64) -> Self { + Self(id) + } + + pub const fn get(self) -> u64 { + self.0 + } + + // For database queries + pub fn as_i64(self) -> i64 { + self.0 as i64 + } +} + +impl From for UserId { + fn from(id: u64) -> Self { + Self(id) + } +} + +impl std::fmt::Display for UserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "user:{}", self.0) + } +} +``` + +## With Serde + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] // Serializes as just the inner value +pub struct UserId(pub u64); + +// JSON: {"user_id": 123} not {"user_id": {"0": 123}} +``` + +## String IDs (UUIDs, etc.) + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SessionId(String); + +impl SessionId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } + + pub fn parse(s: &str) -> Result { + // Validate format + uuid::Uuid::parse_str(s)?; + Ok(Self(s.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} +``` + +## Multiple Related IDs + +```rust +// Macro for consistent ID types +macro_rules! define_id { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct $name(pub u64); + + impl $name { + pub const fn new(id: u64) -> Self { Self(id) } + pub const fn get(self) -> u64 { self.0 } + } + + impl From for $name { + fn from(id: u64) -> Self { Self(id) } + } + }; +} + +define_id!(UserId); +define_id!(PostId); +define_id!(CommentId); +define_id!(TeamId); +``` + +## See Also + +- [api-newtype-safety](api-newtype-safety.md) - Newtypes for type safety +- [type-newtype-validated](type-newtype-validated.md) - Newtypes for validated data +- [api-parse-dont-validate](api-parse-dont-validate.md) - Parse into validated types +- [num-nonzero](num-nonzero.md) - NonZero* for never-zero ids diff --git a/.opencode/skills/rust-skills/rules/type-newtype-validated.md b/.opencode/skills/rust-skills/rules/type-newtype-validated.md new file mode 100644 index 0000000..7ff2515 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-newtype-validated.md @@ -0,0 +1,161 @@ +# type-newtype-validated + +> Use newtypes to enforce validation at construction time + +## Why It Matters + +A validated newtype guarantees its inner value is always valid. Once you have an `Email`, you know it passed validation—no re-checking needed. This "parse, don't validate" pattern catches errors at boundaries and makes invalid states unrepresentable. + +## Bad + +```rust +// Validation scattered throughout code +fn send_email(to: &str, body: &str) -> Result<(), Error> { + if !is_valid_email(to) { // Must check every time + return Err(Error::InvalidEmail); + } + // ... +} + +fn add_recipient(list: &mut Vec, email: &str) -> Result<(), Error> { + if !is_valid_email(email) { // Check again + return Err(Error::InvalidEmail); + } + list.push(email.to_string()); + Ok(()) +} +``` + +## Good + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Email(String); + +impl Email { + pub fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(EmailError::Invalid(s.to_string())) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// No validation needed - Email is always valid +fn send_email(to: &Email, body: &str) -> Result<(), Error> { + // to is guaranteed valid + send_to_address(to.as_str(), body) +} + +fn add_recipient(list: &mut Vec, email: Email) { + // email is guaranteed valid + list.push(email); +} +``` + +## Common Validated Types + +```rust +// URLs +pub struct Url(url::Url); + +impl Url { + pub fn parse(s: &str) -> Result { + url::Url::parse(s) + .map(Url) + .map_err(UrlError::from) + } +} + +// Non-empty strings +pub struct NonEmptyString(String); + +impl NonEmptyString { + pub fn new(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(NonEmptyString(s)) + } + } +} + +// Positive numbers +pub struct PositiveI32(i32); + +impl PositiveI32 { + pub fn new(n: i32) -> Option { + if n > 0 { + Some(PositiveI32(n)) + } else { + None + } + } + + pub fn get(&self) -> i32 { + self.0 + } +} + +// Bounded ranges +pub struct Percentage(f64); + +impl Percentage { + pub fn new(value: f64) -> Result { + if (0.0..=100.0).contains(&value) { + Ok(Percentage(value)) + } else { + Err(RangeError::OutOfBounds) + } + } +} +``` + +## With Serde + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +pub struct Email(String); + +impl<'de> Deserialize<'de> for Email { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Email::new(&s).map_err(serde::de::Error::custom) + } +} + +// JSON deserialization automatically validates +let email: Email = serde_json::from_str(r#""user@example.com""#)?; +``` + +## Compile-Time Validation + +```rust +// For values known at compile time +macro_rules! email { + ($s:literal) => {{ + const _: () = assert!(is_valid_email_const($s)); + Email::new_unchecked($s) + }}; +} + +let admin = email!("admin@example.com"); // Validated at compile time +``` + +## See Also + +- [api-parse-dont-validate](./api-parse-dont-validate.md) - Parse at boundaries +- [api-newtype-safety](./api-newtype-safety.md) - Type-safe distinctions +- [type-newtype-ids](./type-newtype-ids.md) - ID newtypes +- [conv-fromstr-parsing](./conv-fromstr-parsing.md) - FromStr for validated parsing +- [serde-try-from-validate](./serde-try-from-validate.md) - Validate during deserialization diff --git a/.opencode/skills/rust-skills/rules/type-no-stringly.md b/.opencode/skills/rust-skills/rules/type-no-stringly.md new file mode 100644 index 0000000..a44cf2c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-no-stringly.md @@ -0,0 +1,144 @@ +# type-no-stringly + +> Avoid stringly-typed APIs; use enums, newtypes, or validated types + +## Why It Matters + +Strings accept any value—typos, wrong formats, invalid data all compile fine. Enums, newtypes, and validated types catch errors at compile time or construction time, not runtime. They also provide better IDE support, documentation, and make invalid states unrepresentable. + +## Bad + +```rust +// Status as string - easy to get wrong +fn set_status(status: &str) { + match status { + "pending" => { ... } + "active" => { ... } + "completed" => { ... } + _ => panic!("Unknown status"), // Runtime error + } +} + +// Easy to misuse +set_status("pending"); // OK +set_status("Pending"); // Runtime error - wrong case +set_status("aktive"); // Runtime error - typo +set_status("done"); // Runtime error - wrong word + +// Configuration as strings +fn configure(key: &str, value: &str) { + // No type safety, no validation +} +``` + +## Good + +```rust +// Status as enum - compile-time safety +enum Status { + Pending, + Active, + Completed, +} + +fn set_status(status: Status) { + match status { + Status::Pending => { ... } + Status::Active => { ... } + Status::Completed => { ... } + } // Exhaustive - compiler checks all cases +} + +// Can only pass valid values +set_status(Status::Pending); // OK +set_status(Status::Aktivev); // Compile error - typo caught! + +// Configuration with typed builder +struct Config { + timeout: Duration, + retries: u32, + mode: Mode, +} + +enum Mode { Fast, Safe, Balanced } +``` + +## Parsing at Boundaries + +```rust +use std::str::FromStr; + +#[derive(Debug, Clone, Copy)] +enum Priority { + Low, + Medium, + High, +} + +impl FromStr for Priority { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "low" => Ok(Priority::Low), + "medium" | "med" => Ok(Priority::Medium), + "high" => Ok(Priority::High), + _ => Err(ParseError::UnknownPriority(s.to_string())), + } + } +} + +// Parse once at boundary +fn handle_request(priority_str: &str) -> Result<(), Error> { + let priority: Priority = priority_str.parse()?; + // From here, priority is type-safe + process(priority); + Ok(()) +} +``` + +## Validated Newtypes + +```rust +// Instead of string for email +struct Email(String); + +impl Email { + fn new(s: &str) -> Result { + if is_valid_email(s) { + Ok(Email(s.to_string())) + } else { + Err(ValidationError::InvalidEmail) + } + } +} + +// Instead of string for UUID +struct UserId(uuid::Uuid); + +// Instead of string for paths +struct ConfigPath(PathBuf); +``` + +## With Serde + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum EventType { + UserCreated, + UserDeleted, + UserUpdated, +} + +// JSON: {"type": "user_created", ...} +// Automatically validated during deserialization +``` + +## See Also + +- [anti-stringly-typed](./anti-stringly-typed.md) - Anti-pattern details +- [type-newtype-validated](./type-newtype-validated.md) - Validated newtypes +- [type-enum-states](./type-enum-states.md) - Enums for states diff --git a/.opencode/skills/rust-skills/rules/type-numeric-fmt.md b/.opencode/skills/rust-skills/rules/type-numeric-fmt.md new file mode 100644 index 0000000..5dfaf56 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-numeric-fmt.md @@ -0,0 +1,87 @@ +# type-numeric-fmt + +> Implement `LowerHex`, `UpperHex`, `Octal`, and `Binary` for numeric newtypes + +## Why It Matters + +Rust's API Guidelines (C-NUM-FMT) state that numeric types should support `{:x}`, `{:X}`, `{:o}`, and `{:b}` wherever the underlying integer type does. A numeric newtype that silently drops these format specifiers is an ergonomic regression — callers who reach for `{:x}` to debug a bitmask or address will hit a compile error instead. The fix is a one-liner per trait that forwards to the inner value's formatter. + +## Bad + +```rust +use std::fmt; + +struct Mask(u32); + +impl fmt::Display for Mask { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +fn main() { + let m = Mask(0xDEAD_BEEF); + println!("{}", m); // ok + // println!("{:x}", m); // compile error: Mask doesn't implement LowerHex +} +``` + +## Good + +```rust +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Mask(u32); + +impl fmt::Display for Mask { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl fmt::LowerHex for Mask { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::LowerHex::fmt(&self.0, f) + } +} + +impl fmt::UpperHex for Mask { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::UpperHex::fmt(&self.0, f) + } +} + +impl fmt::Octal for Mask { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Octal::fmt(&self.0, f) + } +} + +impl fmt::Binary for Mask { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Binary::fmt(&self.0, f) + } +} + +fn main() { + let m = Mask(0xDEAD_BEEF); + println!("{m}"); // 3735928559 + println!("{m:x}"); // deadbeef + println!("{m:X}"); // DEADBEEF + println!("{m:#010x}"); // 0xdeadbeef + println!("{m:o}"); // 33653337357 + println!("{m:b}"); // 11011110101011011011111011101111 +} +``` + +## Notes + +- Forward through the inner type's trait implementation (e.g., `fmt::LowerHex::fmt(&self.0, f)`) so that format flags like `#`, `0`, and width are handled correctly by the inner type. +- Apply this to any newtype whose inner type is a primitive integer (`u8`–`u128`, `i8`–`i128`, `usize`, `isize`). +- Skip `Octal`/`Binary` if there is genuinely no domain reason to print the value in those bases (e.g., a purely decimal `Count` newtype), but always implement `LowerHex`/`UpperHex` for any mask, address, or identifier type. + +## See Also + +- [type-newtype-ids](type-newtype-ids.md) - wrapping IDs and numeric values in newtypes +- [type-display-vs-debug](type-display-vs-debug.md) - choosing between `Display` and `Debug` diff --git a/.opencode/skills/rust-skills/rules/type-option-nullable.md b/.opencode/skills/rust-skills/rules/type-option-nullable.md new file mode 100644 index 0000000..1d05274 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-option-nullable.md @@ -0,0 +1,137 @@ +# type-option-nullable + +> Use `Option` for values that might not exist + +## Why It Matters + +`Option` explicitly represents "value or nothing" in the type system. Unlike null pointers or sentinel values, you can't accidentally use a missing value—the compiler forces you to handle the `None` case. This eliminates null pointer exceptions at compile time. + +## Bad + +```rust +// Sentinel values - easy to forget to check +fn find_user(id: u64) -> User { + // Returns "empty" user if not found - caller might not check + users.get(&id).cloned().unwrap_or(User::empty()) +} + +// Nullable-style with raw pointers +fn find_user(id: u64) -> *const User { + // Null if not found - unsafe, no compiler help +} + +// Error-prone usage +let user = find_user(42); +println!("{}", user.name); // Might be empty user - silent bug +``` + +## Good + +```rust +// Option makes absence explicit +fn find_user(id: u64) -> Option { + users.get(&id).cloned() +} + +// Must handle the None case +let user = find_user(42); +match user { + Some(u) => println!("{}", u.name), + None => println!("User not found"), +} + +// Or use combinators +let name = find_user(42) + .map(|u| u.name) + .unwrap_or_else(|| "Unknown".to_string()); +``` + +## Common Option Patterns + +```rust +// if let for single case +if let Some(user) = find_user(id) { + process(user); +} + +// Chaining with map +let upper_name = find_user(id) + .map(|u| u.name) + .map(|n| n.to_uppercase()); + +// Providing defaults +let user = find_user(id).unwrap_or_default(); +let user = find_user(id).unwrap_or_else(|| User::guest()); + +// ? operator for propagation +fn get_user_email(id: u64) -> Option { + let user = find_user(id)?; + Some(user.email) +} + +// and_then for chained optionals +fn get_user_country(id: u64) -> Option { + find_user(id) + .and_then(|u| u.address) + .and_then(|a| a.country) +} +``` + +## Struct Fields + +```rust +struct User { + name: String, + email: String, + phone: Option, // Optional field + avatar_url: Option, // Optional field +} + +impl User { + fn display_phone(&self) -> &str { + self.phone.as_deref().unwrap_or("Not provided") + } +} +``` + +## Option vs Result + +```rust +// Option: value might not exist (no error context) +fn find(key: &str) -> Option { ... } + +// Result: operation might fail (with error context) +fn parse(input: &str) -> Result { ... } + +// Convert Option to Result +let value = find("key").ok_or(Error::NotFound)?; + +// Convert Result to Option +let value = parse("input").ok(); // Discards error +``` + +## Option References + +```rust +// Option<&T> for optional borrows +fn get(&self, key: &str) -> Option<&Value> { + self.map.get(key) +} + +// as_ref() to borrow Option contents +let opt: Option = Some("hello".to_string()); +let opt_ref: Option<&String> = opt.as_ref(); +let opt_str: Option<&str> = opt.as_deref(); + +// as_mut() for mutable borrow +let mut opt = Some(vec![1, 2, 3]); +if let Some(v) = opt.as_mut() { + v.push(4); +} +``` + +## See Also + +- [type-result-fallible](./type-result-fallible.md) - Result for errors +- [type-enum-states](./type-enum-states.md) - Enums for states +- [err-no-unwrap-prod](./err-no-unwrap-prod.md) - Handling Option safely diff --git a/.opencode/skills/rust-skills/rules/type-phantom-marker.md b/.opencode/skills/rust-skills/rules/type-phantom-marker.md new file mode 100644 index 0000000..88bcb0c --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-phantom-marker.md @@ -0,0 +1,188 @@ +# type-phantom-marker + +> Use `PhantomData` to express type relationships without runtime cost + +## Why It Matters + +Sometimes your type needs to be parameterized by a type that doesn't appear in any field—for variance, drop order, or semantic purposes. `PhantomData` tells the compiler your type is "associated with" `T` without storing any `T` data. It has zero runtime cost. + +## Bad + +```rust +// Type parameter unused - compiler error +struct Handle { + id: u64, + // Error: parameter `T` is never used +} + +// Workaround with unnecessary storage +struct Handle { + id: u64, + _type: Option, // Wastes memory, requires T: Default +} +``` + +## Good + +```rust +use std::marker::PhantomData; + +struct Handle { + id: u64, + _marker: PhantomData, // Zero-size, tells compiler about T +} + +impl Handle { + fn new(id: u64) -> Self { + Handle { + id, + _marker: PhantomData, + } + } +} + +// Different Handle types are incompatible +struct User; +struct Order; + +fn process_user(h: Handle) { ... } + +let user_handle = Handle::::new(1); +let order_handle = Handle::::new(2); + +process_user(user_handle); // OK +process_user(order_handle); // Error: expected Handle, found Handle +``` + +## Expressing Ownership + +```rust +use std::marker::PhantomData; + +// Owns T conceptually (like Box) +struct Container { + ptr: *mut T, + _marker: PhantomData, // Acts like we own a T +} + +// Drop will be called on T when Container drops +impl Drop for Container { + fn drop(&mut self) { + unsafe { + std::ptr::drop_in_place(self.ptr); + } + } +} +``` + +## Expressing Borrowing + +```rust +use std::marker::PhantomData; + +// Borrows T for lifetime 'a +struct Ref<'a, T> { + ptr: *const T, + _marker: PhantomData<&'a T>, // Acts like &'a T +} + +// Compiler tracks lifetime correctly +impl<'a, T> Ref<'a, T> { + fn get(&self) -> &'a T { + unsafe { &*self.ptr } + } +} +``` + +## Type-Level State Machine + +```rust +use std::marker::PhantomData; + +// States as zero-size types +struct Unlocked; +struct Locked; + +struct Door { + _state: PhantomData, +} + +impl Door { + fn lock(self) -> Door { + println!("Locking..."); + Door { _state: PhantomData } + } + + fn open(&self) { + println!("Opening..."); + } +} + +impl Door { + fn unlock(self) -> Door { + println!("Unlocking..."); + Door { _state: PhantomData } + } + + // Can't call open() on Locked door - method doesn't exist +} + +fn example() { + let door: Door = Door { _state: PhantomData }; + door.open(); // OK + let locked = door.lock(); + // locked.open(); // Error: no method `open` for Door + let unlocked = locked.unlock(); + unlocked.open(); // OK +} +``` + +## Variance Control + +```rust +use std::marker::PhantomData; + +// Covariant in T (PhantomData) +struct Producer { + _marker: PhantomData, // Covariant +} + +// Contravariant in T (PhantomData) +struct Consumer { + _marker: PhantomData, // Contravariant +} + +// Invariant in T (PhantomData T>) +struct Both { + _marker: PhantomData T>, // Invariant +} +``` + +## Common Uses + +```rust +// 1. FFI handles with type safety +struct FileHandle { + fd: i32, + _marker: PhantomData, +} + +// 2. Generic iterators +struct Iter<'a, T> { + ptr: *const T, + end: *const T, + _marker: PhantomData<&'a T>, +} + +// 3. Allocator-aware types +struct Vec { + buf: RawVec, + len: usize, +} +``` + +## See Also + +- [api-typestate](./api-typestate.md) - State machine pattern +- [api-newtype-safety](./api-newtype-safety.md) - Type-safe wrappers +- [type-newtype-ids](./type-newtype-ids.md) - ID types diff --git a/.opencode/skills/rust-skills/rules/type-repr-transparent.md b/.opencode/skills/rust-skills/rules/type-repr-transparent.md new file mode 100644 index 0000000..bd4701f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-repr-transparent.md @@ -0,0 +1,143 @@ +# type-repr-transparent + +> Use `#[repr(transparent)]` for newtypes in FFI contexts + +## Why It Matters + +`#[repr(transparent)]` guarantees a newtype has the same memory layout as its inner type. This is essential for FFI where you need type safety in Rust but must match C ABI layouts. Without it, the compiler may add padding or change layout. + +## Bad + +```rust +// No layout guarantee - might not match inner type in FFI +struct Handle(u64); + +// Passing to C code might fail +extern "C" { + fn process_handle(h: Handle); // May not work correctly +} + +// Wrapping C type without layout guarantee +struct SafePointer(*mut c_void); +``` + +## Good + +```rust +// Guaranteed same layout as inner type +#[repr(transparent)] +struct Handle(u64); + +// Safe for FFI +extern "C" { + fn process_handle(h: Handle); // Works - same layout as u64 +} + +// FFI pointer wrapper +#[repr(transparent)] +struct SafePointer(*mut c_void); + +impl SafePointer { + // Safe Rust API around raw pointer + pub fn new(ptr: *mut c_void) -> Option { + if ptr.is_null() { + None + } else { + Some(SafePointer(ptr)) + } + } +} +``` + +## What repr(transparent) Guarantees + +```rust +use std::mem::{size_of, align_of}; + +#[repr(transparent)] +struct Meters(f64); + +// Same size +assert_eq!(size_of::(), size_of::()); + +// Same alignment +assert_eq!(align_of::(), align_of::()); + +// Same ABI - can pass where f64 expected +extern "C" fn measure(distance: Meters) { ... } +``` + +## With PhantomData + +```rust +use std::marker::PhantomData; + +// PhantomData is zero-sized, doesn't affect layout +#[repr(transparent)] +struct TypedHandle { + raw: u64, + _marker: PhantomData, // Zero-sized, ignored for layout +} + +// Still same layout as u64 +assert_eq!(size_of::>(), size_of::()); +``` + +## NonZero Wrappers + +```rust +use std::num::NonZeroU64; + +#[repr(transparent)] +struct NonZeroHandle(NonZeroU64); + +// Inherits null-pointer optimization +assert_eq!(size_of::>(), size_of::()); +``` + +## FFI Pattern + +```rust +mod ffi { + use std::os::raw::c_int; + + #[repr(transparent)] + pub struct FileDescriptor(c_int); + + extern "C" { + pub fn open(path: *const i8, flags: c_int) -> FileDescriptor; + pub fn close(fd: FileDescriptor) -> c_int; + pub fn read(fd: FileDescriptor, buf: *mut u8, len: usize) -> isize; + } +} + +// Safe wrapper +pub struct File { + fd: ffi::FileDescriptor, +} + +impl File { + pub fn open(path: &str) -> std::io::Result { + let c_path = std::ffi::CString::new(path)?; + let fd = unsafe { ffi::open(c_path.as_ptr(), 0) }; + // ... error handling + Ok(File { fd }) + } +} +``` + +## When to Use + +| Scenario | Use `#[repr(transparent)]`? | +|----------|----------------------------| +| FFI newtype wrappers | Yes | +| Type-safe handles | Yes | +| NonZero optimization | Yes | +| Pure Rust newtypes | Optional (doesn't hurt) | +| Multi-field structs | N/A (only for single-field) | + +## See Also + +- [type-newtype-ids](./type-newtype-ids.md) - Newtype pattern +- [type-phantom-marker](./type-phantom-marker.md) - PhantomData usage +- [api-newtype-safety](./api-newtype-safety.md) - Type-safe newtypes diff --git a/.opencode/skills/rust-skills/rules/type-result-fallible.md b/.opencode/skills/rust-skills/rules/type-result-fallible.md new file mode 100644 index 0000000..fec570f --- /dev/null +++ b/.opencode/skills/rust-skills/rules/type-result-fallible.md @@ -0,0 +1,131 @@ +# type-result-fallible + +> Use `Result` for operations that can fail + +## Why It Matters + +`Result` makes failure explicit in the type system. Callers must acknowledge and handle potential errors—they can't accidentally ignore failures. The `?` operator makes error propagation ergonomic while maintaining explicit error handling. + +## Bad + +```rust +// Returning Option loses error context +fn read_config(path: &str) -> Option { + let content = std::fs::read_to_string(path).ok()?; // Why did it fail? + toml::from_str(&content).ok() // Parse error lost +} + +// Panicking on errors +fn read_config(path: &str) -> Config { + let content = std::fs::read_to_string(path).unwrap(); // Crashes + toml::from_str(&content).unwrap() // Crashes +} + +// Sentinel values +fn divide(a: i32, b: i32) -> i32 { + if b == 0 { return -1; } // Magic value, easy to miss + a / b +} +``` + +## Good + +```rust +// Result with clear error type +fn read_config(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(ConfigError::IoError)?; + toml::from_str(&content) + .map_err(ConfigError::ParseError) +} + +fn divide(a: i32, b: i32) -> Result { + if b == 0 { + return Err(DivisionError::DivideByZero); + } + Ok(a / b) +} + +// Caller must handle +match divide(10, 0) { + Ok(result) => println!("Result: {}", result), + Err(e) => println!("Error: {}", e), +} +``` + +## The ? Operator + +```rust +fn process_file(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // Propagates Err + let parsed: RawData = serde_json::from_str(&content)?; + let validated = validate(parsed)?; + let processed = transform(validated)?; + Ok(processed) +} + +// Equivalent to: +fn process_file(path: &str) -> Result { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => return Err(e.into()), + }; + // ... etc +} +``` + +## Result Combinators + +```rust +let result: Result = Ok(42); + +// map: transform success value +let doubled = result.map(|n| n * 2); // Ok(84) + +// map_err: transform error +let with_context = result.map_err(|e| format!("Failed: {}", e)); + +// and_then: chain fallible operations +let processed = result.and_then(|n| { + if n > 0 { Ok(n * 2) } else { Err(Error::Negative) } +}); + +// unwrap_or: provide default on error +let value = result.unwrap_or(0); + +// ok(): convert to Option, discarding error +let maybe_value: Option = result.ok(); +``` + +## Defining Error Types + +```rust +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("failed to read file: {0}")] + Io(#[from] std::io::Error), + + #[error("failed to parse config: {0}")] + Parse(#[from] toml::de::Error), + + #[error("missing required field: {0}")] + MissingField(String), +} + +fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // Io error + let config: Config = toml::from_str(&content)?; // Parse error + if config.name.is_empty() { + return Err(ConfigError::MissingField("name".into())); + } + Ok(config) +} +``` + +## See Also + +- [err-thiserror-lib](./err-thiserror-lib.md) - Defining error types +- [err-question-mark](./err-question-mark.md) - Using ? operator +- [type-option-nullable](./type-option-nullable.md) - Option vs Result diff --git a/.opencode/skills/rust-skills/rules/unsafe-extern-block.md b/.opencode/skills/rust-skills/rules/unsafe-extern-block.md new file mode 100644 index 0000000..5aaa6eb --- /dev/null +++ b/.opencode/skills/rust-skills/rules/unsafe-extern-block.md @@ -0,0 +1,72 @@ +# unsafe-extern-block + +> In Rust 2024, wrap `extern` blocks in `unsafe extern { }` and annotate each item as `safe` or `unsafe`. + +## Why It Matters + +Before Rust 2024, every function declared inside an `extern "C" { }` block was implicitly unsafe to call — but the block itself carried no `unsafe` keyword. This made it easy to forget that the FFI contract (correct types, valid pointers, no aliasing violations) was entirely the programmer's responsibility. Rust 2024 makes this explicit: the block must be `unsafe extern`, which signals that the *programmer* is asserting the declarations are accurate. Individual items can then be marked `safe` (callable without an `unsafe` block by the caller) or `unsafe` (the default — caller must use `unsafe {}`). + +This change makes FFI boundaries auditable at a glance and lets wrappers expose a safe API while keeping raw declarations accurate. + +## Bad + +```rust +// Rust 2021 style — compiles but forbidden in 2024 edition +extern "C" { + fn strlen(s: *const std::ffi::c_char) -> usize; + fn memcpy(dst: *mut u8, src: *const u8, n: usize) -> *mut u8; + static errno: std::ffi::c_int; +} +``` + +## Good + +```rust +// Rust 2024 style +unsafe extern "C" { + // `strlen` is genuinely unsafe: caller must pass a null-terminated pointer. + pub unsafe fn strlen(s: *const std::ffi::c_char) -> usize; + + // `memcpy` is unsafe: caller must ensure non-overlapping, valid regions. + pub unsafe fn memcpy(dst: *mut u8, src: *const u8, n: usize) -> *mut u8; + + // A function that is always safe to call (hypothetical pure query). + pub safe fn rust_version_major() -> u32; + + // Statics are unsafe to access unless you can guarantee no data races. + pub unsafe static errno: std::ffi::c_int; +} + +// Call sites remain unchanged for `unsafe` items: +fn copy_bytes(dst: *mut u8, src: *const u8, n: usize) { + // SAFETY: dst and src are non-overlapping, both valid for n bytes. + unsafe { memcpy(dst, src, n) }; +} + +// Call sites for `safe` items need no unsafe block: +fn show_version() { + println!("major: {}", rust_version_major()); +} +``` + +## Migration from 2021 + +| 2021 | 2024 | +|------|------| +| `extern "C" { fn foo(); }` | `unsafe extern "C" { unsafe fn foo(); }` | +| `extern "C" { fn bar(); }` (safe to call) | `unsafe extern "C" { safe fn bar(); }` | +| `extern "C" { static X: i32; }` | `unsafe extern "C" { unsafe static X: i32; }` | + +Run `cargo fix --edition` to apply the mechanical part of this migration automatically. Review each item afterward to decide whether `safe` is warranted. + +## Key Points + +- The `unsafe` on the block means "I assert these declarations faithfully describe the external ABI". It does not make calls to the items safe by itself. +- Marking an item `safe` is a promise: if that item is actually unsafe to call, adding `safe` is itself unsound — the compiler will not catch a wrong annotation. +- `bindgen` (0.70+) and `cbindgen` have been updated to emit `unsafe extern` blocks for Rust 2024 output. Update your code generator if you use one. +- The `extern "Rust"` ABI for cross-crate `#[no_mangle]` functions follows the same rules (see `unsafe-no-mangle-unsafe`). + +## See Also + +- [unsafe-no-mangle-unsafe](unsafe-no-mangle-unsafe.md) - mark `#[no_mangle]` as `#[unsafe(no_mangle)]` in Rust 2024 +- [type-repr-transparent](type-repr-transparent.md) - use `#[repr(transparent)]` for FFI newtypes diff --git a/.opencode/skills/rust-skills/rules/unsafe-maybeuninit.md b/.opencode/skills/rust-skills/rules/unsafe-maybeuninit.md new file mode 100644 index 0000000..85dd9e7 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/unsafe-maybeuninit.md @@ -0,0 +1,71 @@ +# unsafe-maybeuninit + +> Use `MaybeUninit` for uninitialized memory; never use `mem::uninitialized()` or `mem::zeroed()` for types with validity invariants. + +## Why It Matters + +`mem::uninitialized()` was deprecated in Rust 1.39 and is immediate undefined behavior for any type whose bit patterns have validity invariants — `bool` (only `0` and `1` are valid), `&T` (must be non-null and aligned), `NonZeroU32`, `char`, and enum types. Even `mem::zeroed()` triggers UB for references and `NonZero*` types. `MaybeUninit` is the correct abstraction: it wraps uninitialized memory without ever producing an invalid `T`, and the compiler cannot optimize around it incorrectly. The standard library uses `MaybeUninit` pervasively for its data structures. + +## Bad + +```rust +use std::mem; + +// Instant UB: `bool` has validity invariants; uninitialized bits are not +// guaranteed to be 0 or 1. The optimizer may miscompile code that follows. +let b: bool = unsafe { mem::uninitialized() }; + +// Also UB for references — a zero reference is immediately invalid. +let r: &u32 = unsafe { mem::zeroed() }; + +// Uninitialized array the wrong way — triggers UB during construction. +let mut buf: [u8; 1024] = unsafe { mem::uninitialized() }; +``` + +## Good + +```rust +use std::mem::MaybeUninit; + +// ---- 1. Single value ---- +let mut x = MaybeUninit::::uninit(); +x.write(42); +// SAFETY: we just wrote a valid u32 via `write`, so the value is initialized. +let value: u32 = unsafe { x.assume_init() }; + +// ---- 2. Array initialization (manual, element-by-element) ---- +// `[const { MaybeUninit::uninit() }; N]` works for any `T` (no `Copy` bound). +let mut buf: [MaybeUninit; 1024] = [const { MaybeUninit::uninit() }; 1024]; +for elem in &mut buf { + elem.write(0u8); +} +// SAFETY: every element was written above. +// `From<[MaybeUninit; N]> for MaybeUninit<[T; N]>` is stable since Rust 1.95. +let buf: [u8; 1024] = unsafe { + MaybeUninit::<[u8; 1024]>::from(buf).assume_init() +}; + +// ---- 3. Growing a Vec into spare capacity ---- +fn fill_vec(v: &mut Vec, extra: usize) { + v.reserve(extra); + let spare = v.spare_capacity_mut(); // &mut [MaybeUninit] + for slot in spare.iter_mut().take(extra) { + slot.write(0u8); + } + // SAFETY: we initialized `extra` elements in the spare capacity. + unsafe { v.set_len(v.len() + extra) }; +} +``` + +## Key Points + +- **`assume_init` is only sound after every byte is initialized** via `write`, an FFI call that fills the buffer, or another provably complete initialization path. Calling it on partially initialized memory is UB. +- Create arrays of uninitialized memory with `[const { MaybeUninit::uninit() }; N]` (works for any `T`). Convert a fully-initialized `[MaybeUninit; N]` to `[T; N]` via `MaybeUninit::<[T; N]>::from(arr)` then `assume_init()` — these `From` conversions are stable since Rust 1.95. The `MaybeUninit::array_assume_init` helper remains nightly-only (feature `maybe_uninit_array_assume_init`). +- `Vec::spare_capacity_mut` returns `&mut [MaybeUninit]` — the idiomatic way to write into Vec capacity before extending its length. +- For zeroed memory where zero is a valid bit pattern for all fields (e.g. `u8`, `i32`, plain C structs with no references), `mem::zeroed()` is technically sound but `MaybeUninit` is still preferred for clarity. +- `mem::uninitialized` is not just deprecated — it is `#[deprecated(since = "1.39.0")]` and has no safe migration; replace every usage with `MaybeUninit`. + +## See Also + +- [unsafe-safety-comment](unsafe-safety-comment.md) - document every unsafe block including `assume_init` calls +- [mem-with-capacity](mem-with-capacity.md) - use `with_capacity` when size is known to avoid extra allocations diff --git a/.opencode/skills/rust-skills/rules/unsafe-minimize-scope.md b/.opencode/skills/rust-skills/rules/unsafe-minimize-scope.md new file mode 100644 index 0000000..670658d --- /dev/null +++ b/.opencode/skills/rust-skills/rules/unsafe-minimize-scope.md @@ -0,0 +1,78 @@ +# unsafe-minimize-scope + +> Keep `unsafe` blocks as small as possible — mark only the operation that requires unsafety, not the surrounding safe code. + +## Why It Matters + +When an entire function is marked `unsafe fn`, every line inside appears equally suspect to an auditor. Shrinking unsafe blocks to the minimum isolates exactly which operation violates Rust's safety invariants, making reviews tractable and bugs easier to find. The Rust 2024 edition enforces this with the `unsafe_op_in_unsafe_fn` lint: unsafe operations inside an `unsafe fn` now require their own explicit `unsafe {}` block rather than inheriting the function's unsafety implicitly. + +## Bad + +```rust +// Entire function body marked unsafe — safe arithmetic, bounds checks, +// and the single unsafe dereference are all equally "dangerous" to a reader. +unsafe fn sum_at(ptr: *const i32, len: usize, index: usize) -> i32 { + let adjusted_len = len.saturating_sub(1); // safe — but looks unsafe + assert!(index <= adjusted_len); // safe — but looks unsafe + let value = *ptr.add(index); // the only actually unsafe op + value + 1 // safe — but looks unsafe +} +``` + +```rust +// Huge unsafe block wrapping safe logic inside an unsafe fn (2024 edition +// now requires unsafe {} here anyway, but large blocks are still bad style). +pub unsafe fn process(ptr: *const u8, len: usize) -> Vec { + unsafe { + let mut result = Vec::with_capacity(len); // safe + for i in 0..len { // safe + result.push(*ptr.add(i)); // unsafe — buried in noise + } + result + } +} +``` + +## Good + +```rust +// Safe wrapper: the single unsafe operation is clearly isolated. +fn sum_at(ptr: *const i32, len: usize, index: usize) -> i32 { + assert!(index < len, "index out of bounds"); + // SAFETY: index < len guarantees ptr.add(index) is within the allocation. + let value = unsafe { *ptr.add(index) }; + value + 1 +} +``` + +```rust +// In a genuinely unsafe fn, 2024 edition still requires unsafe {} per op. +/// # Safety +/// +/// `ptr` must be valid for reads for `len` bytes and properly aligned. +pub unsafe fn process(ptr: *const u8, len: usize) -> Vec { + let mut result = Vec::with_capacity(len); // safe — outside any unsafe block + for i in 0..len { + // SAFETY: caller guarantees ptr is valid for len bytes; i < len. + let byte = unsafe { *ptr.add(i) }; + result.push(byte); + } + result +} +``` + +## Key Points + +- **2024 edition `unsafe_op_in_unsafe_fn`**: even inside an `unsafe fn`, each unsafe operation now needs its own `unsafe {}`. This is a hard error in Rust 2024. +- A safe wrapper around a small `unsafe {}` is almost always preferable to exposing the entire function as `unsafe fn`. +- Each small unsafe block needs its own `// SAFETY:` comment (see `unsafe-safety-comment`). +- If multiple consecutive lines are all unsafe for the *same* invariant reason, a single block covering only those lines is acceptable. + +## When a Larger Block Is Acceptable + +If two unsafe operations share the *exact same precondition* and separating them would require re-stating the identical justification, a single block covering both is fine — but it should still be the minimum necessary scope. + +## See Also + +- [unsafe-safety-comment](unsafe-safety-comment.md) - write `// SAFETY:` above every unsafe block +- [unsafe-send-sync-manual](unsafe-send-sync-manual.md) - document invariants when manually implementing Send/Sync diff --git a/.opencode/skills/rust-skills/rules/unsafe-miri-ci.md b/.opencode/skills/rust-skills/rules/unsafe-miri-ci.md new file mode 100644 index 0000000..acd5f08 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/unsafe-miri-ci.md @@ -0,0 +1,65 @@ +# unsafe-miri-ci + +> Run `cargo miri test` in CI for every crate that contains `unsafe` code. + +## Why It Matters + +Miri is the only tool that *dynamically* detects undefined behavior in Rust programs at test time. It catches out-of-bounds memory accesses, use-after-free, reads of uninitialized memory, invalid pointer provenance, data races in `unsafe` multithreaded code, and violations of the Stacked Borrows / Tree Borrows aliasing models. The Rust standard library, tokio, serde, and many foundational crates all run Miri in CI before merging changes that touch unsafe code. + +Static analysis and code review can miss subtle UB that only manifests at specific memory layouts; Miri's interpreted execution catches it unconditionally. + +## Bad + +```yaml +# CI that tests but never runs Miri — unsafe code ships unverified. +- name: Test + run: cargo test --all-features +``` + +## Good + +```yaml +# .github/workflows/miri.yml +name: Miri + +on: [push, pull_request] + +jobs: + miri: + name: Miri (nightly) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install nightly toolchain with Miri + run: | + rustup toolchain install nightly --component miri + rustup override set nightly + cargo miri setup + + - name: Run Miri + env: + MIRIFLAGS: "-Zmiri-strict-provenance" + run: cargo miri test --all-features +``` + +## Key Points + +- **Nightly only**: Miri requires a nightly toolchain. Pin a specific nightly date in `rust-toolchain.toml` if you need reproducible CI. +- **Slow**: Miri interprets rather than compiles, so test suites run 100–1000× slower than normal. Run Miri on a reduced set of tests or on a separate slower CI job if the full suite is prohibitive. +- **`MIRIFLAGS`**: `-Zmiri-strict-provenance` enables stricter pointer provenance checks, catching casts that violate the provenance model. Add `-Zmiri-tree-borrows` to opt into the newer Tree Borrows model. +- **Stacked Borrows**: Miri's default aliasing model. It catches violations of Rust's borrow rules at the pointer level — invaluable for raw-pointer and FFI code. +- **Setup command**: `cargo miri setup` pre-builds the Miri sysroot so the first test run is not cold. Run it once per CI cache key. +- Only crates with `unsafe` blocks need Miri coverage. Pure-safe crates gain nothing from it. + +## When It's Acceptable to Skip + +- Crates with zero `unsafe` code (safe-only crates get no benefit). +- Generated code or proc-macro output you do not control (audit the generator instead). +- Very large integration tests that make Miri impractical — run a targeted unit-test subset instead. + +## See Also + +- [unsafe-maybeuninit](unsafe-maybeuninit.md) - use `MaybeUninit` for uninitialized memory +- [unsafe-safety-comment](unsafe-safety-comment.md) - document every unsafe block +- [test-criterion-bench](test-criterion-bench.md) - use criterion for benchmarking (separate from Miri) diff --git a/.opencode/skills/rust-skills/rules/unsafe-no-mangle-unsafe.md b/.opencode/skills/rust-skills/rules/unsafe-no-mangle-unsafe.md new file mode 100644 index 0000000..3820058 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/unsafe-no-mangle-unsafe.md @@ -0,0 +1,65 @@ +# unsafe-no-mangle-unsafe + +> In Rust 2024, write `#[unsafe(no_mangle)]`, `#[unsafe(export_name = "...")]`, and `#[unsafe(link_section = "...")]` — not the bare attribute forms. + +## Why It Matters + +`#[no_mangle]`, `#[export_name]`, and `#[link_section]` were reclassified as unsafe in Rust 2024 because they can cause undefined behavior without any `unsafe` block at the call site. If two items in the same binary share the same exported symbol name, the linker silently picks one and discards the other — the "winning" symbol may have a completely different type, signature, or semantics. The result is type-level UB with no diagnostic from the compiler or linker. Requiring `#[unsafe(...)]` makes this footgun visible and auditable. + +## Bad + +```rust +// Rust 2021 — bare attributes accepted, no warning about linker UB +#[no_mangle] +pub extern "C" fn init() { + // ... +} + +#[export_name = "plugin_entry"] +pub fn plugin_main() { + // ... +} + +#[link_section = ".init_array"] +static INIT: extern "C" fn() = init; +``` + +## Good + +```rust +// Rust 2024 — unsafe(...) wrapper makes the risk explicit +#[unsafe(no_mangle)] +pub extern "C" fn init() { + // ... +} + +#[unsafe(export_name = "plugin_entry")] +pub fn plugin_main() { + // ... +} + +#[unsafe(link_section = ".init_array")] +static INIT: extern "C" fn() = init; +``` + +## Migration + +| Rust 2021 | Rust 2024 | +|-----------|-----------| +| `#[no_mangle]` | `#[unsafe(no_mangle)]` | +| `#[export_name = "sym"]` | `#[unsafe(export_name = "sym")]` | +| `#[link_section = ".sec"]` | `#[unsafe(link_section = ".sec")]` | + +Run `cargo fix --edition` when migrating to the 2024 edition — it rewrites bare attribute forms to `#[unsafe(...)]` automatically. Review each one afterward: confirm that the exported symbol name is unique across the binary. + +## Key Points + +- The `unsafe(...)` wrapper does **not** require an `unsafe {}` block at the call site; it marks the *attribute itself* as load-bearing for safety. The annotation documents that the programmer accepted responsibility for symbol uniqueness and ABI correctness. +- Symbol collisions are especially dangerous in plugin architectures, `cdylib` crates, embedded firmware with custom linker scripts, and any codebase that links multiple Rust crates into a single binary. +- These attributes interact with `unsafe extern` blocks (see `unsafe-extern-block`): external symbols you import and symbols you export follow the same 2024-edition safety rules. +- The bare forms (`#[no_mangle]` without `unsafe`) are a hard error in Rust 2024 edition code. They still compile in earlier editions but emit a deprecation warning with `--warn future-incompatible`. + +## See Also + +- [unsafe-extern-block](unsafe-extern-block.md) - wrap `extern` blocks in `unsafe extern` in Rust 2024 +- [type-repr-transparent](type-repr-transparent.md) - use `#[repr(transparent)]` for FFI newtypes diff --git a/.opencode/skills/rust-skills/rules/unsafe-safety-comment.md b/.opencode/skills/rust-skills/rules/unsafe-safety-comment.md new file mode 100644 index 0000000..912811e --- /dev/null +++ b/.opencode/skills/rust-skills/rules/unsafe-safety-comment.md @@ -0,0 +1,72 @@ +# unsafe-safety-comment + +> Write a `// SAFETY:` comment above every `unsafe` block and a `# Safety` section in every `unsafe fn`. + +## Why It Matters + +Unsafe blocks are unauditable without justification. A reviewer cannot verify invariants they cannot read. The `clippy::undocumented_unsafe_blocks` lint enforces this mechanically. The standard library, tokio, and bevy all require both forms before merging unsafe code. + +There are two distinct levels of documentation: + +1. **`# Safety` in a doc comment on an `unsafe fn`** — describes the *caller's* obligations (preconditions that must hold for the call to be sound). +2. **`// SAFETY:` inline comment above each `unsafe {}` block** — explains why *this specific operation* upholds the required invariants at the call site. + +Both are required. Omitting either leaves an auditor unable to verify soundness. + +## Bad + +```rust +// unsafe fn with no # Safety section — caller has no idea what's required +pub unsafe fn read_at(ptr: *const u8, offset: usize) -> u8 { + // no SAFETY comment — why is this dereference sound? + unsafe { *ptr.add(offset) } +} + +// standalone block with no justification +fn process(slice: &[u8]) -> u8 { + unsafe { *slice.as_ptr().add(10) } +} +``` + +## Good + +```rust +/// Returns the byte at `ptr + offset`. +/// +/// # Safety +/// +/// - `ptr` must be valid for reads for at least `offset + 1` bytes. +/// - `ptr` must not be null and must be properly aligned for `u8`. +/// - The memory must not be mutated for the duration of this call. +pub unsafe fn read_at(ptr: *const u8, offset: usize) -> u8 { + // SAFETY: caller guarantees ptr is valid for at least offset + 1 bytes, + // so ptr.add(offset) is in bounds and dereferenceable. + unsafe { *ptr.add(offset) } +} + +fn process(slice: &[u8]) -> Option { + if slice.len() > 10 { + // SAFETY: we just checked that slice has at least 11 elements, + // so index 10 is within bounds. + Some(unsafe { *slice.as_ptr().add(10) }) + } else { + None + } +} +``` + +## Key Points + +- A `# Safety` doc section and a `// SAFETY:` inline comment serve different audiences: the doc section targets *callers*, the inline comment targets *auditors* of the implementation. +- Enable the lint explicitly to catch omissions: + ```rust + #![warn(clippy::undocumented_unsafe_blocks)] + ``` +- When an unsafe block spans multiple operations, write one `// SAFETY:` comment that addresses each distinct invariant, or split into multiple smaller blocks (see `unsafe-minimize-scope`). +- In an `unsafe fn`, the body still requires `// SAFETY:` comments for each inner `unsafe {}` block under the 2024 edition's `unsafe_op_in_unsafe_fn` lint. + +## See Also + +- [unsafe-minimize-scope](unsafe-minimize-scope.md) - keep unsafe blocks as small as possible +- [lint-unsafe-doc](lint-unsafe-doc.md) - enable `clippy::undocumented_unsafe_blocks` +- [doc-safety-section](doc-safety-section.md) - include `# Safety` sections in public unsafe fns diff --git a/.opencode/skills/rust-skills/rules/unsafe-send-sync-manual.md b/.opencode/skills/rust-skills/rules/unsafe-send-sync-manual.md new file mode 100644 index 0000000..13f5302 --- /dev/null +++ b/.opencode/skills/rust-skills/rules/unsafe-send-sync-manual.md @@ -0,0 +1,99 @@ +# unsafe-send-sync-manual + +> Document the invariants when manually implementing `Send` or `Sync`; prefer letting the compiler derive them automatically. + +## Why It Matters + +`Send` and `Sync` are `unsafe` auto-traits. The compiler derives them automatically and correctly for most types — manual implementations signal that the borrow checker cannot verify the invariant on its own. Get it wrong and you introduce data races that are impossible to catch with safe code. A manual `unsafe impl Send` without a clear justification is a liability: the next person to modify the type's fields may silently break the invariant without realizing the impl is load-bearing. + +## Bad + +```rust +use std::cell::Cell; +use std::sync::Arc; + +// Cell is !Sync because it allows non-atomic interior mutation. +// This manual impl removes that protection with no explanation. +struct SharedCounter { + value: Cell, +} + +unsafe impl Sync for SharedCounter {} // data race waiting to happen — no SAFETY comment +unsafe impl Send for SharedCounter {} // likewise +``` + +```rust +// Wrapping a raw pointer but forgetting to opt out of auto-Send/Sync. +struct MyBuffer { + ptr: *mut u8, + len: usize, +} +// *mut u8 is already !Send + !Sync, so the compiler correctly withholds +// auto-impls — but if you blindly add unsafe impls without justification, +// you may send the pointer to another thread while something else mutates it. +unsafe impl Send for MyBuffer {} // no SAFETY: comment — why is this sound? +``` + +## Good + +```rust +use std::marker::PhantomData; + +// ---- 1. Opt OUT of Send/Sync using PhantomData ---- +// If your type logically owns a *const T (e.g. an intrusive pointer), +// use PhantomData to prevent the compiler from auto-deriving Send/Sync. +struct IntrinsiveRef { + ptr: *const T, + // PhantomData<*const T> makes this type !Send + !Sync automatically, + // matching the semantics of a raw non-owning pointer. + _marker: PhantomData<*const T>, +} +// No unsafe impl needed — the compiler correctly withholds Send/Sync. + +// ---- 2. Opt IN with a documented unsafe impl ---- +use std::sync::Mutex; + +/// A buffer owned exclusively by one thread at a time. +/// The raw pointer always points to a heap allocation this struct owns; +/// no other reference to that allocation exists outside this struct. +struct OwnedBuffer { + ptr: *mut u8, + len: usize, +} + +// SAFETY: OwnedBuffer owns its allocation exclusively (no aliasing), +// and access is protected by the caller's Mutex at usage sites. +// The pointer is valid for the entire lifetime of OwnedBuffer. +unsafe impl Send for OwnedBuffer {} + +// SAFETY: OwnedBuffer exposes no shared mutation — all methods require &mut self. +// Concurrent & references cannot mutate the buffer. +unsafe impl Sync for OwnedBuffer {} + +// ---- 3. Prefer newtype wrappers around Arc for sharing ---- +// Instead of manual Sync, wrap in Arc> so the compiler handles it. +use std::sync::Arc; + +struct SafeCounter { + value: Mutex, +} + +// Arc> is Send + Sync automatically — no manual impl required. +fn make_shared() -> Arc { + Arc::new(SafeCounter { value: Mutex::new(0) }) +} +``` + +## Key Points + +- **Auto-derive first**: if your type consists entirely of `Send`/`Sync` fields, the compiler derives the traits for free. Only reach for manual impls when raw pointers, `Cell`, or other non-auto types are involved. +- **`PhantomData<*const T>`** is the canonical way to make a type `!Send + !Sync` — `*const T` is already `!Send + !Sync`, so the marker propagates without any `unsafe impl`. +- **`PhantomData<*mut T>`** makes a type `!Send + !Sync` and also marks it as if it mutably borrows a `T` (useful for invariance over `T`). +- Every `unsafe impl Send`/`unsafe impl Sync` must carry a `// SAFETY:` comment explaining which invariant the programmer is upholding and why it holds. +- Adding `unsafe impl Send` to a type that contains a `Rc` or `Cell` is almost certainly unsound — prefer `Arc` and `Mutex` instead. + +## See Also + +- [unsafe-safety-comment](unsafe-safety-comment.md) - write `// SAFETY:` above every unsafe impl +- [type-phantom-marker](type-phantom-marker.md) - use `PhantomData` for type-level markers +- [own-arc-shared](own-arc-shared.md) - use `Arc` for thread-safe shared ownership From 818250d44e0ab58b30b7fb4d9aac9a279e8928ab Mon Sep 17 00:00:00 2001 From: seho Date: Fri, 23 Oct 2026 21:52:34 +0800 Subject: [PATCH 59/97] doc: update skill --- .opencode/skills/best-practices/SKILL.md | 77 ++++-- .../best-practices/examples/component.md | 183 +++++++++++++ .../examples/component/patterns.md | 240 ------------------ .../best-practices/examples/conventions.md | 101 ++++++++ .../skills/best-practices/examples/css.md | 108 ++++++++ .../best-practices/examples/css/patterns.md | 141 ---------- .../best-practices/examples/defensive.md | 68 +++++ .../skills/best-practices/examples/logic.md | 84 ++++++ .../best-practices/examples/logic/patterns.md | 135 ---------- .../best-practices/examples/performance.md | 73 ++++++ .../skills/best-practices/examples/state.md | 86 +++++++ .../examples/style/coding-basics/patterns.md | 52 ---- .../examples/style/fallback/patterns.md | 69 ----- .../best-practices/examples/style/index.md | 9 - .../examples/style/normalize/patterns.md | 55 ---- .opencode/skills/best-practices/rules/api.md | 57 ----- .../skills/best-practices/rules/component.md | 74 ++---- .../skills/best-practices/rules/constants.md | 32 --- .../best-practices/rules/conventions.md | 32 +++ .opencode/skills/best-practices/rules/css.md | 60 ++--- .../skills/best-practices/rules/defensive.md | 31 +++ .../best-practices/rules/file-structure.md | 141 ---------- .../skills/best-practices/rules/import.md | 25 -- .../skills/best-practices/rules/logic.md | 41 ++- .../best-practices/rules/performance.md | 42 ++- .../skills/best-practices/rules/state.md | 35 +-- .../rules/style/coding-basics.md | 33 --- .../best-practices/rules/style/fallback.md | 37 --- .../best-practices/rules/style/index.md | 19 -- .../best-practices/rules/style/normalize.md | 36 --- .../templates/code-review-checklist.md | 61 ----- .../templates/component-checklist.md | 70 ----- 32 files changed, 927 insertions(+), 1380 deletions(-) create mode 100644 .opencode/skills/best-practices/examples/component.md delete mode 100644 .opencode/skills/best-practices/examples/component/patterns.md create mode 100644 .opencode/skills/best-practices/examples/conventions.md create mode 100644 .opencode/skills/best-practices/examples/css.md delete mode 100644 .opencode/skills/best-practices/examples/css/patterns.md create mode 100644 .opencode/skills/best-practices/examples/defensive.md create mode 100644 .opencode/skills/best-practices/examples/logic.md delete mode 100644 .opencode/skills/best-practices/examples/logic/patterns.md create mode 100644 .opencode/skills/best-practices/examples/performance.md create mode 100644 .opencode/skills/best-practices/examples/state.md delete mode 100644 .opencode/skills/best-practices/examples/style/coding-basics/patterns.md delete mode 100644 .opencode/skills/best-practices/examples/style/fallback/patterns.md delete mode 100644 .opencode/skills/best-practices/examples/style/index.md delete mode 100644 .opencode/skills/best-practices/examples/style/normalize/patterns.md delete mode 100644 .opencode/skills/best-practices/rules/api.md delete mode 100644 .opencode/skills/best-practices/rules/constants.md create mode 100644 .opencode/skills/best-practices/rules/conventions.md create mode 100644 .opencode/skills/best-practices/rules/defensive.md delete mode 100644 .opencode/skills/best-practices/rules/file-structure.md delete mode 100644 .opencode/skills/best-practices/rules/import.md delete mode 100644 .opencode/skills/best-practices/rules/style/coding-basics.md delete mode 100644 .opencode/skills/best-practices/rules/style/fallback.md delete mode 100644 .opencode/skills/best-practices/rules/style/index.md delete mode 100644 .opencode/skills/best-practices/rules/style/normalize.md delete mode 100644 .opencode/skills/best-practices/templates/code-review-checklist.md delete mode 100644 .opencode/skills/best-practices/templates/component-checklist.md diff --git a/.opencode/skills/best-practices/SKILL.md b/.opencode/skills/best-practices/SKILL.md index 844a019..57c8fe7 100644 --- a/.opencode/skills/best-practices/SKILL.md +++ b/.opencode/skills/best-practices/SKILL.md @@ -1,36 +1,67 @@ --- name: best-practices -description: Frontend coding standards. Load when writing, refactoring, or reviewing frontend code, components, state, logic, styles, imports, APIs, performance, or file structure. +description: Frontend coding standards and architecture guardrails. Load when designing, writing, refactoring, or reviewing frontend components, business logic, state, styling, or engineering conventions. --- # Frontend Best Practices -This imported baseline is subordinate to system, developer, and user instructions, local `AGENTS.md`, and security policy. This project uses Svelte 5, SvelteKit 2, TypeScript, Tailwind CSS 4, Bits UI, and Tauri 2. Framework examples and layouts are illustrative: do not migrate frameworks or add dependencies or a testing stack because of them. +Universal architecture and code quality standards for frontend engineering. -> Consult relevant documentation by scenario within the approved task scope. +> `MUST` rules describe foundational baselines. When working in an established codebase, existing project conventions take precedence over illustrative directory aliases or naming examples. -> `MUST` rules describe this methodology's defaults. The target project's established conventions and architecture take precedence over the illustrative paths, aliases, and file names used in these rules. +--- + +## Core Invariants + +1. **Single Source of Truth**: State has one clear owner. Downstream components read values or emit events; they must never mirror parent state into local state just to sync via effects. +2. **Boundary Isolation**: Strict separation between UI presentation, business calculation, and network API layers. +3. **Zero Speculative Abstraction**: Express directly first. Never introduce thin wrapper functions or premature abstraction layers without genuine domain meaning. +4. **Defensive at Boundaries**: Normalize untrusted or raw shapes once at the ingestion boundary. Use single-hop fallbacks; eliminate cascading guessing chains. + +--- + +## Scenario Rules + +| Scenario | Rules File | Examples | Core Focus | +| :--- | :--- | :--- | :--- | +| **Component** | [rules/component.md](rules/component.md) | [examples/component.md](examples/component.md) | Unidirectional data flow, prop destructuring, derived state, imperative handle boundaries | +| **Logic** | [rules/logic.md](rules/logic.md) | [examples/logic.md](examples/logic.md) | Presentation vs domain decoupling, effect teardown & cancellation, async lifecycle | +| **State** | [rules/state.md](rules/state.md) | [examples/state.md](examples/state.md) | State minimization, avoiding prop drilling, immutable updates, single store ownership | +| **Defensive & Style** | [rules/defensive.md](rules/defensive.md) | [examples/defensive.md](examples/defensive.md) | Zero speculative wrappers, normalization at boundary, safe single-hop fallbacks (`??`) | +| **CSS & Styling** | [rules/css.md](rules/css.md) | [examples/css.md](examples/css.md) | Scoped component styles, design tokens / CSS variables, dynamic inline style limits | +| **Performance** | [rules/performance.md](rules/performance.md) | [examples/performance.md](examples/performance.md) | Route & heavy component code-splitting, memoized computations, list virtualization | +| **Conventions** | [rules/conventions.md](rules/conventions.md) | [examples/conventions.md](examples/conventions.md) | Feature-based directory layout, path aliases, centralized constants, decoupled API layer | + +--- + +## Universal Review Checklist -## Rules +Self-check after writing or refactoring code: -> Consult [rules/style/index.md](rules/style/index.md) for coding basics when relevant; local instructions and security constraints take precedence. +### Component & State +- [ ] Props flow down, events flow up; child does not mirror parent state into local state. +- [ ] Derived data computed on the fly rather than synchronized through effects or watchers. +- [ ] Imperative handles (`ref`) expose behavioral actions (`focus()`, `reset()`), not raw state setters. +- [ ] Shared state accessed via dedicated store/context; no prop drilling deeper than 3 layers. -| Scenario | Rules | Examples | Checklist | -| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | -| Component | [rules/component.md](rules/component.md) | [examples/component/patterns.md](examples/component/patterns.md) | [templates/component-checklist.md](templates/component-checklist.md) | -| Logic | [rules/logic.md](rules/logic.md) | [examples/logic/patterns.md](examples/logic/patterns.md) | - | -| **Coding Basics** | [rules/style/coding-basics.md](rules/style/coding-basics.md) | [examples/style/coding-basics/patterns.md](examples/style/coding-basics/patterns.md) | - | -| **Normalize** | [rules/style/normalize.md](rules/style/normalize.md) | [examples/style/normalize/patterns.md](examples/style/normalize/patterns.md) | - | -| **Fallback** | [rules/style/fallback.md](rules/style/fallback.md) | [examples/style/fallback/patterns.md](examples/style/fallback/patterns.md) | - | -| **Style** | [rules/style/index.md](rules/style/index.md) | [examples/style/index.md](examples/style/index.md) | - | -| CSS | [rules/css.md](rules/css.md) | [examples/css/patterns.md](examples/css/patterns.md) | - | -| Import | [rules/import.md](rules/import.md) | - | - | -| Constants | [rules/constants.md](rules/constants.md) | - | - | -| State | [rules/state.md](rules/state.md) | - | - | -| Performance | [rules/performance.md](rules/performance.md) | - | - | -| File Structure | [rules/file-structure.md](rules/file-structure.md) | - | - | -| API | [rules/api.md](rules/api.md) | - | - | +### Logic & Async +- [ ] Business scoring, transformations, and complex orchestration extracted from UI components. +- [ ] Side-effects cleanly tear down listeners, intervals, and cancel ongoing async requests via `AbortSignal`. +- [ ] Async operations handle all states explicitly (loading, success, error) with no unhandled rejections. -## Self-Check +### Defensive Coding & Abstraction +- [ ] No speculative one-line wrapper functions created without new domain meaning. +- [ ] Standard JS collection methods (`map`, `filter`, `find`) used directly without artificial wrappers. +- [ ] Untrusted inputs, query params, and status codes normalized once at boundary via lookup table. +- [ ] Safe defaults use single-hop fallback (`value ?? default`), avoiding long guessing chains. -Use [templates/code-review-checklist.md](templates/code-review-checklist.md) after coding. +### CSS & Conventions +- [ ] Design tokens or CSS custom properties used instead of hardcoded magic values. +- [ ] Third-party UI overrides strictly scoped under a feature container namespace. +- [ ] API endpoints defined in a dedicated API module, not invoked inline inside view components. +- [ ] Shared constants in `SCREAMING_SNAKE_CASE`; no magic strings or numbers. +- [ ] Cross-domain imports use configured path aliases (e.g. `@/`), not deep relative traversals (`../../../`). +- [ ] Tabs implement roving tabindex, `aria-selected`, and arrow key roving. +- [ ] Async buttons use `aria-busy`, disabled guards (`disabled={isPending || isSaving}`), and motion-reduce animation fallbacks. +- [ ] Tables use standard empty/loading row primitives; preview containers are bounded (`max-h-*`, `overflow-y-auto`). +- [ ] Dirty drafts are preserved on failed mutations with actionable user recovery. diff --git a/.opencode/skills/best-practices/examples/component.md b/.opencode/skills/best-practices/examples/component.md new file mode 100644 index 0000000..f5dd709 --- /dev/null +++ b/.opencode/skills/best-practices/examples/component.md @@ -0,0 +1,183 @@ +# Component Architecture & Design Rules - Code Examples + +> Reference code examples for [../rules/component.md](../rules/component.md). + +## Patterns & Examples + +### 1. State Ownership & Props Flow + +```tsx +// ❌ Bad: Child mirrors parent state and attempts to two-way sync +function UserProfile({ user, onUpdate }) { + const [localName, setLocalName] = useState(user.name); + + useEffect(() => { + setLocalName(user.name); + }, [user.name]); + + const handleChange = (e) => { + setLocalName(e.target.value); + onUpdate({ ...user, name: e.target.value }); + }; + + return ; +} + +// ✅ Good: Single owner; child renders value and emits change intent +function UserProfile({ user, onNameChange }) { + return ( + onNameChange(e.target.value)} + /> + ); +} +``` + +### 2. Derived State Computation + +```tsx +// ❌ Bad: Mirroring derived state and syncing with effects +function ItemList({ items }) { + const [activeItems, setActiveItems] = useState([]); + + useEffect(() => { + setActiveItems(items.filter((item) => item.isActive)); + }, [items]); + + return
      {activeItems.map((item) =>
    • {item.name}
    • )}
    ; +} + +// ✅ Good: Derive directly in render (memoize only if computation is heavy) +function ItemList({ items }) { + const activeItems = items.filter((item) => item.isActive); + + return
      {activeItems.map((item) =>
    • {item.name}
    • )}
    ; +} +``` + +### 3. Imperative Handles (Behavior vs Raw Setter) + +```tsx +// ❌ Bad: Leaking raw state setters through an imperative handle +useImperativeHandle(ref, () => ({ + setIsOpen: (open) => setIsOpen(open), + setData: (data) => setData(data), +})); + +// ✅ Good: Exposing explicit behavioral actions +useImperativeHandle(ref, () => ({ + focusInput: () => inputRef.current?.focus(), + resetForm: () => setFormState(initialState), +})); +``` + +### 4. Prop Destructuring & Ordering + +```tsx +// ❌ Bad: Unstructured props access and mixed callback placement +function OrderCard(props) { + return
    {props.order.id}
    ; +} + +// ✅ Good: Destructured, default values explicit, callbacks grouped at end +interface OrderCardProps { + orderId: string; + status?: string; + isExpanded?: boolean; + onSelect: (id: string) => void; + onCancel?: (id: string) => void; +} + +function OrderCard({ + orderId, + status = 'pending', + isExpanded = false, + onSelect, + onCancel, +}: OrderCardProps) { + return ( +
    onSelect(orderId)}> + {orderId} ({status}) +
    + ); +} +``` + +### 5. Accessible Tab Navigation + +```text +Container:
    +Tab buttons: + + +// ✅ Context-aware header action in multi-tab or scoped views +onclick={() => { + if (activeTab === "tabA") refreshTabA(); + else if (activeTab === "tabB") refreshTabB(); +}} +``` + +### 7. Standardized Table Empty and Loading States + +```text +// ✅ Standardized empty row primitive rather than ad-hoc + + ... + + {#if isLoading} + + {:else if !items.length} + + {:else} + {#each items as item} + ... + {/each} + {/if} + +
    +``` + +### 8. Bounded Raw Content Containers + +```text +// ✅ Bounded preformatted text, code, JSON, logs with overflow containment +
    {rawContent}
    +``` + diff --git a/.opencode/skills/best-practices/examples/component/patterns.md b/.opencode/skills/best-practices/examples/component/patterns.md deleted file mode 100644 index 4ef2216..0000000 --- a/.opencode/skills/best-practices/examples/component/patterns.md +++ /dev/null @@ -1,240 +0,0 @@ -# Component Pattern Examples - -## Basic Structure - -```text -UserCard receives name, age, onClick - Read shared user information through a dedicated accessor - Own local isExpanded state (initially false) - On details click: call onClick(name) if provided - Display name and a Details action -Export UserCard -``` - -## Unidirectional Data Flow - -```text -✅ Good: single owner for state, child emits intent only - ItemListPage owns selectedItemId - ItemList receives selectedItemId and forwards select(itemId) intent - DetailPanel displays selectedItemId and emits select(firstItemId) -``` - -```text -❌ Bad: child mirrors parent state, then syncs it back through side effects - ItemList copies selectedItemId into localSelectedItemId - When parent changes: copy selectedItemId into localSelectedItemId - When local changes: emit select(localSelectedItemId) to parent -``` - -```text -✅ Good: derived data stays pure and local to the source of truth - ItemList receives items - visibleItems = items where hidden is not true - Display visibleItems in a table -``` - -```text -❌ Bad: derived data is copied into another state layer - ItemList stores visibleItems separately - When items change: update visibleItems and notify parent - Display stored visibleItems in a table -``` - -```text -❌ Bad: derived data computed in child, then synced back to parent via callback - DetailPanel computes updated and todo counts from items - On every count change: emit statistic to parent - Parent should own this derivation instead -``` - -```text -✅ Good: parent owns data, child receives data + emits events - Parent owns formState = { title: '', status: 'draft' } - ProfileForm receives formState and emits titleChange(title) - Parent updates formState.title when titleChange fires -``` - -```text -❌ Bad: child owns a copy of parent data and mutates it independently - ProfileForm copies formState from its value prop - On input change: update its copy and emit titleChange(title) - Input displays the copy, which can diverge from parent state -``` - -## Ownership Boundary - -```text -✅ Good: state owner exposes explicit actions, not raw setters - Counter owns value (initialValue) - Exposed handle: reset() restores initialValue; increment() adds one - Display value -``` - -```text -❌ Bad: state setter leaked across component boundary - Counter owns value (initialValue) - Exposed handle: setValue(nextValue) allows callers to mutate state directly -``` - -## Imperative Handle - -```text -DataView receives data and onCellClick - Own a reference to its scroll container - Expose only refresh(), getData(), scrollToCell(row, column) - Display data; emit cellClick when a cell is selected -``` - -## Named Imperative Handle - -```text -AuxiliaryPanel receives project and onOpenPanel - Own a reference to its panel - Expose open() and close() actions using the panel reference - Display the panel -``` - -## Conditional Rendering - -```text -ProjectList receives projects and isLoading - If isLoading: display a loading indicator - Else if projects is empty: display "No data" - Else: display a list of ProjectCard items keyed by project.id -``` - -## List/Table Config - -```text -StatisticsTable receives data and showDeletionColumn - Define columns for Project ID (centered) and Status (formatted with statusColorMap) - If showDeletionColumn: append Deleted column displaying Yes or No - Display data using the column configuration -``` - -## Error Handling - -```text -DataFetcher receives apiFn and params - Own data (initially null) and loading (initially false) - When params change, fetch data: - Set loading to true - Try: await apiFn(params), then store result in data - Catch: showError(error) - Finally: set loading to false - Display loading indicator or data -``` - -## Constants Definition - -```text -✅ Constants defined outside component - STATUS_COLOR_MAP = { success: green, error: red, pending: orange } - REQUIRE_TIPS = "Please upload file" - StatusBadge receives status and displays it with STATUS_COLOR_MAP[status] -``` - -## Pure Function Extraction - -```text -✅ Stateless functions extracted outside component - formatText(text) = trimmed text or empty string - getStatusLabel(status) = STATUS_MAP[status] or "Unknown" - StatusCard receives text and status; displays formatText(text) and getStatusLabel(status) -``` - -## Props Destructuring - -```text -✅ Correct: UserCard receives { name, age, onClick } directly -❌ Wrong: UserCard receives props; reads props.name throughout its body -``` - -## Prop Ordering - -```text -✅ Correct: data → state → config → callbacks, onXxx last - ItemList receives { - records, options, - selectedItemId, - isReadOnly, showToolbar, - onSelectItem, onSubmit, onReset - } -``` - -```text -✅ Correct: runtime prop validation, if used by the project, mirrors the same ordering - ItemList validation: - records: array; options: object - selectedItemId: string - isReadOnly: boolean; showToolbar: boolean - onSelectItem: function; onSubmit: function; onReset: function -``` - -```text -❌ Wrong: callbacks mixed with data, no grouping - ItemList receives { - onSelectItem, records, onSubmit, isReadOnly, - options, onReset, selectedItemId, showToolbar - } -``` - -```text -❌ Wrong: runtime prop validation order does not match received prop order - ItemList validation: - onSelectItem: function; records: array; isReadOnly: boolean - options: object; onSubmit: function -``` - -```text -✅ Correct: call site also groups callbacks last - Display ItemList with - records, options, selectedItemId, isReadOnly, showToolbar, - onSelectItem: handleSelectItem, onSubmit: handleSubmit, onReset: handleReset -``` - -```text -❌ Wrong: call site mixes callbacks between data props - Display ItemList with - onSelectItem: handleSelectItem, records, onSubmit: handleSubmit, - isReadOnly, options -``` - -## Event Handling - -```text -✅ Event function naming handleXxx: - handleClick, handleSubmit, handleChange - -✅ Request function naming fetchXxx/getXxx: - fetchData, getUserInfo -``` - -## Build-Time Flags - -```text -✅ Build-time flag selects an optional feature before the application ships - If build flag ENABLE_EXPORT is set: include Export action - Otherwise: omit Export action from the build - -❌ Runtime branch for a build-specific feature - On every render: check environment to decide whether to display Export action -``` - -## Prohibited Patterns - -```text -❌ Component with inherited lifecycle and duplicated state ownership - UserCard inherits behavior and mirrors parent props into local state - -❌ Direct DOM manipulation - Search the document for an element and change its contents manually - -❌ Multi-line inline event handler - Button click event runs doA(), doB(), doC() inline - Prefer a named handleClick action - -❌ Direct access to shared state internals - Read raw shared state in each component instead of using a dedicated accessor -``` diff --git a/.opencode/skills/best-practices/examples/conventions.md b/.opencode/skills/best-practices/examples/conventions.md new file mode 100644 index 0000000..147e814 --- /dev/null +++ b/.opencode/skills/best-practices/examples/conventions.md @@ -0,0 +1,101 @@ +# Engineering Conventions & Project Structure - Code Examples + +> Reference code examples for [../rules/conventions.md](../rules/conventions.md). + +## Patterns & Examples + +### 1. API Layer Separation + +```tsx +// ❌ Bad: Embedding direct network requests and endpoints in UI components +function UserCard({ userId }: { userId: string }) { + const [user, setUser] = useState(null); + + useEffect(() => { + fetch(`https://api.example.com/v1/users/${userId}`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }) + .then((res) => res.json()) + .then((data) => setUser(data)); + }, [userId]); + + return
    {user?.name}
    ; +} + +// ✅ Good: Centralized API definition; component consumes clean function +// src/api/user.ts +export async function getUserProfile(userId: string): Promise { + return httpClient.get(`/users/${userId}`); +} + +// src/components/UserCard.tsx +import { getUserProfile } from '@/api/user'; + +function UserCard({ userId }: { userId: string }) { + const { data: user } = useQuery(['user', userId], () => getUserProfile(userId)); + return
    {user?.name}
    ; +} +``` + +### 2. Path Aliases vs Fragile Traversal + +```ts +// ❌ Bad: Fragile, deep relative path navigation +import { Button } from '../../../../components/common/Button'; +import { formatDate } from '../../../utils/date'; +import { USER_ROLES } from '../../constants'; + +// ✅ Good: Stable, project-configured path alias +import { Button } from '@/components/common/Button'; +import { formatDate } from '@/utils/date'; +import { USER_ROLES } from '@/constants/roles'; +``` + +### 3. Centralized Constants vs Hardcoded Literals + +```ts +// ❌ Bad: Hardcoded string literals and magic numbers scattered in code +if (user.role === 'admin' && status === 2) { + expireInDays = 30; +} + +// ✅ Good: Typed constants imported from domain constants module +export const USER_ROLES = { + ADMIN: 'admin', + MEMBER: 'member', + GUEST: 'guest', +} as const; + +export const ORDER_STATUS = { + PENDING: 1, + ACTIVE: 2, + EXPIRED: 3, +} as const; + +export const DEFAULT_SESSION_EXPIRATION_DAYS = 30; + +// Call site: +if (user.role === USER_ROLES.ADMIN && status === ORDER_STATUS.ACTIVE) { + expireInDays = DEFAULT_SESSION_EXPIRATION_DAYS; +} +``` + +### 4. File & Directory Layout Guideline (Illustrative) + +```text +src/ +├── api/ # API endpoint functions (*.api.ts or domain modules) +├── assets/ # Static assets (images, icons, fonts) +├── components/ # Shared, domain-agnostic UI components +├── features/ (or pages/) # Feature or domain modules +│ └── order-management/ +│ ├── components/ # Domain-specific components +│ ├── hooks/ # Feature business logic +│ ├── constants.ts # Feature-specific constants +│ └── index.tsx # Feature entry +├── hooks/ # Global / shared hooks +├── services/ # Core HTTP client instances, interceptors, telemetry +├── styles/ # Global tokens, resets, theme variables +├── types/ # Global TypeScript definitions +└── utils/ # Pure shared utility functions +``` diff --git a/.opencode/skills/best-practices/examples/css.md b/.opencode/skills/best-practices/examples/css.md new file mode 100644 index 0000000..96e5800 --- /dev/null +++ b/.opencode/skills/best-practices/examples/css.md @@ -0,0 +1,108 @@ +# CSS & Styling Rules - Code Examples + +> Reference code examples for [../rules/css.md](../rules/css.md). + +## Patterns & Examples + +### 1. Scoped Third-Party UI Overrides + +```css +/* ❌ Bad: Global override polluting other pages and features */ +.ant-btn-primary, .el-button--primary { + border-radius: 20px; + background-color: #ff5722; +} + +/* ✅ Good: Scoped strictly under a feature container */ +.marketing-banner .ant-btn-primary, +.marketing-banner .el-button--primary { + border-radius: 20px; + background-color: var(--color-marketing-accent); +} +``` + +### 2. Design Tokens vs Magic Numbers + +```css +/* ❌ Bad: Hardcoded magic colors and arbitrary pixel spacings */ +.user-card { + color: #1890ff; + border: 1px solid #d9d9d9; + border-radius: 4px; + padding: 12px 16px; +} + +/* ✅ Good: Using design tokens / CSS custom properties */ +.user-card { + color: var(--color-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--spacing-md) var(--spacing-lg); +} +``` + +### 3. Dynamic Inline Styles vs Class Toggles + +```tsx +// ❌ Bad: Static styling dumped into inline style object +function Badge({ isActive, label }) { + return ( + + {label} + + ); +} + +// ✅ Good: Static rules in CSS; dynamic states toggled via classes or CSS variables +function Badge({ isActive, label }) { + return ( + + {label} + + ); +} + +// ✅ Good: Inline style reserved for genuine dynamic calculations +function ProgressBar({ percent }: { percent: number }) { + return ( +
    +
    +
    + ); +} +``` + +### 4. Motion Reduction Compliance + +```css +/* ✅ Provide fallback when user enables reduced motion */ +@media (prefers-reduced-motion: reduce) { + .animate-spin, + .animate-pulse { + animation: none !important; + transition: none !important; + } +} +``` + +### 5. Centralized Scrollbar Utility + +```css +/* ✅ Ensure scrollable viewports share unified scrollbar styling */ +.scrollable-container, +.form-body, +.table-wrap, +pre { + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: var(--color-scrollbar-thumb) transparent; +} +``` + diff --git a/.opencode/skills/best-practices/examples/css/patterns.md b/.opencode/skills/best-practices/examples/css/patterns.md deleted file mode 100644 index 1bd65dd..0000000 --- a/.opencode/skills/best-practices/examples/css/patterns.md +++ /dev/null @@ -1,141 +0,0 @@ -# Style Pattern Examples - -## File Organization - -```text -src/styles/ -├── global.css # Global styles -├── utilities.css # Reusable classes -├── variables.css # Global custom properties -├── pages/{domain}/ # Page styles -├── components/{component}/ # Component styles -└── header.css # Header styles -``` - -## className Usage - -```javascript -// ✅ Simple class names -const wrapperClass = 'project-wrapper detail-list-wrapper'; - -// ✅ Conditional class names using native JavaScript -const statusClass = ['status-tag', isActive && 'status-tag--active', isDisabled && 'status-tag--disabled'] - .filter(Boolean).join(' '); - -// ✅ Dynamic class name -const userCardClass = `user-card user-card--${status}`; -``` - -## Inline Style Limit - -```text -✅ A single dynamic width may be inline: style = { width: '230px' } -❌ Do not put color, font size, padding, and margin together inline -✅ Use a complex-card class for multiple style properties -``` - -## CSS Custom Properties - -```css -/* ✅ Use custom properties */ -:root { - --primary-color: #1890ff; - --border-radius: 4px; -} - -.card { - color: var(--primary-color); - border-radius: var(--border-radius); -} - -/* ❌ Repeated hardcoded theme colors */ -.hardcoded-card { - color: #1890ff; -} -``` - -## Reusable Classes - -```css -.single-line-ellipsis { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.two-line-ellipsis { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; -} -``` - -## Third-Party UI Overrides - -```css -/* ✅ Scope overrides to a feature namespace */ -.feature-layout-wrapper .external-table-header, -.feature-layout-wrapper .external-table-body { - min-height: 40px; -} - -/* ❌ Global override affects unrelated features */ -.external-table-header { - min-height: 40px; -} -``` - -## Style Import - -```javascript -// ✅ Use a configured alias for style imports -import '@/styles/pages/order/order-detail.css'; - -// ✅ Relative path for styles in the same directory -import './styles/index.css'; - -// ❌ Avoid deep relative paths -import '../../../styles/pages/order/order-detail.css'; -``` - -## Global Styles - -```css -/* src/styles/global.css */ -@import './utilities.css'; - -:root { - --link-color: #1677ff; -} - -.fill-vertical { - height: 100%; -} - -/* Feature-specific overrides belong under a feature namespace, not here. */ -``` - -## Responsive Design - -```css -.responsive-card { - padding: 16px; -} - -@media (max-width: 768px) { - .responsive-card { - padding: 8px; - } -} -``` - -## Prohibited Patterns - -```text -❌ New scattered style file in a component directory instead of the agreed style location -❌ More than three inline style properties instead of a class -❌ Repeated hardcoded theme colors instead of CSS custom properties -❌ Deep relative path for a style import -❌ Global overrides of third-party UI selectors -``` diff --git a/.opencode/skills/best-practices/examples/defensive.md b/.opencode/skills/best-practices/examples/defensive.md new file mode 100644 index 0000000..0334c18 --- /dev/null +++ b/.opencode/skills/best-practices/examples/defensive.md @@ -0,0 +1,68 @@ +# Defensive Coding & Simplicity Rules - Code Examples + +> Reference code examples for [../rules/defensive.md](../rules/defensive.md). + +## Patterns & Examples + +### 1. Zero Speculative Abstraction + +```ts +// ❌ Bad: Thin wrapper functions that merely forward native expressions +const getActiveUsers = (users) => users.filter((u) => u.isActive); +const getUserDisplayName = (user) => user.name ?? 'Anonymous'; +const isPositive = (count) => count > 0; + +// ✅ Good: Use direct, idiomatic expressions where intent is obvious +const activeUsers = users.filter((u) => u.isActive); +const displayName = user.name ?? 'Anonymous'; +if (count > 0) { /* ... */ } + +// ✅ Good: Extract ONLY when genuine domain policy or multi-step logic exists +function canPublishArticle(article: Article): boolean { + return article.status === 'approved' && !article.isArchived && article.wordCount >= 300; +} +``` + +### 2. Normalization at Ingestion Boundary + +```ts +// ❌ Bad: Scattering ad-hoc parsing and case checks across multiple UI components +function OrderBadge({ status }: { status: string }) { + if (status === 'PAID' || status === 'paid' || status === '1') { + return Paid; + } + return Pending; +} + +// ✅ Good: Normalize at data receipt; downstream logic uses consistent enum/map lookup +type OrderStatus = 'paid' | 'pending' | 'canceled'; + +const STATUS_LABEL_MAP: Record = { + paid: 'Paid', + pending: 'Pending', + canceled: 'Canceled', +}; + +function normalizeOrderStatus(raw: unknown): OrderStatus { + const normalized = String(raw ?? '').trim().toLowerCase(); + return normalized in STATUS_LABEL_MAP ? (normalized as OrderStatus) : 'pending'; +} + +function OrderBadge({ status }: { status: OrderStatus }) { + return {STATUS_LABEL_MAP[status]}; +} +``` + +### 3. Safe Fallbacks vs Guessing Chains + +```ts +// ❌ Bad: Speculative fallback chains guessing where data might hide +const title = item.title ?? item.name ?? item.label ?? item.header ?? 'Untitled'; + +// ❌ Bad: Unintentional truthy check breaking valid zero values +const count = response.count || 10; // When count is 0, incorrectly defaults to 10! + +// ✅ Good: Explicit nullish coalescing with guaranteed boundary contract +const count = response.count ?? 10; +const title = item.title ?? 'Untitled'; +``` diff --git a/.opencode/skills/best-practices/examples/logic.md b/.opencode/skills/best-practices/examples/logic.md new file mode 100644 index 0000000..c269113 --- /dev/null +++ b/.opencode/skills/best-practices/examples/logic.md @@ -0,0 +1,84 @@ +# Business Logic & Async Rules - Code Examples + +> Reference code examples for [../rules/logic.md](../rules/logic.md). + +## Patterns & Examples + +### 1. Presentation & Logic Separation + +```tsx +// ❌ Bad: Bloating the UI component with business calculations and async logic +function UserStatusWidget({ userId }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let active = true; + setLoading(true); + fetch(`/api/users/${userId}/metrics`) + .then((res) => res.json()) + .then((res) => { + if (!active) return; + // Complex domain scoring calculation mixed in view layer + const score = res.points * 1.5 + (res.tier === 'VIP' ? 100 : 0); + setData({ ...res, score }); + }) + .finally(() => active && setLoading(false)); + return () => { active = false; }; + }, [userId]); + + if (loading) return
    Loading...
    ; + return
    Score: {data?.score}
    ; +} + +// ✅ Good: Domain logic extracted into a reusable hook or utility function +function UserStatusWidget({ userId }) { + const { data, isLoading } = useUserMetrics(userId); + + if (isLoading) return
    Loading...
    ; + return
    Score: {data?.score}
    ; +} +``` + +### 2. Side Effect Cleanup & Cancellation + +```tsx +// ❌ Bad: Uncleaned listeners and fire-and-forget requests that leak or set state after unmount +useEffect(() => { + window.addEventListener('resize', handleResize); + fetchData(id).then((result) => setData(result)); + // Missing cleanup listener and missing abort signal +}, [id]); + +// ✅ Good: Clean up listeners and cancel pending requests +useEffect(() => { + const controller = new AbortController(); + + const handleResize = () => setDimensions(window.innerWidth); + window.addEventListener('resize', handleResize); + + fetchData(id, { signal: controller.signal }) + .then((result) => setData(result)) + .catch((error) => { + if (error.name !== 'AbortError') { + showErrorMessage(error); + } + }); + + return () => { + window.removeEventListener('resize', handleResize); + controller.abort(); + }; +}, [id]); +``` + +### 3. Predictable Async State Machine + +```tsx +// ✅ Good: Explicit status representation prevents impossible states (e.g., loading && error) +type AsyncState = + | { status: 'idle'; data: null; error: null } + | { status: 'loading'; data: T | null; error: null } + | { status: 'success'; data: T; error: null } + | { status: 'error'; data: T | null; error: Error }; +``` diff --git a/.opencode/skills/best-practices/examples/logic/patterns.md b/.opencode/skills/best-practices/examples/logic/patterns.md deleted file mode 100644 index b0bf830..0000000 --- a/.opencode/skills/best-practices/examples/logic/patterns.md +++ /dev/null @@ -1,135 +0,0 @@ -# Logic Pattern Examples - -## Basic Logic Unit - -```javascript -export async function fetchStatisticsData(type, params) { - return (await getStats(type, params)).data; -} -``` - -```text -Logic unit owns data (initially []), loading (initially false) - If autoFetch: call fetchData on initialization - fetchData(params): - Set loading to true - Try: data = await fetchStatisticsData(type, params) - Catch: showError(error) - Finally: set loading to false - Expose data, loading, fetchData -``` - -## Shared State Accessor - -```text -✅ Correct: shared state is read through a dedicated accessor - Shared appConfig is initialized by the application - getAppConfig() returns appConfig ?? {} (safe default) - Components call getAppConfig() instead of reading shared storage directly - -❌ Wrong: each component reads raw shared state without a safe default -``` - -## Async State Machine - -```javascript -const dataFetchReducer = (state, action) => { - switch (action.type) { - case 'FETCH': - return { ...state, isLoading: true, isError: false }; - case 'FETCH_SUCCESS': - return { ...state, isLoading: false, data: action.payload }; - case 'FETCH_FAILURE': - return { ...state, isLoading: false, isError: true }; - default: - throw new Error(`Unknown action(${action.type})`); - } -}; - -const createAsyncTask = (func, initialData) => { - let state = { isLoading: false, isError: false, data: initialData }; - const dispatch = action => { state = dataFetchReducer(state, action); }; - - return { - getState: () => state, - run: async (...params) => { - dispatch({ type: 'FETCH' }); - try { - const data = await func(...params); - dispatch({ type: 'FETCH_SUCCESS', payload: data }); - return data; - } catch (error) { - dispatch({ type: 'FETCH_FAILURE' }); - throw error; - } - }, - }; -}; -``` - -## Cancelable Request - -```javascript -const createCancelableRequest = (request, { autoCancel = true } = {}) => { - let activeController; - const cancel = () => activeController?.abort(); - - return { - cancel, - request: async (...params) => { - if (autoCancel) cancel(); - const controller = new AbortController(); - activeController = controller; - try { - return await request(...params, { signal: controller.signal }); - } catch (error) { - if (error.name === 'AbortError') return { isCanceled: true }; - throw error; - } finally { - if (activeController === controller) activeController = undefined; - } - }, - }; -}; - -// Call cancel() when the owner is disposed. -``` - -## Logic Order - -```text -Component logic order: - 1. Third-party integrations - 2. Shared state via dedicated accessor - 3. Local state: loading and data - 4. Derived data: active items from data - 5. Handlers: handleSubmit updates loading and submits data - 6. Side effects: fetch initial data and clean up on disposal - 7. Display loading indicator or active items -``` - -## Naming Convention - -```text -✅ use{Domain}{Action} for a project convention describing reusable logic - useProjectOperation - useUserInfo - useStatisticsData - -❌ Use{Domain}{Action} (uppercase first letter) - UseEditTemplate - UseAuditConfig -``` - -## Prohibited Patterns - -```text -❌ Direct shared-state access - Component reads internal userInfo storage instead of getUserInfo() - -❌ Stale derived values - Cache filteredData without updating it when source data changes - -❌ Shared state without a dedicated accessor - Each component reads a raw shared value and invents its own fallback -``` diff --git a/.opencode/skills/best-practices/examples/performance.md b/.opencode/skills/best-practices/examples/performance.md new file mode 100644 index 0000000..2e54849 --- /dev/null +++ b/.opencode/skills/best-practices/examples/performance.md @@ -0,0 +1,73 @@ +# Performance Optimization Rules - Code Examples + +> Reference code examples for [../rules/performance.md](../rules/performance.md). + +## Patterns & Examples + +### 1. Lazy Loading Routes and Heavy Components + +```tsx +// ❌ Bad: Statically bundling massive libraries into the initial page bundle +import HeavyDataChart from './HeavyDataChart'; + +function Dashboard() { + const [showChart, setShowChart] = useState(false); + return ( +
    + + {showChart && } +
    + ); +} + +// ✅ Good: Dynamically import heavy component on demand +import { lazy, Suspense } from 'react'; + +const HeavyDataChart = lazy(() => import('./HeavyDataChart')); + +function Dashboard() { + const [showChart, setShowChart] = useState(false); + return ( +
    + + {showChart && ( + Loading chart...
    }> + + + )} +
    + ); +} +``` + +### 2. Expensive Derivations & Callback Stability + +```tsx +// ❌ Bad: Running expensive multi-step filter/sort on every unrelated re-render +function AnalyticsTable({ rawLogs, filterText, onSelectLog }) { + // Re-runs even if rawLogs and filterText did not change! + const processedLogs = rawLogs + .filter((log) => log.message.includes(filterText)) + .sort((a, b) => b.timestamp - a.timestamp); + + // Re-creates new function reference on every parent render + const handleSelect = (id: string) => onSelectLog(id); + + return ; +} + +// ✅ Good: Memoize expensive data derivations and stabilize handler references +function AnalyticsTable({ rawLogs, filterText, onSelectLog }) { + const processedLogs = useMemo(() => { + return rawLogs + .filter((log) => log.message.includes(filterText)) + .sort((a, b) => b.timestamp - a.timestamp); + }, [rawLogs, filterText]); + + const handleSelect = useCallback((id: string) => { + onSelectLog(id); + }, [onSelectLog]); + + return ; +} +``` diff --git a/.opencode/skills/best-practices/examples/state.md b/.opencode/skills/best-practices/examples/state.md new file mode 100644 index 0000000..41f6d89 --- /dev/null +++ b/.opencode/skills/best-practices/examples/state.md @@ -0,0 +1,86 @@ +# State Management Rules - Code Examples + +> Reference code examples for [../rules/state.md](../rules/state.md). + +## Patterns & Examples + +### 1. Minimal State vs Redundant Derived State + +```tsx +// ❌ Bad: Redundant state variables that must be synchronized manually +function TodoApp() { + const [todos, setTodos] = useState([]); + const [completedCount, setCompletedCount] = useState(0); // Redundant + + const handleToggle = (id: string) => { + const updated = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)); + setTodos(updated); + setCompletedCount(updated.filter((t) => t.done).length); // Prone to drift + }; + // ... +} + +// ✅ Good: Derive counts directly from the primary state +function TodoApp() { + const [todos, setTodos] = useState([]); + const completedCount = todos.filter((t) => t.done).length; + + const handleToggle = (id: string) => { + setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))); + }; + // ... +} +``` + +### 2. Prop Drilling vs Composition / Context + +```tsx +// ❌ Bad: Drilling currentUser through unrelated intermediate layout components +function App({ currentUser }) { + return ; +} +function Layout({ currentUser }) { + return ; +} +function Sidebar({ currentUser }) { + return ; +} + +// ✅ Good (Option A - Composition): Pass the leaf component directly via children +function App({ currentUser }) { + return ( + + + + + + ); +} + +// ✅ Good (Option B - Shared Context/Store): Access state directly at the consumer +function UserProfile() { + const currentUser = useCurrentUser(); + return
    {currentUser.name}
    ; +} +``` + +### 3. Isolated Editing Buffer vs State Mirroring + +```tsx +// ✅ Good: Copying to local state is ONLY valid when creating an isolated draft buffer +function EditDialog({ initialData, onSave, onCancel }) { + // Deliberate draft copy: edits do NOT reflect into parent until confirmed + const [draft, setDraft] = useState(initialData); + + return ( +
    + setDraft({ ...draft, title: e.target.value })} + /> + + +
    + ); +} +``` diff --git a/.opencode/skills/best-practices/examples/style/coding-basics/patterns.md b/.opencode/skills/best-practices/examples/style/coding-basics/patterns.md deleted file mode 100644 index aa10c10..0000000 --- a/.opencode/skills/best-practices/examples/style/coding-basics/patterns.md +++ /dev/null @@ -1,52 +0,0 @@ -# Coding Basics Pattern Examples - -## Speculative Function Extraction - -```javascript -// ❌ Direct JS was enough; wrapper adds no business meaning -const getDisplayName = item => item.key ?? item.name; - -const displayName = getDisplayName(item); - -// ✅ Keep the direct expression when shape is already known -const displayName = item.key ?? item.name; -``` - -## Wrapper That Only Forwards Arguments - -```javascript -// ❌ Thin wrapper created only to look more testable -export const handleProjectList = list => formatProjectList(list); - -// ✅ Export or call the original function directly -export { formatProjectList }; -``` - -## Extract Only When Meaning Changes - -```javascript -// ❌ Renames a JS statement but adds no new meaning -const isEditableProject = project => project.status === 'draft'; - -// ✅ Inline when the condition is local and obvious -const canEdit = project.status === 'draft'; - -// ✅ Extract when the function becomes a real domain rule -const isProjectEditable = project => { - return project.status === 'draft' && !project.isArchived; -}; -``` - -## Native JS First - -```javascript -// ✅ Native is enough -const activeUsers = users.filter(user => user.active); -const hasAdmin = users.some(user => user.role === 'admin'); -const name = user?.profile?.name ?? '-'; - -// ❌ Wrapping native operations adds no value here -const activeUsers = selectActiveUsers(users); // Merely calls users.filter(...) -const hasAdmin = checkAdmin(users); // Merely calls users.some(...) -const name = readNameWithFallback(user); // Merely reads user?.profile?.name ?? '-' -``` diff --git a/.opencode/skills/best-practices/examples/style/fallback/patterns.md b/.opencode/skills/best-practices/examples/style/fallback/patterns.md deleted file mode 100644 index 8d44fb3..0000000 --- a/.opencode/skills/best-practices/examples/style/fallback/patterns.md +++ /dev/null @@ -1,69 +0,0 @@ -# Fallback Pattern Examples - -## Put Fallback at the Boundary - -```javascript -// ✅ Parse layer: unknown shape -const normalizeJson = str => { - try { - return JSON.parse(str); - } catch (e) { - return null; - } -}; - -// ✅ Display layer: known shape -const displayValue = data?.message ?? 'No message available'; -``` - -## One Backup Is Normal, Long Chains Are Suspicious - -```javascript -// ✅ Known shape, one clear default -const displayName = projectName ?? '-'; - -// ❌ AI-style chain: too many guesses in one expression -const displayName = item.projectName ?? item.name ?? item.title ?? item.content; - -// ✅ Normalize once at the boundary, then read directly -const normalizedItem = normalizeProject(item); -const displayName = normalizedItem.projectName; -``` - -## Avoid Speculative Wrapper Fallback - -```javascript -// ❌ Wrapper only adds fallback noise -export const transformCustomPayload = values => - transformCustomPayloadFromProject(values || {}); - -// ✅ Put fallback in the real boundary -export const transformCustomPayloadFromProject = (values = {}) => { - const { name, type, status } = values; - return { name, type, status }; -}; -``` - -## Avoid Long Fallback Chains - -```javascript -// ❌ Hard to reason about -const displayName = item.key || item.title || item.name || item.content; - -// ✅ Match the real data shape -const displayName = item.key ?? item.name; - -// ✅ Or use an explicit default -const displayName = item.key ?? '-'; -``` - -## Keep the Call Site Honest - -```javascript -// ❌ Wrapper hides missing behavior -export const transformReportPayload = ({ values }) => - transformCustomPayload(values); - -// ✅ Keep the call site explicit when no real adaptation exists -const payload = transformCustomPayload(values); -``` diff --git a/.opencode/skills/best-practices/examples/style/index.md b/.opencode/skills/best-practices/examples/style/index.md deleted file mode 100644 index 07f8caa..0000000 --- a/.opencode/skills/best-practices/examples/style/index.md +++ /dev/null @@ -1,9 +0,0 @@ -# Style Examples Index - -`examples/style/` groups examples by subdomain. - -| Topic | Examples | -| ------------- | ------------------------------------------------------ | -| Coding Basics | [coding-basics/patterns.md](coding-basics/patterns.md) | -| Normalize | [normalize/patterns.md](normalize/patterns.md) | -| Fallback | [fallback/patterns.md](fallback/patterns.md) | diff --git a/.opencode/skills/best-practices/examples/style/normalize/patterns.md b/.opencode/skills/best-practices/examples/style/normalize/patterns.md deleted file mode 100644 index ddc30ed..0000000 --- a/.opencode/skills/best-practices/examples/style/normalize/patterns.md +++ /dev/null @@ -1,55 +0,0 @@ -# Normalize Pattern Examples - -## Single Source of Truth Map - -```javascript -const STAGE_DISPLAY = new Map([ - ['draft', 'Draft'], - ['pending', 'Pending'], - ['done', 'Done'], -]); - -const getStageLabel = stage => { - // ?? keeps valid falsy values like 0 (|| drops them); Map avoids inherited keys like __proto__. - const normalizedStage = String(stage ?? '').trim().toLowerCase(); - return STAGE_DISPLAY.get(normalizedStage) ?? '-'; -}; -``` - -## Normalize at Parse Layer - -```javascript -const normalizeStatus = status => - String(status ?? '') - .trim() - .toLowerCase(); - -const normalizeProject = item => ({ - ...item, - status: normalizeStatus(item.status), -}); - -const list = response.list.map(normalizeProject); -``` - -## Avoid Repeated Branches - -```javascript -// ❌ Repeated comparisons in multiple places -if (status === 'DONE' || status === 'done' || status === 'Done') { - return 'Done'; -} - -// ✅ Normalize once, then lookup once -const normalizedStatus = String(status ?? '') - .trim() - .toLowerCase(); -return STAGE_DISPLAY.get(normalizedStatus) ?? '-'; -``` - -## Derive Downstream from Normalized Value - -```javascript -const grouped = Object.groupBy(list, item => item.status); -const doneCount = grouped.done?.length ?? 0; -``` diff --git a/.opencode/skills/best-practices/rules/api.md b/.opencode/skills/best-practices/rules/api.md deleted file mode 100644 index 86a72d8..0000000 --- a/.opencode/skills/best-practices/rules/api.md +++ /dev/null @@ -1,57 +0,0 @@ -# API Layer Rules - -## MUST - -| Rule | Description | -| ----------------- | ----------------------------------- | -| API call location | Keep API calls in the project's designated API layer | -| HTTP client instance | Created only in the project's shared HTTP layer | - -## SHOULD - -| Rule | Description | -| --------------- | -------------------------------------- | -| Request failure | Use try/catch + the app's user-facing error notifier | - -## Typical Layout (illustrative — follow the project's conventions) - -- API definitions: `src/api/*.api.js` -- HTTP instances: `src/services/http*.js` -- Error handling: `src/services/util.js` - -## Examples - -```js -// ✅ API call kept in the project's API layer -export const getProjectByIdApi = id => http.get(`/projects/${id}`); - -// ✅ Request failure handling -async function fetchData(id) { - try { - setLoading(true); - const result = await getProjectByIdApi(id); - setData(result); - } catch (error) { - showError(error); - } finally { - setLoading(false); - } -} -``` - ---- - -# Error Handling Rules - -## MUST - -| Rule | Description | -| ------------------ | --------------------------------------------- | -| HTTP common errors | Handled by services interceptor (401/403/500) | - -## SHOULD - -| Rule | Description | -| ---------------------- | ------------------------------------------------------ | -| Business errors | Use `try/catch` + the app's user-facing error notifier | -| Production environment | Use error monitoring / session replay only through an already installed, explicitly approved integration. No new SDKs, dependencies, data collection, or outbound telemetry by default. | diff --git a/.opencode/skills/best-practices/rules/component.md b/.opencode/skills/best-practices/rules/component.md index 229bf6a..150cdb8 100644 --- a/.opencode/skills/best-practices/rules/component.md +++ b/.opencode/skills/best-practices/rules/component.md @@ -1,56 +1,38 @@ -# Component Rules +# Component Architecture & Design Rules -## MUST - -| Rule | Description | -| -------------------------------- | --------------------------------------------------------------------- | -| Function-style components | Class components prohibited | -| Props destructuring | `({ name, age }) => {}` not `(props) => {}` | -| Explicit imperative handles | Complex components expose handles through a documented API; direct DOM manipulation prohibited | -| Consistent exports | One export style per project | -| Single source of truth | State has one owner; downstream reads or emits intent, never mirrors | -| Derived data via pure function | Compute at the source; never mirror it into local state and sync with effects | -| Callbacks report events only | `onXxx` describes what happened; not a channel to replicate state | -| Prop ordering | Data → state → config → callbacks; `onXxx` last (see Prop Ordering) | - -## SHOULD +## Core Principles -| Rule | Description | -| -------------------------------- | ----------------------------------------------------------------------------- | -| Stable handlers | Keep long or repeatedly passed handlers stable | -| Event naming `handleXxx` | `handleClick`, `handleSubmit`, `handleChange` | -| Request naming `fetchXxx/getXxx` | `fetchData`, `getUserInfo` | -| Debug names | Preserve component names for debugging tools | -| Early returns preferred | Return loading/empty states before the main render | +- **Unidirectional Data Flow**: Props flow down, events flow up. Components are view boundaries that project inputs into UI and emit user intent. +- **Single Source of Truth**: State has one owner. Downstream components read values or emit events; they must never mirror parent state into local state just to keep them in sync. +- **Pure Derivation**: Compute derived values synchronously on the fly. Do not sync derived state back to parent via callbacks or cache them in redundant local state. +- **Behavioral Imperative Handles**: Reserve imperative handles (`ref`) for actions (focus, scroll, reset). Never expose raw state setters or duplicate state snapshots. -## Design Principles - -- A component is a boundary, not a data bus: inputs flow in, events flow out, and each piece of state has one owner. -- Prefer a single source of truth. Downstream components should read values or emit intent; they should not mirror parent state just to stay in sync. -- Keep derived data close to the source of truth. If a consumer needs another shape, expose a pure selector or derived prop, not a synchronization callback. -- Use callbacks to report events and user intent. `onXxx` should describe what happened, not be a state replication channel. -- Reserve imperative handles for actions such as focus, scroll, export, or reset—not raw state setters or duplicate snapshots. -- Treat config objects as policy boundaries. Stable behavior belongs in config; changing runtime state belongs in state, context, or selectors. - -See [examples/component/patterns.md](../examples/component/patterns.md#unidirectional-data-flow) for good/bad flow examples and [examples/component/patterns.md](../examples/component/patterns.md#ownership-boundary) for state boundary examples. +## MUST -## Prop Ordering +| Rule | Description | +| ---- | ----------- | +| Unidirectional flow | Parent owns state; child receives data and emits event callbacks | +| Explicit prop destructuring | Destructure props with default parameters at the component signature | +| No state mirroring | Never duplicate parent props into local state unless intentionally creating an isolated draft buffer | +| No direct DOM queries | Direct DOM queries (`document.querySelector`, `getElementById`) are prohibited; use framework refs | +| Behavior-only handles | Imperative handles expose actions (`focus()`, `reset()`), never internal state mutators | +| Accessible tab navigation | Tabs must use `role="tablist"`/`tab`/`tabpanel`, roving `tabindex`, and arrow key roving | +| Async action feedback & guards | Buttons must indicate state via `aria-busy`, disabled guard (`disabled={isPending || isSaving}`), and motion-safe indicator | +| Context-aware action dispatch | Global/header actions in multi-tab or multi-scoped views must target the active context | +| Standardized table empty states | Tables must use shared empty/loading row primitives; ad-hoc `` prohibited | +| Bounded raw content containers | Raw text, `
    `, logs, and JSON previews must have explicit max-bounds and overflow containment |
    +| Form vs detail page frames | Constrained forms scroll only `.form-body` above pinned actions; read-only details preserve document flow |
     
    -- Keep props grouped by meaning: data and identity first, state and derived values next, config and flags after that, and callbacks last.
    -- Put `onXxx` callbacks together at the end of destructured props, runtime prop validation (if the project uses it), and call sites when practical.
    -- Mirror the same ordering in runtime prop validation (if the project uses it) and default values so a component reads the same way in every file.
    -- If a component grows too many props, split by semantic sub-objects instead of mixing unrelated groups.
    -- Do not use object spreads to hide prop ordering. Make the shape explicit and readable.
    +## SHOULD
     
    -## Prohibited Patterns
    +| Rule | Description |
    +| ---- | ----------- |
    +| Semantic prop ordering | Group props: identity/data → state/flags → event callbacks (`onXxx` last) |
    +| Early returns | Return early for loading, empty, and error states before primary render |
    +| Pure helper extraction | Stateless rendering helpers without closure dependencies should live outside the component |
     
    -```text
    -❌ Class-based component
    -❌ Direct DOM manipulation: document.getElementById(...)
    -❌ Multi-line inline handler instead of a named handler
    -❌ Passing the whole props object instead of destructuring
    -```
    +---
     
     ## Examples
     
    -See [examples/component/patterns.md](../examples/component/patterns.md)
    +See [../examples/component.md](../examples/component.md) for concrete patterns and code examples.
    diff --git a/.opencode/skills/best-practices/rules/constants.md b/.opencode/skills/best-practices/rules/constants.md
    deleted file mode 100644
    index 910c6a7..0000000
    --- a/.opencode/skills/best-practices/rules/constants.md
    +++ /dev/null
    @@ -1,32 +0,0 @@
    -# Constants Rules
    -
    -## MUST
    -
    -| Rule                       | Description                                    |
    -| -------------------------- | ---------------------------------------------- |
    -| Import from the constants module | Shared constants come from the project's constants module, not inline literals |
    -| `SCREAMING_SNAKE_CASE`     | Constants naming convention                    |
    -| No hardcoding              | Hardcoded strings prohibited                   |
    -
    -## SHOULD
    -
    -| Rule               | Description                       |
    -| ------------------ | --------------------------------- |
    -| Business constants | Import from domain `constants.js` |
    -
    -## Typical Locations (illustrative — follow the project's conventions)
    -
    -- Global: `src/utils/constants.js`
    -- Business domain: `src/utils/{domain}-constants.js`
    -
    -## Examples
    -
    -```js
    -// ✅ Correct: import from constants.js
    -import { ORDER_STATUS_DICT } from '@/utils/constants'; // the project's configured path alias
    -
    -const isActive = status !== ORDER_STATUS_DICT.ACTIVE;
    -
    -// ❌ Wrong: hardcoded string
    -const isActive = status !== 'active';
    -```
    diff --git a/.opencode/skills/best-practices/rules/conventions.md b/.opencode/skills/best-practices/rules/conventions.md
    new file mode 100644
    index 0000000..fe0aa7c
    --- /dev/null
    +++ b/.opencode/skills/best-practices/rules/conventions.md
    @@ -0,0 +1,32 @@
    +# Engineering Conventions & Project Structure
    +
    +## Core Principles
    +
    +- **Feature & Domain Boundary**: Organize codebase by business features or architectural layers. Directories express domain ownership; files express specific artifacts.
    +- **Decoupled API Layer**: Keep HTTP client configuration, endpoint contracts, and request functions outside UI components in a designated API/service layer.
    +- **Centralized Constants & Enums**: Extract magic numbers, business status codes, and configuration keys into dedicated constants modules with `SCREAMING_SNAKE_CASE`.
    +- **Path Aliases over Deep Traversal**: Use configured module path aliases (e.g. `@/`) for cross-domain imports. Never write fragile relative paths (`../../../../`).
    +
    +## MUST
    +
    +| Rule | Description |
    +| ---- | ----------- |
    +| Path aliases | Cross-domain and deep imports must use the project's configured alias (e.g. `@/components/`, `@/utils/`) |
    +| No deep relative imports | Prohibit relative traversal deeper than parent directory (`../` is okay for siblings; `../../..` is banned) |
    +| Decoupled API calls | UI components must not call raw HTTP libraries (`axios.get`, `fetch`) inline; import from an API/service module |
    +| No magic strings/numbers | Business statuses and fixed domain values must be imported from constants |
    +| File naming consistency | Components use `PascalCase`; logic/hooks and utilities use `camelCase`; styles and configs use `kebab-case` |
    +
    +## SHOULD
    +
    +| Rule | Description |
    +| ---- | ----------- |
    +| Directory naming | Directories use `kebab-case` (e.g. `order-detail/`, `user-profile/`) |
    +| Structured import order | 3rd-party dependencies → internal alias modules → local relative modules → style files |
    +| File size budget | Keep files focused (< 300 lines normal; > 500 lines strongly indicates a need to refactor) |
    +
    +---
    +
    +## Examples
    +
    +See [../examples/conventions.md](../examples/conventions.md) for concrete patterns and code examples.
    diff --git a/.opencode/skills/best-practices/rules/css.md b/.opencode/skills/best-practices/rules/css.md
    index d721480..1c181a3 100644
    --- a/.opencode/skills/best-practices/rules/css.md
    +++ b/.opencode/skills/best-practices/rules/css.md
    @@ -1,46 +1,32 @@
    -# CSS Rules
    +# CSS & Styling Rules
    +
    +## Core Principles
    +
    +- **Scoped Component Styles**: Styles must remain strictly scoped to their owning component or feature domain. Never leak selectors into the global namespace.
    +- **Design Tokens over Magic Literals**: Use CSS custom properties or theme tokens for colors, spacing, borders, and typography.
    +- **Minimal Inline Styles**: Restrict inline `style` attributes to purely dynamic runtime values (e.g. calculated dimensions, translation coordinates, dynamic colors). Static styling belongs in classes.
    +- **Namespace Third-Party Overrides**: When overriding third-party component library classes, always prefix the selector with a local feature namespace wrapper.
     
     ## MUST
     
    -| Rule                            | Description                                                |
    -| ------------------------------- | ---------------------------------------------------------- |
    -| Style file location | Follow the project's style organization; when styles are centralized (e.g. `src/styles/`), do not add style files inside component directories |
    -| Class names in kebab-case       | `user-card`, `detail-list-wrapper`                         |
    -| > 3 properties: no inline style | Must use a class for 3+ properties                         |
    +| Rule | Description |
    +| ---- | ----------- |
    +| Scoped overrides | Third-party UI overrides must be nested under a unique feature or page class container |
    +| No magic color/spacing literals | Use CSS custom properties (`var(--color-primary)`) or project design tokens |
    +| Dynamic inline styles only | Inline `style` is reserved for runtime calculations; static CSS properties must use classes |
    +| No deep relative style imports | Use path aliases (e.g. `@/styles/...`) or localized component style imports |
    +| Uniform scrollbar theming | Every scrollable container (`pre`, `textarea`, `.form-body`, `.table-wrap`, drawer) must share the centralized scrollbar theme utility |
    +| Motion reduction compliance | Continuous animations (spinners, pulses) must respect `prefers-reduced-motion` or provide `motion-reduce:animate-none` fallbacks |
     
     ## SHOULD
     
    -| Rule                                    | Description                                                                                             |
    -| --------------------------------------- | ------------------------------------------------------------------------------------------------------- |
    -| Compose conditional class names with a small helper or template literals | Keep conditional styles explicit |
    -| Reuse design tokens/variables and mixins only when truly reused | Colors, sizes, common patterns only when reuse reduces maintenance burden |
    -| No forced extraction                    | Do not extract variables/mixins just for conciseness; extract only when multiple usages benefit         |
    -| Third-party UI overrides scoped under a feature namespace | Keep overrides scoped to the owning feature |
    -| Style imports use the project's alias when configured | Avoid deep relative style paths |
    -
    -## Example Layout (illustrative — adapt to the project)
    -
    -```
    -src/styles/
    -├── global.css                     # Global styles
    -├── mixin.css                      # Global mixin
    -├── variables.css                  # Global variables
    -├── pages/{domain}/                # Page styles
    -├── components/{component}/        # Component styles
    -└── header.css                     # Header styles
    -```
    -
    -## Prohibited Patterns
    -
    -Path examples assume the centralized layout shown above.
    -
    -```text
    -❌ New style file in component directory: src/components/UserCard.css
    -❌ More than 3 properties in an inline style: color, font size, padding, margin
    -❌ Hardcoded colors: .card { color: #1890ff; }
    -❌ Deep relative path for style import: ../../../styles/pages/order/order-detail.css
    -```
    +| Rule | Description |
    +| ---- | ----------- |
    +| Consistent class naming | Use consistent naming (e.g. BEM, CSS Modules, or project utility classes) |
    +| Layout vs Presentation | Keep layout properties (margin, grid placement) on the parent; component root handles internal padding |
    +
    +---
     
     ## Examples
     
    -See [../examples/css/patterns.md](../examples/css/patterns.md)
    +See [../examples/css.md](../examples/css.md) for concrete patterns and code examples.
    diff --git a/.opencode/skills/best-practices/rules/defensive.md b/.opencode/skills/best-practices/rules/defensive.md
    new file mode 100644
    index 0000000..289b670
    --- /dev/null
    +++ b/.opencode/skills/best-practices/rules/defensive.md
    @@ -0,0 +1,31 @@
    +# Defensive Coding & Simplicity Rules
    +
    +## Core Principles
    +
    +- **Zero Speculative Abstraction**: Write direct code first. Never extract a one-line wrapper function or create intermediate helper layers simply to claim code is "more testable" or "more decoupled". Extract only when real business logic is encapsulated, duplication is eliminated, or a public boundary is formed.
    +- **Normalize at Boundaries**: Parse and normalize untrusted, heterogeneous, or raw external inputs (API responses, URL params, local storage) once at the ingestion boundary. Downstream components and logic must receive predictable, strongly typed shapes.
    +- **Single-Hop Defensive Fallback**: One fallback hop is healthy (`value ?? defaultValue`). Long guessing chains (`a ?? b ?? c ?? d`) signal an ambiguous data contract that should be normalized at the source rather than patched in UI render logic.
    +- **Native JS First**: Standard JavaScript methods (`map`, `filter`, `find`, `some`, `every`, `includes`) are clear and self-documenting. Do not invent thin alias functions around them.
    +
    +## MUST
    +
    +| Rule | Description |
    +| ---- | ----------- |
    +| Direct expressions first | Do not extract trivial single-caller wrapper functions that add no domain meaning |
    +| Normalize at ingestion | Transform raw API enums, casings, or nullish properties at the parse layer once |
    +| Single lookup tables | Use lookup dictionaries / maps for status/enum mappings instead of sprawling `if/else` or `switch` chains |
    +| Single-hop fallbacks | Provide immediate, safe defaults; avoid cascading fallback chains in display code |
    +| Predictable nullish handling | Prefer `??` and `?.` over loose truthy `||` to prevent falsy bugs with `0`, `""`, and `false` |
    +
    +## SHOULD
    +
    +| Rule | Description |
    +| ---- | ----------- |
    +| Fail-safe return shapes | Boundary parsers should return guaranteed empty shapes (`[]` for missing lists, `{}` for missing objects) |
    +| Early exit / guard clauses | Check boundary conditions early to eliminate nested blocks |
    +
    +---
    +
    +## Examples
    +
    +See [../examples/defensive.md](../examples/defensive.md) for concrete patterns and code examples.
    diff --git a/.opencode/skills/best-practices/rules/file-structure.md b/.opencode/skills/best-practices/rules/file-structure.md
    deleted file mode 100644
    index 5fb8d9d..0000000
    --- a/.opencode/skills/best-practices/rules/file-structure.md
    +++ /dev/null
    @@ -1,141 +0,0 @@
    -# File Structure Rules
    -
    -## MUST
    -
    -| Rule                | Description                                   |
    -| ------------------- | --------------------------------------------- |
    -| Shared components   | In the project's shared components directory (e.g. `src/components/`) |
    -| Business components | Inside the owning domain's directory |
    -| Line limit          | < 300 lines normal; > 500 lines must refactor |
    -
    -## SHOULD
    -
    -| Rule                    | Description                |
    -| ----------------------- | -------------------------- |
    -| Business logic          | Extract to `logic/`        |
    -| Conditional compilation | Build-time flags, not runtime checks |
    -
    -## Example Layout (illustrative — the project's actual structure wins)
    -
    -```
    -src/
    -├── api/              # API definitions (*.api.js)
    -├── components/       # Shared components
    -│   ├── state/        # Global shared state
    -│   ├── logic/        # Shared logic units
    -│   └── {domain}/     # Business components
    -├── pages/            # Page modules
    -│   └── {domain}/     # By business domain
    -├── services/         # HTTP instances, config
    -├── styles/           # Global styles
    -├── utils/            # Utilities, constants
    -└── router/           # Route config
    -```
    -
    -## Naming Rules
    -
    -The project's existing structure and naming always win; the tables below are defaults for new code when no convention exists.
    -
    -Naming is determined by "object type" first, then casing.
    -
    -### Core Principle
    -
    -- Directories express module / business domain / ownership
    -- Filenames express specific code artifacts
    -- Do not use long filename prefixes to simulate namespace
    -- Do not rename historically stable files just for consistency; new files must follow these rules
    -
    -### Directory Naming
    -
    -Directories use kebab-case by default.
    -
    -| Type                      | Convention                          | Example                                              |
    -| ------------------------- | ----------------------------------- | ---------------------------------------------------- |
    -| Business domain           | kebab-case                          | `order-page/`, `user-profile/`                       |
    -| Feature / template folder | kebab-case                          | `annual-report/`, `upload-files/`                    |
    -| Common dirs               | keep as-is                          | `components/`, `logic/`, `utils/`, `__tests__/`      |
    -
    -```text
    -✅ Correct:
    -  annual-report/
    -  order-page/
    -  user-profile/
    -  upload-files/
    -
    -❌ Wrong:
    -  annualReport/
    -  AnnualReport/
    -  orderPage/
    -```
    -
    -### File Naming
    -
    -| Type             | Convention         | Example                           |
    -| ---------------- | ------------------ | --------------------------------- |
    -| Component        | PascalCase        | `UserCard.js`                     |
    -| Logic unit       | camelCase or useCamelCase | `useUserInfo.js`           |
    -| Utility / helper | camelCase.js       | `formatDate.js`, `formHelpers.js` |
    -| Style            | kebab-case        | `user-card.css`                   |
    -| API module       | kebab-case.api.js  | `user.api.js`                     |
    -| Constants file   | constants.js       | `utils/constants.js`              |
    -| Field protocol   | field.js           | `utils/field.js`                  |
    -| Payload logic    | payload.js         | `utils/template-name/payload.js`  |
    -| Barrel export    | index.js           | `utils/index.js`                  |
    -
    -### Template / Feature Utility Directory
    -
    -Template-specific shared utilities must be wrapped in a kebab-case folder under `utils/`, not flat prefixed files.
    -
    -```text
    -✅ Correct:
    -  utils/
    -    annual-report/
    -      constants.js
    -      field.js
    -      payload.js
    -      index.js
    -
    -❌ Wrong:
    -  utils/
    -    annualReport.constants.js
    -    annualReportPayload.js
    -    annualReport/
    -```
    -
    -### Naming Decision Table
    -
    -| Scenario                    | Convention           |
    -| --------------------------- | -------------------- |
    -| Component file              | `PascalCase`         |
    -| Logic unit file             | `camelCase` or `useCamelCase` |
    -| Utility JS file             | `camelCase.js`       |
    -| API module                  | `kebab-case.api.js`  |
    -| Style                       | `kebab-case`         |
    -| Any new directory           | `kebab-case/`        |
    -| Template-specific directory | `kebab-case/`        |
    -| constants file              | `constants.js`       |
    -| field protocol file         | `field.js`           |
    -| payload file                | `payload.js`         |
    -| barrel export               | `index.js`           |
    -
    -### Anti-patterns
    -
    -```text
    -❌ Use filename prefix instead of directory boundary:
    -  annualReport.constants.js
    -  annualReportPayload.js
    -
    -❌ Component using non-PascalCase:
    -  create-project
    -
    -❌ Utility using PascalCase:
    -  FormHelpers.js
    -
    -❌ Style using camelCase / PascalCase:
    -  projectWizardStepTwo.css
    -
    -❌ Mixed naming in same directory:
    -  formHelpers.js
    -  api-helpers.js
    -  PayloadHelper.js
    -```
    diff --git a/.opencode/skills/best-practices/rules/import.md b/.opencode/skills/best-practices/rules/import.md
    deleted file mode 100644
    index b401253..0000000
    --- a/.opencode/skills/best-practices/rules/import.md
    +++ /dev/null
    @@ -1,25 +0,0 @@
    -# Import Rules
    -
    -## MUST
    -
    -| Rule                   | Description                            |
    -| ---------------------- | -------------------------------------- |
    -| Path alias             | Cross-domain and deep imports use the project's configured path alias (e.g. `@/`) |
    -| No deep relative paths | Avoid `../../../..`-style imports; use the alias instead |
    -
    -## SHOULD
    -
    -| Rule         | Description                                   |
    -| ------------ | --------------------------------------------- |
    -| Import order | 3rd-party → internal alias → relative → styles |
    -
    -## Examples
    -
    -```js
    -// ✅ Correct: use the project's alias
    -import { useUserInfo } from '@/utils/useUserInfo';
    -import { ORDER_STATUS_DICT } from '@/utils/constants';
    -
    -// ❌ Wrong: deep relative path
    -import { useUserInfo } from '../../../utils/useUserInfo';
    -```
    diff --git a/.opencode/skills/best-practices/rules/logic.md b/.opencode/skills/best-practices/rules/logic.md
    index 9e899ad..a7a58d1 100644
    --- a/.opencode/skills/best-practices/rules/logic.md
    +++ b/.opencode/skills/best-practices/rules/logic.md
    @@ -1,39 +1,30 @@
    -# Logic Rules
    +# Business Logic & Async Rules
    +
    +## Core Principles
    +
    +- **Separation of Presentation & Domain Logic**: Components focus on rendering and user interaction; business calculation, async orchestration, and state transitions belong in dedicated hooks, composables, or domain service functions.
    +- **Explicit Lifecycle & Cleanup**: Side effects that establish listeners, timers, or ongoing network requests must be safely cleaned up on unmount or dependency change.
    +- **Predictable Async States**: Asynchronous workflows must handle pending, fulfilled, and rejected outcomes explicitly.
     
     ## MUST
     
     | Rule | Description |
     | ---- | ----------- |
    -| Naming | Reusable logic units follow the project convention (commonly useXxx) |
    -| Order: third-party → shared state → local state → derived values → handlers → side effects | Keep logic in a consistent order at the component boundary |
    -| Shared state via dedicated accessor | Direct shared-state access prohibited |
    +| Separation of concerns | Complex business calculations and multi-step data transformations live outside UI components |
    +| Effect cleanup | Clean up subscriptions, DOM event listeners, intervals, and pending network requests on teardown |
    +| Error boundaries | Async functions must handle errors gracefully without unhandled promise rejections |
    +| No stale closures | Effect and callback dependencies must remain accurate and synchronized |
     
     ## SHOULD
     
     | Rule | Description |
     | ---- | ----------- |
    -| Async requests: shared state-machine logic | Unified FETCH/SUCCESS/FAILURE handling |
    -| Lists: shared list-state reducer | Manage loading, pagination, search, data list |
    -| Polling pauses when the page/tab is hidden | Avoid background requests |
    -| Shared-state accessor fallback | Return a safe default when the value is missing |
    -
    -## Prohibited Patterns
    -
    -```text
    -❌ Read shared state directly instead of using the dedicated accessor
    -❌ Side effect reads stale data because its dependencies are not updated
    -❌ Expose shared state without a dedicated accessor or safe default
    -```
    -
    -## Shared State Pattern
    +| Reusable logic abstraction | Abstract cross-cutting or shared logic into standard composables/hooks matching framework idioms |
    +| Abortable requests | Pass `AbortSignal` to async requests when ongoing work should be canceled on re-trigger or unmount |
    +| Return explicit states | Logic units handling async operations should expose state (`data`, `isLoading`, `error`) and actions |
     
    -```text
    -✅ Shared state + dedicated accessor
    -  create shared state: AppConfig
    -  expose it at the app boundary
    -  access only through useAppConfig(), which returns a safe default
    -```
    +---
     
     ## Examples
     
    -See [examples/logic/patterns.md](../examples/logic/patterns.md)
    +See [../examples/logic.md](../examples/logic.md) for concrete patterns and code examples.
    diff --git a/.opencode/skills/best-practices/rules/performance.md b/.opencode/skills/best-practices/rules/performance.md
    index 35ddff3..ca6ad8c 100644
    --- a/.opencode/skills/best-practices/rules/performance.md
    +++ b/.opencode/skills/best-practices/rules/performance.md
    @@ -1,31 +1,29 @@
    -# Performance Rules
    +# Performance Optimization Rules
    +
    +## Core Principles
    +
    +- **Code Splitting & Lazy Loading**: Split bundles by route and defer heavy non-critical components (e.g. rich text editors, charts, modal drawers) until needed.
    +- **Selective Memoization**: Memoize expensive computations and callback references when passing to memoized children to prevent cascade re-renders. Avoid speculative memoization on trivial computations.
    +- **DOM Virtualization**: When rendering collections with hundreds or thousands of items, virtualize the list to keep active DOM nodes constant.
     
     ## MUST
     
    -| Rule         | Description                                                |
    -| ------------ | ---------------------------------------------------------- |
    -| Lazy loading | Route-level and large components use code splitting and lazy loading |
    +| Rule | Description |
    +| ---- | ----------- |
    +| Route code-splitting | Application routes and heavy conditional modules must be loaded asynchronously |
    +| No heavy sync work in render | Avoid running heavy synchronous loops, deep cloning, or large JSON parsing inside component render passes |
    +| Unique and stable keys | Collection items must use unique, stable entity IDs for keys, not array indices when items can reorder/filter |
     
     ## SHOULD
     
    -| Rule                             | Description                                           |
    -| -------------------------------- | ----------------------------------------------------- |
    -| Stable callbacks                 | Handlers passed to children keep stable references   |
    -| Memoized computations            | Expensive derived data is memoized                    |
    -| Memoized pure display components | Consider memoization for pure display components     |
    -| Virtual scrolling                | Large tables use virtual scrolling                    |
    +| Rule | Description |
    +| ---- | ----------- |
    +| Memoize expensive transforms | Cache sorting, grouping, or complex transformations with framework memoization tools (`useMemo`, `computed`) |
    +| Stable callback references | Keep event handler references stable when passed to memoized children or effect dependency arrays |
    +| Virtualize large datasets | Use virtualized lists for tables and feeds exceeding viewport capacity (~100+ items) |
     
    -## Examples
    +---
     
    -```text
    -✅ Lazy loading
    -  load ProjectPage only when its route is entered
    -  display a loading state while its code downloads
    -
    -✅ Stable callbacks
    -  keep handleClick stable when passing it to a child
    -  handleClick emits submit(data)
    +## Examples
     
    -✅ Memoized computations
    -  compute columns from their source inputs only when those inputs change
    -```
    +See [../examples/performance.md](../examples/performance.md) for concrete patterns and code examples.
    diff --git a/.opencode/skills/best-practices/rules/state.md b/.opencode/skills/best-practices/rules/state.md
    index 3995501..afefea0 100644
    --- a/.opencode/skills/best-practices/rules/state.md
    +++ b/.opencode/skills/best-practices/rules/state.md
    @@ -1,24 +1,29 @@
     # State Management Rules
     
    +## Core Principles
    +
    +- **State Minimization**: Store only essential, raw state. If a value can be computed from existing state or props, calculate it dynamically on the fly.
    +- **Single Source of Truth**: Every piece of state has a designated owner. Shared state belongs in a well-defined store, context, or service layer with explicit accessors.
    +- **Avoid Prop Drilling**: When values or callbacks pass through more than 3 component layers without intermediate consumption, use context, composition, or the project's state store.
    +- **Immutable Updates**: State transitions must be pure and immutable to ensure predictable change detection and render consistency.
    +
     ## MUST
     
    -| Rule                        | Description                                              |
    -| --------------------------- | -------------------------------------------------------- |
    -| Shared state access through a dedicated accessor | Use a dedicated accessor for shared state |
    -| Deep prop chains | Prop drilling beyond ~3 layers is a smell; prefer the project's shared state layer or pub/sub mechanism |
    +| Rule | Description |
    +| ---- | ----------- |
    +| No duplicated state | Never copy shared state or props into local state unless intentionally creating an isolated editing buffer |
    +| Predictable mutations | Shared state mutations must go through explicit actions, dispatchers, or store methods |
    +| Shallow state scope | Place state as close to where it is consumed as possible; do not lift to global scope prematurely |
     
     ## SHOULD
     
    -| Rule                       | Description                                     |
    -| -------------------------- | ----------------------------------------------- |
    -| Cross-module communication | Use the project's shared state layer or pub/sub mechanism instead of deep prop chains |
    -| Stable references | Memoize complex shared values |
    +| Rule | Description |
    +| ---- | ----------- |
    +| Derive over sync | Calculate filtered lists, counts, and active items directly in render or memoized selectors |
    +| Component composition | Prefer slot/children composition over passing state down multiple intermediate container components |
    +
    +---
     
    -## Shared State Pattern
    +## Examples
     
    -```text
    -✅ Shared state + dedicated accessor
    -  create shared state: AppConfig
    -  expose it at the app boundary
    -  read only through useAppConfig(), which returns a safe default when missing
    -```
    +See [../examples/state.md](../examples/state.md) for concrete patterns and code examples.
    diff --git a/.opencode/skills/best-practices/rules/style/coding-basics.md b/.opencode/skills/best-practices/rules/style/coding-basics.md
    deleted file mode 100644
    index 7099c16..0000000
    --- a/.opencode/skills/best-practices/rules/style/coding-basics.md
    +++ /dev/null
    @@ -1,33 +0,0 @@
    -# Coding Basics
    -
    -Mandatory entry before coding. After this file, open the specialized rule that matches the change; use [index.md](index.md) to pick one when unsure.
    -
    -## MUST
    -
    -- Prefer concise, intention-revealing code.
    -- Do not add speculative function extraction just to replace direct JS statements or to claim it is “more testable”.
    -- Extract a function only when it adds real business meaning, removes real duplication, or isolates a real boundary.
    -- Use native JS for obvious `map/filter/some/every/find/includes`; reach for a utility library only when it clearly removes boilerplate or branches.
    -
    -These are broad coding rules. Boundary fallback, normalization, and CSS rules belong to their specialized chapters.
    -
    -## Main AI Failure Modes
    -
    -1. **Abstract too early**: direct JS would be enough, but it extracts a thin wrapper function and calls it “easier to test”.
    -2. **Name things without new meaning**: it wraps or renames a statement even though the code did not gain domain meaning.
    -
    -The default rule is:
    -
    -> Direct expression first.
    -> No new meaning: do not extract a function.
    -
    -## Read Next
    -
    -- General style: [index.md](index.md)
    -- Normalize: [normalize.md](normalize.md)
    -- Fallback: [fallback.md](fallback.md)
    -- CSS: [../css.md](../css.md)
    -
    -## Examples
    -
    -See [../../examples/style/coding-basics/patterns.md](../../examples/style/coding-basics/patterns.md)
    diff --git a/.opencode/skills/best-practices/rules/style/fallback.md b/.opencode/skills/best-practices/rules/style/fallback.md
    deleted file mode 100644
    index 4a455d9..0000000
    --- a/.opencode/skills/best-practices/rules/style/fallback.md
    +++ /dev/null
    @@ -1,37 +0,0 @@
    -# Fallback Patterns
    -
    -## When to Fallback
    -
    -1. **Known shapes** (API explicit in TS signature) → use native (`??`, `?.`, default param)
    -2. **Unknown shapes** (dynamic inputs, config objects) → use safe access helpers or native optional chaining / nullish coalescing
    -3. **Business priority** (fallback chain order mirrors importance) → keep consistent
    -4. **Long chains need proof** → if you cannot point to a real boundary contract for `a ?? b ?? c`, normalize once instead
    -
    -## Avoid Speculative Fallbacks
    -
    -- Default return for _all_ types → type-safe code should handle expected shapes
    -- Only fallback when `null`/`undefined` are actual possibilities
    -- Do not repeat the same fallback decision at multiple call sites
    -- Do not hide unknown data shape behind long fallback chains
    -
    -## Where to Fallback
    -
    -```
    -Data parse layer (API response)
    -  ↓ Validate unknown shapes and use safe access
    -  transform(input) => validated value
    -
    -Component display layer
    -  ↓ Use native access, shape guaranteed by the boundary
    -  display formatted value
    -```
    -
    -## Chain Limit
    -
    -- One direct backup is normal: `value ?? '-'`
    -- Two or more fallback hops are suspicious: `a ?? b ?? c`
    -- If the chain is long because upstream data is messy, fix it in parse/normalize instead of repeating the chain in business logic or render code
    -
    -## Examples
    -
    -See [../../examples/style/fallback/patterns.md](../../examples/style/fallback/patterns.md)
    diff --git a/.opencode/skills/best-practices/rules/style/index.md b/.opencode/skills/best-practices/rules/style/index.md
    deleted file mode 100644
    index be95216..0000000
    --- a/.opencode/skills/best-practices/rules/style/index.md
    +++ /dev/null
    @@ -1,19 +0,0 @@
    -# Style Rules Index
    -
    -`style/` groups the coding-basics rules and their related sub-rules.
    -
    -## Read Order
    -
    -1. Read [coding-basics.md](coding-basics.md) before writing code.
    -2. Read the matching specialized rule when the change touches normalize or fallback.
    -3. Read [../css.md](../css.md) when the change touches CSS or styles.
    -
    -## Topics
    -
    -| Topic         | Rule                                 | Examples                                                                                         | Use when                                |
    -| ------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------- |
    -| Coding Basics | [coding-basics.md](coding-basics.md) | [../../examples/style/coding-basics/patterns.md](../../examples/style/coding-basics/patterns.md) | Mandatory entry before coding           |
    -| Style         | This index                           | [../../examples/style/index.md](../../examples/style/index.md)                                   | Utility choice, wrapper/alias decisions |
    -| Normalize     | [normalize.md](normalize.md)         | [../../examples/style/normalize/patterns.md](../../examples/style/normalize/patterns.md)         | Parse layer, enum/status normalization  |
    -| Fallback      | [fallback.md](fallback.md)           | [../../examples/style/fallback/patterns.md](../../examples/style/fallback/patterns.md)           | Default values, unknown shapes          |
    -| CSS           | [../css.md](../css.md)               | [../../examples/css/patterns.md](../../examples/css/patterns.md)                                 | Class names, CSS, style imports         |
    diff --git a/.opencode/skills/best-practices/rules/style/normalize.md b/.opencode/skills/best-practices/rules/style/normalize.md
    deleted file mode 100644
    index 0db2476..0000000
    --- a/.opencode/skills/best-practices/rules/style/normalize.md
    +++ /dev/null
    @@ -1,36 +0,0 @@
    -# Normalize Rules
    -
    -## Single Source of Truth Map
    -
    -- Define your status/event enum map **once** at module level.
    -- Use **single lookup** (e.g. a `Map` with `.get()` and a default) rather than repeated `if` chains.
    -- Map stays small (< 20 entries) → easy to verify manually.
    -
    -## When to Use a Utility Library
    -
    -- Map content is **dynamic** (user input, API changes at runtime)
    -- Map keys/behavior are complex (nested objects, normalization need)
    -- Performance critical with high volume (not a simple one-off)
    -
    -## When to Stick with Native
    -
    -- Simple field extraction (direct property access)
    -- Basic filtering/reducing that adds clarity
    -- Deep cloning when immutable operations needed
    -- Currying, throttle, debounce
    -
    -## Flow Considerations
    -
    -```
    -API response (raw status code)
    -      ↓ 1. Parse (normalize)
    -Normalized status (lowercase enum key)
    -      ↓ 2. Display
    -Human-readable label (via STATUS_MAP)
    -      ↓ 3. Sort/Group (derived state)
    -Orderings, summaries, filters
    -```
    -
    -## Examples
    -
    -See [../../examples/style/normalize/patterns.md](../../examples/style/normalize/patterns.md)
    diff --git a/.opencode/skills/best-practices/templates/code-review-checklist.md b/.opencode/skills/best-practices/templates/code-review-checklist.md
    deleted file mode 100644
    index 7905108..0000000
    --- a/.opencode/skills/best-practices/templates/code-review-checklist.md
    +++ /dev/null
    @@ -1,61 +0,0 @@
    -# Code Review Checklist
    -
    -Self-check after coding. Correct/wrong examples in [../examples/](../examples/).
    -
    -## Component
    -
    -- [ ] Component responsibilities and state ownership clear? No unnecessary inheritance?
    -- [ ] Props destructured directly? No direct DOM manipulation?
    -- [ ] Imperative access, if needed, exposes named behaviors rather than raw state setters?
    -
    -## Logic
    -
    -- [ ] Order: third-party integrations → shared state → local state → derived data → handlers → side effects?
    -- [ ] Shared state accessed via a dedicated accessor with a safe default?
    -- [ ] Derived values update when inputs change? Side effects cleaned up on disposal?
    -
    -## Functions
    -
    -- [ ] Multi-line event handlers named instead of written inline?
    -- [ ] Stateless functions extracted outside component?
    -- [ ] Event naming `handleXxx`? Request naming `fetchXxx/getXxx`?
    -
    -## Style
    -
    -- [ ] Utility code uses a utility library only when it reduces real boilerplate? Native JS for obvious `map/filter/some`?
    -- [ ] No meaningless aliases, wrappers, or hand-written common predicates?
    -
    -## CSS
    -
    -- [ ] Class names kebab-case? > 3 properties not inline style?
    -- [ ] Styles in `src/styles/`? No scattered style files in component directories?
    -
    -## Import
    -
    -- [ ] Configured alias for shared imports? No `../../../..`?
    -
    -## Constants
    -
    -- [ ] Import from `constants.js`? `SCREAMING_SNAKE_CASE`? No hardcoding?
    -
    -## Performance
    -
    -- [ ] Table columns and other derived configurations recomputed only when inputs change?
    -- [ ] Handlers passed to children stable when their dependencies have not changed, if needed?
    -- [ ] Route-level or large components loaded on demand where beneficial?
    -
    -## File
    -
    -- [ ] < 300 lines? Shared in `src/components/`? Business in `pages/{domain}/components/`?
    -- [ ] Reusable logic extracted to `logic/`?
    -- [ ] Component files named in PascalCase?
    -- [ ] Reusable logic files named in camelCase?
    -- [ ] Utility files named in camelCase?
    -- [ ] Style/API files named in kebab-case?
    -- [ ] New directories use `kebab-case/`?
    -- [ ] Domain-specific utilities folder-scoped under `utils/{kebab-name}/`, not flat prefixed files?
    -- [ ] Entry file only acts as public export surface?
    -
    -## Error Handling
    -
    -- [ ] Async uses `try/catch`? Errors shown through the app's error display? Loading managed correctly?
    diff --git a/.opencode/skills/best-practices/templates/component-checklist.md b/.opencode/skills/best-practices/templates/component-checklist.md
    deleted file mode 100644
    index 475bbee..0000000
    --- a/.opencode/skills/best-practices/templates/component-checklist.md
    +++ /dev/null
    @@ -1,70 +0,0 @@
    -# Component Creation Checklist
    -
    -Check before creating new components:
    -
    -## Basic Structure
    -
    -- [ ] Component has a clear single responsibility
    -- [ ] Component name PascalCase, filename matches component name
    -- [ ] Props destructured directly, no `props.xxx`
    -- [ ] Use the project's standard export convention
    -- [ ] Props ordered as `data/state` → `config/flags` → `onXxx` callbacks
    -- [ ] `onXxx` callbacks grouped at the end of destructuring, call sites, and runtime validation (if used)
    -- [ ] Data flows one way: parent owns state, child emits intent only
    -- [ ] Child state is not a mirror of parent state unless there is a real local ownership boundary
    -
    -## Logic Usage
    -
    -- [ ] Reusable logic has a clear owner and interface
    -- [ ] Order: third-party integrations → shared state → local state → derived data → handlers → side effects
    -- [ ] Shared state accessed via dedicated accessor with a safe default
    -- [ ] Derived data stays in sync with its inputs; side effects are cleaned up
    -
    -## Event Handling
    -
    -- [ ] Event function naming `handleXxx`
    -- [ ] Request function naming `fetchXxx/getXxx`
    -- [ ] Multi-line inline handlers moved into named functions
    -- [ ] Stateless functions extracted outside component
    -
    -## Style Handling
    -
    -- [ ] Class names in kebab-case
    -- [ ] Conditional styles use native string composition or class lists
    -- [ ] > 3 properties not inline style
    -- [ ] Style files in `src/styles/`
    -
    -## Performance Optimization
    -
    -- [ ] Table columns recomputed only when their inputs change
    -- [ ] Handlers passed to children stable when needed
    -- [ ] Large components consider on-demand loading
    -- [ ] Pure display components avoid unnecessary updates
    -
    -## File Organization
    -
    -- [ ] Shared components in `src/components/`
    -- [ ] Business components in `src/pages/{domain}/components/`
    -- [ ] Business logic extracted to `logic/`
    -- [ ] File < 300 lines
    -- [ ] Build-specific features use build-time flags rather than unnecessary runtime branches
    -
    -## Imperative Access
    -
    -- [ ] Complex components expose imperative handles only when needed
    -- [ ] Exposed methods describe actions, not internal implementation
    -- [ ] No direct DOM manipulation
    -- [ ] Exposed handles are behaviors, not mirrored state setters
    -
    -## Constants Definition
    -
    -- [ ] Shared constants imported from `src/utils/constants.js`
    -- [ ] Business constants from corresponding `constants.js`
    -- [ ] Constants named `SCREAMING_SNAKE_CASE`
    -- [ ] No hardcoded strings
    -
    -## Error Handling
    -
    -- [ ] Async operations use `try/catch`
    -- [ ] Error messages use the app's error display
    -- [ ] Loading state managed correctly
    
    From 7ea6e897ccb45cbaf33e494abf63b6462dc27935 Mon Sep 17 00:00:00 2001
    From: seho 
    Date: Fri, 23 Oct 2026 21:58:57 +0800
    Subject: [PATCH 60/97] ci: update ci
    
    ---
     .github/workflows/ci.yml | 72 ++++++++++++++++++++++++++++++++++++----
     1 file changed, 65 insertions(+), 7 deletions(-)
    
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index dce0386..d898d1a 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -37,10 +37,10 @@ jobs:
             run: |
               node --input-type=module -e 'import { verifyVersions } from "./scripts/release-version.mjs"; import { appendFileSync } from "node:fs"; appendFileSync(process.env.GITHUB_OUTPUT, `version=${verifyVersions(process.cwd())}\n`);'
     
    -      - name: Setup Rust 1.96.0 with rustfmt
    +      - name: Setup Rust 1.96.0 with rustfmt and Clippy
             uses: dtolnay/rust-toolchain@1.96.0
             with:
    -          components: rustfmt
    +          components: rustfmt, clippy
     
           - name: Cache Rust build outputs
             uses: Swatinem/rust-cache@v2
    @@ -59,6 +59,12 @@ jobs:
           - name: Check Rust formatting
             run: cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check
     
    +      - name: Lint Rust backend
    +        run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --locked -- -D warnings
    +
    +      - name: Run Rust backend tests
    +        run: cargo test --manifest-path src-tauri/Cargo.toml --locked
    +
           - name: Build Windows Tauri package
             run: npm run tauri -- build --ci --target x86_64-pc-windows-msvc --bundles nsis -- --locked
     
    @@ -102,10 +108,10 @@ jobs:
             run: |
               node --input-type=module -e 'import { verifyVersions } from "./scripts/release-version.mjs"; import { appendFileSync } from "node:fs"; appendFileSync(process.env.GITHUB_OUTPUT, `version=${verifyVersions(process.cwd())}\n`);'
     
    -      - name: Setup Rust 1.96.0 with rustfmt
    +      - name: Setup Rust 1.96.0 with rustfmt and Clippy
             uses: dtolnay/rust-toolchain@1.96.0
             with:
    -          components: rustfmt
    +          components: rustfmt, clippy
     
           - name: Cache Rust build outputs
             uses: Swatinem/rust-cache@v2
    @@ -121,13 +127,32 @@ jobs:
           - name: Build frontend
             run: npm run build
     
    +      - name: Fetch macOS helper dependencies
    +        run: cargo fetch --manifest-path src-tauri/native/macos-helper/Cargo.toml --locked --target aarch64-apple-darwin
    +
    +      - name: Check macOS helper formatting
    +        run: cargo fmt --manifest-path src-tauri/native/macos-helper/Cargo.toml --all -- --check
    +
    +      - name: Check macOS helper
    +        run: cargo check --manifest-path src-tauri/native/macos-helper/Cargo.toml --all-targets --offline --locked
    +
    +      - name: Test macOS helper
    +        run: cargo test --manifest-path src-tauri/native/macos-helper/Cargo.toml --offline --locked
    +
    +      - name: Lint macOS helper
    +        run: cargo clippy --manifest-path src-tauri/native/macos-helper/Cargo.toml --all-targets --offline --locked -- -D warnings
    +
           - name: Check Rust formatting
             run: cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check
     
    +      - name: Lint Rust backend
    +        run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --locked -- -D warnings
    +
    +      - name: Run Rust backend tests
    +        run: cargo test --manifest-path src-tauri/Cargo.toml --locked
    +
           - name: Build macOS Tauri packages
    -        run: |
    -          cargo fetch --manifest-path src-tauri/native/macos-helper/Cargo.toml --locked --target aarch64-apple-darwin
    -          npm run tauri -- build --ci --target aarch64-apple-darwin --bundles app,dmg -- --locked
    +        run: npm run tauri -- build --ci --target aarch64-apple-darwin --bundles app,dmg -- --locked
     
           - name: Assert exact macOS app and DMG artifacts
             shell: bash
    @@ -145,3 +170,36 @@ jobs:
               path: |
                 src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/opencode-mom_${{ steps.version.outputs.version }}_aarch64.dmg
               if-no-files-found: error
    +
    +  rust-msrv:
    +    name: Rust minimum supported versions
    +    runs-on: macos-15
    +
    +    steps:
    +      - name: Checkout
    +        uses: actions/checkout@v4
    +
    +      - name: Setup helper Rust 1.77.2
    +        uses: dtolnay/rust-toolchain@1.77.2
    +
    +      - name: Setup backend Rust 1.90.0
    +        uses: dtolnay/rust-toolchain@1.90.0
    +
    +      - name: Cache Rust build outputs
    +        uses: Swatinem/rust-cache@v2
    +        with:
    +          workspaces: |
    +            src-tauri -> target
    +            src-tauri/native/macos-helper -> target
    +
    +      - name: Fetch macOS helper dependencies
    +        run: cargo +1.90.0 fetch --manifest-path src-tauri/native/macos-helper/Cargo.toml --locked --target aarch64-apple-darwin
    +
    +      - name: Check Rust backend MSRV
    +        run: cargo +1.90.0 check --manifest-path src-tauri/Cargo.toml --all-targets --locked
    +
    +      - name: Check macOS helper MSRV
    +        run: cargo +1.77.2 check --manifest-path src-tauri/native/macos-helper/Cargo.toml --all-targets --offline --locked
    +
    +      - name: Test macOS helper MSRV
    +        run: cargo +1.77.2 test --manifest-path src-tauri/native/macos-helper/Cargo.toml --offline --locked
    
    From 08281b5bcd65f6a4ae1c05ce9f9ad60905e17be3 Mon Sep 17 00:00:00 2001
    From: seho 
    Date: Fri, 23 Oct 2026 22:01:02 +0800
    Subject: [PATCH 61/97] test: refactor test module
    
    ---
     src-tauri/src/commands/app/tests.rs           |   2 +-
     src-tauri/src/mcp/tests.rs                    |  56 ++
     src-tauri/src/models/tests.rs                 |   6 +-
     .../active/tests.rs                           |   2 +-
     .../command/tests.rs                          |   7 +-
     .../installation/tests.rs                     |   9 +-
     .../island/tests.rs                           |   2 +-
     .../models/tests.rs                           |   2 +-
     .../reload/tests.rs                           |   2 +-
     .../target/tests.rs                           | 382 +++++++-
     .../token_usage/tests.rs                      |  41 +-
     .../transport/tests.rs                        |  17 +-
     src-tauri/src/opencode_cli/AGENTS.md          |  92 --
     src-tauri/src/opencode_cli/active.rs          | 118 ---
     src-tauri/src/opencode_cli/binary.rs          | 141 ---
     src-tauri/src/opencode_cli/command.rs         | 176 ----
     src-tauri/src/opencode_cli/installation.rs    | 885 ------------------
     src-tauri/src/opencode_cli/island.rs          | 345 -------
     src-tauri/src/opencode_cli/island/events.rs   | 190 ----
     .../src/opencode_cli/island/projection.rs     | 654 -------------
     src-tauri/src/opencode_cli/mod.rs             |  17 -
     src-tauri/src/opencode_cli/models.rs          | 145 ---
     src-tauri/src/opencode_cli/reload.rs          |  13 -
     src-tauri/src/opencode_cli/target.rs          | 558 -----------
     src-tauri/src/opencode_cli/target/windows.rs  | 215 -----
     src-tauri/src/opencode_cli/token_usage.rs     | 553 -----------
     .../src/opencode_cli/token_usage/transport.rs | 702 --------------
     src-tauri/src/power/tests.rs                  | 147 +++
     src-tauri/src/resource_file/tests.rs          |   4 +-
     src-tauri/tests/app.rs                        |  16 +-
     src-tauri/tests/resources.rs                  |   5 +-
     31 files changed, 643 insertions(+), 4861 deletions(-)
     rename src-tauri/src/{opencode_cli => opencode}/active/tests.rs (97%)
     rename src-tauri/src/{opencode_cli => opencode}/command/tests.rs (93%)
     rename src-tauri/src/{opencode_cli => opencode}/installation/tests.rs (97%)
     rename src-tauri/src/{opencode_cli => opencode}/island/tests.rs (99%)
     rename src-tauri/src/{opencode_cli => opencode}/models/tests.rs (94%)
     rename src-tauri/src/{opencode_cli => opencode}/reload/tests.rs (92%)
     rename src-tauri/src/{opencode_cli => opencode}/target/tests.rs (67%)
     rename src-tauri/src/{opencode_cli => opencode}/token_usage/tests.rs (96%)
     rename src-tauri/src/{opencode_cli/token_usage => opencode}/transport/tests.rs (97%)
     delete mode 100644 src-tauri/src/opencode_cli/AGENTS.md
     delete mode 100644 src-tauri/src/opencode_cli/active.rs
     delete mode 100644 src-tauri/src/opencode_cli/binary.rs
     delete mode 100644 src-tauri/src/opencode_cli/command.rs
     delete mode 100644 src-tauri/src/opencode_cli/installation.rs
     delete mode 100644 src-tauri/src/opencode_cli/island.rs
     delete mode 100644 src-tauri/src/opencode_cli/island/events.rs
     delete mode 100644 src-tauri/src/opencode_cli/island/projection.rs
     delete mode 100644 src-tauri/src/opencode_cli/mod.rs
     delete mode 100644 src-tauri/src/opencode_cli/models.rs
     delete mode 100644 src-tauri/src/opencode_cli/reload.rs
     delete mode 100644 src-tauri/src/opencode_cli/target.rs
     delete mode 100644 src-tauri/src/opencode_cli/target/windows.rs
     delete mode 100644 src-tauri/src/opencode_cli/token_usage.rs
     delete mode 100644 src-tauri/src/opencode_cli/token_usage/transport.rs
    
    diff --git a/src-tauri/src/commands/app/tests.rs b/src-tauri/src/commands/app/tests.rs
    index f5b878e..0139f7d 100644
    --- a/src-tauri/src/commands/app/tests.rs
    +++ b/src-tauri/src/commands/app/tests.rs
    @@ -102,7 +102,7 @@ fn add_project_ipc_rejects_global_overlaps_before_persistence_without_opening_pi
             fs::canonicalize(&valid).unwrap().to_str().unwrap()
         );
         let mut config = models::load_config(&paths.config_file()).unwrap();
    -    assert_eq!(config.projects, [project.clone()]);
    +    assert_eq!(config.projects.as_slice(), std::slice::from_ref(&project));
         assert_eq!(config.state.selected_project_id, Some(project.id));
         config.state.island_enabled = true;
         config.state.preferences.locale = models::LocalePreference::Zh;
    diff --git a/src-tauri/src/mcp/tests.rs b/src-tauri/src/mcp/tests.rs
    index 0c256ba..bc66289 100644
    --- a/src-tauri/src/mcp/tests.rs
    +++ b/src-tauri/src/mcp/tests.rs
    @@ -4,6 +4,62 @@ use std::os::unix::fs::{symlink, PermissionsExt};
     use super::*;
     use crate::error::ErrorCode;
     
    +#[test]
    +fn create_rejects_non_utf8_config_filename_before_creating_directories() {
    +    use std::ffi::OsString;
    +    use std::os::unix::ffi::OsStringExt;
    +
    +    let home = tempfile::tempdir().unwrap();
    +    let parent = home.path().join("missing-parent");
    +    let target = parent.join(OsString::from_vec(b"config-\xff.jsonc".to_vec()));
    +    let paths = ConfigPaths::with_resource_parts(
    +        home.path().to_owned(),
    +        Some(target.clone()),
    +        None,
    +        home.path().join(".config"),
    +        home.path().join("cwd"),
    +    );
    +    let error = create(
    +        &paths,
    +        McpDraft {
    +            name: "new".to_owned(),
    +            config: serde_json::from_value(serde_json::json!({
    +                "type": "local",
    +                "command": ["run"]
    +            }))
    +            .unwrap(),
    +        },
    +    )
    +    .unwrap_err();
    +
    +    assert_eq!(error.code, ErrorCode::ValidationFailed);
    +    assert!(!target.exists());
    +    assert!(!parent.exists());
    +    assert_eq!(fs::read_dir(home.path()).unwrap().count(), 0);
    +}
    +
    +#[test]
    +fn managed_document_error_retains_safe_jsonc_position() {
    +    let home = tempfile::tempdir().unwrap();
    +    let paths = ConfigPaths::for_home(home.path());
    +    let path = paths.opencode_file();
    +    fs::create_dir_all(path.parent().unwrap()).unwrap();
    +    fs::write(
    +        &path,
    +        "{\n  \"secret\": \"PRIVATE_TOKEN\",\n  \"mcp\" []\n}\n",
    +    )
    +    .unwrap();
    +
    +    let error = managed_documents(&paths).err().unwrap();
    +
    +    assert_eq!(error.code, ErrorCode::ValidationFailed);
    +    assert!(error.message.contains(&path.display().to_string()));
    +    assert!(error.message.contains("expected colon"), "{error}");
    +    assert!(error.message.contains("line 3"), "{error}");
    +    assert!(error.message.contains("column "), "{error}");
    +    assert!(!format!("{error:?}").contains("PRIVATE_TOKEN"));
    +}
    +
     const ORIGINAL: &str = r#"{"mcp":{"servers":{"secret":{"type":"local","command":["run"],"environment":{"TOKEN":"private"}}}},"model":"keep"}"#;
     
     struct Fixture {
    diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs
    index ee55cf8..6f94624 100644
    --- a/src-tauri/src/models/tests.rs
    +++ b/src-tauri/src/models/tests.rs
    @@ -152,6 +152,7 @@ fn save_config_publishes_new_bytes_without_truncating_the_previous_file() {
     #[test]
     fn save_config_staging_failure_keeps_the_previous_bytes() {
         use std::os::unix::fs::PermissionsExt;
    +    // SAFETY: geteuid takes no pointers and only reads the effective UID to skip root's permission bypass.
         if unsafe { libc::geteuid() } == 0 {
             return;
         }
    @@ -239,11 +240,12 @@ fn save_config_partial_write_failure_preserves_original_bytes() {
             rlim_cur: 64,
             rlim_max: 64,
         };
    -    // The file-size fault is process-local; never constrain the parent test runner.
         assert_ne!(
    +        // SAFETY: SIG_IGN is a valid disposition for SIGXFSZ; this isolated child installs no Rust handler.
             unsafe { libc::signal(libc::SIGXFSZ, libc::SIG_IGN) },
             libc::SIG_ERR
         );
    +    // SAFETY: limit is a live, initialized rlimit; only this disposable child gets the file-size limit.
         assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_FSIZE, &limit) }, 0);
         let error = save_config(&path, &AppConfig::default()).unwrap_err();
         assert_eq!(error.code, crate::error::ErrorCode::ConfigurationFailed);
    @@ -253,7 +255,7 @@ fn save_config_partial_write_failure_preserves_original_bytes() {
         // Control: a raw in-place write loses the original under the same fault.
         assert!(fs::write(
             &path,
    -        &serde_json::to_vec_pretty(&AppConfig::default()).unwrap()
    +        serde_json::to_vec_pretty(&AppConfig::default()).unwrap()
         )
         .is_err());
         assert_ne!(fs::read(&path).unwrap(), original);
    diff --git a/src-tauri/src/opencode_cli/active/tests.rs b/src-tauri/src/opencode/active/tests.rs
    similarity index 97%
    rename from src-tauri/src/opencode_cli/active/tests.rs
    rename to src-tauri/src/opencode/active/tests.rs
    index 4c3ed75..7dfd27a 100644
    --- a/src-tauri/src/opencode_cli/active/tests.rs
    +++ b/src-tauri/src/opencode/active/tests.rs
    @@ -3,7 +3,7 @@ use std::time::{Duration, Instant};
     use serde_json::{json, Value};
     
     use super::*;
    -use crate::opencode_cli::token_usage::tests::{connection, healthy_response, respond, FakeApi};
    +use crate::opencode::token_usage::tests::{connection, healthy_response, respond, FakeApi};
     
     fn parse(bytes: &[u8]) -> Result {
         transport::decode_json::>(bytes, "opencode session.active")
    diff --git a/src-tauri/src/opencode_cli/command/tests.rs b/src-tauri/src/opencode/command/tests.rs
    similarity index 93%
    rename from src-tauri/src/opencode_cli/command/tests.rs
    rename to src-tauri/src/opencode/command/tests.rs
    index b87283d..9870786 100644
    --- a/src-tauri/src/opencode_cli/command/tests.rs
    +++ b/src-tauri/src/opencode/command/tests.rs
    @@ -75,6 +75,7 @@ impl FakeCli {
         fn assert_direct_reaped_and_descendant_alive(&self) {
             let mut status = 0;
             assert_eq!(
    +            // SAFETY: status is writable and live; the recorded direct-child PID selects only that child.
                 unsafe { libc::waitpid(self.direct_pid, &mut status, libc::WNOHANG) },
                 -1
             );
    @@ -82,6 +83,7 @@ impl FakeCli {
                 io::Error::last_os_error().raw_os_error(),
                 Some(libc::ECHILD)
             );
    +        // SAFETY: signal 0 only probes the fixture-reported numeric PID and takes no pointers.
             assert_eq!(unsafe { libc::kill(self.descendant_pid.unwrap(), 0) }, 0);
         }
     }
    @@ -93,7 +95,8 @@ impl Drop for FakeCli {
                 let _ = child.wait();
             }
             if let Some(pid) = self.descendant_pid {
    -            // This fixture's sleep stays alive until cleanup; never signal a process group.
    +            // SAFETY: pid is the positive PID reported by this fixture; SIGKILL takes no pointers.
    +            // Cleanup is best-effort: a numeric PID does not rule out exit and reuse before this call.
                 let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
             }
             let _ = fs::remove_dir_all(&self.directory);
    @@ -189,7 +192,7 @@ fn collector_repeated_inherited_stdout_closes_all_reader_fds() {
             let output = Command::new(std::env::current_exe().unwrap())
                 .args([
                     "--exact",
    -                "opencode_cli::command::tests::collector_repeated_inherited_stdout_closes_all_reader_fds",
    +                "opencode::command::tests::collector_repeated_inherited_stdout_closes_all_reader_fds",
                     "--test-threads=1",
                 ])
                 .env(PROBE_ENV, "1")
    diff --git a/src-tauri/src/opencode_cli/installation/tests.rs b/src-tauri/src/opencode/installation/tests.rs
    similarity index 97%
    rename from src-tauri/src/opencode_cli/installation/tests.rs
    rename to src-tauri/src/opencode/installation/tests.rs
    index 3ceec93..62fa6be 100644
    --- a/src-tauri/src/opencode_cli/installation/tests.rs
    +++ b/src-tauri/src/opencode/installation/tests.rs
    @@ -6,7 +6,7 @@ use std::net::TcpListener;
     use std::thread;
     
     use super::*;
    -use crate::opencode_cli::binary::resolve_path_command_in;
    +use crate::opencode::binary::resolve_path_command_in;
     
     #[cfg(unix)]
     thread_local! {
    @@ -436,6 +436,7 @@ fn metadata_cannot_block_on_fifo_or_follow_symlink() {
         let fixture = Fixture::new();
         let fifo = fixture.0.join("fifo");
         let name = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
    +    // SAFETY: name is a live NUL-terminated CString for a path inside this temporary fixture.
         assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
         let started = Instant::now();
         assert!(bounded_file(&fifo, 1024).is_none());
    @@ -747,7 +748,10 @@ fn native_upgrade_failures_discard_diagnostics_and_success_does_not_signal_reape
         .unwrap();
         run_upgrade(&mut command(), PROCESS_TIMEOUT).unwrap();
         let pid: i32 = fs::read_to_string(pidfile).unwrap().parse().unwrap();
    +    // SAFETY: signal 0 only probes the fixture-reported numeric PID and takes no pointers.
         let alive = unsafe { libc::kill(pid, 0) };
    +    // SAFETY: the fixture records its spawned sleep PID; SIGKILL takes only scalar arguments.
    +    // This is best-effort fixture cleanup, not an identity guarantee against PID reuse.
         let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
         assert_eq!(
             alive, 0,
    @@ -783,12 +787,15 @@ fn upgrade_timeout_cleans_its_group_without_touching_an_existing_service() {
         let error = run_upgrade(&mut command, Duration::from_secs(1)).unwrap_err();
         assert!(error.message.contains("timed out"));
         assert!(started.elapsed() < Duration::from_secs(3));
    +    // SAFETY: signal 0 takes no pointers and only probes the PID held by the unreaped fixture Child.
         assert_eq!(unsafe { libc::kill(outside.0.id() as libc::pid_t, 0) }, 0);
         let pid: i32 = fs::read_to_string(pidfile).unwrap().parse().unwrap();
         let deadline = Instant::now() + Duration::from_secs(2);
    +    // SAFETY: signal 0 only probes this recorded numeric PID; it sends no signal and takes no pointers.
         while unsafe { libc::kill(pid, 0) } == 0 && Instant::now() < deadline {
             thread::sleep(Duration::from_millis(10));
         }
    +    // SAFETY: signal 0 only probes the same numeric PID; it does not establish process identity.
         assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
     }
     
    diff --git a/src-tauri/src/opencode_cli/island/tests.rs b/src-tauri/src/opencode/island/tests.rs
    similarity index 99%
    rename from src-tauri/src/opencode_cli/island/tests.rs
    rename to src-tauri/src/opencode/island/tests.rs
    index 0f9ac65..862b5c3 100644
    --- a/src-tauri/src/opencode_cli/island/tests.rs
    +++ b/src-tauri/src/opencode/island/tests.rs
    @@ -242,7 +242,7 @@ fn write_urls_bodies_and_decisions_are_exact_and_not_retried() {
     #[test]
     fn post_reserves_target_until_completion_or_local_cancellation_without_transition_lock() {
         use crate::error::ErrorCode;
    -    use crate::opencode_cli::target::{self, TargetRuntime};
    +    use crate::opencode::target::{self, TargetRuntime};
     
         for cancel in [false, true] {
             let directory =
    diff --git a/src-tauri/src/opencode_cli/models/tests.rs b/src-tauri/src/opencode/models/tests.rs
    similarity index 94%
    rename from src-tauri/src/opencode_cli/models/tests.rs
    rename to src-tauri/src/opencode/models/tests.rs
    index 08ff556..f508291 100644
    --- a/src-tauri/src/opencode_cli/models/tests.rs
    +++ b/src-tauri/src/opencode/models/tests.rs
    @@ -1,5 +1,5 @@
     use super::*;
    -use crate::opencode_cli::token_usage::tests::{connection, healthy_response, respond, FakeApi};
    +use crate::opencode::token_usage::tests::{connection, healthy_response, respond, FakeApi};
     
     #[test]
     fn model_route_uses_provider_model_identity_and_preserves_disabled_config_metadata() {
    diff --git a/src-tauri/src/opencode_cli/reload/tests.rs b/src-tauri/src/opencode/reload/tests.rs
    similarity index 92%
    rename from src-tauri/src/opencode_cli/reload/tests.rs
    rename to src-tauri/src/opencode/reload/tests.rs
    index 86e725c..dc2cdc7 100644
    --- a/src-tauri/src/opencode_cli/reload/tests.rs
    +++ b/src-tauri/src/opencode/reload/tests.rs
    @@ -1,6 +1,6 @@
     use std::time::{Duration, Instant};
     
    -use crate::opencode_cli::token_usage::tests::{connection, healthy_response, respond, FakeApi};
    +use crate::opencode::token_usage::tests::{connection, healthy_response, respond, FakeApi};
     
     #[test]
     fn reload_accepts_204_and_never_retries_or_exposes_errors() {
    diff --git a/src-tauri/src/opencode_cli/target/tests.rs b/src-tauri/src/opencode/target/tests.rs
    similarity index 67%
    rename from src-tauri/src/opencode_cli/target/tests.rs
    rename to src-tauri/src/opencode/target/tests.rs
    index a7e2e57..3e262e2 100644
    --- a/src-tauri/src/opencode_cli/target/tests.rs
    +++ b/src-tauri/src/opencode/target/tests.rs
    @@ -1,9 +1,10 @@
    +use std::fs;
     use std::time::{Duration, Instant};
     
     use serde_json::json;
     
     use super::*;
    -use crate::opencode_cli::token_usage::transport;
    +use crate::opencode::transport;
     
     struct Fixture(PathBuf);
     
    @@ -162,9 +163,7 @@ fn malformed_oversized_nonprivate_and_nonregular_configuration_fail_closed() {
             br#"{}"#.to_vec(),
             br#"{"url":"http://localhost:1234","url":null,"password":null}"#.to_vec(),
         ] {
    -        let mut file = private_file(&path).unwrap();
    -        file.write_all(&bytes).unwrap();
    -        drop(file);
    +        private_file::publish(&path, &bytes).unwrap();
             let error = load(&path).err().unwrap();
             assert!(!error.message.contains("PRIVATE"));
             assert!(fixture.runtime().snapshot().is_err());
    @@ -179,6 +178,7 @@ fn malformed_oversized_nonprivate_and_nonregular_configuration_fail_closed() {
             assert!(load(&path).is_err());
             fs::remove_file(&path).unwrap();
             let name = CString::new(path.as_os_str().as_bytes()).unwrap();
    +        // SAFETY: name owns a NUL-terminated path for this call; mkfifo retains no pointer.
             assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
             let now = Instant::now();
             assert!(load(&path).is_err());
    @@ -189,6 +189,55 @@ fn malformed_oversized_nonprivate_and_nonregular_configuration_fail_closed() {
         }
     }
     
    +#[cfg(not(windows))]
    +#[test]
    +fn temporary_target_bytes_are_wiped_after_load_and_failed_persist() {
    +    let fixture = Fixture::new();
    +    let path = fixture.0.join("target.json");
    +    let wiped = || crate::protected_data::WIPED_BYTES.with(std::cell::Cell::get);
    +    let selection = Selection {
    +        url: Some("http://127.0.0.1:1234".into()),
    +        password: Some("private-test-password".into()),
    +    };
    +    let before = wiped();
    +    persist(&path, &selection).unwrap();
    +    assert!(wiped() > before, "serialized target buffer must be wiped");
    +    let length = fs::metadata(&path).unwrap().len() as usize;
    +    let before = wiped();
    +    let loaded = load(&path).unwrap();
    +    assert_eq!(loaded.password, selection.password);
    +    assert_eq!(wiped() - before, length);
    +    private_file::publish(&path, b"invalid private target").unwrap();
    +    let before = wiped();
    +    assert!(load(&path).is_err());
    +    assert_eq!(wiped() - before, b"invalid private target".len());
    +    fs::remove_file(&path).unwrap();
    +    fs::create_dir(&path).unwrap();
    +    let before = wiped();
    +    assert!(persist(&path, &selection).is_err());
    +    assert!(
    +        wiped() > before,
    +        "failed publication must wipe its serialized buffer"
    +    );
    +}
    +
    +#[test]
    +fn temporary_decrypted_target_password_is_wiped_on_success_and_validation_failure() {
    +    let bytes = br#"{"url":"http://localhost:1234","encryptedPassword":[1]}"#;
    +    let wiped = || crate::protected_data::WIPED_BYTES.with(std::cell::Cell::get);
    +    for plaintext in [
    +        b"private-test-password".to_vec(),
    +        vec![255],
    +        vec![b'x'; 4097],
    +    ] {
    +        let length = plaintext.len();
    +        let before = wiped();
    +        let result = windows::decode(bytes, |_| Ok(plaintext));
    +        assert_eq!(result.is_ok(), length == b"private-test-password".len());
    +        assert_eq!(wiped() - before, length);
    +    }
    +}
    +
     #[test]
     fn missing_shared_status_does_not_bootstrap_and_always_serializes_nulls() {
         let fixture = Fixture::new();
    @@ -209,8 +258,8 @@ fn missing_shared_status_does_not_bootstrap_and_always_serializes_nulls() {
     
     #[test]
     fn fixed_offline_save_succeeds_and_current_directory_is_captured_in_catalog_query() {
    -    use crate::opencode_cli::models;
    -    use crate::opencode_cli::token_usage::tests::{healthy_response, respond, FakeApi};
    +    use crate::opencode::models;
    +    use crate::opencode::token_usage::tests::{healthy_response, respond, FakeApi};
         let fixture = Fixture::new();
         let runtime = fixture.runtime();
         let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
    @@ -262,8 +311,8 @@ fn fixed_offline_save_succeeds_and_current_directory_is_captured_in_catalog_quer
     
     #[test]
     fn stale_long_read_and_old_async_connection_cannot_return_or_write_after_target_change() {
    -    use crate::opencode_cli::active;
    -    use crate::opencode_cli::token_usage::tests::{healthy_response, respond, FakeApi};
    +    use crate::opencode::active;
    +    use crate::opencode::token_usage::tests::{healthy_response, respond, FakeApi};
         let fixture = Fixture::new();
         let runtime = fixture.runtime();
         let changed = runtime.clone();
    @@ -320,6 +369,233 @@ fn dispatch_lock_wait_is_bounded_by_operation_deadline() {
         drop(guard);
     }
     
    +#[test]
    +fn async_reservation_connect_yields_and_cancelled_worker_retains_guard() {
    +    use crate::opencode::token_usage::tests::{healthy_response, FakeApi};
    +
    +    let fixture = Fixture::new();
    +    let target = fixture.runtime();
    +    let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel();
    +    let (release_tx, release_rx) = std::sync::mpsc::channel();
    +    let release_rx = std::sync::Mutex::new(release_rx);
    +    let api = FakeApi::new(move |path, stream| {
    +        assert_eq!(path, "/api/info");
    +        entered_tx.send(()).unwrap();
    +        release_rx
    +            .lock()
    +            .unwrap()
    +            .recv_timeout(Duration::from_secs(5))
    +            .unwrap();
    +        healthy_response(stream);
    +    });
    +    let snapshot = target
    +        .set(Some(api.url()), Some("private".into()), || Ok(()))
    +        .unwrap();
    +    let runtime = tokio::runtime::Builder::new_current_thread()
    +        .enable_all()
    +        .build()
    +        .unwrap();
    +    let guard = transition().unwrap();
    +    let heartbeat = runtime.block_on(async {
    +        let mut connecting = Box::pin(crate::opencode::island::connect_snapshot(
    +            snapshot,
    +            Instant::now() + Duration::from_secs(1),
    +        ));
    +        let pending = futures::poll!(&mut connecting).is_pending();
    +        tokio::task::yield_now().await;
    +        drop(connecting);
    +        drop(guard);
    +        if !pending {
    +            return false;
    +        }
    +        tokio::time::timeout(Duration::from_secs(2), entered_rx.recv())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(target.in_flight.load(Ordering::SeqCst), 1);
    +        assert_eq!(
    +            target.set(None, None, || Ok(())).err().unwrap().code,
    +            ErrorCode::Busy
    +        );
    +        release_tx.send(()).unwrap();
    +        tokio::time::timeout(Duration::from_secs(2), async {
    +            while target.in_flight.load(Ordering::SeqCst) != 0 || Arc::strong_count(&target) != 1 {
    +                tokio::task::yield_now().await;
    +            }
    +        })
    +        .await
    +        .unwrap();
    +        true
    +    });
    +    assert!(
    +        heartbeat,
    +        "connect reservation blocked the current-thread heartbeat"
    +    );
    +    target.set(None, None, || Ok(())).unwrap();
    +}
    +
    +#[test]
    +fn async_reservation_pending_write_yields_and_drops_unclaimed_guard() {
    +    use crate::opencode::token_usage::tests::{healthy_response, FakeApi};
    +
    +    let fixture = Fixture::new();
    +    let target = fixture.runtime();
    +    let calls = Arc::new(AtomicUsize::new(0));
    +    let count = calls.clone();
    +    let api = FakeApi::new(move |path, stream| {
    +        assert_eq!(path, "/api/info");
    +        count.fetch_add(1, Ordering::SeqCst);
    +        healthy_response(stream);
    +    });
    +    let snapshot = target
    +        .set(Some(api.url()), Some("private".into()), || Ok(()))
    +        .unwrap();
    +    let connection = transport::connect_target(
    +        snapshot,
    +        &target.registration,
    +        Instant::now() + Duration::from_secs(2),
    +        |_| panic!("fake fixed target must not bootstrap"),
    +    )
    +    .unwrap()
    +    .into_async()
    +    .unwrap();
    +    let owners = Arc::strong_count(&target);
    +    let runtime = tokio::runtime::Builder::new_current_thread()
    +        .enable_all()
    +        .build()
    +        .unwrap();
    +    let guard = transition().unwrap();
    +    let heartbeat = runtime.block_on(async {
    +        let mut prepare =
    +            Box::pin(connection.prepare_write(Instant::now() + Duration::from_secs(1)));
    +        let pending = futures::poll!(&mut prepare).is_pending();
    +        tokio::task::yield_now().await;
    +        drop(prepare);
    +        let owned = Arc::strong_count(&target) > owners;
    +        drop(guard);
    +        if !pending {
    +            return false;
    +        }
    +        assert!(
    +            owned,
    +            "cancelled waiter must leave the blocking snapshot owned"
    +        );
    +        tokio::time::timeout(Duration::from_secs(2), async {
    +            while target.in_flight.load(Ordering::SeqCst) != 0
    +                || Arc::strong_count(&target) != owners
    +            {
    +                tokio::task::yield_now().await;
    +            }
    +        })
    +        .await
    +        .unwrap();
    +        true
    +    });
    +    assert!(
    +        heartbeat,
    +        "write reservation blocked the current-thread heartbeat"
    +    );
    +    assert_eq!(
    +        calls.load(Ordering::SeqCst),
    +        1,
    +        "cancelled acquisition must not dispatch health"
    +    );
    +    target.set(None, None, || Ok(())).unwrap();
    +}
    +
    +#[test]
    +fn async_reservation_write_covers_health_and_request_until_completion_or_cancel() {
    +    use crate::opencode::token_usage::tests::{healthy_response, respond, FakeApi};
    +    use std::io::Read;
    +
    +    for cancel in [false, true] {
    +        let fixture = Fixture::new();
    +        let target = fixture.runtime();
    +        let health = Arc::new(AtomicUsize::new(0));
    +        let count = health.clone();
    +        let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel();
    +        let (release_tx, release_rx) = std::sync::mpsc::channel();
    +        let release_rx = std::sync::Mutex::new(release_rx);
    +        let api = FakeApi::new(move |path, stream| {
    +            if path == "/api/info" {
    +                if count.fetch_add(1, Ordering::SeqCst) > 0 {
    +                    entered_tx.send("health").unwrap();
    +                    release_rx
    +                        .lock()
    +                        .unwrap()
    +                        .recv_timeout(Duration::from_secs(5))
    +                        .unwrap();
    +                }
    +                healthy_response(stream);
    +            } else {
    +                assert_eq!(path, "/api/session/ses_fake/prompt");
    +                let mut body = vec![
    +                    0;
    +                    serde_json::to_vec(&json!({
    +                        "id": "msg_fake", "text": "fake text", "delivery": "queue",
    +                    }))
    +                    .unwrap()
    +                    .len()
    +                ];
    +                stream.read_exact(&mut body).unwrap();
    +                entered_tx.send("request").unwrap();
    +                release_rx
    +                    .lock()
    +                    .unwrap()
    +                    .recv_timeout(Duration::from_secs(5))
    +                    .unwrap();
    +                respond(stream, 200, "", b"null");
    +            }
    +        });
    +        let snapshot = target
    +            .set(Some(api.url()), Some("private".into()), || Ok(()))
    +            .unwrap();
    +        let runtime = tokio::runtime::Builder::new_current_thread()
    +            .enable_all()
    +            .build()
    +            .unwrap();
    +        runtime.block_on(async {
    +            let api = crate::opencode::island::connect_snapshot(
    +                snapshot,
    +                Instant::now() + Duration::from_secs(2),
    +            )
    +            .await
    +            .unwrap();
    +            assert_eq!(target.in_flight.load(Ordering::SeqCst), 0);
    +            let waiter =
    +                tokio::spawn(async move { api.send("ses_fake", "fake text", "msg_fake").await });
    +            for phase in ["health", "request"] {
    +                assert_eq!(
    +                    tokio::time::timeout(Duration::from_secs(2), entered_rx.recv())
    +                        .await
    +                        .unwrap()
    +                        .unwrap(),
    +                    phase
    +                );
    +                assert_eq!(target.in_flight.load(Ordering::SeqCst), 1);
    +                assert_eq!(
    +                    target.set(None, None, || Ok(())).err().unwrap().code,
    +                    ErrorCode::Busy
    +                );
    +                if phase == "health" {
    +                    release_tx.send(()).unwrap();
    +                }
    +            }
    +            if cancel {
    +                waiter.abort();
    +                assert!(waiter.await.unwrap_err().is_cancelled());
    +                assert_eq!(target.in_flight.load(Ordering::SeqCst), 0);
    +                release_tx.send(()).unwrap();
    +            } else {
    +                release_tx.send(()).unwrap();
    +                waiter.await.unwrap().unwrap();
    +                assert_eq!(target.in_flight.load(Ordering::SeqCst), 0);
    +            }
    +        });
    +        target.set(None, None, || Ok(())).unwrap();
    +    }
    +}
    +
     #[test]
     fn reservation_blocks_save_without_holding_transition_and_rejects_stale_dispatch() {
         let fixture = Fixture::new();
    @@ -351,7 +627,7 @@ fn reservation_blocks_save_without_holding_transition_and_rejects_stale_dispatch
     
     #[test]
     fn in_flight_reload_post_rejects_target_save_before_prepare() {
    -    use crate::opencode_cli::token_usage::tests::{healthy_response, respond, FakeApi};
    +    use crate::opencode::token_usage::tests::{healthy_response, respond, FakeApi};
     
         let fixture = Fixture::new();
         let runtime = fixture.runtime();
    @@ -615,8 +891,8 @@ fn native_dpapi_roundtrip_corruption_and_legacy_repair_are_fail_closed() {
         disk["encryptedPassword"] = json!([1, 2, 3, 4]);
         fs::write(&runtime.path, serde_json::to_vec(&disk).unwrap()).unwrap();
         assert!(load(&runtime.path).is_err());
    -    assert!(windows::unprotect(&[]).is_err());
    -    assert!(windows::unprotect(&[1, 2, 3]).is_err());
    +    assert!(crate::protected_data::unprotect(&[]).is_err());
    +    assert!(crate::protected_data::unprotect(&[1, 2, 3]).is_err());
         fs::write(
             &runtime.path,
             br#"{"url":"http://localhost:1234","password":"local-secret"}"#,
    @@ -635,6 +911,86 @@ fn native_dpapi_roundtrip_corruption_and_legacy_repair_are_fail_closed() {
         assert!(load(&runtime.path).unwrap().password.is_none());
     }
     
    +#[test]
    +fn private_publication_failure_preserves_original_and_cleans_owned_temporary() {
    +    let fixture = Fixture::new();
    +    let path = fixture.0.join("target.json");
    +    fs::write(&path, b"original bytes").unwrap();
    +    let result = private_file::publish_with(
    +        &path,
    +        b"replacement bytes",
    +        |_, _| Err(std::io::Error::other("injected")),
    +        || Ok(()),
    +    );
    +    assert_eq!(result, Err(private_file::PublishError::Publication));
    +    assert_eq!(fs::read(&path).unwrap(), b"original bytes");
    +    assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
    +}
    +
    +#[cfg(unix)]
    +#[test]
    +fn private_publication_reports_directory_sync_failure_after_commit() {
    +    let fixture = Fixture::new();
    +    let path = fixture.0.join("target.json");
    +    let outcome = private_file::publish_with(
    +        &path,
    +        b"committed bytes",
    +        |source, destination| fs::rename(source, destination),
    +        || Err(std::io::Error::other("injected")),
    +    )
    +    .unwrap();
    +    assert_eq!(outcome, private_file::PublishOutcome::CommittedWithWarning);
    +    assert_eq!(fs::read(&path).unwrap(), b"committed bytes");
    +    use std::os::unix::fs::{MetadataExt, PermissionsExt};
    +    let metadata = fs::metadata(&path).unwrap();
    +    assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
    +    // SAFETY: geteuid has no pointer arguments or preconditions and only reads process identity.
    +    assert_eq!(metadata.uid(), unsafe { libc::geteuid() });
    +}
    +
    +#[cfg(unix)]
    +#[test]
    +fn failed_target_save_keeps_existing_private_file_bytes_and_runtime_selection() {
    +    // SAFETY: geteuid has no pointer arguments or preconditions and only reads process identity.
    +    if unsafe { libc::geteuid() } == 0 {
    +        return;
    +    }
    +    use std::os::unix::fs::PermissionsExt;
    +    let fixture = Fixture::new();
    +    let runtime = fixture.runtime();
    +    runtime
    +        .set(
    +            Some("http://localhost:1234".into()),
    +            Some("private".into()),
    +            || Ok(()),
    +        )
    +        .unwrap();
    +    let original = fs::read(&runtime.path).unwrap();
    +    let directory_mode = fs::metadata(&fixture.0).unwrap().permissions().mode() & 0o777;
    +    fs::set_permissions(&fixture.0, fs::Permissions::from_mode(0o500)).unwrap();
    +    let result = runtime.set(
    +        Some("http://localhost:1235".into()),
    +        Some("replacement".into()),
    +        || Ok(()),
    +    );
    +    fs::set_permissions(&fixture.0, fs::Permissions::from_mode(directory_mode)).unwrap();
    +    assert!(result.is_err());
    +    assert_eq!(fs::read(&runtime.path).unwrap(), original);
    +    assert_eq!(
    +        runtime.snapshot().unwrap().selection.url.as_deref(),
    +        Some("http://127.0.0.1:1234")
    +    );
    +    assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
    +}
    +
    +#[cfg(not(windows))]
    +#[test]
    +fn non_windows_data_protection_stub_fails_closed_without_claiming_dpapi() {
    +    let error = crate::protected_data::protect(b"bytes").unwrap_err();
    +    assert!(error.message.contains("unavailable"));
    +    assert!(crate::protected_data::unprotect(b"bytes").is_err());
    +}
    +
     #[test]
     fn invalid_selection_prepare_failure_and_failed_persist_never_invalidate_monitors() {
         let fixture = Fixture::new();
    @@ -678,7 +1034,7 @@ fn invalid_selection_prepare_failure_and_failed_persist_never_invalidate_monitor
     
     #[test]
     fn commit_publishes_then_invalidates_under_guards_and_finish_precedes_probe_without_transition() {
    -    use crate::opencode_cli::token_usage::tests::{healthy_response, FakeApi};
    +    use crate::opencode::token_usage::tests::{healthy_response, FakeApi};
         use std::sync::atomic::AtomicBool;
     
         let fixture = Fixture::new();
    @@ -736,7 +1092,7 @@ fn commit_publishes_then_invalidates_under_guards_and_finish_precedes_probe_with
     
     #[test]
     fn post_commit_status_race_returns_saved_public_target_not_an_error_or_later_target() {
    -    use crate::opencode_cli::token_usage::tests::{healthy_response, FakeApi};
    +    use crate::opencode::token_usage::tests::{healthy_response, FakeApi};
     
         let fixture = Fixture::new();
         let runtime = fixture.runtime();
    diff --git a/src-tauri/src/opencode_cli/token_usage/tests.rs b/src-tauri/src/opencode/token_usage/tests.rs
    similarity index 96%
    rename from src-tauri/src/opencode_cli/token_usage/tests.rs
    rename to src-tauri/src/opencode/token_usage/tests.rs
    index d1da3e8..58b56e2 100644
    --- a/src-tauri/src/opencode_cli/token_usage/tests.rs
    +++ b/src-tauri/src/opencode/token_usage/tests.rs
    @@ -6,7 +6,7 @@ use serde_json::{json, Value};
     
     use super::*;
     
    -pub(super) fn deadline() -> Instant {
    +pub(in crate::opencode) fn deadline() -> Instant {
         Instant::now() + SCAN_TIMEOUT
     }
     
    @@ -40,7 +40,7 @@ fn copied(mut message: Value, id: &str) -> Value {
         message
     }
     
    -pub(super) fn page(data: Vec, next: Option<&str>) -> Value {
    +pub(in crate::opencode) fn page(data: Vec, next: Option<&str>) -> Value {
         json!({ "data": data, "cursor": { "previous": null, "next": next } })
     }
     
    @@ -434,13 +434,22 @@ fn fork_zero_owned_prefix_and_child_with_old_timestamp_are_not_time_filtered() {
     }
     
     #[test]
    -fn fork_missing_origin_boundary_prefix_mismatch_and_cycle_fail_closed() {
    +fn fork_missing_origin_falls_back_to_root() {
    +    let a = usage("a", "assistant", 2, 10, 1);
    +    let records = fixture_records(
    +        vec![fork("fork", "missing", "a", "through")],
    +        vec![vec![copied(a, "copy")]],
    +    )
    +    .unwrap();
    +    assert_eq!(records.len(), 1);
    +    assert_eq!(records[0].input, 10);
    +    assert_eq!(records[0].output, 1);
    +}
    +
    +#[test]
    +fn fork_missing_boundary_prefix_mismatch_and_cycle_fail_closed() {
         let a = usage("a", "assistant", 2, 10, 1);
         for (sessions, timelines) in [
    -        (
    -            vec![fork("fork", "missing", "a", "through")],
    -            vec![vec![copied(a.clone(), "copy")]],
    -        ),
             (
                 vec![session("root"), fork("fork", "root", "missing", "before")],
                 vec![vec![a.clone()], vec![]],
    @@ -643,14 +652,14 @@ fn oversized_page_metadata_is_rejected_before_unbounded_allocation() {
         assert!(serde_json::from_value::>(page(items, None)).is_err());
     }
     
    -pub(in crate::opencode_cli) struct FakeApi {
    +pub(in crate::opencode) struct FakeApi {
         pub(super) address: SocketAddr,
         stopping: Arc,
         thread: Option>,
     }
     
     impl FakeApi {
    -    pub(in crate::opencode_cli) fn new(
    +    pub(in crate::opencode) fn new(
             handler: impl Fn(&str, &mut TcpStream) + Send + Sync + 'static,
         ) -> Self {
             let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    @@ -707,7 +716,7 @@ impl FakeApi {
             }
         }
     
    -    pub(in crate::opencode_cli) fn url(&self) -> String {
    +    pub(in crate::opencode) fn url(&self) -> String {
             format!("http://{}", self.address)
         }
     }
    @@ -720,7 +729,7 @@ impl Drop for FakeApi {
         }
     }
     
    -pub(in crate::opencode_cli) fn respond(
    +pub(in crate::opencode) fn respond(
         stream: &mut TcpStream,
         status: u16,
         headers: &str,
    @@ -734,11 +743,11 @@ pub(in crate::opencode_cli) fn respond(
         let _ = stream.write_all(body);
     }
     
    -pub(in crate::opencode_cli) fn healthy_response(stream: &mut TcpStream) {
    +pub(in crate::opencode) fn healthy_response(stream: &mut TcpStream) {
         respond(stream, 200, "", br#"{"version":"2.0.22","pid":42}"#);
     }
     
    -pub(in crate::opencode_cli) fn connection(api: &FakeApi) -> ManagedConnection {
    +pub(in crate::opencode) fn connection(api: &FakeApi) -> ManagedConnection {
         transport::test_connection(&api.url(), deadline())
     }
     
    @@ -766,7 +775,7 @@ fn official_http_paging_workers_large_message_and_fork_aggregation() {
                 ),
                 "/api/session?limit=1000&cursor=sessions-next" => page(vec![], None),
                 "/api/session/root/message?limit=100&order=asc" => {
    -                page(vec![user.clone(), a.clone()], Some("root-next"))
    +                page(vec![user, a.clone()], Some("root-next"))
                 }
                 "/api/session/root/message?limit=100&cursor=root-next" => {
                     page(vec![usage("c", "compaction", 3, 20, 2)], None)
    @@ -929,10 +938,10 @@ mod process {
         use super::*;
     
         #[test]
    -    #[ignore = "Read-only live OpenCode scan; run explicitly after reviewing the managed CLI target"]
    +    #[ignore = "Read-only live OpenCode scan; run explicitly after reviewing the selected service target"]
         fn read_only_live_scan() {
             let started = Instant::now();
    -        let result = token_usage_records_via_cli().unwrap();
    +        let result = token_usage_records_via_service().unwrap();
             let input: u64 = result.iter().map(|record| record.input).sum();
             let output: u64 = result.iter().map(|record| record.output).sum();
             println!(
    diff --git a/src-tauri/src/opencode_cli/token_usage/transport/tests.rs b/src-tauri/src/opencode/transport/tests.rs
    similarity index 97%
    rename from src-tauri/src/opencode_cli/token_usage/transport/tests.rs
    rename to src-tauri/src/opencode/transport/tests.rs
    index a223a1a..5f3661c 100644
    --- a/src-tauri/src/opencode_cli/token_usage/transport/tests.rs
    +++ b/src-tauri/src/opencode/transport/tests.rs
    @@ -8,10 +8,10 @@ use std::thread;
     use serde_json::{json, Value};
     
     use super::*;
    -use crate::opencode_cli::token_usage::tests::{
    +use crate::opencode::token_usage::tests::{
         connection, deadline, healthy_response, page, respond, FakeApi,
     };
    -use crate::opencode_cli::token_usage::{Message, Session};
    +use crate::opencode::token_usage::{Message, Session};
     
     struct Fixture(PathBuf);
     
    @@ -146,12 +146,13 @@ fn fifo_and_symlink_fifo_registrations_fail_without_waiting_or_bootstrap() {
         let fixture = Fixture::new();
         let fifo = fixture.0.join("PRIVATE_FIFO_CONTENT_PASSWORD");
         let path = CString::new(fifo.as_os_str().as_bytes()).unwrap();
    +    // SAFETY: path owns a NUL-terminated path for this call; mkfifo retains no pointer.
         assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
         symlink(&fifo, fixture.registration()).unwrap();
         let mut child = Command::new(std::env::current_exe().unwrap())
             .args([
                 "--exact",
    -            "opencode_cli::token_usage::transport::tests::fifo_and_symlink_fifo_registrations_fail_without_waiting_or_bootstrap",
    +            "opencode::transport::tests::fifo_and_symlink_fifo_registrations_fail_without_waiting_or_bootstrap",
                 "--test-threads=1",
             ])
             .env(PROBE_ENV, &fixture.0)
    @@ -317,8 +318,8 @@ fn health_unauthorized_fails_closed_without_bootstrap() {
     
     #[test]
     fn fixed_target_selects_b_for_active_catalog_reload_usage_and_async_island_never_registered_a() {
    -    use crate::opencode_cli::target::{Selection, Snapshot};
    -    use crate::opencode_cli::{active, models, token_usage};
    +    use crate::opencode::target::{Selection, Snapshot};
    +    use crate::opencode::{active, models, token_usage};
     
         let a_calls = Arc::new(AtomicUsize::new(0));
         let calls = a_calls.clone();
    @@ -383,7 +384,7 @@ fn fixed_target_selects_b_for_active_catalog_reload_usage_and_async_island_never
     
     #[test]
     fn explicit_unreachable_unauthorized_redirect_and_invalid_info_never_bootstrap_or_fallback() {
    -    use crate::opencode_cli::target::{Selection, Snapshot};
    +    use crate::opencode::target::{Selection, Snapshot};
         let fixture = Fixture::new();
         let a_calls = Arc::new(AtomicUsize::new(0));
         let count = a_calls.clone();
    @@ -653,7 +654,7 @@ fn managed_api_does_not_send_requests_to_environment_proxies() {
             let mut command = Command::new(std::env::current_exe().unwrap());
             command.args([
                 "--exact",
    -            "opencode_cli::token_usage::transport::tests::managed_api_does_not_send_requests_to_environment_proxies",
    +            "opencode::transport::tests::managed_api_does_not_send_requests_to_environment_proxies",
                 "--test-threads=1",
             ]).env(PROBE_ENV, "1").env("NO_PROXY", "").env("no_proxy", "");
             for name in [
    @@ -862,6 +863,8 @@ mod bootstrap {
             assert!(started.elapsed() < Duration::from_secs(2));
             let mut status = 0;
             assert_eq!(
    +            // SAFETY: pid is the spawned fixture child's positive PID; status is writable
    +            // for this call, and WNOHANG cannot block while checking that it was reaped.
                 unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) },
                 -1
             );
    diff --git a/src-tauri/src/opencode_cli/AGENTS.md b/src-tauri/src/opencode_cli/AGENTS.md
    deleted file mode 100644
    index ad69273..0000000
    --- a/src-tauri/src/opencode_cli/AGENTS.md
    +++ /dev/null
    @@ -1,92 +0,0 @@
    -# OpenCode CLI
    -
    -## Overview
    -
    -Integrates with the CLI for binary discovery, installation inspection and explicit stable V2 upgrades. One selected local service powers model catalogs, reload, active counts, usage and Mini Island: validated Shared Service by default, or an explicit fixed loopback V2 endpoint without fallback/bootstrap. Usage scans bounded HTTP pages with fork-aware attribution; Island uses the same pinned transport asynchronously.
    -
    -## Directory Structure
    -
    -```text
    -opencode_cli/                 # Local CLI and managed-service integration
    -├── AGENTS.md                 # Directory-specific guidance
    -├── mod.rs                    # Public function and DTO re-exports
    -├── binary.rs                 # Ordered binary candidates and resolver
    -├── command.rs                # Deadline- and byte-bounded stdout collector
    -├── installation.rs           # Local ownership detection, stable V2 checks and guarded upgrades
    -├── installation/             # Installation test submodule
    -│   └── tests.rs              # Fake binaries/managers/installers, HTTP bounds and upgrade guards
    -├── target.rs                 # Endpoint persistence, revision, short transitions and in-flight reservations
    -├── target/                   # URL/password/private-file and monitor transition tests
    -├── models.rs                 # V2 model API refs merged with local provider metadata
    -├── models/                   # Catalog identity and configured disabled metadata tests
    -├── reload.rs                 # Bounded non-retried HTTP reload
    -├── active.rs                 # Strict V2 running-session map parser
    -├── island.rs                 # Crate-private DTOs, async connection and guarded writes
    -├── island/                   # Bounded Mini Island V2 API lane
    -│   ├── projection.rs         # Recent roots, active children, pending forms and public text
    -│   ├── events.rs             # Bounded SSE frames and native event classification
    -│   └── tests.rs              # Synthetic HTTP, DTO/privacy, answer guards and SSE
    -├── token_usage.rs            # Paging, budgets, worker scan, fork attribution
    -├── command/                  # Collector test submodule
    -│   └── tests.rs              # EOF, timeout, limits, child ownership, FD cleanup
    -├── reload/                   # Reload test submodule
    -│   └── tests.rs              # 204 success, private errors and no retries
    -├── active/                   # Active-session test submodule
    -│   └── tests.rs              # Shape validation, redaction, count and bounds
    -└── token_usage/              # Usage tests and internally shared HTTP transport
    -    ├── tests.rs              # Paging, forks, limits, fake HTTP, ignored live scan
    -    ├── transport.rs          # Registration, identity, bootstrap, HTTP requests
    -    └── transport/            # Transport test submodule
    -        └── tests.rs          # Trust checks, bootstrap, response bounds, deadlines
    -```
    -
    -## Key Files
    -
    -| Responsibility | File | Description |
    -| --- | --- | --- |
    -| Public boundary | `mod.rs` | Re-exports CLI operations, `ModelCatalogEntry`, `TokenUsageRecord`, `OpenCodeInstallation`, and `OpenCodeUpdateCheck`; installation operations stay crate-private. |
    -| Binary discovery | `binary.rs` | `opencode_candidates` and `resolve_opencode_binary`; final bare command resolves through PATH. |
    -| Installation | `installation.rs` | Inspects exact executable ownership without service access; checks the fixed stable V2 endpoint and explicitly runs guarded native upgrades for proven Unix curl/npm/bun/pnpm/yarn installations. |
    -| Bounded collection | `command.rs` | `collect_output` drains stdout without blocking indefinitely and reaps only its direct child. |
    -| Service target | `target.rs` | Captured private path/registration/directory, atomic saves with Unix 0600 plaintext or Windows current-account DPAPI credentials, normalized loopback URLs, password-retention rules, revisions and monitor transition guards. |
    -| Model catalog | `models.rs` | GET `/api/model` with captured `location[directory]`, enabled `providerID/modelID` refs plus configured models/metadata. |
    -| Reload | `reload.rs` | POST `/api/location/reload`, bounded five-second dispatch, 204 success, no retry. |
    -| Active work | `active.rs` | GET `/api/session/active`, shared strict map/object parser and running-session count. |
    -| Mini Island | `island.rs`, `island/` | Async managed V2 requests, public camelCase DTOs, bounded snapshots/messages, guarded writes and live-only SSE. |
    -| Usage attribution | `token_usage.rs` | `token_usage_records_via_cli` scans timelines and returns only `{time, input, output}`. |
    -| Managed transport | `token_usage/transport.rs` | Validated registration, Basic auth, health identity checks, optional CLI bootstrap, bounded HTTP pages and credential-private async RequestBuilder. |
    -
    -## Conventions
    -
    -- **Candidate order**: Reuse `opencode_candidates`: existing trimmed `OPENCODE_BIN`, known install paths, then bare `opencode`. Skip missing explicit paths but still try the final PATH candidate.
    -- **Installation safety**: An invalid explicit `OPENCODE_BIN` never falls back for installation operations. Automatic upgrade requires strictly proven Unix curl/npm/bun/pnpm/yarn ownership and current stable V2 >=2.0.25; Windows/Homebrew/Vite Plus/older V2/unproven/manual/bundled sources remain manual. Execute only `selected_binary upgrade  --method ` with expected path/version and latest stable target guards, reject concurrent upgrades with `busy`, revalidate ownership before dispatch and read back the same path. Never use sudo, upgrade bundled binaries, or start/stop/restart the OpenCode service; CLI version is not running backend version. Installer cleanup signals only its isolated process group. Tests use fake processes and local HTTP, never real upgrades.
    -- **Target authority**: Initialize `target` before power/Island startup. Missing private config selects Shared; invalid/unreadable/nonregular/oversized config fails closed without crashing management. Fixed URLs accept only HTTP literal 127.0.0.1/[::1] with positive explicit port or exact localhost normalized without DNS. No credentials/path/query/fragment, remote hosts, shortened/numeric loopback, fallback or explicit bootstrap. Status probes never bootstrap.
    -- **Target credentials**: Persist the endpoint and backend-only credential in `opencode-service.json`, outside AppConfig. Unix uses a regular account-owned 0600 plaintext file capped at 8 KiB. Windows uses numeric-byte `encryptedPassword` protected by native current-account DPAPI (32 KiB file/8 KiB ciphertext), with no plaintext fallback; credential decryption failures fail closed. Normal Shared registration credentials are handled separately. Never expose passwords to frontend DTOs/errors/logs/events. Neither Unix permissions nor DPAPI protect against account compromise, same-account malicious processes or administrators. Windows-native DPAPI tests have not run on this macOS host; native acceptance remains outside automated host tests and Windows ACL protection is not claimed verified.
    -- **Target saves**: Use create_new owned UUID temporary and atomic publication; sync the final Unix directory, treating post-commit sync failures as private warnings rather than leaving runtime behind disk. Null URL clears Shared/password; omitted password retains only on unchanged normalized URL, empty clears. The global transition mutex also guards PowerService::set and IslandService::set_enabled inside main-thread dispatch. Saves validate the selection, prepare pure monitor guards, persist and publish the new selection/revision, then infallibly invalidate old consumers under the same transition/monitor guards; failed preparation/persistence leaves monitors unchanged. Lock order is transition -> monitor effects/shared/cache -> target state write, dropped before commit. No native/network/await work runs under these guards; finish restarts readers/native work only after transition release and before the five-second status probe. A stale/unavailable post-save probe returns disconnected public status for the committed selection, never a failed save. Normal status/read revision checks remain fail-closed. Bootstrap/reload/Island writes acquire a runtime-counted RAII reservation under transition+revision check, then release the mutex before network/await/native dispatch; target saves return busy while reserved. Island blocking workers own their reservation even when their async caller is dropped.
    -- **Bootstrap fallback**: Only Shared may bootstrap missing/unreachable/identity-mismatched registration; invalid/auth/HTTP errors never bootstrap. CLI candidates stop after successful spawn even when bootstrap fails. The resolver checks existence, not executable validity.
    -- **Collector ownership**: Bounded operations pipe stdout and null stdin/stderr. Success requires both successful exit and stdout EOF; nonzero exit discards stdout immediately. On failure, kill/reap only the direct CLI child, never a process group that might contain the service.
    -- **Timeout scope**: Active queries use 5 seconds/1 MiB; bootstrap at most 10 seconds/32 KiB within the operation deadline. Catalog uses 20 seconds overall, five-second requests/8 MiB; reload uses 20 seconds overall and five-second POST. Blocking catalog/status/saves run in spawn_blocking.
    -- **Active-session contract**: Accept only an object envelope with a `data` map of unique `ses_` IDs with nonempty ASCII alphanumeric/underscore/hyphen suffixes and object values of type `running`. Count children too; malformed responses and nonzero exits are errors, not idle.
    -- **Error privacy**: All service operations retain only error kind/category/location/status, never response bodies, identifiers, credentials or raw serde/HTTP/CLI stderr/stdout errors. Read failures are not idle.
    -- **Registration trust**: Read the regular, at-most-8-KiB `opencode/service.json` under absolute `XDG_STATE_HOME` or the captured ConfigPaths home `~/.local/state`, never inferred config parents. Require a UUID, positive PID, bounded valid major-2 semantic version, nonempty bounded password, and explicit-port HTTP URL on literal `127.0.0.1` or `[::1]` only. An uninitialized runtime fails closed; library tests inject runtimes/paths rather than touching live registration.
    -- **Managed identity**: With a password use Basic auth user `opencode`; a passwordless fixed endpoint sends no Authorization on health/read/reload/async requests. No Bearer/UI pairing inference or 401/403 fallback; only direct OpenCode backend endpoints, not UI proxies. Disable proxies/redirects/retries, strictly parse object `/api/info` with positive PID/bounded valid V2 version. Shared requires exact registration PID/version; fixed captures the probed identity without registration. Requests pin endpoint/auth/identity/revision and writes recheck health before dispatch; an external restart between health and dispatch remains unpreventable without server support. A reachable info route is not proof of protected-route access; no implicit restart for a CLI version mismatch.
    -- **Island transport**: Run initial connect/health/bootstrap only in `spawn_blocking`; subsequent requests/write-health/chunks are async and cancellation drops local in-flight work. Blocking bootstrap is not cancellable and keeps its reservation until actual worker completion. A cancelled POST may already have been accepted by the server; never claim withdrawal or retry. Credentials stay inside transport, async clients disable all automatic retries, write-health plus dispatch share a five-second deadline, and bodies/retained snapshots cap at 2 MiB. The domain spawns no background task.
    -- **Island snapshots**: Fetch only the latest 20 root candidates and the global validated running map; fetch missing active details including children. Check per-session pending permission/form lists for both active sessions and recent roots, never location-scoped global pending lists. Four in-flight reads, 15-second snapshot ceiling, 100 total sessions/pending requests; bounds fail explicitly instead of returning partial data. Public directory is `location.directory`; active state wins over last failed outcome and `connected` never determines idle.
    -- **Island interaction**: Keep original request/session/form IDs and field keys. Permission replies permit only `once`/`always`/`reject`; Always requires explicit user consent and rereads the per-session pending list, requiring the exact session/request identity and nonempty server-provided `save` rules. Missing save projects to `[]`; supplied rules use the same bounded strings/plaintext checks as resources. Stale, invalid, or unsavable Always fails validation before POST, never falls back to Once, and sends only `{decision}`; the server owns rule/project binding, with no custom rules or MOM permission persistence. GET and POST stay in one cancellation-owned future, never a queued background write. Send posts to `/api/session/:id/prompt` with caller `msg_` ID and queue delivery (`/message` is read-only). Duplicate message IDs return HTTP 409 as a rejected write, never success; keep the stable ID and never automatically resend. Question replies reread form detail `state.status=pending` and validate required/custom options before posting `{answer}`. Only top-level form `metadata.kind=question` string/multiselect forms are answerable; inert `hidden=false`/`when=[]` are accepted, multiselect `minItems=0/1` maps to optional/required, and other constraints fail closed. Empty optional strings/selections are omitted before option validation; required blanks remain rejected. No metadata/discriminator fallback; fieldless/unsupported forms stay read-only. Writes are never retried; failures retain generic acceptance ambiguity, not raw bodies.
    -- **Island public data**: Retain only `user.text` and assistant content of type `text`, excluding synthetic/hidden/internal flags at both value and metadata levels, and all reasoning/tool/provider state. Fetch one newest 20-message page, project chronological DTOs, cap each UTF-8 text at 16 KiB, and mark truncation. IDs/plaintext/date numbers are validated; malformed V1/unverified shapes fail closed.
    -- **Island events**: `/api/event` uses native `{id,type,created,location,data}` JSON with object `data` (the client JSON-decodes the SSE frame once), live only with no last-event replay or plugin hooks. Parse fragmented multiline/comment/CRLF frames with a 64-KiB frame cap and 2-MiB chunk cap. Native execution/session status/idle/pending lifecycles, `server.connected` recovery, and `location.shutdown` invalidate snapshots; snapshot-only session/permission/location events do not assume a shared ID shape (`session.created` owns `data.session.id`). Public text, `session.message.content.updated`, and inbox delivery require `data.sessionID` and invalidate messages, never per-token snapshots. `form.created` owns its session under `data.form`; non-session global forms are ignored. Reconnect, debounce, cache, and service lifecycle belong to the native Island consumer.
    -- **HTTP paging**: Fetch sessions at limit 1000 and messages at limit 100, initially ascending; thereafter follow cursors until an empty page or no next cursor. Encode session IDs as path segments and cursors as query values; reject duplicate IDs and cursor loops.
    -- **Scan limits**: Share one 60-second deadline across connection, pages, bodies, workers, and aggregation. Use at most four timeline workers, 32 MiB per page, 300,000 metadata items/64 MiB retained metadata, and 100,000 records; failures never return partial usage.
    -- **Recorded usage**: Include recorded root `tokens.input/output` only for non-pending assistant/compaction messages, including terminal cancellation/error cases. Missing usage is not zero billing; exclude reasoning/cache fields and enforce safe JavaScript integer/date bounds.
    -- **Fork ownership**: Validate origin chains, before/through boundaries, native copy eligibility, and copied metadata projections before excluding inherited prefixes; missing origins/boundaries, cycles, and mismatches fail the scan.
    -- **Catalog editing**: GET `/api/model` returns `{location,data: Model.Info[]}`; use enabled entries' `providerID/modelID`, not `id` or providerInfo. Keep configured models including disabled ones; config metadata is authoritative and provider filtering remains client-side.
    -- **Test fixtures**: Service tests use injected snapshots/paths, dual FakeApi servers and synthetic Basic credentials, never global environment or real API/bootstrap. Collector/bootstrap fake shells remain Unix-gated. Keep `process::read_only_live_scan` ignored.
    -
    -## Anti-patterns
    -
    -- Do not fetch large usage pages through CLI stdout: CLI `process.exit` can truncate responses; stdout is reserved for the small bootstrap health result.
    -- Do not deduplicate fork usage by message IDs or timestamp cutoffs: copied messages receive new IDs, and owned child messages may have older timestamps.
    -
    -## Sync
    -
    -This file is maintained as the directory scope changes. When files in this directory are added, modified, or deleted (components, logic units, utilities, constants, etc.), update this file's directory structure, key files, and conventions to keep the documentation in sync with the code.
    diff --git a/src-tauri/src/opencode_cli/active.rs b/src-tauri/src/opencode_cli/active.rs
    deleted file mode 100644
    index f9f6b4e..0000000
    --- a/src-tauri/src/opencode_cli/active.rs
    +++ /dev/null
    @@ -1,118 +0,0 @@
    -use std::collections::HashSet;
    -use std::fmt;
    -use std::marker::PhantomData;
    -use std::time::{Duration, Instant};
    -
    -use serde::de::{self, MapAccess, Visitor};
    -use serde::{Deserialize, Deserializer};
    -
    -use crate::error::AppError;
    -
    -use super::token_usage::transport::{self, ManagedConnection};
    -
    -const ACTIVE_SESSIONS_TIMEOUT: Duration = Duration::from_secs(5);
    -const ACTIVE_SESSIONS_MAX_BYTES: u64 = 1024 * 1024;
    -
    -#[derive(Deserialize)]
    -struct ActiveSessionsEnvelope {
    -    #[serde(deserialize_with = "count_running_sessions")]
    -    data: usize,
    -}
    -
    -#[derive(Deserialize)]
    -#[serde(tag = "type")]
    -enum ActiveSession {
    -    #[serde(rename = "running")]
    -    Running,
    -}
    -
    -pub(super) struct JsonObject(pub(super) T);
    -
    -impl<'de, T: Deserialize<'de>> Deserialize<'de> for JsonObject {
    -    fn deserialize>(deserializer: D) -> Result {
    -        struct ObjectVisitor(PhantomData);
    -
    -        impl<'de, T: Deserialize<'de>> Visitor<'de> for ObjectVisitor {
    -            type Value = JsonObject;
    -
    -            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
    -                formatter.write_str("a JSON object")
    -            }
    -
    -            fn visit_map>(self, map: M) -> Result {
    -                T::deserialize(de::value::MapAccessDeserializer::new(map)).map(JsonObject)
    -            }
    -        }
    -
    -        deserializer.deserialize_map(ObjectVisitor(PhantomData))
    -    }
    -}
    -
    -fn count_running_sessions<'de, D>(deserializer: D) -> Result
    -where
    -    D: Deserializer<'de>,
    -{
    -    running_sessions(deserializer).map(|ids| ids.len())
    -}
    -
    -pub(super) fn running_sessions<'de, D>(deserializer: D) -> Result, D::Error>
    -where
    -    D: Deserializer<'de>,
    -{
    -    struct RunningSessions;
    -
    -    impl<'de> Visitor<'de> for RunningSessions {
    -        type Value = HashSet;
    -
    -        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
    -            formatter.write_str("a map of running session IDs")
    -        }
    -
    -        fn visit_map(self, mut map: M) -> Result
    -        where
    -            M: MapAccess<'de>,
    -        {
    -            let mut ids = HashSet::new();
    -            while let Some(id) = map.next_key::()? {
    -                let valid = id.strip_prefix("ses_").is_some_and(|suffix| {
    -                    !suffix.is_empty()
    -                        && suffix.bytes().all(|byte| {
    -                            byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
    -                        })
    -                });
    -                if !valid || !ids.insert(id) {
    -                    return Err(de::Error::custom("invalid or duplicate session ID"));
    -                }
    -                map.next_value::>()?;
    -            }
    -            Ok(ids)
    -        }
    -    }
    -
    -    deserializer.deserialize_map(RunningSessions)
    -}
    -
    -/// Queries only the selected local service; Shared mode may bootstrap through the CLI.
    -/// This is not a machine-wide inventory of OpenCode processes or servers.
    -pub fn active_sessions_via_cli() -> Result {
    -    let deadline = Instant::now() + ACTIVE_SESSIONS_TIMEOUT;
    -    active_sessions(&transport::connect(deadline)?, deadline)
    -}
    -
    -pub(super) fn active_sessions(
    -    connection: &ManagedConnection,
    -    deadline: Instant,
    -) -> Result {
    -    let envelope: JsonObject = connection.get_json(
    -        &["session", "active"],
    -        &[],
    -        deadline,
    -        ACTIVE_SESSIONS_MAX_BYTES,
    -        "opencode session.active",
    -        "1 MiB",
    -    )?;
    -    Ok(envelope.0.data)
    -}
    -
    -#[cfg(test)]
    -mod tests;
    diff --git a/src-tauri/src/opencode_cli/binary.rs b/src-tauri/src/opencode_cli/binary.rs
    deleted file mode 100644
    index 8c6a000..0000000
    --- a/src-tauri/src/opencode_cli/binary.rs
    +++ /dev/null
    @@ -1,141 +0,0 @@
    -use std::env;
    -use std::ffi::OsStr;
    -use std::path::{Path, PathBuf};
    -
    -/// Candidate opencode binaries, in priority order:
    -/// 1. `$OPENCODE_BIN` explicit override (if the file exists)
    -/// 2. Known install locations — probed directly so the app works even when
    -///    launched with a minimal PATH (e.g. GUI launch on Windows)
    -/// 3. Plain `opencode` — resolved through PATH by `Command::new` at spawn time
    -pub(super) fn opencode_candidates() -> Vec {
    -    let mut candidates: Vec = Vec::new();
    -
    -    if let Ok(candidate) = env::var("OPENCODE_BIN") {
    -        let path = PathBuf::from(candidate.trim());
    -        if path.exists() {
    -            candidates.push(path);
    -        }
    -    }
    -
    -    // npm's Windows shim also supports the modern @opencode/cli package.
    -    #[cfg(windows)]
    -    if let Ok(appdata) = env::var("APPDATA") {
    -        let npm = PathBuf::from(appdata).join("npm");
    -        candidates.push(npm.join("opencode.exe"));
    -        candidates.push(npm.join("opencode.cmd"));
    -        candidates.push(npm.join("node_modules/opencode-ai/bin/opencode.exe"));
    -    }
    -    // Bundled CLI with the OpenChamber desktop app (Windows)
    -    if let Ok(local_appdata) = env::var("LOCALAPPDATA") {
    -        candidates.push(
    -            PathBuf::from(local_appdata)
    -                .join("Programs/@openchamberelectron/resources/opencode-cli/opencode.exe"),
    -        );
    -    }
    -    // Home directories (macOS / Linux)
    -    if let Some(home) = dirs::home_dir() {
    -        let name = if cfg!(windows) {
    -            "opencode.exe"
    -        } else {
    -            "opencode"
    -        };
    -        for directory in [
    -            ".opencode/bin",
    -            ".local/bin",
    -            ".npm-global/bin",
    -            ".bun/bin",
    -            ".cargo/bin",
    -        ] {
    -            candidates.push(home.join(directory).join(name));
    -        }
    -    }
    -    candidates.push(PathBuf::from("/opt/homebrew/bin/opencode"));
    -    candidates.push(PathBuf::from("/usr/local/bin/opencode"));
    -
    -    // Last resort: PATH resolution via `Command::new`.
    -    candidates.push(PathBuf::from("opencode"));
    -    candidates
    -}
    -
    -/// Resolve the opencode binary: `$OPENCODE_BIN`, then known install locations,
    -/// otherwise plain `opencode` (resolved through PATH by `Command::new`).
    -pub fn resolve_opencode_binary() -> PathBuf {
    -    opencode_candidates()
    -        .into_iter()
    -        .find(|p| p.exists())
    -        .unwrap_or_else(|| PathBuf::from("opencode"))
    -}
    -
    -/// Resolve the exact executable without canonicalizing its logical installation path.
    -pub(super) fn resolve_path_command(command: &Path) -> Option {
    -    resolve_path_command_in(
    -        command,
    -        env::var_os("PATH").as_deref(),
    -        &env::current_dir().ok()?,
    -    )
    -}
    -
    -pub(super) fn resolve_path_command_in(
    -    command: &Path,
    -    path: Option<&OsStr>,
    -    cwd: &Path,
    -) -> Option {
    -    if command.as_os_str().is_empty() {
    -        return None;
    -    }
    -    if command.is_absolute() || command.components().count() > 1 {
    -        let absolute = if command.is_absolute() {
    -            command.to_path_buf()
    -        } else {
    -            cwd.join(command)
    -        };
    -        return executable(&absolute).then_some(absolute);
    -    }
    -    for directory in env::split_paths(path?) {
    -        let directory = if directory.is_absolute() {
    -            directory
    -        } else {
    -            cwd.join(directory)
    -        };
    -        let candidate = directory.join(command);
    -        #[cfg(not(windows))]
    -        if executable(&candidate) {
    -            return Some(candidate);
    -        }
    -        #[cfg(windows)]
    -        {
    -            if candidate.extension().is_some() {
    -                if executable(&candidate) {
    -                    return Some(candidate);
    -                }
    -                continue;
    -            }
    -            // These are the native/shim extensions supported by Command on Windows.
    -            for extension in ["exe", "com", "cmd", "bat"] {
    -                let candidate = candidate.with_extension(extension);
    -                if executable(&candidate) {
    -                    return Some(candidate);
    -                }
    -            }
    -        }
    -    }
    -    None
    -}
    -
    -fn executable(path: &Path) -> bool {
    -    let Ok(metadata) = path.metadata() else {
    -        return false;
    -    };
    -    if !metadata.is_file() {
    -        return false;
    -    }
    -    #[cfg(unix)]
    -    {
    -        use std::os::unix::fs::PermissionsExt;
    -        metadata.permissions().mode() & 0o111 != 0
    -    }
    -    #[cfg(not(unix))]
    -    {
    -        true
    -    }
    -}
    diff --git a/src-tauri/src/opencode_cli/command.rs b/src-tauri/src/opencode_cli/command.rs
    deleted file mode 100644
    index 1ecb2c6..0000000
    --- a/src-tauri/src/opencode_cli/command.rs
    +++ /dev/null
    @@ -1,176 +0,0 @@
    -use std::io::{self, Read};
    -use std::process::{Child, ChildStdout, Output};
    -use std::thread;
    -use std::time::{Duration, Instant};
    -
    -use crate::error::AppError;
    -
    -pub(super) fn collect_output(
    -    mut child: Child,
    -    timeout: Duration,
    -    max_bytes: u64,
    -    operation: &str,
    -    limit: &str,
    -) -> Result {
    -    let started = Instant::now();
    -    let mut stdout = child.stdout.take();
    -    let result = (|| {
    -        let stdout = stdout.as_mut().ok_or_else(|| {
    -            AppError::configuration(format!("failed to read {operation}: stdout is not piped"))
    -        })?;
    -        let read_failure = |error: io::Error| {
    -            AppError::configuration(format!("failed to read {operation}: {:?}", error.kind()))
    -        };
    -        prepare_stdout(stdout).map_err(read_failure)?;
    -        let mut bytes = Vec::new();
    -        let mut buffer = [0; 8192];
    -        let mut status = None;
    -        let mut eof = false;
    -        let mut read_error = None;
    -        loop {
    -            if status.is_none() {
    -                status = child.try_wait().map_err(|error| {
    -                    AppError::configuration(format!(
    -                        "failed to wait for {operation}: {:?}",
    -                        error.kind()
    -                    ))
    -                })?;
    -            }
    -            if let Some(status) = status {
    -                if !status.success() {
    -                    return Ok(Output {
    -                        status,
    -                        stdout: Vec::new(),
    -                        stderr: Vec::new(),
    -                    });
    -                }
    -                if let Some(error) = read_error.take() {
    -                    return Err(read_failure(error));
    -                }
    -                if eof {
    -                    return Ok(Output {
    -                        status,
    -                        stdout: bytes,
    -                        stderr: Vec::new(),
    -                    });
    -                }
    -            }
    -            if started.elapsed() >= timeout {
    -                return Err(AppError::configuration(if status.is_some() {
    -                    format!("{operation} output timed out.")
    -                } else {
    -                    format!("{operation} timed out. Check the OpenCode CLI and retry.")
    -                }));
    -            }
    -            if !eof && read_error.is_none() {
    -                let remaining = max_bytes
    -                    .saturating_sub(bytes.len() as u64)
    -                    .saturating_add(1)
    -                    .min(buffer.len() as u64) as usize;
    -                match read_available(stdout, &mut buffer[..remaining]) {
    -                    Ok(0) => eof = true,
    -                    Ok(count) => {
    -                        bytes.extend_from_slice(&buffer[..count]);
    -                        if bytes.len() as u64 > max_bytes {
    -                            return Err(AppError::configuration(format!(
    -                                "{operation} response exceeds the {limit} limit."
    -                            )));
    -                        }
    -                        continue;
    -                    }
    -                    Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
    -                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
    -                    Err(error) => read_error = Some(error),
    -                }
    -            }
    -            thread::sleep(Duration::from_millis(20).min(timeout.saturating_sub(started.elapsed())));
    -        }
    -    })();
    -    drop(stdout);
    -    if result.is_err() && child.try_wait().ok().flatten().is_none() {
    -        // Only reap this CLI child; a descendant may be the persistent OpenCode service.
    -        let _ = child.kill();
    -        let _ = child.wait();
    -    }
    -    result
    -}
    -
    -#[cfg(unix)]
    -fn prepare_stdout(stdout: &ChildStdout) -> io::Result<()> {
    -    use std::os::fd::AsRawFd;
    -
    -    let fd = stdout.as_raw_fd();
    -    // The owned stdout keeps this descriptor valid throughout both fcntl calls.
    -    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
    -    if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
    -        return Err(io::Error::last_os_error());
    -    }
    -    Ok(())
    -}
    -
    -#[cfg(unix)]
    -fn read_available(stdout: &mut ChildStdout, buffer: &mut [u8]) -> io::Result {
    -    stdout.read(buffer)
    -}
    -
    -#[cfg(windows)]
    -fn prepare_stdout(stdout: &ChildStdout) -> io::Result<()> {
    -    use std::os::windows::io::AsRawHandle;
    -    use windows_sys::Win32::Foundation::{ERROR_INVALID_HANDLE, INVALID_HANDLE_VALUE};
    -
    -    let handle = stdout.as_raw_handle();
    -    if handle.is_null() || handle == INVALID_HANDLE_VALUE {
    -        return Err(io::Error::from_raw_os_error(ERROR_INVALID_HANDLE as i32));
    -    }
    -    Ok(())
    -}
    -
    -#[cfg(windows)]
    -fn read_available(stdout: &mut ChildStdout, buffer: &mut [u8]) -> io::Result {
    -    use std::os::windows::io::AsRawHandle;
    -    use std::ptr::null_mut;
    -    use windows_sys::Win32::Foundation::ERROR_BROKEN_PIPE;
    -    use windows_sys::Win32::System::Pipes::PeekNamedPipe;
    -
    -    let mut available = 0;
    -    // This collector owns the sole reader; only read bytes already buffered in the pipe.
    -    let success = unsafe {
    -        PeekNamedPipe(
    -            stdout.as_raw_handle(),
    -            null_mut(),
    -            0,
    -            null_mut(),
    -            &mut available,
    -            null_mut(),
    -        )
    -    };
    -    if success == 0 {
    -        let error = io::Error::last_os_error();
    -        return if error.raw_os_error() == Some(ERROR_BROKEN_PIPE as i32) {
    -            Ok(0)
    -        } else {
    -            Err(error)
    -        };
    -    }
    -    if available == 0 {
    -        return Err(io::ErrorKind::WouldBlock.into());
    -    }
    -    let count = buffer.len().min(available as usize);
    -    match stdout.read(&mut buffer[..count]) {
    -        Err(error) if error.raw_os_error() == Some(ERROR_BROKEN_PIPE as i32) => Ok(0),
    -        result => result,
    -    }
    -}
    -
    -#[cfg(not(any(unix, windows)))]
    -fn prepare_stdout(_: &ChildStdout) -> io::Result<()> {
    -    Err(io::ErrorKind::Unsupported.into())
    -}
    -
    -#[cfg(not(any(unix, windows)))]
    -fn read_available(_: &mut ChildStdout, _: &mut [u8]) -> io::Result {
    -    Err(io::ErrorKind::Unsupported.into())
    -}
    -
    -#[cfg(all(test, unix))]
    -mod tests;
    diff --git a/src-tauri/src/opencode_cli/installation.rs b/src-tauri/src/opencode_cli/installation.rs
    deleted file mode 100644
    index c81a1cb..0000000
    --- a/src-tauri/src/opencode_cli/installation.rs
    +++ /dev/null
    @@ -1,885 +0,0 @@
    -use std::fs::{self, OpenOptions};
    -use std::io::Read;
    -use std::path::{Component, Path, PathBuf};
    -use std::process::{Command, Stdio};
    -use std::sync::Mutex;
    -use std::time::{Duration, Instant};
    -
    -use reqwest::blocking::Client;
    -use serde::{Deserialize, Serialize};
    -
    -use crate::error::{AppError, ErrorCode};
    -
    -use super::binary::{opencode_candidates, resolve_path_command};
    -use super::command::collect_output;
    -
    -const LATEST_URL: &str = "https://opencode.ai/update/api/latest/cli/npm";
    -const PACKAGE: &str = "@opencode/cli";
    -const FORMULA: &str = "anomalyco/tap/opencode-v2";
    -const LOCAL_TIMEOUT: Duration = Duration::from_secs(15);
    -const PROCESS_TIMEOUT: Duration = Duration::from_secs(3);
    -const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
    -// Verified native updater at 434f7b2a3670dd932794688bee5e34029e546630; earlier V2 builds stay manual.
    -const MIN_NATIVE_UPGRADE: [u32; 3] = [2, 0, 25];
    -// Native updater can wait 15 minutes for its flock, then run two five-minute curl operations.
    -const UPGRADE_TIMEOUT: Duration = Duration::from_secs(26 * 60);
    -static UPGRADE_LOCK: Mutex<()> = Mutex::new(());
    -
    -#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub struct OpenCodeInstallation {
    -    pub binary_path: Option,
    -    pub version: Option,
    -    pub method: String,
    -    pub can_upgrade: bool,
    -    pub upgrade_reason: Option,
    -    pub manual_command: Option,
    -}
    -
    -#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub struct OpenCodeUpdateCheck {
    -    pub installation: OpenCodeInstallation,
    -    pub latest_version: String,
    -    pub update_available: bool,
    -}
    -
    -#[derive(Debug, Clone, PartialEq, Eq)]
    -struct Version {
    -    text: String,
    -    numbers: [u32; 3],
    -    unstable: bool,
    -}
    -
    -fn version(text: &str) -> Option {
    -    let text = text.trim().strip_prefix('v').unwrap_or(text.trim());
    -    if text.is_empty() || text.len() > 96 || !text.is_ascii() {
    -        return None;
    -    }
    -    let (core, suffix) = text
    -        .split_once('-')
    -        .map_or((text, None), |(core, suffix)| (core, Some(suffix)));
    -    let mut numbers = [0; 3];
    -    let mut parts = core.split('.');
    -    for number in &mut numbers {
    -        let part = parts.next()?;
    -        if part.is_empty()
    -            || !part.bytes().all(|byte| byte.is_ascii_digit())
    -            || (part.len() > 1 && part.starts_with('0'))
    -        {
    -            return None;
    -        }
    -        *number = part.parse().ok()?;
    -    }
    -    if parts.next().is_some() {
    -        return None;
    -    }
    -    if let Some(suffix) = suffix {
    -        if suffix.split('.').any(|part| {
    -            part.is_empty()
    -                || !part
    -                    .bytes()
    -                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
    -                || (part.len() > 1
    -                    && part.starts_with('0')
    -                    && part.bytes().all(|byte| byte.is_ascii_digit()))
    -        }) {
    -            return None;
    -        }
    -    }
    -    Some(Version {
    -        text: text.to_owned(),
    -        numbers,
    -        unstable: suffix.is_some(),
    -    })
    -}
    -
    -fn stable_v2(text: &str) -> Result {
    -    version(text)
    -        .filter(|version| version.numbers[0] == 2 && !version.unstable)
    -        .ok_or_else(|| AppError::validation("A stable OpenCode V2 version is required"))
    -}
    -
    -#[derive(Debug, Clone, PartialEq, Eq)]
    -enum Source {
    -    Curl {
    -        bash: PathBuf,
    -        curl: PathBuf,
    -        home: PathBuf,
    -    },
    -    Manager {
    -        method: &'static str,
    -        manager: PathBuf,
    -        root: PathBuf,
    -        bin: PathBuf,
    -    },
    -}
    -
    -#[derive(Debug, Clone, PartialEq, Eq)]
    -struct Detected {
    -    info: OpenCodeInstallation,
    -    logical: Option,
    -    canonical: Option,
    -    source: Option,
    -}
    -
    -fn manual_command(method: &str) -> Option {
    -    Some(
    -        match method {
    -            "curl" => "curl -fsSL https://opencode.ai/v2/install | bash",
    -            "npm" => "npm install -g @opencode/cli@2",
    -            "bun" => "bun install -g --trust @opencode/cli@2",
    -            "pnpm" => "pnpm add -g --allow-build=@opencode/cli @opencode/cli@2",
    -            "yarn" => "yarn global add @opencode/cli@2",
    -            "vite-plus" => "vp install -g @opencode/cli@2",
    -            "brew" => "brew upgrade anomalyco/tap/opencode-v2",
    -            _ => return None,
    -        }
    -        .to_owned(),
    -    )
    -}
    -
    -fn not_found() -> Detected {
    -    Detected {
    -        info: OpenCodeInstallation {
    -            binary_path: None,
    -            version: None,
    -            method: "not-found".to_owned(),
    -            can_upgrade: false,
    -            upgrade_reason: Some("not_found".to_owned()),
    -            manual_command: None,
    -        },
    -        logical: None,
    -        canonical: None,
    -        source: None,
    -    }
    -}
    -
    -fn selected_binary() -> Option {
    -    selected_binary_with(
    -        std::env::var_os("OPENCODE_BIN").as_deref(),
    -        opencode_candidates(),
    -        resolve_path_command,
    -    )
    -}
    -
    -fn selected_binary_with(
    -    explicit: Option<&std::ffi::OsStr>,
    -    candidates: impl IntoIterator,
    -    mut resolve: impl FnMut(&Path) -> Option,
    -) -> Option {
    -    if let Some(explicit) = explicit {
    -        // An invalid override must never redirect an upgrade to another installation.
    -        return resolve(Path::new(explicit.to_str()?.trim()));
    -    }
    -    candidates
    -        .into_iter()
    -        .find_map(|candidate| resolve(&candidate))
    -}
    -
    -fn sanitized(command: &mut Command) {
    -    command.env_clear();
    -    for key in [
    -        "PATH",
    -        "HOME",
    -        "USER",
    -        "LOGNAME",
    -        "TMPDIR",
    -        "TMP",
    -        "TEMP",
    -        "LANG",
    -        "LC_ALL",
    -        "SystemRoot",
    -        "COMSPEC",
    -        "APPDATA",
    -        "LOCALAPPDATA",
    -        "NPM_CONFIG_PREFIX",
    -        "BUN_INSTALL",
    -        "PNPM_HOME",
    -    ] {
    -        if let Some(value) = std::env::var_os(key) {
    -            command.env(key, value);
    -        }
    -    }
    -    command
    -        .env("OPENCODE_DISABLE_AUTOUPDATE", "1")
    -        .env("HOMEBREW_NO_AUTO_UPDATE", "1")
    -        .env("HOMEBREW_NO_INSTALL_FROM_API", "1")
    -        .env("HOMEBREW_NO_ANALYTICS", "1")
    -        .stdin(Stdio::null())
    -        .stdout(Stdio::piped())
    -        .stderr(Stdio::null());
    -}
    -
    -fn executable_command(path: &Path) -> Command {
    -    #[cfg(all(test, unix))]
    -    if tests::is_fake_executable(path) {
    -        let mut command = Command::new("/bin/sh");
    -        command.arg(path);
    -        return command;
    -    }
    -    Command::new(path)
    -}
    -
    -fn read_output(
    -    command: &mut Command,
    -    deadline: Instant,
    -    max_bytes: u64,
    -    operation: &str,
    -) -> Result, AppError> {
    -    let timeout = deadline
    -        .checked_duration_since(Instant::now())
    -        .filter(|duration| !duration.is_zero())
    -        .ok_or_else(|| AppError::configuration(format!("{operation} timed out")))?
    -        .min(PROCESS_TIMEOUT);
    -    sanitized(command);
    -    let child = command.spawn().map_err(|error| {
    -        AppError::configuration(format!("{operation} could not start: {:?}", error.kind()))
    -    })?;
    -    let output = collect_output(child, timeout, max_bytes, operation, "bounded output")?;
    -    if !output.status.success() {
    -        return Err(AppError::configuration(format!(
    -            "{operation} failed (exit {:?})",
    -            output.status.code()
    -        )));
    -    }
    -    Ok(output.stdout)
    -}
    -
    -fn read_version(path: &Path, deadline: Instant) -> Result, AppError> {
    -    let bytes = read_output(
    -        executable_command(path).arg("--version"),
    -        deadline,
    -        1024,
    -        "OpenCode version query",
    -    )?;
    -    Ok(std::str::from_utf8(&bytes).ok().and_then(version))
    -}
    -
    -fn query_path(manager: &Path, args: &[&str], deadline: Instant) -> Option {
    -    let bytes = read_output(
    -        executable_command(manager).args(args),
    -        deadline,
    -        8192,
    -        "OpenCode manager ownership query",
    -    )
    -    .ok()?;
    -    let text = std::str::from_utf8(&bytes).ok()?.trim();
    -    if text.is_empty() || text.contains(['\n', '\r', '\0']) {
    -        return None;
    -    }
    -    let path = PathBuf::from(text);
    -    path.is_absolute().then_some(path)
    -}
    -
    -fn bounded_file(path: &Path, max_bytes: u64) -> Option> {
    -    if !fs::symlink_metadata(path).ok()?.file_type().is_file() {
    -        return None;
    -    }
    -    let mut options = OpenOptions::new();
    -    options.read(true);
    -    #[cfg(unix)]
    -    {
    -        use std::os::unix::fs::OpenOptionsExt;
    -        options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW);
    -    }
    -    let file = options.open(path).ok()?;
    -    let metadata = file.metadata().ok()?;
    -    if !metadata.is_file() || metadata.len() > max_bytes {
    -        return None;
    -    }
    -    let mut bytes = Vec::new();
    -    file.take(max_bytes + 1).read_to_end(&mut bytes).ok()?;
    -    (bytes.len() as u64 <= max_bytes).then_some(bytes)
    -}
    -
    -fn package_bin(root: &Path) -> Option {
    -    let root = root.canonicalize().ok()?;
    -    let package = root.join("@opencode/cli");
    -    let bytes = bounded_file(&package.join("package.json"), 64 * 1024)?;
    -    let metadata: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    -    if metadata.get("name").and_then(|value| value.as_str()) != Some(PACKAGE) {
    -        return None;
    -    }
    -    let bin = metadata.get("bin");
    -    let bin = bin.and_then(|value| {
    -        value
    -            .as_str()
    -            .or_else(|| value.get("opencode").and_then(|value| value.as_str()))
    -    })?;
    -    let relative = Path::new(bin);
    -    if relative.as_os_str().is_empty()
    -        || relative
    -            .components()
    -            .any(|part| !matches!(part, Component::Normal(_) | Component::CurDir))
    -    {
    -        return None;
    -    }
    -    let path = package.join(relative).canonicalize().ok()?;
    -    path.starts_with(root).then_some(path)
    -}
    -
    -fn package_owns(root: &Path, binary: &Path) -> bool {
    -    package_bin(root).as_deref() == Some(binary)
    -}
    -
    -fn global_bin_owns(root: &Path, bin: &Path, logical: &Path, canonical: &Path) -> bool {
    -    if package_owns(root, canonical)
    -        && ["opencode", "opencode.exe"]
    -            .iter()
    -            .any(|name| bin.join(name).canonicalize().ok().as_deref() == Some(canonical))
    -    {
    -        return true;
    -    }
    -    // Windows npm shims are readable installations, never automatic upgrade candidates.
    -    if cfg!(windows) && logical == bin.join("opencode.cmd") && package_bin(root).is_some() {
    -        return npm_native_shim(logical);
    -    }
    -    false
    -}
    -
    -fn npm_native_shim(path: &Path) -> bool {
    -    bounded_file(path, 16 * 1024)
    -        .and_then(|bytes| String::from_utf8(bytes).ok())
    -        .is_some_and(|text| {
    -            text.contains("\"%dp0%\\node_modules\\@opencode\\cli\\bin\\opencode.exe\"")
    -        })
    -}
    -
    -fn bundled(path: &Path) -> bool {
    -    for ancestor in path.ancestors().skip(1) {
    -        let Some(name) = ancestor.file_name().and_then(|name| name.to_str()) else {
    -            continue;
    -        };
    -        if name == "Contents"
    -            && ancestor
    -                .parent()
    -                .is_some_and(|parent| parent.extension() == Some(std::ffi::OsStr::new("app")))
    -        {
    -            return true;
    -        }
    -        if name.eq_ignore_ascii_case("resources")
    -            && ancestor
    -                .parent()
    -                .and_then(|parent| parent.file_name())
    -                .and_then(|name| name.to_str())
    -                .is_some_and(|name| {
    -                    ["opencode", "openchamber", "@openchamberelectron"]
    -                        .iter()
    -                        .any(|app| name.eq_ignore_ascii_case(app))
    -                })
    -        {
    -            return true;
    -        }
    -    }
    -    false
    -}
    -
    -fn official_curl_path(logical: &Path, canonical: &Path, home: &Path) -> bool {
    -    let name = if cfg!(windows) {
    -        "opencode.exe"
    -    } else {
    -        "opencode"
    -    };
    -    let expected = home.join(".opencode/bin").join(name);
    -    logical == expected
    -        && fs::symlink_metadata(logical)
    -            .ok()
    -            .is_some_and(|metadata| metadata.file_type().is_file())
    -        && home
    -            .canonicalize()
    -            .ok()
    -            .is_some_and(|home| canonical == home.join(".opencode/bin").join(name))
    -}
    -
    -fn brew_formula(path: &Path, deadline: Instant) -> Option {
    -    let cellar = path.ancestors().find(|ancestor| {
    -        ancestor.file_name() == Some(std::ffi::OsStr::new("opencode-v2"))
    -            && ancestor
    -                .parent()
    -                .is_some_and(|parent| parent.file_name() == Some(std::ffi::OsStr::new("Cellar")))
    -    })?;
    -    let manager = cellar.parent()?.parent()?.join("bin/brew");
    -    let manager = resolve_path_command(&manager)?;
    -    let prefix = query_path(&manager, &["--prefix", FORMULA], deadline)?
    -        .canonicalize()
    -        .ok()?;
    -    if prefix.join("bin/opencode").canonicalize().ok().as_deref() != Some(path)
    -        || !prefix.starts_with(cellar)
    -    {
    -        return Some(false);
    -    }
    -    let bytes = read_output(
    -        executable_command(&manager).args(["info", "--json=v2", "--installed", FORMULA]),
    -        deadline,
    -        64 * 1024,
    -        "Homebrew ownership query",
    -    )
    -    .ok()?;
    -    let metadata: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    -    Some(metadata.get("formulae")?.as_array()?.iter().any(|formula| {
    -        formula.get("name").and_then(|value| value.as_str()) == Some("opencode-v2")
    -            && formula.get("full_name").and_then(|value| value.as_str()) == Some(FORMULA)
    -            && formula
    -                .get("installed")
    -                .and_then(|value| value.as_array())
    -                .is_some_and(|values| !values.is_empty())
    -    }))
    -}
    -
    -fn identify_source(
    -    logical: &Path,
    -    canonical: &Path,
    -    home: Option<&Path>,
    -    deadline: Instant,
    -) -> (String, Option, &'static str) {
    -    identify_source_with(logical, canonical, home, deadline, resolve_path_command)
    -}
    -
    -fn identify_source_with(
    -    logical: &Path,
    -    canonical: &Path,
    -    home: Option<&Path>,
    -    deadline: Instant,
    -    mut resolve: impl FnMut(&Path) -> Option,
    -) -> (String, Option, &'static str) {
    -    if bundled(logical) || bundled(canonical) {
    -        return ("bundled".to_owned(), None, "bundled");
    -    }
    -    if let Some(home) = home.filter(|home| official_curl_path(logical, canonical, home)) {
    -        #[cfg(unix)]
    -        if let (Some(bash), Some(curl)) = (resolve(Path::new("bash")), resolve(Path::new("curl"))) {
    -            return (
    -                "curl".to_owned(),
    -                Some(Source::Curl {
    -                    bash,
    -                    curl,
    -                    home: home.to_path_buf(),
    -                }),
    -                "",
    -            );
    -        }
    -        return (
    -            "curl".to_owned(),
    -            None,
    -            if cfg!(unix) {
    -                "manager_unavailable"
    -            } else {
    -                "unsupported"
    -            },
    -        );
    -    }
    -    if let Some(proven) = brew_formula(canonical, deadline) {
    -        // Homebrew has no verified version-pinned upgrade for this formula.
    -        return (
    -            if proven { "brew" } else { "unknown" }.to_owned(),
    -            None,
    -            if proven { "unsupported" } else { "ambiguous" },
    -        );
    -    }
    -    if !canonical
    -        .ancestors()
    -        .any(|path| path.file_name() == Some(std::ffi::OsStr::new("node_modules")))
    -        && !(cfg!(windows) && logical.file_name() == Some(std::ffi::OsStr::new("opencode.cmd")))
    -    {
    -        return ("manual".to_owned(), None, "unsupported");
    -    }
    -    if let Some(manager) = resolve(Path::new("npm")) {
    -        if let (Some(root), Some(prefix)) = (
    -            query_path(&manager, &["root", "-g"], deadline),
    -            query_path(&manager, &["prefix", "-g"], deadline),
    -        ) {
    -            let expected_root = prefix.join(if cfg!(windows) {
    -                "node_modules"
    -            } else {
    -                "lib/node_modules"
    -            });
    -            let bin = if cfg!(windows) {
    -                prefix
    -            } else {
    -                prefix.join("bin")
    -            };
    -            if root.canonicalize().ok() == expected_root.canonicalize().ok()
    -                && global_bin_owns(&root, &bin, logical, canonical)
    -            {
    -                return (
    -                    "npm".to_owned(),
    -                    cfg!(unix).then_some(Source::Manager {
    -                        method: "npm",
    -                        manager,
    -                        root,
    -                        bin,
    -                    }),
    -                    if cfg!(unix) { "" } else { "unsupported" },
    -                );
    -            }
    -        }
    -    }
    -    // Other manager shims/layouts must match both their queried global root and package bin.
    -    for (name, method, root_args, bin_args) in [
    -        ("pnpm", "pnpm", &["root", "-g"][..], &["bin", "-g"][..]),
    -        (
    -            "yarn",
    -            "yarn",
    -            &["global", "dir"][..],
    -            &["global", "bin"][..],
    -        ),
    -        ("bun", "bun", &[][..], &["pm", "bin", "-g"][..]),
    -    ] {
    -        let Some(manager) = resolve(Path::new(name)) else {
    -            continue;
    -        };
    -        let Some(bin) = query_path(&manager, bin_args, deadline) else {
    -            continue;
    -        };
    -        let root = if method == "bun" {
    -            bin.parent()
    -                .map(|parent| parent.join("install/global/node_modules"))
    -        } else {
    -            query_path(&manager, root_args, deadline).map(|path| {
    -                if method == "yarn" {
    -                    path.join("node_modules")
    -                } else {
    -                    path
    -                }
    -            })
    -        };
    -        if let Some(root) = root.filter(|root| global_bin_owns(root, &bin, logical, canonical)) {
    -            return (
    -                method.to_owned(),
    -                cfg!(unix).then_some(Source::Manager {
    -                    method,
    -                    manager,
    -                    root,
    -                    bin,
    -                }),
    -                if cfg!(unix) { "" } else { "unsupported" },
    -            );
    -        }
    -    }
    -    ("unknown".to_owned(), None, "ambiguous")
    -}
    -
    -fn inspect_path(logical: PathBuf, home: Option<&Path>) -> Result {
    -    let deadline = Instant::now() + LOCAL_TIMEOUT;
    -    let canonical = logical.canonicalize().map_err(|error| {
    -        AppError::configuration(format!(
    -            "OpenCode binary is unavailable: {:?}",
    -            error.kind()
    -        ))
    -    })?;
    -    let current = read_version(&logical, deadline)?;
    -    let (method, source, source_reason) = identify_source(&logical, &canonical, home, deadline);
    -    let reason = if method == "bundled" {
    -        "bundled"
    -    } else {
    -        match ¤t {
    -            None => "unsupported",
    -            Some(current) if current.numbers[0] != 2 => "not_v2",
    -            Some(current) if current.unstable => "unstable",
    -            Some(current) if current.numbers < MIN_NATIVE_UPGRADE => "unsupported",
    -            Some(_) => source_reason,
    -        }
    -    };
    -    let binary_path = logical
    -        .to_str()
    -        .ok_or_else(|| AppError::configuration("OpenCode binary path is not valid UTF-8"))?
    -        .to_owned();
    -    Ok(Detected {
    -        info: OpenCodeInstallation {
    -            binary_path: Some(binary_path),
    -            version: current.map(|version| version.text),
    -            manual_command: manual_command(&method),
    -            method,
    -            can_upgrade: reason.is_empty(),
    -            upgrade_reason: (!reason.is_empty()).then(|| reason.to_owned()),
    -        },
    -        logical: Some(logical),
    -        canonical: Some(canonical),
    -        source,
    -    })
    -}
    -
    -fn inspect_selected() -> Result {
    -    match selected_binary() {
    -        Some(path) => inspect_path(path, dirs::home_dir().as_deref()),
    -        None => Ok(not_found()),
    -    }
    -}
    -
    -/// Local discovery only; never contacts or starts the OpenCode service.
    -pub fn get_installation() -> Result {
    -    Ok(inspect_selected()?.info)
    -}
    -
    -fn http_client() -> Result {
    -    Client::builder()
    -        .timeout(HTTP_TIMEOUT)
    -        .connect_timeout(Duration::from_secs(5))
    -        .no_proxy()
    -        .redirect(reqwest::redirect::Policy::none())
    -        .retry(reqwest::retry::never())
    -        .build()
    -        .map_err(|_| AppError::configuration("OpenCode update HTTP initialization failed"))
    -}
    -
    -fn fetch_bytes(client: &Client, url: &str, max_bytes: u64) -> Result, AppError> {
    -    let response = client
    -        .get(url)
    -        .header(reqwest::header::USER_AGENT, "opencode-mom")
    -        .send()
    -        .map_err(|_| AppError::configuration("OpenCode update request failed"))?;
    -    if !response.status().is_success() {
    -        return Err(AppError::configuration(format!(
    -            "OpenCode update request failed (HTTP {})",
    -            response.status().as_u16()
    -        )));
    -    }
    -    if response
    -        .content_length()
    -        .is_some_and(|length| length > max_bytes)
    -    {
    -        return Err(AppError::configuration(
    -            "OpenCode update response exceeds its size limit",
    -        ));
    -    }
    -    let mut bytes = Vec::new();
    -    response
    -        .take(max_bytes + 1)
    -        .read_to_end(&mut bytes)
    -        .map_err(|_| AppError::configuration("OpenCode update response could not be read"))?;
    -    if bytes.len() as u64 > max_bytes {
    -        return Err(AppError::configuration(
    -            "OpenCode update response exceeds its size limit",
    -        ));
    -    }
    -    Ok(bytes)
    -}
    -
    -#[derive(Deserialize)]
    -struct Latest {
    -    channel: String,
    -    name: String,
    -    distribution: String,
    -    version: String,
    -    metadata: LatestMetadata,
    -    active: bool,
    -}
    -
    -#[derive(Deserialize)]
    -struct LatestMetadata {
    -    package: String,
    -}
    -
    -fn latest_version(bytes: &[u8]) -> Result {
    -    let invalid = || {
    -        AppError::configuration("OpenCode update metadata is not an active stable V2 CLI release")
    -    };
    -    let latest: Latest = serde_json::from_slice(bytes).map_err(|_| invalid())?;
    -    if latest.channel != "latest"
    -        || latest.name != "cli"
    -        || latest.distribution != "npm"
    -        || !latest.active
    -        || latest.metadata.package != PACKAGE
    -    {
    -        return Err(invalid());
    -    }
    -    stable_v2(&latest.version).map_err(|_| invalid())
    -}
    -
    -fn fetch_latest() -> Result {
    -    latest_version(&fetch_bytes(&http_client()?, LATEST_URL, 16 * 1024)?)
    -}
    -
    -pub fn check_for_update() -> Result {
    -    let installation = get_installation()?;
    -    let latest = fetch_latest()?;
    -    let update_available = installation
    -        .version
    -        .as_deref()
    -        .and_then(version)
    -        .is_some_and(|current| {
    -            current.numbers[0] == 2 && !current.unstable && latest.numbers > current.numbers
    -        });
    -    Ok(OpenCodeUpdateCheck {
    -        installation,
    -        latest_version: latest.text,
    -        update_available,
    -    })
    -}
    -
    -fn upgrade_command(detected: &Detected, target: &str) -> Result {
    -    let target = stable_v2(target)?.text;
    -    let source = detected
    -        .source
    -        .as_ref()
    -        .ok_or_else(|| AppError::validation("OpenCode installation ownership is unproven"))?;
    -    let binary = detected
    -        .logical
    -        .as_ref()
    -        .ok_or_else(|| AppError::validation("OpenCode binary is unavailable"))?;
    -    let method = match source {
    -        Source::Curl { .. } => "curl",
    -        Source::Manager { method, .. } => *method,
    -    };
    -    let mut command = executable_command(binary);
    -    command.args(["upgrade", &target, "--method", method]);
    -    sanitized(&mut command);
    -    command.stdout(Stdio::null());
    -    if let Source::Curl { home, .. } = source {
    -        command.env("HOME", home);
    -    }
    -    Ok(command)
    -}
    -
    -#[cfg(unix)]
    -fn run_upgrade(command: &mut Command, timeout: Duration) -> Result<(), AppError> {
    -    use std::os::unix::process::CommandExt;
    -
    -    command.process_group(0);
    -    let mut child = command.spawn().map_err(|error| {
    -        AppError::configuration(format!(
    -            "OpenCode upgrade could not start: {:?}",
    -            error.kind()
    -        ))
    -    })?;
    -    let group = child.id() as libc::pid_t;
    -    let deadline = Instant::now() + timeout;
    -    loop {
    -        match child.try_wait() {
    -            Ok(Some(status)) if status.success() => return Ok(()),
    -            Ok(Some(status)) => {
    -                return Err(AppError::configuration(format!(
    -                    "OpenCode upgrade failed (exit {:?}); use the installation's manual command",
    -                    status.code()
    -                )))
    -            }
    -            Err(error) => {
    -                let _ = child.kill();
    -                let _ = child.wait();
    -                return Err(AppError::configuration(format!(
    -                    "OpenCode upgrade wait failed: {:?}",
    -                    error.kind()
    -                )));
    -            }
    -            Ok(None) => {}
    -        }
    -        if Instant::now() >= deadline {
    -            // The group leader is still unreaped: its PID cannot be reused. Never signal the service.
    -            let _ = unsafe { libc::kill(-group, libc::SIGKILL) };
    -            let _ = child.kill();
    -            let _ = child.wait();
    -            return Err(AppError::configuration(
    -                "OpenCode upgrade timed out; check the selected installation before trying again",
    -            ));
    -        }
    -        std::thread::sleep(
    -            Duration::from_millis(20).min(deadline.saturating_duration_since(Instant::now())),
    -        );
    -    }
    -}
    -
    -#[cfg(not(unix))]
    -fn run_upgrade(_: &mut Command, _: Duration) -> Result<(), AppError> {
    -    Err(AppError::configuration(
    -        "Automatic OpenCode upgrade is unsupported on this platform",
    -    ))
    -}
    -
    -fn execute_upgrade(detected: &Detected, target: &str) -> Result<(), AppError> {
    -    run_upgrade(&mut upgrade_command(detected, target)?, UPGRADE_TIMEOUT)
    -}
    -
    -fn verify_snapshot(
    -    detected: &Detected,
    -    expected_path: &str,
    -    expected_version: &str,
    -) -> Result<(), AppError> {
    -    if detected.info.binary_path.as_deref() != Some(expected_path)
    -        || detected.info.version.as_deref() != Some(expected_version)
    -    {
    -        return Err(AppError::validation(
    -            "OpenCode installation changed; refresh before upgrading",
    -        ));
    -    }
    -    if !detected.info.can_upgrade || detected.source.is_none() {
    -        return Err(AppError::validation(format!(
    -            "OpenCode automatic upgrade is unavailable ({}); use the installation's manual command",
    -            detected
    -                .info
    -                .upgrade_reason
    -                .as_deref()
    -                .unwrap_or("unsupported")
    -        )));
    -    }
    -    Ok(())
    -}
    -
    -fn guarded_upgrade(
    -    expected_path: &str,
    -    expected_version: &str,
    -    target: &str,
    -    mut inspect: impl FnMut(Option<&Path>) -> Result,
    -    latest: impl FnOnce() -> Result,
    -    execute: impl FnOnce(&Detected, &str) -> Result<(), AppError>,
    -) -> Result {
    -    let target = stable_v2(target)?;
    -    let current = stable_v2(expected_version)?;
    -    if current.numbers < MIN_NATIVE_UPGRADE {
    -        return Err(AppError::validation(
    -            "This OpenCode V2 version requires a manual upgrade",
    -        ));
    -    }
    -    if target.numbers <= current.numbers {
    -        return Err(AppError::validation(
    -            "OpenCode upgrades cannot downgrade or reinstall the current version",
    -        ));
    -    }
    -    let before = inspect(None)?;
    -    verify_snapshot(&before, expected_path, expected_version)?;
    -    if stable_v2(&latest()?.text)?.text != target.text {
    -        return Err(AppError::validation(
    -            "OpenCode target is no longer the latest stable V2 release; check again",
    -        ));
    -    }
    -    let refreshed = inspect(None)?;
    -    verify_snapshot(&refreshed, expected_path, expected_version)?;
    -    if before.logical != refreshed.logical
    -        || before.canonical != refreshed.canonical
    -        || before.source != refreshed.source
    -    {
    -        return Err(AppError::validation(
    -            "OpenCode installation source changed; refresh before upgrading",
    -        ));
    -    }
    -    execute(&refreshed, &target.text)?;
    -    let after = inspect(before.logical.as_deref())?;
    -    if after.logical != before.logical || after.info.version.as_deref() != Some(&target.text) {
    -        return Err(AppError::configuration("OpenCode upgrade finished, but the selected binary does not report the target version; no fallback installation was used"));
    -    }
    -    Ok(after.info)
    -}
    -
    -pub fn upgrade(
    -    expected_binary_path: &str,
    -    expected_version: &str,
    -    target_version: &str,
    -) -> Result {
    -    let _guard = UPGRADE_LOCK
    -        .try_lock()
    -        .map_err(|_| AppError::new(ErrorCode::Busy, "OpenCode upgrade is already in progress"))?;
    -    guarded_upgrade(
    -        expected_binary_path,
    -        expected_version,
    -        target_version,
    -        |path| match path {
    -            Some(path) => inspect_path(path.to_path_buf(), dirs::home_dir().as_deref()),
    -            None => inspect_selected(),
    -        },
    -        fetch_latest,
    -        execute_upgrade,
    -    )
    -}
    -
    -#[cfg(test)]
    -mod tests;
    diff --git a/src-tauri/src/opencode_cli/island.rs b/src-tauri/src/opencode_cli/island.rs
    deleted file mode 100644
    index f0b0ec5..0000000
    --- a/src-tauri/src/opencode_cli/island.rs
    +++ /dev/null
    @@ -1,345 +0,0 @@
    -use std::collections::HashSet;
    -use std::sync::Arc;
    -use std::time::{Duration, Instant};
    -
    -use reqwest::{Method, RequestBuilder, Response};
    -use serde::{Deserialize, Serialize};
    -use serde_json::{Map, Value};
    -
    -use crate::error::AppError;
    -
    -use super::token_usage::transport::{self, ManagedAsyncConnection};
    -
    -mod events;
    -mod projection;
    -
    -pub(crate) use events::IslandChange;
    -pub(crate) type IslandEvents = events::IslandEvents;
    -
    -const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
    -const MAX_BYTES: usize = 2 * 1024 * 1024;
    -const MAX_SESSIONS: usize = 100;
    -const MAX_PENDING: usize = 100;
    -const MAX_FIELDS: usize = 32;
    -const MAX_OPTIONS: usize = 100;
    -const MAX_TEXT: usize = 16 * 1024;
    -const JS_MAX_DATE: u64 = 8_640_000_000_000_000;
    -
    -#[derive(Default, Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub(crate) struct IslandSnapshot {
    -    pub sessions: Vec,
    -    pub permissions: Vec,
    -    pub questions: Vec,
    -}
    -
    -#[derive(Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub(crate) struct IslandSession {
    -    pub id: String,
    -    pub title: String,
    -    pub directory: String,
    -    pub updated_at: u64,
    -    pub parent_id: Option,
    -    pub status: String,
    -}
    -
    -#[derive(Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub(crate) struct IslandPermission {
    -    pub id: String,
    -    pub session_id: String,
    -    pub action: String,
    -    pub resources: Vec,
    -    pub save: Vec,
    -    pub message: Option,
    -}
    -
    -#[derive(Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub(crate) struct IslandQuestion {
    -    pub id: String,
    -    pub session_id: String,
    -    pub title: String,
    -    pub answerable: bool,
    -    pub fields: Vec,
    -}
    -
    -#[derive(Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub(crate) struct IslandQuestionField {
    -    pub key: String,
    -    #[serde(rename = "type")]
    -    pub kind: String,
    -    pub title: Option,
    -    pub description: Option,
    -    pub options: Vec,
    -    pub custom: bool,
    -    pub required: bool,
    -}
    -
    -#[derive(Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub(crate) struct IslandQuestionOption {
    -    pub value: String,
    -    pub label: String,
    -    pub description: Option,
    -}
    -
    -#[derive(Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub(crate) struct IslandMessage {
    -    pub id: String,
    -    pub role: String,
    -    pub text: String,
    -    pub time: u64,
    -    pub truncated: bool,
    -}
    -
    -#[derive(Clone)]
    -pub(crate) struct IslandApi {
    -    connection: Arc,
    -}
    -
    -pub(crate) async fn connect() -> Result {
    -    let snapshot = super::target::snapshot()?;
    -    let deadline = Instant::now() + Duration::from_secs(20);
    -    let reservation = snapshot.reserve_side_effect(deadline)?;
    -    let connection = tauri::async_runtime::spawn_blocking(move || {
    -        // A dropped async caller cannot cancel a blocking bootstrap worker.
    -        let _reservation = reservation;
    -        transport::connect_snapshot(snapshot, deadline)?.into_async()
    -    })
    -    .await
    -    .map_err(|_| failure("connection worker failed"))??;
    -    Ok(IslandApi {
    -        connection: Arc::new(connection),
    -    })
    -}
    -
    -#[cfg(test)]
    -pub(crate) fn test_api(url: &str) -> IslandApi {
    -    IslandApi {
    -        connection: Arc::new(
    -            transport::test_connection(url, Instant::now() + REQUEST_TIMEOUT)
    -                .into_async()
    -                .unwrap(),
    -        ),
    -    }
    -}
    -
    -#[derive(Deserialize)]
    -struct Data {
    -    data: T,
    -}
    -
    -fn failure(kind: &str) -> AppError {
    -    AppError::configuration(format!("opencode island: {kind}."))
    -}
    -
    -fn invalid(kind: &str) -> AppError {
    -    AppError::validation(format!("opencode island: invalid {kind}."))
    -}
    -
    -fn identifier(value: &str, prefix: &str) -> Result<(), AppError> {
    -    if value.len() <= 256
    -        && value.strip_prefix(prefix).is_some_and(|suffix| {
    -            !suffix.is_empty()
    -                && suffix
    -                    .bytes()
    -                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
    -        })
    -    {
    -        Ok(())
    -    } else {
    -        Err(invalid("identifier"))
    -    }
    -}
    -
    -fn plain(value: &str, max: usize, multiline: bool) -> Result<(), AppError> {
    -    if value.len() > max
    -        || value
    -            .chars()
    -            .any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t')))
    -    {
    -        Err(invalid("plaintext"))
    -    } else {
    -        Ok(())
    -    }
    -}
    -
    -fn safe_time(value: u64) -> Result {
    -    if value <= JS_MAX_DATE {
    -        Ok(value)
    -    } else {
    -        Err(invalid("time"))
    -    }
    -}
    -
    -fn request_error(error: reqwest::Error) -> AppError {
    -    failure(if error.is_timeout() {
    -        "API request timed out"
    -    } else if error.is_connect() {
    -        "API connection failed"
    -    } else {
    -        "API request failed"
    -    })
    -}
    -
    -async fn body(mut response: Response) -> Result, AppError> {
    -    if response
    -        .content_length()
    -        .is_some_and(|size| size > MAX_BYTES as u64)
    -    {
    -        return Err(failure("response byte limit exceeded"));
    -    }
    -    let mut bytes = Vec::new();
    -    while let Some(chunk) = response.chunk().await.map_err(request_error)? {
    -        if bytes.len() + chunk.len() > MAX_BYTES {
    -            return Err(failure("response byte limit exceeded"));
    -        }
    -        bytes.extend_from_slice(&chunk);
    -    }
    -    Ok(bytes)
    -}
    -
    -impl IslandApi {
    -    fn request(&self, method: Method, path: &[&str]) -> Result {
    -        Ok(self
    -            .connection
    -            .request(method, path)?
    -            .timeout(REQUEST_TIMEOUT))
    -    }
    -
    -    async fn read Deserialize<'de>>(
    -        &self,
    -        path: &[&str],
    -        query: &[(&str, &str)],
    -    ) -> Result {
    -        let response = self
    -            .request(Method::GET, path)?
    -            .query(query)
    -            .send()
    -            .await
    -            .map_err(request_error)?;
    -        if response.status() != reqwest::StatusCode::OK {
    -            return Err(failure(&format!("API HTTP {}", response.status().as_u16())));
    -        }
    -        let value = transport::decode_json::>(
    -            &body(response).await?,
    -            "island response",
    -        )
    -        .map(|value| value.0)?;
    -        self.connection.ensure_current()?;
    -        Ok(value)
    -    }
    -
    -    async fn write(&self, request: RequestBuilder) -> Result<(), AppError> {
    -        let deadline = Instant::now() + REQUEST_TIMEOUT;
    -        let _reservation = self.connection.prepare_write(deadline).await?;
    -        let timeout = deadline
    -            .checked_duration_since(Instant::now())
    -            .filter(|timeout| !timeout.is_zero())
    -            .ok_or_else(|| failure("write timed out before dispatch"))?;
    -        let request = request.timeout(timeout);
    -        let response = request
    -            .send()
    -            .await
    -            .map_err(|_| failure("write outcome unknown; may have been accepted; do not retry"))?;
    -        let status = response.status();
    -        if !status.is_success() {
    -            if status.is_client_error() && status != reqwest::StatusCode::REQUEST_TIMEOUT {
    -                return Err(failure(&format!(
    -                    "API HTTP {}; write rejected",
    -                    status.as_u16()
    -                )));
    -            }
    -            return Err(failure(&format!(
    -                "API HTTP {}; write may have been accepted; do not retry",
    -                status.as_u16()
    -            )));
    -        }
    -        body(response)
    -            .await
    -            .map_err(|_| failure("write outcome unknown; may have been accepted; do not retry"))?;
    -        self.connection.ensure_current()?;
    -        Ok(())
    -    }
    -
    -    pub(crate) async fn send(
    -        &self,
    -        session_id: &str,
    -        text: &str,
    -        message_id: &str,
    -    ) -> Result<(), AppError> {
    -        identifier(session_id, "ses_")?;
    -        identifier(message_id, "msg_")?;
    -        plain(text, MAX_TEXT, true)?;
    -        if text.trim().is_empty() {
    -            return Err(invalid("message"));
    -        }
    -        self.write(
    -            self.request(Method::POST, &["session", session_id, "prompt"])?
    -                .json(&serde_json::json!({ "id": message_id, "text": text, "delivery": "queue" })),
    -        )
    -        .await
    -    }
    -
    -    pub(crate) async fn reply_permission(
    -        &self,
    -        session_id: &str,
    -        request_id: &str,
    -        decision: &str,
    -    ) -> Result<(), AppError> {
    -        identifier(session_id, "ses_")?;
    -        identifier(request_id, "per")?;
    -        if !matches!(decision, "once" | "always" | "reject") {
    -            return Err(invalid("permission decision"));
    -        }
    -        if decision == "always"
    -            && !self
    -                .permissions(session_id)
    -                .await
    -                .map_err(|_| invalid("pending permission"))?
    -                .iter()
    -                .any(|permission| {
    -                    permission.session_id == session_id
    -                        && permission.id == request_id
    -                        && !permission.save.is_empty()
    -                })
    -        {
    -            return Err(invalid("stale or unsavable permission"));
    -        }
    -        self.write(
    -            self.request(
    -                Method::POST,
    -                &["session", session_id, "permission", request_id, "reply"],
    -            )?
    -            .json(&serde_json::json!({ "decision": decision })),
    -        )
    -        .await
    -    }
    -
    -    pub(crate) async fn interrupt(&self, session_id: &str) -> Result<(), AppError> {
    -        identifier(session_id, "ses_")?;
    -        self.write(
    -            self.request(Method::POST, &["session", session_id, "interrupt"])?
    -                .query(&[("resume", "false")]),
    -        )
    -        .await
    -    }
    -
    -    pub(crate) async fn cancel_question(
    -        &self,
    -        session_id: &str,
    -        form_id: &str,
    -    ) -> Result<(), AppError> {
    -        identifier(session_id, "ses_")?;
    -        identifier(form_id, "frm_")?;
    -        self.write(self.request(Method::DELETE, &["session", session_id, "form", form_id])?)
    -            .await
    -    }
    -}
    -
    -#[cfg(test)]
    -mod tests;
    diff --git a/src-tauri/src/opencode_cli/island/events.rs b/src-tauri/src/opencode_cli/island/events.rs
    deleted file mode 100644
    index 5ec5982..0000000
    --- a/src-tauri/src/opencode_cli/island/events.rs
    +++ /dev/null
    @@ -1,190 +0,0 @@
    -use super::*;
    -
    -const MAX_FRAME: usize = 64 * 1024;
    -
    -#[derive(Debug, Clone, PartialEq, Eq)]
    -pub(crate) enum IslandChange {
    -    Snapshot,
    -    Messages { session_id: String },
    -}
    -
    -pub(crate) struct IslandEvents {
    -    response: Response,
    -    buffer: Vec,
    -    offset: usize,
    -    frame: Frame,
    -}
    -
    -#[derive(Default)]
    -pub(super) struct Frame {
    -    line: Vec,
    -    data: Vec,
    -    size: usize,
    -    skip_lf: bool,
    -}
    -
    -impl Frame {
    -    pub(super) fn byte(&mut self, byte: u8) -> Result>, AppError> {
    -        if self.skip_lf {
    -            self.skip_lf = false;
    -            if byte == b'\n' {
    -                return Ok(None);
    -            }
    -        }
    -        self.size += 1;
    -        if self.size > MAX_FRAME {
    -            return Err(failure("event frame byte limit exceeded"));
    -        }
    -        if !matches!(byte, b'\r' | b'\n') {
    -            self.line.push(byte);
    -            return Ok(None);
    -        }
    -        self.skip_lf = byte == b'\r';
    -        if self.line.is_empty() {
    -            self.size = 0;
    -            if self.data.last() == Some(&b'\n') {
    -                self.data.pop();
    -            }
    -            return Ok(Some(std::mem::take(&mut self.data)));
    -        }
    -        if let Some(data) = self.line.strip_prefix(b"data:") {
    -            self.data
    -                .extend_from_slice(data.strip_prefix(b" ").unwrap_or(data));
    -            self.data.push(b'\n');
    -        }
    -        self.line.clear();
    -        Ok(None)
    -    }
    -}
    -
    -impl IslandApi {
    -    pub(crate) async fn events(&self) -> Result {
    -        // Bound the handshake only; a quiet live stream must not become snapshot polling.
    -        let response = tokio::time::timeout(
    -            REQUEST_TIMEOUT,
    -            self.connection
    -                .request(Method::GET, &["event"])?
    -                .header(reqwest::header::ACCEPT, "text/event-stream")
    -                .send(),
    -        )
    -        .await
    -        .map_err(|_| failure("event connection timed out"))?
    -        .map_err(request_error)?;
    -        if response.status() != reqwest::StatusCode::OK {
    -            return Err(failure(&format!("API HTTP {}", response.status().as_u16())));
    -        }
    -        if response
    -            .headers()
    -            .get(reqwest::header::CONTENT_TYPE)
    -            .and_then(|value| value.to_str().ok())
    -            .and_then(|value| value.split(';').next())
    -            != Some("text/event-stream")
    -        {
    -            return Err(invalid("event content type"));
    -        }
    -        Ok(IslandEvents {
    -            response,
    -            buffer: Vec::new(),
    -            offset: 0,
    -            frame: Frame::default(),
    -        })
    -    }
    -}
    -
    -impl IslandEvents {
    -    pub(crate) async fn next(&mut self) -> Result {
    -        loop {
    -            while self.offset < self.buffer.len() {
    -                let byte = self.buffer[self.offset];
    -                self.offset += 1;
    -                if let Some(data) = self.frame.byte(byte)? {
    -                    if !data.is_empty() {
    -                        if let Some(change) = classify(&data)? {
    -                            return Ok(change);
    -                        }
    -                    }
    -                }
    -            }
    -            self.buffer.clear();
    -            self.offset = 0;
    -            let chunk = self
    -                .response
    -                .chunk()
    -                .await
    -                .map_err(request_error)?
    -                .ok_or_else(|| failure("event stream ended"))?;
    -            if chunk.len() > MAX_BYTES {
    -                return Err(failure("event chunk byte limit exceeded"));
    -            }
    -            self.buffer.extend_from_slice(&chunk);
    -        }
    -    }
    -}
    -
    -pub(super) fn classify(bytes: &[u8]) -> Result, AppError> {
    -    let event: Value = transport::decode_json(bytes, "island event")?;
    -    let kind = event
    -        .get("type")
    -        .and_then(Value::as_str)
    -        .ok_or_else(|| invalid("event type"))?;
    -    let snapshot = matches!(
    -        kind,
    -        "server.connected"
    -            | "session.created"
    -            | "session.deleted"
    -            | "session.forked"
    -            | "session.renamed"
    -            | "session.moved"
    -            | "session.status"
    -            | "session.idle"
    -            | "session.execution.started"
    -            | "session.execution.succeeded"
    -            | "session.execution.failed"
    -            | "session.execution.interrupted"
    -            | "permission.asked"
    -            | "permission.replied"
    -            | "form.created"
    -            | "form.replied"
    -            | "form.cancelled"
    -            | "location.shutdown"
    -    );
    -    let messages = matches!(
    -        kind,
    -        "session.text.started"
    -            | "session.text.delta"
    -            | "session.text.ended"
    -            | "session.message.content.updated"
    -            | "session.inbox.delivered"
    -    );
    -    if !snapshot && !messages {
    -        return Ok(None);
    -    }
    -    let data = event
    -        .get("data")
    -        .filter(|value| value.is_object())
    -        .ok_or_else(|| invalid("event data"))?;
    -    // Snapshot invalidation needs no routing ID; native lifecycle payloads differ by event.
    -    if snapshot && !kind.starts_with("form.") {
    -        return Ok(Some(IslandChange::Snapshot));
    -    }
    -    let data = if kind == "form.created" {
    -        data.get("form").ok_or_else(|| invalid("form event"))?
    -    } else {
    -        data
    -    };
    -    let session_id = data
    -        .get("sessionID")
    -        .and_then(Value::as_str)
    -        .ok_or_else(|| invalid("event session"))?;
    -    if session_id == "global" && kind.starts_with("form.") {
    -        return Ok(None);
    -    }
    -    identifier(session_id, "ses_")?;
    -    if snapshot {
    -        Ok(Some(IslandChange::Snapshot))
    -    } else {
    -        Ok(Some(IslandChange::Messages {
    -            session_id: session_id.to_owned(),
    -        }))
    -    }
    -}
    diff --git a/src-tauri/src/opencode_cli/island/projection.rs b/src-tauri/src/opencode_cli/island/projection.rs
    deleted file mode 100644
    index c8894c0..0000000
    --- a/src-tauri/src/opencode_cli/island/projection.rs
    +++ /dev/null
    @@ -1,654 +0,0 @@
    -use super::*;
    -
    -#[derive(Deserialize)]
    -struct Active {
    -    #[serde(deserialize_with = "super::super::active::running_sessions")]
    -    data: HashSet,
    -}
    -
    -#[derive(Deserialize)]
    -#[serde(rename_all = "camelCase")]
    -struct Session {
    -    id: String,
    -    title: Option,
    -    location: Location,
    -    outcome: Option,
    -    #[serde(rename = "parentID")]
    -    parent_id: Option,
    -    time: SessionTime,
    -}
    -
    -#[derive(Deserialize)]
    -struct Location {
    -    directory: String,
    -}
    -
    -#[derive(Deserialize)]
    -struct SessionTime {
    -    updated: u64,
    -    archived: Option,
    -}
    -
    -impl Session {
    -    fn public(self, running: bool) -> Result {
    -        identifier(&self.id, "ses_")?;
    -        if let Some(parent) = &self.parent_id {
    -            identifier(parent, "ses_")?;
    -        }
    -        let title = self.title.unwrap_or_default();
    -        plain(&title, 4096, false)?;
    -        plain(&self.location.directory, 4096, false)?;
    -        if self
    -            .outcome
    -            .as_deref()
    -            .is_some_and(|outcome| !matches!(outcome, "succeeded" | "failed" | "interrupted"))
    -        {
    -            return Err(invalid("session outcome"));
    -        }
    -        if let Some(time) = self.time.archived {
    -            safe_time(time)?;
    -        }
    -        Ok(IslandSession {
    -            id: self.id,
    -            title,
    -            directory: self.location.directory,
    -            updated_at: safe_time(self.time.updated)?,
    -            parent_id: self.parent_id,
    -            status: if running {
    -                "running"
    -            } else if self.outcome.as_deref() == Some("failed") {
    -                "error"
    -            } else {
    -                "idle"
    -            }
    -            .into(),
    -        })
    -    }
    -}
    -
    -async fn bounded>>(
    -    futures: impl IntoIterator,
    -    mut retain: impl FnMut(T) -> Result<(), AppError>,
    -) -> Result<(), AppError> {
    -    let mut input = futures.into_iter();
    -    let mut pending: Vec<_> = input.by_ref().take(4).map(Box::pin).collect();
    -    while !pending.is_empty() {
    -        let (index, result) = std::future::poll_fn(|context| {
    -            for (index, future) in pending.iter_mut().enumerate() {
    -                if let std::task::Poll::Ready(result) = future.as_mut().poll(context) {
    -                    return std::task::Poll::Ready((index, result));
    -                }
    -            }
    -            std::task::Poll::Pending
    -        })
    -        .await;
    -        retain(result?)?;
    -        if let Some(future) = input.next() {
    -            pending[index] = Box::pin(future);
    -        } else {
    -            drop(pending.swap_remove(index));
    -        }
    -    }
    -    Ok(())
    -}
    -
    -impl IslandApi {
    -    pub(super) async fn permissions(
    -        &self,
    -        session_id: &str,
    -    ) -> Result, AppError> {
    -        let pending: Data> = self
    -            .read(&["session", session_id, "permission"], &[])
    -            .await?;
    -        if pending.data.len() > MAX_PENDING {
    -            return Err(failure("permission display limit exceeded"));
    -        }
    -        let mut ids = HashSet::new();
    -        pending
    -            .data
    -            .into_iter()
    -            .map(|request| {
    -                let id = string(&request, "id")?;
    -                identifier(id, "per")?;
    -                if !ids.insert(id.to_owned()) || string(&request, "sessionID")? != session_id {
    -                    return Err(invalid("permission identity"));
    -                }
    -                let action = string(&request, "action")?;
    -                plain(action, 1024, false)?;
    -                if action.is_empty() {
    -                    return Err(invalid("permission action"));
    -                }
    -                Ok(IslandPermission {
    -                    id: id.to_owned(),
    -                    session_id: session_id.to_owned(),
    -                    action: action.to_owned(),
    -                    resources: bounded_strings(
    -                        request
    -                            .get("resources")
    -                            .ok_or_else(|| invalid("permission resources"))?,
    -                        MAX_OPTIONS,
    -                    )?,
    -                    save: request
    -                        .get("save")
    -                        .map(|value| bounded_strings(value, MAX_OPTIONS))
    -                        .transpose()?
    -                        .unwrap_or_default(),
    -                    message: optional_string(&request, "message", 4096)?,
    -                })
    -            })
    -            .collect()
    -    }
    -
    -    async fn questions(&self, session_id: &str) -> Result, AppError> {
    -        let pending: Data> = self.read(&["session", session_id, "form"], &[]).await?;
    -        if pending.data.len() > MAX_PENDING {
    -            return Err(failure("form display limit exceeded"));
    -        }
    -        let mut ids = HashSet::new();
    -        pending
    -            .data
    -            .into_iter()
    -            .map(|form| {
    -                let question = public_question(&form, session_id)?;
    -                if !ids.insert(question.id.clone()) {
    -                    return Err(invalid("duplicate form"));
    -                }
    -                Ok(question)
    -            })
    -            .collect()
    -    }
    -
    -    pub(crate) async fn reply_question(
    -        &self,
    -        session_id: &str,
    -        form_id: &str,
    -        mut answer: Map,
    -    ) -> Result<(), AppError> {
    -        identifier(session_id, "ses_")?;
    -        identifier(form_id, "frm_")?;
    -        if answer.len() > MAX_FIELDS {
    -            return Err(invalid("question answer limit"));
    -        }
    -        let current: Data = self
    -            .read(&["session", session_id, "form", form_id], &[])
    -            .await?;
    -        if current
    -            .data
    -            .get("state")
    -            .and_then(|state| state.get("status"))
    -            .and_then(Value::as_str)
    -            != Some("pending")
    -        {
    -            return Err(invalid("stale question"));
    -        }
    -        let question = public_question(¤t.data, session_id)?;
    -        if question.id != form_id {
    -            return Err(invalid("form identity"));
    -        }
    -        answer_valid(&question, &mut answer)?;
    -        self.write(
    -            self.request(
    -                Method::POST,
    -                &["session", session_id, "form", form_id, "reply"],
    -            )?
    -            .json(&serde_json::json!({"answer":answer})),
    -        )
    -        .await
    -    }
    -
    -    pub(crate) async fn snapshot(&self) -> Result {
    -        let snapshot = tokio::time::timeout(Duration::from_secs(15), self.snapshot_inner())
    -            .await
    -            .map_err(|_| failure("snapshot timed out"))??;
    -        self.connection.ensure_current()?;
    -        Ok(snapshot)
    -    }
    -
    -    async fn snapshot_inner(&self) -> Result {
    -        let recent: Data> = self
    -            .read(
    -                &["session"],
    -                &[("limit", "20"), ("order", "desc"), ("parentID", "null")],
    -            )
    -            .await?;
    -        if recent.data.len() > 20 {
    -            return Err(failure("recent session limit exceeded"));
    -        }
    -        let active: Active = self.read(&["session", "active"], &[]).await?;
    -        if active.data.len() > MAX_SESSIONS {
    -            return Err(failure("active session display limit exceeded"));
    -        }
    -        for id in &active.data {
    -            identifier(id, "ses_")?;
    -        }
    -        let mut seen = HashSet::new();
    -        let mut sessions = Vec::new();
    -        for session in recent.data {
    -            if session.parent_id.is_some() || !seen.insert(session.id.clone()) {
    -                return Err(invalid("recent root session"));
    -            }
    -            let archived = session.time.archived.is_some();
    -            let running = active.data.contains(&session.id);
    -            let session = session.public(running)?;
    -            if !archived || running {
    -                sessions.push(session);
    -            }
    -        }
    -        let missing: Vec<_> = active
    -            .data
    -            .iter()
    -            .filter(|id| !sessions.iter().any(|session| &session.id == *id))
    -            .cloned()
    -            .collect();
    -        if sessions.len() + missing.len() > MAX_SESSIONS {
    -            return Err(failure("session display limit exceeded"));
    -        }
    -        let details: Vec<_> = missing
    -            .into_iter()
    -            .map(|id| async move {
    -                let session: Data = self.read(&["session", &id], &[]).await?;
    -                if session.data.id != id {
    -                    return Err(invalid("session identity"));
    -                }
    -                session.data.public(true)
    -            })
    -            .collect();
    -        bounded(details, |session| {
    -            sessions.push(session);
    -            Ok(())
    -        })
    -        .await?;
    -        let mut snapshot = IslandSnapshot {
    -            sessions,
    -            ..IslandSnapshot::default()
    -        };
    -        let mut retained_bytes = serde_json::to_vec(&snapshot)
    -            .map_err(|_| failure("snapshot encoding failed"))?
    -            .len();
    -        if retained_bytes > MAX_BYTES {
    -            return Err(failure("snapshot retained byte limit exceeded"));
    -        }
    -        // Recent roots are checked once too: pending work is not inferred from connection state.
    -        let ids: Vec<_> = snapshot
    -            .sessions
    -            .iter()
    -            .map(|session| session.id.clone())
    -            .collect();
    -        let pending: Vec<_> = ids
    -            .into_iter()
    -            .map(|id| async move {
    -                let permissions = self.permissions(&id).await?;
    -                let questions = self.questions(&id).await?;
    -                Ok((permissions, questions))
    -            })
    -            .collect();
    -        bounded(pending, |(permissions, questions)| {
    -            if snapshot.permissions.len()
    -                + snapshot.questions.len()
    -                + permissions.len()
    -                + questions.len()
    -                > MAX_PENDING
    -            {
    -                return Err(failure("pending request display limit exceeded"));
    -            }
    -            retained_bytes += serde_json::to_vec(&(&permissions, &questions))
    -                .map_err(|_| failure("snapshot encoding failed"))?
    -                .len();
    -            if retained_bytes > MAX_BYTES {
    -                return Err(failure("snapshot retained byte limit exceeded"));
    -            }
    -            snapshot.permissions.extend(permissions);
    -            snapshot.questions.extend(questions);
    -            Ok(())
    -        })
    -        .await?;
    -        let pending_ids: HashSet<_> = snapshot
    -            .permissions
    -            .iter()
    -            .map(|permission| permission.session_id.as_str())
    -            .chain(
    -                snapshot
    -                    .questions
    -                    .iter()
    -                    .map(|question| question.session_id.as_str()),
    -            )
    -            .collect();
    -        snapshot.sessions.sort_by(|left, right| {
    -            let priority = |session: &IslandSession| {
    -                if pending_ids.contains(session.id.as_str()) {
    -                    0
    -                } else if session.status == "running" {
    -                    1
    -                } else {
    -                    2
    -                }
    -            };
    -            priority(left)
    -                .cmp(&priority(right))
    -                .then_with(|| right.updated_at.cmp(&left.updated_at))
    -                .then_with(|| left.id.cmp(&right.id))
    -        });
    -        Ok(snapshot)
    -    }
    -
    -    pub(crate) async fn messages(&self, session_id: &str) -> Result, AppError> {
    -        identifier(session_id, "ses_")?;
    -        let messages: Data> = self
    -            .read(
    -                &["session", session_id, "message"],
    -                &[("limit", "20"), ("order", "desc")],
    -            )
    -            .await?;
    -        public_messages(messages.data)
    -    }
    -}
    -
    -pub(super) fn public_question(form: &Value, session_id: &str) -> Result {
    -    let id = string(form, "id")?;
    -    identifier(id, "frm_")?;
    -    if string(form, "sessionID")? != session_id {
    -        return Err(invalid("form owner"));
    -    }
    -    let title = string(form, "title")?;
    -    plain(title, 4096, true)?;
    -    let fields = form
    -        .get("fields")
    -        .and_then(Value::as_array)
    -        .ok_or_else(|| invalid("form fields"))?;
    -    if fields.len() > MAX_FIELDS {
    -        return Err(failure("form field display limit exceeded"));
    -    }
    -    let mut question = IslandQuestion {
    -        id: id.to_owned(),
    -        session_id: session_id.to_owned(),
    -        title: title.to_owned(),
    -        answerable: !fields.is_empty()
    -            && form
    -                .get("metadata")
    -                .and_then(|metadata| metadata.get("kind"))
    -                .and_then(Value::as_str)
    -                == Some("question"),
    -        fields: Vec::new(),
    -    };
    -    let mut keys = HashSet::new();
    -    for field in fields {
    -        let key = string(field, "key")?;
    -        plain(key, 256, false)?;
    -        if key.is_empty() || !keys.insert(key.to_owned()) {
    -            return Err(invalid("form field key"));
    -        }
    -        let kind = string(field, "type")?;
    -        plain(kind, 64, false)?;
    -        let mut required = public_flag(field, "required")?;
    -        let conditional = match field.get("when") {
    -            None | Some(Value::Null) => false,
    -            Some(Value::Array(conditions)) => !conditions.is_empty(),
    -            _ => return Err(invalid("form conditions")),
    -        };
    -        let selection_minimum = match field.get("minItems") {
    -            None | Some(Value::Null) => false,
    -            Some(value) if kind == "multiselect" && value.as_u64() == Some(0) => false,
    -            Some(value) if kind == "multiselect" && value.as_u64() == Some(1) => {
    -                required = true;
    -                false
    -            }
    -            Some(_) => true,
    -        };
    -        // Constraints/conditions and non-question forms require the full OpenCode client.
    -        if !matches!(kind, "string" | "multiselect")
    -            || public_flag(field, "hidden")?
    -            || conditional
    -            || selection_minimum
    -            || ["format", "minLength", "maxLength", "pattern", "maxItems"]
    -                .iter()
    -                .any(|key| field.get(*key).is_some_and(|value| !value.is_null()))
    -        {
    -            question.answerable = false;
    -        }
    -        let mut options = Vec::new();
    -        let mut values = HashSet::new();
    -        if let Some(list) = field.get("options").filter(|value| !value.is_null()) {
    -            let list = list.as_array().ok_or_else(|| invalid("form options"))?;
    -            if list.len() > MAX_OPTIONS {
    -                return Err(failure("form option display limit exceeded"));
    -            }
    -            for option in list {
    -                let value = string(option, "value")?;
    -                let label = string(option, "label")?;
    -                plain(value, 4096, true)?;
    -                plain(label, 4096, true)?;
    -                if !values.insert(value.to_owned()) {
    -                    return Err(invalid("duplicate form option"));
    -                }
    -                options.push(IslandQuestionOption {
    -                    value: value.to_owned(),
    -                    label: label.to_owned(),
    -                    description: optional_string(option, "description", 4096)?,
    -                });
    -            }
    -        } else if kind == "multiselect" {
    -            question.answerable = false;
    -        }
    -        let custom = match field.get("custom") {
    -            None | Some(Value::Null) => options.is_empty(),
    -            Some(Value::Bool(custom)) => *custom,
    -            _ => return Err(invalid("form custom flag")),
    -        };
    -        question.fields.push(IslandQuestionField {
    -            key: key.to_owned(),
    -            kind: kind.to_owned(),
    -            title: optional_string(field, "title", 4096)?,
    -            description: optional_string(field, "description", 4096)?,
    -            options,
    -            custom,
    -            required,
    -        });
    -    }
    -    if !question.answerable {
    -        question.fields.clear();
    -    }
    -    Ok(question)
    -}
    -
    -pub(super) fn public_messages(messages: Vec) -> Result, AppError> {
    -    if messages.len() > 20 {
    -        return Err(failure("message display limit exceeded"));
    -    }
    -    let mut result = Vec::new();
    -    let mut ids = HashSet::new();
    -    for message in messages {
    -        let id = string(&message, "id")?;
    -        identifier(id, "msg_")?;
    -        if !ids.insert(id.to_owned()) {
    -            return Err(invalid("duplicate message"));
    -        }
    -        let role = string(&message, "type")?;
    -        plain(role, 64, false)?;
    -        if !matches!(role, "user" | "assistant") {
    -            continue;
    -        }
    -        if private_content(&message)? {
    -            continue;
    -        }
    -        let time = safe_time(
    -            message
    -                .get("time")
    -                .and_then(|time| time.get("created"))
    -                .and_then(Value::as_u64)
    -                .ok_or_else(|| invalid("message time"))?,
    -        )?;
    -        let mut text = String::new();
    -        let mut truncated = false;
    -        let mut append = |value: &str| -> Result<(), AppError> {
    -            plain(value, MAX_BYTES, true)?;
    -            let (piece, cut) = trim_text(value, MAX_TEXT - text.len());
    -            text.push_str(piece);
    -            truncated |= cut;
    -            Ok(())
    -        };
    -        if role == "user" {
    -            append(string(&message, "text")?)?;
    -        } else {
    -            let content = message
    -                .get("content")
    -                .and_then(Value::as_array)
    -                .ok_or_else(|| invalid("assistant content"))?;
    -            if content.len() > 256 {
    -                return Err(failure("message content item limit exceeded"));
    -            }
    -            for part in content {
    -                if part.get("type").and_then(Value::as_str) == Some("text")
    -                    && !private_content(part)?
    -                {
    -                    append(string(part, "text")?)?;
    -                }
    -            }
    -        }
    -        if !text.is_empty() {
    -            result.push(IslandMessage {
    -                id: id.to_owned(),
    -                role: role.to_owned(),
    -                text,
    -                time,
    -                truncated,
    -            });
    -        }
    -    }
    -    result.sort_by(|left, right| {
    -        left.time
    -            .cmp(&right.time)
    -            .then_with(|| left.id.cmp(&right.id))
    -    });
    -    Ok(result)
    -}
    -
    -fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str, AppError> {
    -    value
    -        .get(key)
    -        .and_then(Value::as_str)
    -        .ok_or_else(|| invalid("response field"))
    -}
    -
    -fn optional_string(value: &Value, key: &str, max: usize) -> Result, AppError> {
    -    match value.get(key) {
    -        None | Some(Value::Null) => Ok(None),
    -        Some(Value::String(text)) => {
    -            plain(text, max, true)?;
    -            Ok(Some(text.clone()))
    -        }
    -        _ => Err(invalid("response field")),
    -    }
    -}
    -
    -fn bounded_strings(value: &Value, max_items: usize) -> Result, AppError> {
    -    let values = value.as_array().ok_or_else(|| invalid("response list"))?;
    -    if values.len() > max_items {
    -        return Err(failure("response item limit exceeded"));
    -    }
    -    values
    -        .iter()
    -        .map(|value| {
    -            let text = value.as_str().ok_or_else(|| invalid("response field"))?;
    -            plain(text, 4096, true)?;
    -            Ok(text.to_owned())
    -        })
    -        .collect()
    -}
    -
    -fn trim_text(text: &str, remaining: usize) -> (&str, bool) {
    -    let mut end = text.len().min(remaining);
    -    while !text.is_char_boundary(end) {
    -        end -= 1;
    -    }
    -    (&text[..end], end < text.len())
    -}
    -
    -fn public_flag(value: &Value, key: &str) -> Result {
    -    match value.get(key) {
    -        None | Some(Value::Null) => Ok(false),
    -        Some(Value::Bool(flag)) => Ok(*flag),
    -        _ => Err(invalid("public content flag")),
    -    }
    -}
    -
    -fn private_content(value: &Value) -> Result {
    -    for flags in
    -        std::iter::once(value).chain(value.get("metadata").filter(|metadata| !metadata.is_null()))
    -    {
    -        if !flags.is_object() {
    -            return Err(invalid("content metadata"));
    -        }
    -        for key in ["hidden", "synthetic", "internal"] {
    -            if public_flag(flags, key)? {
    -                return Ok(true);
    -            }
    -        }
    -    }
    -    Ok(false)
    -}
    -
    -fn answer_valid(
    -    question: &IslandQuestion,
    -    answer: &mut Map,
    -) -> Result<(), AppError> {
    -    if !question.answerable || answer.len() > MAX_FIELDS {
    -        return Err(invalid("question answer"));
    -    }
    -    for key in answer.keys() {
    -        if !question.fields.iter().any(|field| field.key == *key) {
    -            return Err(invalid("question answer key"));
    -        }
    -    }
    -    for field in &question.fields {
    -        if !field.required
    -            && answer.get(&field.key).is_some_and(|value| {
    -                (field.kind == "string" && value.as_str() == Some(""))
    -                    || (field.kind == "multiselect" && value.as_array().is_some_and(Vec::is_empty))
    -            })
    -        {
    -            answer.remove(&field.key);
    -            continue;
    -        }
    -        let Some(value) = answer.get(&field.key) else {
    -            if field.required {
    -                return Err(invalid("required question answer"));
    -            }
    -            continue;
    -        };
    -        let values = match field.kind.as_str() {
    -            "string" => vec![value
    -                .as_str()
    -                .ok_or_else(|| invalid("question answer type"))?],
    -            "multiselect" => {
    -                let values = value
    -                    .as_array()
    -                    .ok_or_else(|| invalid("question answer type"))?;
    -                if values.len() > MAX_OPTIONS {
    -                    return Err(invalid("question answer limit"));
    -                }
    -                values
    -                    .iter()
    -                    .map(|value| {
    -                        value
    -                            .as_str()
    -                            .ok_or_else(|| invalid("question answer type"))
    -                    })
    -                    .collect::, _>>()?
    -            }
    -            _ => return Err(invalid("question answer type")),
    -        };
    -        if field.required && (values.is_empty() || values.iter().any(|v| v.trim().is_empty())) {
    -            return Err(invalid("required question answer"));
    -        }
    -        let mut unique = HashSet::new();
    -        for value in values {
    -            plain(value, 4096, true)?;
    -            if !unique.insert(value)
    -                || (!field.custom && !field.options.iter().any(|option| option.value == value))
    -            {
    -                return Err(invalid("question answer option"));
    -            }
    -        }
    -    }
    -    Ok(())
    -}
    diff --git a/src-tauri/src/opencode_cli/mod.rs b/src-tauri/src/opencode_cli/mod.rs
    deleted file mode 100644
    index d72c1a3..0000000
    --- a/src-tauri/src/opencode_cli/mod.rs
    +++ /dev/null
    @@ -1,17 +0,0 @@
    -mod active;
    -mod binary;
    -mod command;
    -pub(crate) mod installation;
    -pub(crate) mod island;
    -mod models;
    -mod reload;
    -pub(crate) mod target;
    -mod token_usage;
    -
    -pub use active::active_sessions_via_cli;
    -pub use binary::resolve_opencode_binary;
    -pub use installation::{OpenCodeInstallation, OpenCodeUpdateCheck};
    -pub use models::{list_models_via_cli, ModelCatalogEntry};
    -pub use reload::reload_via_cli;
    -pub use target::{OpenCodeServiceStatus, OpenCodeServiceTarget};
    -pub use token_usage::{token_usage_records_via_cli, TokenUsageRecord};
    diff --git a/src-tauri/src/opencode_cli/models.rs b/src-tauri/src/opencode_cli/models.rs
    deleted file mode 100644
    index 22cf0b1..0000000
    --- a/src-tauri/src/opencode_cli/models.rs
    +++ /dev/null
    @@ -1,145 +0,0 @@
    -use std::collections::BTreeSet;
    -use std::time::{Duration, Instant};
    -
    -use serde::{Deserialize, Serialize};
    -use serde_json::Value;
    -
    -use crate::error::AppError;
    -use crate::paths::ConfigPaths;
    -
    -use super::token_usage::transport::{self, ManagedConnection};
    -
    -/// Frontend-facing catalog entry; local config remains authoritative for metadata.
    -#[derive(Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub struct ModelCatalogEntry {
    -    pub provider_id: String,
    -    pub model_id: String,
    -    #[serde(rename = "ref")]
    -    pub model_ref: String,
    -    pub name: String,
    -    pub is_custom: bool,
    -    pub limit: Option,
    -    pub variants: Option,
    -}
    -
    -#[derive(Deserialize)]
    -struct Catalog {
    -    data: Vec,
    -}
    -
    -#[derive(Deserialize)]
    -#[serde(rename_all = "camelCase")]
    -struct ApiModel {
    -    id: String,
    -    #[serde(rename = "providerID")]
    -    provider_id: String,
    -    #[serde(rename = "modelID")]
    -    model_id: String,
    -    enabled: bool,
    -}
    -
    -pub(super) fn model_refs(
    -    connection: &ManagedConnection,
    -    deadline: Instant,
    -) -> Result, AppError> {
    -    let directory = connection.directory().and_then(|path| path.to_str());
    -    let query: Vec<_> = directory
    -        .into_iter()
    -        .map(|directory| ("location[directory]", directory))
    -        .collect();
    -    let catalog: super::active::JsonObject = connection.get_json(
    -        &["model"],
    -        &query,
    -        deadline,
    -        8 * 1024 * 1024,
    -        "model catalog",
    -        "8 MiB",
    -    )?;
    -    let mut refs = BTreeSet::new();
    -    for model in catalog.0.data {
    -        if [&model.id, &model.provider_id, &model.model_id]
    -            .into_iter()
    -            .any(|value| {
    -                value.is_empty()
    -                    || value.len() > 4096
    -                    || value.contains(char::is_whitespace)
    -                    || value.chars().any(char::is_control)
    -            })
    -            || model.provider_id.contains('/')
    -        {
    -            return Err(AppError::configuration(
    -                "Invalid OpenCode model catalog identity",
    -            ));
    -        }
    -        if model.enabled {
    -            refs.insert(format!("{}/{}", model.provider_id, model.model_id));
    -        }
    -    }
    -    Ok(refs)
    -}
    -
    -/// Lists enabled models from the selected local service plus all configured models.
    -pub fn list_models_via_cli(
    -    paths: &ConfigPaths,
    -    provider_filter: Option<&str>,
    -) -> Result, AppError> {
    -    let deadline = Instant::now() + Duration::from_secs(20);
    -    let connection = transport::connect(deadline)?;
    -    list_models(paths, provider_filter, &connection, deadline)
    -}
    -
    -fn list_models(
    -    paths: &ConfigPaths,
    -    provider_filter: Option<&str>,
    -    connection: &ManagedConnection,
    -    deadline: Instant,
    -) -> Result, AppError> {
    -    let mut refs = model_refs(connection, deadline)?;
    -
    -    let config_providers = crate::providers::list_providers(&paths.opencode_file())?;
    -    let custom_ids = crate::providers::custom_provider_ids(&paths.opencode_file())?;
    -
    -    // Config models are authoritative for metadata and must stay listed.
    -    for provider in &config_providers {
    -        for model_id in provider.models.keys() {
    -            refs.insert(format!("{}/{}", provider.name, model_id));
    -        }
    -    }
    -
    -    let mut catalog = Vec::with_capacity(refs.len());
    -    for model_ref in refs {
    -        let Some((provider_id, model_id)) = model_ref.split_once('/') else {
    -            continue;
    -        };
    -        if provider_filter.is_some_and(|filter| provider_id != filter) {
    -            continue;
    -        }
    -        let config_model = config_providers
    -            .iter()
    -            .find(|provider| provider.name == provider_id)
    -            .and_then(|provider| provider.models.get(model_id));
    -        let name = config_model
    -            .and_then(|model| model.name.clone())
    -            .unwrap_or_else(|| model_id.to_owned());
    -        let is_custom = custom_ids.contains(provider_id);
    -        catalog.push(ModelCatalogEntry {
    -            provider_id: provider_id.to_owned(),
    -            model_id: model_id.to_owned(),
    -            model_ref,
    -            name,
    -            is_custom,
    -            limit: config_model
    -                .and_then(|model| model.limit.clone())
    -                .and_then(|limit| serde_json::to_value(limit).ok()),
    -            variants: config_model
    -                .and_then(|model| model.variants.clone())
    -                .and_then(|variants| serde_json::to_value(variants).ok()),
    -        });
    -    }
    -    connection.ensure_current()?;
    -    Ok(catalog)
    -}
    -
    -#[cfg(test)]
    -mod tests;
    diff --git a/src-tauri/src/opencode_cli/reload.rs b/src-tauri/src/opencode_cli/reload.rs
    deleted file mode 100644
    index cb3696e..0000000
    --- a/src-tauri/src/opencode_cli/reload.rs
    +++ /dev/null
    @@ -1,13 +0,0 @@
    -use std::time::{Duration, Instant};
    -
    -use crate::error::AppError;
    -
    -use super::token_usage::transport;
    -
    -pub fn reload_via_cli() -> Result<(), AppError> {
    -    let deadline = Instant::now() + Duration::from_secs(20);
    -    transport::connect(deadline)?.reload(deadline)
    -}
    -
    -#[cfg(test)]
    -mod tests;
    diff --git a/src-tauri/src/opencode_cli/target.rs b/src-tauri/src/opencode_cli/target.rs
    deleted file mode 100644
    index f01e4cb..0000000
    --- a/src-tauri/src/opencode_cli/target.rs
    +++ /dev/null
    @@ -1,558 +0,0 @@
    -use std::fs::{self, File, OpenOptions};
    -use std::io::{Read, Write};
    -use std::path::{Path, PathBuf};
    -use std::sync::atomic::{AtomicUsize, Ordering};
    -use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock};
    -
    -use serde::{Deserialize, Serialize};
    -
    -use crate::error::{AppError, ErrorCode};
    -use crate::paths::ConfigPaths;
    -
    -#[cfg(not(windows))]
    -const MAX_BYTES: u64 = 8192;
    -#[cfg(windows)]
    -const MAX_BYTES: u64 = windows::MAX_FILE_BYTES as u64;
    -
    -#[cfg(any(windows, test))]
    -mod windows;
    -static RUNTIME: OnceLock> = OnceLock::new();
    -static TRANSITION: Mutex<()> = Mutex::new(());
    -
    -#[derive(Clone, Deserialize, Serialize, Default)]
    -#[serde(deny_unknown_fields)]
    -pub(super) struct Selection {
    -    #[serde(deserialize_with = "nullable_string")]
    -    pub(super) url: Option,
    -    #[serde(deserialize_with = "nullable_string")]
    -    pub(super) password: Option,
    -}
    -
    -fn nullable_string<'de, D: serde::Deserializer<'de>>(
    -    deserializer: D,
    -) -> Result, D::Error> {
    -    Option::deserialize(deserializer)
    -}
    -
    -#[derive(Debug, Clone, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub struct OpenCodeServiceTarget {
    -    pub mode: String,
    -    pub url: Option,
    -    pub has_password: bool,
    -}
    -
    -#[derive(Debug, Serialize)]
    -#[serde(rename_all = "camelCase")]
    -pub struct OpenCodeServiceStatus {
    -    pub target: OpenCodeServiceTarget,
    -    pub connected: bool,
    -    pub url: Option,
    -    pub pid: Option,
    -    pub version: Option,
    -    pub error: Option,
    -}
    -
    -impl Selection {
    -    pub(super) fn public(&self) -> OpenCodeServiceTarget {
    -        OpenCodeServiceTarget {
    -            mode: if self.url.is_some() {
    -                "explicit"
    -            } else {
    -                "shared"
    -            }
    -            .into(),
    -            url: self.url.clone(),
    -            has_password: self.password.is_some(),
    -        }
    -    }
    -}
    -
    -struct State {
    -    selection: Result,
    -    revision: u64,
    -}
    -
    -pub(crate) struct TargetRuntime {
    -    path: PathBuf,
    -    pub(super) registration: PathBuf,
    -    pub(super) directory: PathBuf,
    -    state: RwLock,
    -    in_flight: AtomicUsize,
    -}
    -
    -pub(super) struct SideEffectReservation(Option>);
    -
    -impl Drop for SideEffectReservation {
    -    fn drop(&mut self) {
    -        if let Some(runtime) = &self.0 {
    -            runtime.in_flight.fetch_sub(1, Ordering::SeqCst);
    -        }
    -    }
    -}
    -
    -#[derive(Clone)]
    -pub(super) struct Snapshot {
    -    pub(super) selection: Selection,
    -    pub(super) revision: u64,
    -    runtime: Option>,
    -}
    -
    -impl Snapshot {
    -    pub(super) fn reserve_side_effect(
    -        &self,
    -        deadline: std::time::Instant,
    -    ) -> Result {
    -        let _transition = transition_until(deadline)?;
    -        self.ensure_current()?;
    -        if let Some(runtime) = &self.runtime {
    -            runtime.in_flight.fetch_add(1, Ordering::SeqCst);
    -        }
    -        Ok(SideEffectReservation(self.runtime.clone()))
    -    }
    -
    -    pub(super) fn ensure_current(&self) -> Result<(), AppError> {
    -        if let Some(runtime) = &self.runtime {
    -            let state = runtime
    -                .state
    -                .read()
    -                .map_err(|_| failure("state unavailable"))?;
    -            if state.revision != self.revision {
    -                return Err(failure("target changed; retry the operation"));
    -            }
    -        }
    -        Ok(())
    -    }
    -
    -    pub(super) fn directory(&self) -> Option<&Path> {
    -        self.runtime
    -            .as_ref()
    -            .map(|runtime| runtime.directory.as_path())
    -    }
    -
    -    pub(super) fn registration(&self) -> Result<&Path, AppError> {
    -        self.runtime
    -            .as_ref()
    -            .map(|runtime| runtime.registration.as_path())
    -            .ok_or_else(|| failure("registration path unavailable"))
    -    }
    -
    -    pub(super) fn detached(selection: Selection) -> Self {
    -        Self {
    -            selection,
    -            revision: 0,
    -            runtime: None,
    -        }
    -    }
    -}
    -
    -pub(crate) fn transition() -> Result, AppError> {
    -    TRANSITION
    -        .lock()
    -        .map_err(|_| failure("transition unavailable"))
    -}
    -
    -pub(super) fn transition_until(
    -    deadline: std::time::Instant,
    -) -> Result, AppError> {
    -    loop {
    -        if std::time::Instant::now() >= deadline {
    -            return Err(failure("transition timed out"));
    -        }
    -        match TRANSITION.try_lock() {
    -            Ok(guard) => return Ok(guard),
    -            Err(std::sync::TryLockError::Poisoned(_)) => {
    -                return Err(failure("transition unavailable"))
    -            }
    -            Err(std::sync::TryLockError::WouldBlock) => {
    -                std::thread::sleep(std::time::Duration::from_millis(5))
    -            }
    -        }
    -    }
    -}
    -
    -fn failure(reason: &str) -> AppError {
    -    AppError::configuration(format!("OpenCode service target: {reason}."))
    -}
    -
    -pub(super) fn normalize_url(value: &str) -> Result {
    -    let invalid = || {
    -        AppError::validation("Use HTTP localhost, 127.0.0.1 or [::1] with an explicit positive port and no path, credentials, query or fragment")
    -    };
    -    if value.len() > 256 || value.chars().any(char::is_control) {
    -        return Err(invalid());
    -    }
    -    let authority = value.strip_prefix("http://").ok_or_else(invalid)?;
    -    let authority = authority.strip_suffix('/').unwrap_or(authority);
    -    let (host, port) = authority.rsplit_once(':').ok_or_else(invalid)?;
    -    if !matches!(host, "localhost" | "127.0.0.1" | "[::1]")
    -        || port.is_empty()
    -        || !port.bytes().all(|byte| byte.is_ascii_digit())
    -    {
    -        return Err(invalid());
    -    }
    -    let port = port
    -        .parse::()
    -        .ok()
    -        .filter(|port| *port != 0)
    -        .ok_or_else(invalid)?;
    -    Ok(format!(
    -        "http://{}:{port}",
    -        if host == "localhost" {
    -            "127.0.0.1"
    -        } else {
    -            host
    -        }
    -    ))
    -}
    -
    -fn password_valid(password: &str) -> bool {
    -    password.len() <= 4096 && !password.chars().any(char::is_control)
    -}
    -
    -fn updated(
    -    old: Option<&Selection>,
    -    url: Option,
    -    password: Option,
    -) -> Result {
    -    let Some(url) = url else {
    -        return Ok(Selection::default());
    -    };
    -    let url = normalize_url(&url)?;
    -    if password
    -        .as_deref()
    -        .is_some_and(|password| !password_valid(password))
    -    {
    -        return Err(AppError::validation(
    -            "Service password must be at most 4096 bytes without control characters",
    -        ));
    -    }
    -    let password = match password {
    -        Some(password) => (!password.is_empty()).then_some(password),
    -        None => old
    -            .filter(|old| old.url.as_deref() == Some(&url))
    -            .and_then(|old| old.password.clone()),
    -    };
    -    Ok(Selection {
    -        url: Some(url),
    -        password,
    -    })
    -}
    -
    -fn load(path: &Path) -> Result {
    -    let mut options = OpenOptions::new();
    -    options.read(true);
    -    #[cfg(unix)]
    -    {
    -        use std::os::unix::fs::OpenOptionsExt;
    -        options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW);
    -    }
    -    #[cfg(windows)]
    -    {
    -        use std::os::windows::fs::OpenOptionsExt;
    -        options.custom_flags(0x00200000);
    -    }
    -    let file = match options.open(path) {
    -        Ok(file) => file,
    -        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
    -            return Ok(Selection::default())
    -        }
    -        Err(_) => return Err(failure("private configuration unreadable")),
    -    };
    -    let metadata = file
    -        .metadata()
    -        .map_err(|_| failure("private configuration unreadable"))?;
    -    if !metadata.is_file() || metadata.len() > MAX_BYTES {
    -        return Err(failure("configuration must be a bounded regular file"));
    -    }
    -    #[cfg(windows)]
    -    {
    -        use std::os::windows::fs::MetadataExt;
    -        if metadata.file_attributes() & 0x400 != 0 {
    -            return Err(failure("configuration reparse points are not supported"));
    -        }
    -    }
    -    #[cfg(unix)]
    -    {
    -        use std::os::unix::fs::MetadataExt;
    -        if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o777 != 0o600 {
    -            return Err(failure(
    -                "configuration must be account-owned with mode 0600",
    -            ));
    -        }
    -    }
    -    let mut bytes = Vec::new();
    -    file.take(MAX_BYTES + 1)
    -        .read_to_end(&mut bytes)
    -        .map_err(|_| failure("private configuration unreadable"))?;
    -    if bytes.len() as u64 > MAX_BYTES {
    -        return Err(failure("configuration byte limit exceeded"));
    -    }
    -    #[cfg(windows)]
    -    return windows::decode(&bytes, windows::unprotect);
    -    #[cfg(not(windows))]
    -    {
    -        let selection: super::active::JsonObject =
    -            serde_json::from_slice(&bytes).map_err(|_| failure("invalid private configuration"))?;
    -        validate_selection(selection.0)
    -    }
    -}
    -
    -fn validate_selection(selection: Selection) -> Result {
    -    match &selection.url {
    -        Some(url) => {
    -            let url = normalize_url(url).map_err(|_| failure("invalid configured endpoint"))?;
    -            if selection
    -                .password
    -                .as_deref()
    -                .is_some_and(|password| password.is_empty() || !password_valid(password))
    -            {
    -                return Err(failure("invalid configured password"));
    -            }
    -            Ok(Selection {
    -                url: Some(url),
    -                password: selection.password,
    -            })
    -        }
    -        None if selection.password.is_none() => Ok(selection),
    -        None => Err(failure("shared target cannot contain a password")),
    -    }
    -}
    -
    -fn persist(path: &Path, selection: &Selection) -> Result<(), AppError> {
    -    let directory = path
    -        .parent()
    -        .ok_or_else(|| failure("configuration directory unavailable"))?;
    -    fs::create_dir_all(directory).map_err(|_| failure("configuration directory unavailable"))?;
    -    #[cfg(unix)]
    -    let directory_handle =
    -        File::open(directory).map_err(|_| failure("configuration directory unavailable"))?;
    -    #[cfg(not(windows))]
    -    let bytes =
    -        serde_json::to_vec(selection).map_err(|_| failure("configuration serialization failed"))?;
    -    #[cfg(windows)]
    -    let bytes = windows::encode(selection, windows::protect)?;
    -    if bytes.len() as u64 > MAX_BYTES {
    -        return Err(failure("configuration byte limit exceeded"));
    -    }
    -    let temporary = directory.join(format!(
    -        ".opencode-service-{}.pending",
    -        uuid::Uuid::new_v4()
    -    ));
    -    let mut owned = false;
    -    let result = (|| {
    -        let mut file = private_file(&temporary)?;
    -        owned = true;
    -        file.write_all(&bytes)
    -            .and_then(|_| file.sync_all())
    -            .map_err(|_| failure("private configuration write failed"))?;
    -        drop(file);
    -        #[cfg(not(windows))]
    -        fs::rename(&temporary, path)
    -            .map_err(|_| failure("private configuration publication failed"))?;
    -        #[cfg(windows)]
    -        {
    -            use std::os::windows::ffi::OsStrExt;
    -            use windows_sys::Win32::Storage::FileSystem::{
    -                MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
    -            };
    -            let source: Vec = temporary.as_os_str().encode_wide().chain(Some(0)).collect();
    -            let destination: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect();
    -            if unsafe {
    -                MoveFileExW(
    -                    source.as_ptr(),
    -                    destination.as_ptr(),
    -                    MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
    -                )
    -            } == 0
    -            {
    -                return Err(failure("private configuration publication failed"));
    -            }
    -        }
    -        #[cfg(unix)]
    -        if directory_handle.sync_all().is_err() {
    -            // Publication already committed; keep disk and runtime selection consistent.
    -            eprintln!("OpenCode service target saved, but directory sync failed");
    -        }
    -        Ok(())
    -    })();
    -    // Only this create_new UUID path is ours; never remove somebody else's temporary file.
    -    if owned {
    -        let _ = fs::remove_file(&temporary);
    -    }
    -    result
    -}
    -
    -fn private_file(path: &Path) -> Result {
    -    let mut options = OpenOptions::new();
    -    options.write(true).create_new(true);
    -    #[cfg(unix)]
    -    {
    -        use std::os::unix::fs::OpenOptionsExt;
    -        options.mode(0o600);
    -    }
    -    let file = options
    -        .open(path)
    -        .map_err(|_| failure("private temporary file creation failed"))?;
    -    #[cfg(unix)]
    -    {
    -        use std::os::unix::fs::PermissionsExt;
    -        if file
    -            .set_permissions(fs::Permissions::from_mode(0o600))
    -            .is_err()
    -        {
    -            let _ = fs::remove_file(path);
    -            return Err(failure("private file permissions failed"));
    -        }
    -    }
    -    Ok(file)
    -}
    -
    -impl TargetRuntime {
    -    pub(super) fn new(path: PathBuf, registration: PathBuf, directory: PathBuf) -> Arc {
    -        Arc::new(Self {
    -            state: RwLock::new(State {
    -                selection: load(&path),
    -                revision: 1,
    -            }),
    -            in_flight: AtomicUsize::new(0),
    -            path,
    -            registration,
    -            directory,
    -        })
    -    }
    -
    -    pub(super) fn snapshot(self: &Arc) -> Result {
    -        let state = self
    -            .state
    -            .read()
    -            .map_err(|_| failure("state unavailable"))?;
    -        Ok(Snapshot {
    -            selection: state.selection.clone()?,
    -            revision: state.revision,
    -            runtime: Some(self.clone()),
    -        })
    -    }
    -
    -    #[cfg(test)]
    -    pub(super) fn set(
    -        self: &Arc,
    -        url: Option,
    -        password: Option,
    -        preflight: impl FnOnce() -> Result<(), AppError>,
    -    ) -> Result {
    -        self.set_with_refresh(url, password, preflight, |()| ())
    -            .map(|(snapshot, ())| snapshot)
    -    }
    -
    -    pub(super) fn set_with_refresh(
    -        self: &Arc,
    -        url: Option,
    -        password: Option,
    -        prepare: impl FnOnce() -> Result,
    -        commit: impl FnOnce(G) -> R,
    -    ) -> Result<(Snapshot, R), AppError> {
    -        let _transition = transition()?;
    -        if self.in_flight.load(Ordering::SeqCst) != 0 {
    -            return Err(AppError::new(
    -                ErrorCode::Busy,
    -                "OpenCode service operation still in flight; wait before changing the target",
    -            ));
    -        }
    -        let selection = {
    -            let state = self
    -                .state
    -                .read()
    -                .map_err(|_| failure("state unavailable"))?;
    -            updated(state.selection.as_ref().ok(), url, password)?
    -        };
    -        let guards = prepare()?;
    -        let mut state = self
    -            .state
    -            .write()
    -            .map_err(|_| failure("state unavailable"))?;
    -        persist(&self.path, &selection)?;
    -        state.selection = Ok(selection.clone());
    -        state.revision += 1;
    -        let snapshot = Snapshot {
    -            selection,
    -            revision: state.revision,
    -            runtime: Some(self.clone()),
    -        };
    -        drop(state);
    -        // Only local invalidation is allowed here; native work/readers wait until finish.
    -        let refresh = commit(guards);
    -        Ok((snapshot, refresh))
    -    }
    -}
    -
    -pub(crate) fn initialize(paths: &ConfigPaths) -> Result<(), AppError> {
    -    let registration =
    -        registration_path(paths, std::env::var_os("XDG_STATE_HOME").map(PathBuf::from));
    -    let directory =
    -        std::env::current_dir().map_err(|_| failure("working directory unavailable"))?;
    -    RUNTIME
    -        .set(TargetRuntime::new(
    -            paths.app_config_dir().join("opencode-service.json"),
    -            registration,
    -            directory,
    -        ))
    -        .map_err(|_| failure("already initialized"))
    -}
    -
    -fn registration_path(paths: &ConfigPaths, state_home: Option) -> PathBuf {
    -    state_home
    -        .filter(|path| path.is_absolute())
    -        .unwrap_or_else(|| paths.home_dir().join(".local/state"))
    -        .join("opencode/service.json")
    -}
    -
    -pub(crate) fn runtime() -> Result<&'static Arc, AppError> {
    -    RUNTIME.get().ok_or_else(|| failure("not initialized"))
    -}
    -
    -pub(super) fn snapshot() -> Result {
    -    runtime()?.snapshot()
    -}
    -
    -pub(crate) fn get_status() -> Result {
    -    super::token_usage::transport::service_status(
    -        snapshot()?,
    -        std::time::Instant::now() + std::time::Duration::from_secs(5),
    -    )
    -}
    -
    -pub(crate) fn set_target(
    -    url: Option,
    -    password: Option,
    -    prepare: impl FnOnce() -> Result,
    -    commit: impl FnOnce(G) -> R,
    -    finish: impl FnOnce(R),
    -) -> Result {
    -    let (snapshot, refresh) = runtime()?.set_with_refresh(url, password, prepare, commit)?;
    -    Ok(finish_target(snapshot, refresh, finish))
    -}
    -
    -fn finish_target(
    -    snapshot: Snapshot,
    -    refresh: R,
    -    finish: impl FnOnce(R),
    -) -> OpenCodeServiceStatus {
    -    let target = snapshot.selection.public();
    -    finish(refresh);
    -    super::token_usage::transport::service_status(
    -        snapshot,
    -        std::time::Instant::now() + std::time::Duration::from_secs(5),
    -    )
    -    .unwrap_or_else(|_| OpenCodeServiceStatus {
    -        target,
    -        connected: false,
    -        url: None,
    -        pid: None,
    -        version: None,
    -        error: Some("OpenCode service target changed or connection status unavailable".into()),
    -    })
    -}
    -
    -#[cfg(test)]
    -mod tests;
    diff --git a/src-tauri/src/opencode_cli/target/windows.rs b/src-tauri/src/opencode_cli/target/windows.rs
    deleted file mode 100644
    index 3fa4e05..0000000
    --- a/src-tauri/src/opencode_cli/target/windows.rs
    +++ /dev/null
    @@ -1,215 +0,0 @@
    -use serde::de::{self, SeqAccess, Visitor};
    -use serde::{Deserialize, Deserializer, Serialize};
    -
    -use super::{failure, nullable_string, validate_selection, AppError, Selection};
    -
    -pub(super) const MAX_FILE_BYTES: usize = 32 * 1024;
    -const MAX_CIPHER_BYTES: usize = 8192;
    -const MAX_PASSWORD_BYTES: usize = 4096;
    -
    -#[derive(Deserialize, Serialize)]
    -#[serde(rename_all = "camelCase", deny_unknown_fields)]
    -struct WindowsSelection {
    -    #[serde(deserialize_with = "nullable_string")]
    -    url: Option,
    -    #[serde(deserialize_with = "encrypted_bytes")]
    -    encrypted_password: Option>,
    -}
    -
    -fn encrypted_bytes<'de, D: Deserializer<'de>>(
    -    deserializer: D,
    -) -> Result>, D::Error> {
    -    struct Bytes(Vec);
    -    impl<'de> Deserialize<'de> for Bytes {
    -        fn deserialize>(deserializer: D) -> Result {
    -            struct BoundedBytes;
    -            impl<'de> Visitor<'de> for BoundedBytes {
    -                type Value = Bytes;
    -                fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
    -                    formatter.write_str("a bounded nonempty encrypted byte array")
    -                }
    -                fn visit_seq>(self, mut sequence: A) -> Result {
    -                    let mut bytes = Vec::new();
    -                    while let Some(byte) = sequence.next_element::()? {
    -                        if bytes.len() == MAX_CIPHER_BYTES {
    -                            return Err(de::Error::custom(
    -                                "encrypted password byte limit exceeded",
    -                            ));
    -                        }
    -                        bytes.push(byte);
    -                    }
    -                    if bytes.is_empty() {
    -                        return Err(de::Error::custom("empty encrypted password"));
    -                    }
    -                    Ok(Bytes(bytes))
    -                }
    -            }
    -            deserializer.deserialize_seq(BoundedBytes)
    -        }
    -    }
    -    Option::::deserialize(deserializer).map(|bytes| bytes.map(|bytes| bytes.0))
    -}
    -
    -pub(super) fn encode(
    -    selection: &Selection,
    -    protect: impl FnOnce(&[u8]) -> Result, AppError>,
    -) -> Result, AppError> {
    -    let selection = validate_selection(selection.clone())?;
    -    let encrypted_password = selection
    -        .password
    -        .as_deref()
    -        .map(|password| {
    -            let bytes =
    -                protect(password.as_bytes()).map_err(|_| failure("password protection failed"))?;
    -            if bytes.is_empty() || bytes.len() > MAX_CIPHER_BYTES {
    -                return Err(failure("invalid protected password size"));
    -            }
    -            Ok(bytes)
    -        })
    -        .transpose()?;
    -    let bytes = serde_json::to_vec(&WindowsSelection {
    -        url: selection.url,
    -        encrypted_password,
    -    })
    -    .map_err(|_| failure("configuration serialization failed"))?;
    -    if bytes.len() > MAX_FILE_BYTES {
    -        return Err(failure("configuration byte limit exceeded"));
    -    }
    -    Ok(bytes)
    -}
    -
    -pub(super) fn decode(
    -    bytes: &[u8],
    -    unprotect: impl FnOnce(&[u8]) -> Result, AppError>,
    -) -> Result {
    -    if bytes.len() > MAX_FILE_BYTES {
    -        return Err(failure("configuration byte limit exceeded"));
    -    }
    -    let disk: super::super::active::JsonObject =
    -        serde_json::from_slice(bytes).map_err(|_| failure("invalid encrypted configuration"))?;
    -    let disk = disk.0;
    -    let mut selection = validate_selection(Selection {
    -        url: disk.url,
    -        password: None,
    -    })?;
    -    if let Some(ciphertext) = disk.encrypted_password {
    -        if selection.url.is_none() {
    -            return Err(failure("shared target cannot contain a password"));
    -        }
    -        let mut plaintext =
    -            unprotect(&ciphertext).map_err(|_| failure("password decryption failed"))?;
    -        let password = if plaintext.len() > MAX_PASSWORD_BYTES {
    -            Err(failure("invalid decrypted password size"))
    -        } else {
    -            std::str::from_utf8(&plaintext)
    -                .map(str::to_owned)
    -                .map_err(|_| failure("invalid decrypted password"))
    -        };
    -        wipe(&mut plaintext);
    -        selection.password = Some(password?);
    -    }
    -    validate_selection(selection)
    -}
    -
    -fn wipe(bytes: &mut [u8]) {
    -    for byte in bytes {
    -        // Volatile writes keep temporary plaintext clearing from being optimized away.
    -        unsafe { std::ptr::write_volatile(byte, 0) };
    -    }
    -    std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
    -}
    -
    -#[cfg(windows)]
    -pub(super) fn protect(bytes: &[u8]) -> Result, AppError> {
    -    dpapi(bytes, true)
    -}
    -
    -#[cfg(windows)]
    -pub(super) fn unprotect(bytes: &[u8]) -> Result, AppError> {
    -    dpapi(bytes, false)
    -}
    -
    -#[cfg(windows)]
    -fn dpapi(bytes: &[u8], protecting: bool) -> Result, AppError> {
    -    use windows_sys::Win32::Foundation::LocalFree;
    -    use windows_sys::Win32::Security::Cryptography::{
    -        CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
    -    };
    -
    -    struct LocalBlob {
    -        blob: CRYPT_INTEGER_BLOB,
    -        wipe: bool,
    -    }
    -    impl Drop for LocalBlob {
    -        fn drop(&mut self) {
    -            if !self.blob.pbData.is_null() {
    -                if self.wipe {
    -                    wipe(unsafe {
    -                        std::slice::from_raw_parts_mut(self.blob.pbData, self.blob.cbData as usize)
    -                    });
    -                }
    -                unsafe { LocalFree(self.blob.pbData.cast()) };
    -            }
    -        }
    -    }
    -
    -    let input_limit = if protecting {
    -        MAX_PASSWORD_BYTES
    -    } else {
    -        MAX_CIPHER_BYTES
    -    };
    -    if bytes.is_empty() || bytes.len() > input_limit {
    -        return Err(failure("invalid data protection input size"));
    -    }
    -    let mut input = bytes.to_vec();
    -    let blob = CRYPT_INTEGER_BLOB {
    -        cbData: input.len() as u32,
    -        pbData: input.as_mut_ptr(),
    -    };
    -    let mut output = LocalBlob {
    -        blob: CRYPT_INTEGER_BLOB::default(),
    -        wipe: false,
    -    };
    -    // Default user scope: no LOCAL_MACHINE, optional entropy, prompt, or description output.
    -    let succeeded = unsafe {
    -        if protecting {
    -            CryptProtectData(
    -                &blob,
    -                std::ptr::null(),
    -                std::ptr::null(),
    -                std::ptr::null(),
    -                std::ptr::null(),
    -                CRYPTPROTECT_UI_FORBIDDEN,
    -                &mut output.blob,
    -            )
    -        } else {
    -            CryptUnprotectData(
    -                &blob,
    -                std::ptr::null_mut(),
    -                std::ptr::null(),
    -                std::ptr::null(),
    -                std::ptr::null(),
    -                CRYPTPROTECT_UI_FORBIDDEN,
    -                &mut output.blob,
    -            )
    -        }
    -    };
    -    wipe(&mut input);
    -    let output_limit = if protecting {
    -        MAX_CIPHER_BYTES
    -    } else {
    -        MAX_PASSWORD_BYTES
    -    };
    -    if succeeded == 0
    -        || output.blob.pbData.is_null()
    -        || output.blob.cbData == 0
    -        || output.blob.cbData as usize > output_limit
    -    {
    -        return Err(failure("Windows data protection failed"));
    -    }
    -    output.wipe = !protecting;
    -    Ok(
    -        unsafe { std::slice::from_raw_parts(output.blob.pbData, output.blob.cbData as usize) }
    -            .to_vec(),
    -    )
    -}
    diff --git a/src-tauri/src/opencode_cli/token_usage.rs b/src-tauri/src/opencode_cli/token_usage.rs
    deleted file mode 100644
    index 6d97c7b..0000000
    --- a/src-tauri/src/opencode_cli/token_usage.rs
    +++ /dev/null
    @@ -1,553 +0,0 @@
    -use std::collections::{HashMap, HashSet};
    -use std::mem::size_of;
    -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
    -use std::thread;
    -use std::time::{Duration, Instant};
    -
    -use serde::{
    -    de::{self, IgnoredAny, SeqAccess, Visitor},
    -    Deserialize, Deserializer, Serialize,
    -};
    -
    -use crate::error::AppError;
    -
    -pub(in crate::opencode_cli) mod transport;
    -
    -use transport::ManagedConnection;
    -
    -const SCAN_TIMEOUT: Duration = Duration::from_secs(60);
    -const PAGE_MAX_BYTES: u64 = 32 * 1024 * 1024;
    -const MAX_RECORDS: usize = 100_000;
    -const MAX_METADATA_ITEMS: usize = 300_000;
    -const MAX_METADATA_BYTES: usize = 64 * 1024 * 1024;
    -const JS_MAX_INTEGER: u64 = 9_007_199_254_740_991;
    -const JS_MAX_DATE: u64 = 8_640_000_000_000_000;
    -const WORKERS: usize = 4;
    -
    -#[derive(Debug, PartialEq, Eq, Serialize)]
    -pub struct TokenUsageRecord {
    -    pub time: u64,
    -    pub input: u64,
    -    pub output: u64,
    -}
    -
    -#[derive(Deserialize)]
    -#[serde(bound(deserialize = "T: Deserialize<'de>"))]
    -struct Page {
    -    #[serde(deserialize_with = "page_items")]
    -    data: Vec,
    -    cursor: Cursor,
    -}
    -
    -fn page_items<'de, D, T>(deserializer: D) -> Result, D::Error>
    -where
    -    D: Deserializer<'de>,
    -    T: Deserialize<'de>,
    -{
    -    struct Items(std::marker::PhantomData);
    -    impl<'de, T: Deserialize<'de>> Visitor<'de> for Items {
    -        type Value = Vec;
    -
    -        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
    -            formatter.write_str("at most 1000 page items")
    -        }
    -
    -        fn visit_seq>(self, mut sequence: A) -> Result, A::Error> {
    -            let mut items = Vec::new();
    -            while let Some(item) = sequence.next_element()? {
    -                if items.len() == 1000 {
    -                    return Err(de::Error::custom("page item limit exceeded"));
    -                }
    -                items.push(item);
    -            }
    -            Ok(items)
    -        }
    -    }
    -    deserializer.deserialize_seq(Items(std::marker::PhantomData))
    -}
    -
    -#[derive(Deserialize)]
    -struct Cursor {
    -    next: Option,
    -}
    -
    -#[derive(Deserialize)]
    -struct Session {
    -    id: String,
    -    fork: Option,
    -}
    -
    -#[derive(Deserialize)]
    -struct Fork {
    -    #[serde(rename = "sessionID")]
    -    session_id: String,
    -    boundary: Boundary,
    -}
    -
    -#[derive(Deserialize)]
    -struct Boundary {
    -    #[serde(rename = "type")]
    -    kind: BoundaryKind,
    -    #[serde(rename = "messageID")]
    -    message_id: String,
    -}
    -
    -#[derive(Deserialize)]
    -#[serde(rename_all = "lowercase")]
    -enum BoundaryKind {
    -    Before,
    -    Through,
    -}
    -
    -// No content, titles, model details, or error payloads enter the retained metadata.
    -#[derive(Deserialize)]
    -struct Message {
    -    id: String,
    -    #[serde(rename = "type")]
    -    kind: String,
    -    time: MessageTime,
    -    tokens: Option,
    -    finish: Option,
    -    status: Option,
    -    #[serde(default, deserialize_with = "error_present")]
    -    error: bool,
    -}
    -
    -#[derive(Deserialize, PartialEq, Eq)]
    -struct MessageTime {
    -    created: u64,
    -    completed: Option,
    -    streamed: Option,
    -}
    -
    -#[derive(Deserialize, PartialEq, Eq)]
    -struct Tokens {
    -    input: u64,
    -    output: u64,
    -}
    -
    -fn error_present<'de, D: Deserializer<'de>>(deserializer: D) -> Result {
    -    struct ErrorValue;
    -    impl<'de> Visitor<'de> for ErrorValue {
    -        type Value = bool;
    -
    -        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
    -            formatter.write_str("an error payload or boolean")
    -        }
    -
    -        fn visit_bool(self, value: bool) -> Result {
    -            Ok(value)
    -        }
    -
    -        fn visit_none(self) -> Result {
    -            Ok(false)
    -        }
    -
    -        fn visit_unit(self) -> Result {
    -            Ok(false)
    -        }
    -
    -        fn visit_some>(self, deserializer: D) -> Result {
    -            deserializer.deserialize_any(ErrorValue)
    -        }
    -
    -        fn visit_map>(self, mut access: A) -> Result {
    -            while access.next_entry::()?.is_some() {}
    -            Ok(true)
    -        }
    -
    -        fn visit_seq>(self, mut access: A) -> Result {
    -            while access.next_element::()?.is_some() {}
    -            Ok(true)
    -        }
    -
    -        fn visit_str(self, value: &str) -> Result {
    -            Ok(!value.is_empty())
    -        }
    -
    -        fn visit_string(self, value: String) -> Result {
    -            self.visit_str(&value)
    -        }
    -    }
    -    deserializer.deserialize_option(ErrorValue)
    -}
    -
    -#[derive(Default)]
    -struct Budget {
    -    items: AtomicUsize,
    -    bytes: AtomicUsize,
    -}
    -
    -impl Budget {
    -    fn reserve(&self, items: usize, bytes: usize) -> Result<(), AppError> {
    -        for (counter, amount, max) in [
    -            (&self.items, items, MAX_METADATA_ITEMS),
    -            (&self.bytes, bytes, MAX_METADATA_BYTES),
    -        ] {
    -            counter
    -                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
    -                    value.checked_add(amount).filter(|value| *value <= max)
    -                })
    -                .map_err(|_| failure("metadata limit exceeded"))?;
    -        }
    -        Ok(())
    -    }
    -}
    -
    -fn failure(reason: &str) -> AppError {
    -    AppError::configuration(format!("opencode token usage: {reason}."))
    -}
    -
    -fn remaining(deadline: Instant) -> Result {
    -    deadline
    -        .checked_duration_since(Instant::now())
    -        .filter(|remaining| !remaining.is_zero())
    -        .ok_or_else(|| failure("scan timed out"))
    -}
    -
    -fn valid_text(value: &str, max: usize) -> bool {
    -    !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
    -}
    -
    -fn inspect_session(session: &Session) -> Result<(&str, usize), AppError> {
    -    if !valid_text(&session.id, 4096) {
    -        return Err(failure("invalid session identifier"));
    -    }
    -    let mut bytes = size_of::() + 2 * session.id.len() + size_of::();
    -    if let Some(fork) = &session.fork {
    -        if !valid_text(&fork.session_id, 4096) || !valid_text(&fork.boundary.message_id, 4096) {
    -            return Err(failure("invalid fork provenance"));
    -        }
    -        bytes += fork.session_id.len() + fork.boundary.message_id.len();
    -    }
    -    Ok((&session.id, bytes))
    -}
    -
    -impl Message {
    -    fn usage(&self) -> Option<&Tokens> {
    -        if !matches!(self.kind.as_str(), "assistant" | "compaction") {
    -            return None;
    -        }
    -        let pending = match self.kind.as_str() {
    -            "compaction" => {
    -                self.status.as_deref() == Some("running")
    -                    && self.finish.is_none()
    -                    && !self.error
    -                    && self.time.completed.is_none()
    -            }
    -            "assistant" => {
    -                self.finish.is_none()
    -                    && !self.error
    -                    && self.time.completed.is_none()
    -                    && matches!(self.status.as_deref(), None | Some("running"))
    -            }
    -            _ => false,
    -        };
    -        if pending {
    -            None
    -        } else {
    -            // Absent recorded usage does not establish zero provider billing.
    -            self.tokens.as_ref()
    -        }
    -    }
    -
    -    fn copied_by_fork(&self) -> bool {
    -        match self.kind.as_str() {
    -            "assistant" => self.time.completed.is_some(),
    -            "shell" | "compaction" => self.status.as_deref() != Some("running"),
    -            _ => true,
    -        }
    -    }
    -
    -    fn same_projection(&self, other: &Self) -> bool {
    -        self.kind == other.kind
    -            && self.time == other.time
    -            && self.tokens == other.tokens
    -            && self.finish == other.finish
    -            && self.status == other.status
    -            && self.error == other.error
    -    }
    -}
    -
    -fn inspect_message(message: &Message) -> Result<(&str, usize), AppError> {
    -    if !valid_text(&message.id, 4096)
    -        || !valid_text(&message.kind, 256)
    -        || [&message.finish, &message.status]
    -            .into_iter()
    -            .flatten()
    -            .any(|value| !valid_text(value, 256))
    -        || [
    -            Some(message.time.created),
    -            message.time.completed,
    -            message.time.streamed,
    -        ]
    -        .into_iter()
    -        .flatten()
    -        .any(|time| time > JS_MAX_DATE)
    -    {
    -        return Err(failure("invalid message metadata"));
    -    }
    -    if let Some(tokens) = &message.tokens {
    -        safe_sum(tokens.input, tokens.output)?;
    -    }
    -    Ok((
    -        &message.id,
    -        size_of::()
    -            + size_of::()
    -            + 2 * message.id.len()
    -            + message.kind.len()
    -            + message.finish.as_ref().map_or(0, String::len)
    -            + message.status.as_ref().map_or(0, String::len),
    -    ))
    -}
    -
    -fn safe_sum(left: u64, right: u64) -> Result {
    -    left.checked_add(right)
    -        .filter(|sum| *sum <= JS_MAX_INTEGER)
    -        .ok_or_else(|| failure("token counts exceed safe integer bounds"))
    -}
    -
    -fn read_pages(
    -    mut fetch: impl FnMut(Option<&str>) -> Result, AppError>,
    -    inspect: impl Fn(&T) -> Result<(&str, usize), AppError>,
    -    budget: &Budget,
    -    deadline: Instant,
    -) -> Result, AppError> {
    -    let mut items = Vec::new();
    -    let mut ids = HashSet::new();
    -    let mut cursors = HashSet::new();
    -    let mut cursor: Option = None;
    -    loop {
    -        remaining(deadline)?;
    -        let page = fetch(cursor.as_deref())?;
    -        remaining(deadline)?;
    -        let empty = page.data.is_empty();
    -        for item in page.data {
    -            let (id, bytes) = inspect(&item)?;
    -            budget.reserve(1, bytes)?;
    -            if !ids.insert(id.to_owned()) {
    -                return Err(failure("duplicate identifier across pages"));
    -            }
    -            items.push(item);
    -        }
    -        remaining(deadline)?;
    -        if empty {
    -            break;
    -        }
    -        let Some(next) = page.cursor.next else {
    -            break;
    -        };
    -        if !valid_text(&next, 4096) {
    -            return Err(failure("invalid page cursor"));
    -        }
    -        budget.reserve(0, 2 * next.len() + size_of::())?;
    -        if !cursors.insert(next.clone()) {
    -            return Err(failure("page cursor loop"));
    -        }
    -        cursor = Some(next);
    -    }
    -    Ok(items)
    -}
    -
    -fn read_timelines(
    -    connection: &ManagedConnection,
    -    sessions: &[Session],
    -    budget: &Budget,
    -    deadline: Instant,
    -) -> Result>, AppError> {
    -    let index = AtomicUsize::new(0);
    -    let failed = AtomicBool::new(false);
    -    thread::scope(|scope| {
    -        let mut workers = Vec::new();
    -        for _ in 0..WORKERS.min(sessions.len()) {
    -            workers.push(scope.spawn(|| {
    -                let result = (|| {
    -                    let mut timelines = Vec::new();
    -                    loop {
    -                        remaining(deadline)?;
    -                        if failed.load(Ordering::Relaxed) {
    -                            return Err(failure("scan cancelled after a page failure"));
    -                        }
    -                        let index = index.fetch_add(1, Ordering::Relaxed);
    -                        let Some(session) = sessions.get(index) else {
    -                            break;
    -                        };
    -                        let messages = read_pages(
    -                            |cursor| {
    -                                if failed.load(Ordering::Relaxed) {
    -                                    return Err(failure("scan cancelled after a page failure"));
    -                                }
    -                                connection.fetch_page(Some(&session.id), cursor, deadline)
    -                            },
    -                            inspect_message,
    -                            budget,
    -                            deadline,
    -                        )?;
    -                        timelines.push((index, messages));
    -                    }
    -                    Ok(timelines)
    -                })();
    -                if result.is_err() {
    -                    failed.store(true, Ordering::Relaxed);
    -                }
    -                result
    -            }));
    -        }
    -        let mut timelines = Vec::new();
    -        let mut error = None;
    -        // Every in-flight request and body read shares the scan deadline.
    -        for worker in workers {
    -            let worker_error = match worker.join() {
    -                Ok(Ok(mut result)) => {
    -                    timelines.append(&mut result);
    -                    continue;
    -                }
    -                Ok(Err(error)) => error,
    -                Err(_) => {
    -                    failed.store(true, Ordering::Relaxed);
    -                    failure("timeline worker failed")
    -                }
    -            };
    -            if error.as_ref().map_or(true, |error: &AppError| {
    -                error.message == failure("scan cancelled after a page failure").message
    -            }) {
    -                error = Some(worker_error);
    -            }
    -        }
    -        if let Some(error) = error {
    -            return Err(error);
    -        }
    -        remaining(deadline)?;
    -        timelines.sort_unstable_by_key(|(index, _)| *index);
    -        Ok(timelines
    -            .into_iter()
    -            .map(|(_, messages)| messages)
    -            .collect())
    -    })
    -}
    -
    -fn fork_offsets(
    -    sessions: &[Session],
    -    timelines: &[Vec],
    -    deadline: Instant,
    -) -> Result, AppError> {
    -    let lookup: HashMap<_, _> = sessions
    -        .iter()
    -        .enumerate()
    -        .map(|(index, session)| (session.id.as_str(), index))
    -        .collect();
    -    let mut states = vec![0; sessions.len()];
    -    let mut offsets = vec![0; sessions.len()];
    -    for start in 0..sessions.len() {
    -        remaining(deadline)?;
    -        let mut path = Vec::new();
    -        let mut current = start;
    -        loop {
    -            remaining(deadline)?;
    -            match states[current] {
    -                2 => break,
    -                1 => return Err(failure("fork provenance unavailable: origin cycle")),
    -                _ => {}
    -            }
    -            states[current] = 1;
    -            path.push(current);
    -            let Some(fork) = &sessions[current].fork else {
    -                break;
    -            };
    -            current = *lookup
    -                .get(fork.session_id.as_str())
    -                .ok_or_else(|| failure("fork provenance unavailable: missing origin"))?;
    -        }
    -        for index in path.into_iter().rev() {
    -            remaining(deadline)?;
    -            if let Some(fork) = &sessions[index].fork {
    -                let origin = &timelines[lookup[fork.session_id.as_str()]];
    -                let boundary = origin
    -                    .iter()
    -                    .position(|message| message.id == fork.boundary.message_id)
    -                    .ok_or_else(|| failure("fork provenance unavailable: missing boundary"))?;
    -                let prefix = boundary
    -                    + match fork.boundary.kind {
    -                        BoundaryKind::Before => 0,
    -                        BoundaryKind::Through => 1,
    -                    };
    -                let child = &timelines[index];
    -                // Native forks copy eligible projected prefixes with new IDs, not a timestamp cutoff.
    -                let expected = origin[..prefix]
    -                    .iter()
    -                    .filter(|message| message.copied_by_fork());
    -                let copied = expected.clone().count();
    -                if child.len() < copied
    -                    || !expected
    -                        .zip(&child[..copied])
    -                        .all(|(original, copied)| original.same_projection(copied))
    -                {
    -                    return Err(failure(
    -                        "fork provenance unavailable: copied prefix mismatch",
    -                    ));
    -                }
    -                offsets[index] = copied;
    -            }
    -            states[index] = 2;
    -        }
    -    }
    -    Ok(offsets)
    -}
    -
    -fn records(
    -    sessions: &[Session],
    -    timelines: &[Vec],
    -    deadline: Instant,
    -) -> Result, AppError> {
    -    let offsets = fork_offsets(sessions, timelines, deadline)?;
    -    let mut records = Vec::new();
    -    let mut input = 0;
    -    let mut output = 0;
    -    for (timeline, offset) in timelines.iter().zip(offsets) {
    -        for message in &timeline[offset..] {
    -            remaining(deadline)?;
    -            let Some(tokens) = message.usage() else {
    -                continue;
    -            };
    -            if records.len() == MAX_RECORDS {
    -                return Err(failure("100,000 record limit exceeded"));
    -            }
    -            input = safe_sum(input, tokens.input)?;
    -            output = safe_sum(output, tokens.output)?;
    -            safe_sum(input, output)?;
    -            records.push(TokenUsageRecord {
    -                time: message.time.created,
    -                input: tokens.input,
    -                output: tokens.output,
    -            });
    -        }
    -    }
    -    remaining(deadline)?;
    -    Ok(records)
    -}
    -
    -pub fn token_usage_records_via_cli() -> Result, AppError> {
    -    let deadline = Instant::now() + SCAN_TIMEOUT;
    -    let connection = transport::connect(deadline)?;
    -    scan(&connection, deadline)
    -}
    -
    -pub(super) fn scan(
    -    connection: &ManagedConnection,
    -    deadline: Instant,
    -) -> Result, AppError> {
    -    let budget = Budget::default();
    -    let sessions = read_pages(
    -        |cursor| connection.fetch_page(None, cursor, deadline),
    -        inspect_session,
    -        &budget,
    -        deadline,
    -    )?;
    -    let timelines = read_timelines(connection, &sessions, &budget, deadline)?;
    -    let records = records(&sessions, &timelines, deadline)?;
    -    connection.ensure_current()?;
    -    Ok(records)
    -}
    -
    -#[cfg(test)]
    -pub(in crate::opencode_cli) mod tests;
    diff --git a/src-tauri/src/opencode_cli/token_usage/transport.rs b/src-tauri/src/opencode_cli/token_usage/transport.rs
    deleted file mode 100644
    index c7c97d7..0000000
    --- a/src-tauri/src/opencode_cli/token_usage/transport.rs
    +++ /dev/null
    @@ -1,702 +0,0 @@
    -use std::fs::OpenOptions;
    -use std::io::{self, Read};
    -#[cfg(unix)]
    -use std::os::unix::fs::OpenOptionsExt;
    -use std::path::{Path, PathBuf};
    -use std::process::{Command, Stdio};
    -
    -use reqwest::blocking::{Client, RequestBuilder, Response};
    -use reqwest::{StatusCode, Url};
    -
    -use super::{failure, remaining, valid_text, AppError, Deserialize, Duration, Instant, Page};
    -use super::{PAGE_MAX_BYTES, SCAN_TIMEOUT};
    -use crate::opencode_cli::binary::opencode_candidates;
    -use crate::opencode_cli::command::collect_output;
    -use crate::opencode_cli::target::{
    -    self, OpenCodeServiceStatus, Selection, SideEffectReservation, Snapshot,
    -};
    -
    -const REGISTRATION_MAX_BYTES: u64 = 8192;
    -const INFO_MAX_BYTES: u64 = 32 * 1024;
    -const HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
    -const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(10);
    -
    -#[derive(Deserialize)]
    -struct Registration {
    -    id: String,
    -    version: String,
    -    url: String,
    -    pid: u32,
    -    password: Option,
    -}
    -
    -#[derive(Deserialize)]
    -struct Info {
    -    version: String,
    -    pid: u32,
    -}
    -
    -pub(in crate::opencode_cli) struct ManagedConnection {
    -    client: Client,
    -    url: Url,
    -    registration: Registration,
    -    snapshot: Snapshot,
    -}
    -
    -impl ManagedConnection {
    -    pub(in crate::opencode_cli) fn into_async(self) -> Result {
    -        self.ensure_current()?;
    -        let client = reqwest::Client::builder()
    -            .no_proxy()
    -            .redirect(reqwest::redirect::Policy::none())
    -            .retry(reqwest::retry::never())
    -            .connect_timeout(HEALTH_TIMEOUT)
    -            .build()
    -            .map_err(|_| failure("managed API client initialization failed"))?;
    -        Ok(ManagedAsyncConnection {
    -            client,
    -            url: self.url,
    -            password: self.registration.password,
    -            pid: self.registration.pid,
    -            version: self.registration.version,
    -            snapshot: self.snapshot,
    -        })
    -    }
    -
    -    fn new(client: Client, registration: Registration) -> Result {
    -        let invalid = || failure("invalid managed service registration");
    -        let port = registration
    -            .url
    -            .strip_prefix("http://127.0.0.1:")
    -            .or_else(|| registration.url.strip_prefix("http://[::1]:"))
    -            .and_then(|port| port.strip_suffix('/').unwrap_or(port).parse::().ok())
    -            .filter(|port| *port != 0)
    -            .ok_or_else(invalid)?;
    -        let url = Url::parse(®istration.url).map_err(|_| invalid())?;
    -        if url.scheme() != "http"
    -            || !matches!(url.host_str(), Some("127.0.0.1" | "[::1]"))
    -            || !url.username().is_empty()
    -            || url.password().is_some()
    -            || url.port_or_known_default() != Some(port)
    -            || url.path() != "/"
    -            || url.query().is_some()
    -            || url.fragment().is_some()
    -            || uuid::Uuid::parse_str(®istration.id).is_err()
    -            || registration.pid == 0
    -            || !valid_v2_version(®istration.version)
    -            || !registration
    -                .password
    -                .as_deref()
    -                .is_some_and(|password| valid_text(password, 4096))
    -        {
    -            return Err(invalid());
    -        }
    -        Ok(Self {
    -            client,
    -            url,
    -            registration,
    -            snapshot: Snapshot::detached(Selection::default()),
    -        })
    -    }
    -
    -    fn request(
    -        &self,
    -        url: Url,
    -        deadline: Instant,
    -        timeout: Duration,
    -    ) -> Result {
    -        self.ensure_current()?;
    -        let request = self
    -            .client
    -            .get(url)
    -            .timeout(remaining(deadline)?.min(timeout));
    -        Ok(match &self.registration.password {
    -            Some(password) => request.basic_auth("opencode", Some(password)),
    -            None => request,
    -        })
    -    }
    -
    -    fn healthy(&self, deadline: Instant) -> Result {
    -        let mut url = self.url.clone();
    -        url.set_path("/api/info");
    -        let response = match self.request(url, deadline, HEALTH_TIMEOUT)?.send() {
    -            Ok(response) => response,
    -            Err(error) if error.is_connect() || error.is_timeout() => {
    -                remaining(deadline)?;
    -                return Ok(false);
    -            }
    -            Err(error) => return Err(request_failure(error)),
    -        };
    -        let info: super::super::active::JsonObject =
    -            response_json(response, deadline, INFO_MAX_BYTES, "health", "32 KiB")?;
    -        let info = info.0;
    -        validate_info(&info)?;
    -        Ok(info.pid == self.registration.pid && info.version == self.registration.version)
    -    }
    -
    -    pub(in crate::opencode_cli) fn ensure_current(&self) -> Result<(), AppError> {
    -        self.snapshot.ensure_current()
    -    }
    -
    -    pub(in crate::opencode_cli) fn directory(&self) -> Option<&Path> {
    -        self.snapshot.directory()
    -    }
    -
    -    pub(in crate::opencode_cli) fn get_json Deserialize<'de>>(
    -        &self,
    -        segments: &[&str],
    -        query: &[(&str, &str)],
    -        deadline: Instant,
    -        max_bytes: u64,
    -        operation: &str,
    -        limit: &str,
    -    ) -> Result {
    -        let mut url = api_url(&self.url, segments)?;
    -        if !query.is_empty() {
    -            url.query_pairs_mut().extend_pairs(query.iter().copied());
    -        }
    -        let response = self
    -            .request(url, deadline, HEALTH_TIMEOUT)?
    -            .send()
    -            .map_err(request_failure)?;
    -        let value = response_json(response, deadline, max_bytes, operation, limit)?;
    -        self.ensure_current()?;
    -        Ok(value)
    -    }
    -
    -    pub(in crate::opencode_cli) fn reload(&self, deadline: Instant) -> Result<(), AppError> {
    -        let _reservation = self.snapshot.reserve_side_effect(deadline)?;
    -        if !self.healthy(deadline)? {
    -            return Err(failure(
    -                "service identity changed; reconnect before writing",
    -            ));
    -        }
    -        let mut url = api_url(&self.url, &["location", "reload"])?;
    -        if let Some(directory) = self.directory().and_then(|path| path.to_str()) {
    -            url.query_pairs_mut()
    -                .append_pair("location[directory]", directory);
    -        }
    -        let request = self
    -            .client
    -            .post(url)
    -            .timeout(remaining(deadline)?.min(HEALTH_TIMEOUT));
    -        let request = match &self.registration.password {
    -            Some(password) => request.basic_auth("opencode", Some(password)),
    -            None => request,
    -        };
    -        let response = request
    -            .send()
    -            .map_err(|_| failure("reload outcome unknown; do not automatically retry"))?;
    -        remaining(deadline)?;
    -        if response.status() != StatusCode::NO_CONTENT {
    -            return Err(failure(&format!(
    -                "reload API HTTP {}; do not automatically retry",
    -                response.status().as_u16()
    -            )));
    -        }
    -        self.ensure_current()
    -    }
    -
    -    fn page_url(&self, session_id: Option<&str>, cursor: Option<&str>) -> Result {
    -        let mut url = self.url.clone();
    -        {
    -            let mut path = url
    -                .path_segments_mut()
    -                .map_err(|_| failure("invalid managed API base URL"))?;
    -            path.clear().push("api").push("session");
    -            if let Some(id) = session_id {
    -                if !valid_text(id, 4096) || matches!(id, "." | "..") {
    -                    return Err(failure("invalid session identifier"));
    -                }
    -                path.push(id).push("message");
    -            }
    -        }
    -        let mut query = url.query_pairs_mut();
    -        query.append_pair("limit", if session_id.is_some() { "100" } else { "1000" });
    -        if let Some(cursor) = cursor {
    -            if !valid_text(cursor, 4096) {
    -                return Err(failure("invalid page cursor"));
    -            }
    -            query.append_pair("cursor", cursor);
    -        } else {
    -            query.append_pair("order", "asc");
    -        }
    -        drop(query);
    -        Ok(url)
    -    }
    -
    -    pub(super) fn fetch_page Deserialize<'de>>(
    -        &self,
    -        session_id: Option<&str>,
    -        cursor: Option<&str>,
    -        deadline: Instant,
    -    ) -> Result, AppError> {
    -        let response = self
    -            .request(self.page_url(session_id, cursor)?, deadline, SCAN_TIMEOUT)?
    -            .send()
    -            .map_err(request_failure)?;
    -        let page: Page = response_json(response, deadline, PAGE_MAX_BYTES, "page", "32 MiB")?;
    -        self.ensure_current()?;
    -        if session_id.is_some() && page.data.len() > 100 {
    -            return Err(failure("message page item limit exceeded"));
    -        }
    -        Ok(page)
    -    }
    -}
    -
    -pub(in crate::opencode_cli) struct ManagedAsyncConnection {
    -    client: reqwest::Client,
    -    url: Url,
    -    password: Option,
    -    pid: u32,
    -    version: String,
    -    snapshot: Snapshot,
    -}
    -
    -impl ManagedAsyncConnection {
    -    pub(in crate::opencode_cli) fn request(
    -        &self,
    -        method: reqwest::Method,
    -        segments: &[&str],
    -    ) -> Result {
    -        self.ensure_current()?;
    -        let url = api_url(&self.url, segments)?;
    -        let request = self.client.request(method, url);
    -        Ok(match &self.password {
    -            Some(password) => request.basic_auth("opencode", Some(password)),
    -            None => request,
    -        })
    -    }
    -
    -    pub(in crate::opencode_cli) async fn prepare_write(
    -        &self,
    -        deadline: Instant,
    -    ) -> Result {
    -        let reservation = self.snapshot.reserve_side_effect(deadline)?;
    -        let mut response = self
    -            .request(reqwest::Method::GET, &["info"])?
    -            .timeout(remaining(deadline)?.min(HEALTH_TIMEOUT))
    -            .send()
    -            .await
    -            .map_err(request_failure)?;
    -        if response.status() != StatusCode::OK {
    -            return Err(failure(&format!("API HTTP {}", response.status().as_u16())));
    -        }
    -        if response
    -            .content_length()
    -            .is_some_and(|length| length > INFO_MAX_BYTES)
    -        {
    -            return Err(failure("health response exceeds the 32 KiB limit"));
    -        }
    -        let mut bytes = Vec::new();
    -        while let Some(chunk) = response.chunk().await.map_err(request_failure)? {
    -            if bytes.len() + chunk.len() > INFO_MAX_BYTES as usize {
    -                return Err(failure("health response exceeds the 32 KiB limit"));
    -            }
    -            bytes.extend_from_slice(&chunk);
    -        }
    -        let info: super::super::active::JsonObject = decode_json(&bytes, "health")?;
    -        validate_info(&info.0)?;
    -        if info.0.pid != self.pid || info.0.version != self.version {
    -            return Err(failure(
    -                "service identity changed; reconnect before writing",
    -            ));
    -        }
    -        remaining(deadline)?;
    -        self.ensure_current()?;
    -        Ok(reservation)
    -    }
    -
    -    pub(in crate::opencode_cli) fn ensure_current(&self) -> Result<(), AppError> {
    -        // PID/version are the identity verified at connect; all async requests pin this connection.
    -        if self.pid == 0 || !valid_v2_version(&self.version) {
    -            return Err(failure("invalid pinned service identity"));
    -        }
    -        self.snapshot.ensure_current()
    -    }
    -}
    -
    -fn api_url(base: &Url, segments: &[&str]) -> Result {
    -    let mut url = base.clone();
    -    let mut path = url
    -        .path_segments_mut()
    -        .map_err(|_| failure("invalid managed API base URL"))?;
    -    path.clear().push("api");
    -    for segment in segments {
    -        if !valid_text(segment, 4096) || matches!(*segment, "." | "..") {
    -            return Err(failure("invalid managed API path segment"));
    -        }
    -        path.push(segment);
    -    }
    -    drop(path);
    -    Ok(url)
    -}
    -
    -fn validate_info(info: &Info) -> Result<(), AppError> {
    -    if info.pid == 0 || !valid_v2_version(&info.version) {
    -        return Err(failure("invalid V2 service identity"));
    -    }
    -    Ok(())
    -}
    -
    -fn valid_v2_version(version: &str) -> bool {
    -    if version.len() > 128 {
    -        return false;
    -    }
    -    let identifiers = |value: &str, prerelease: bool| {
    -        value.split('.').all(|part| {
    -            !part.is_empty()
    -                && part
    -                    .bytes()
    -                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
    -                && !(prerelease
    -                    && part.len() > 1
    -                    && part.starts_with('0')
    -                    && part.bytes().all(|byte| byte.is_ascii_digit()))
    -        })
    -    };
    -    let (version, build) = version
    -        .split_once('+')
    -        .map_or((version, None), |(version, build)| (version, Some(build)));
    -    if build.is_some_and(|build| !identifiers(build, false)) {
    -        return false;
    -    }
    -    let (version, prerelease) = version
    -        .split_once('-')
    -        .map_or((version, None), |(version, prerelease)| {
    -            (version, Some(prerelease))
    -        });
    -    if prerelease.is_some_and(|prerelease| !identifiers(prerelease, true)) {
    -        return false;
    -    }
    -    let parts: Vec<_> = version.split('.').collect();
    -    parts.len() == 3
    -        && parts[0] == "2"
    -        && parts.iter().all(|part| {
    -            !part.is_empty()
    -                && (part.len() == 1 || !part.starts_with('0'))
    -                && part.bytes().all(|byte| byte.is_ascii_digit())
    -                && part.parse::().is_ok()
    -        })
    -}
    -
    -fn request_failure(error: reqwest::Error) -> AppError {
    -    failure(if error.is_timeout() {
    -        "API request timed out"
    -    } else if error.is_connect() {
    -        "API connection failed"
    -    } else {
    -        "API request failed"
    -    })
    -}
    -
    -pub(in crate::opencode_cli) fn decode_json Deserialize<'de>>(
    -    bytes: &[u8],
    -    operation: &str,
    -) -> Result {
    -    serde_json::from_slice(bytes).map_err(|error| {
    -        // Serde and HTTP errors can include private values; retain only category/location.
    -        failure(&format!(
    -            "invalid {operation} ({:?} at line {}, column {})",
    -            error.classify(),
    -            error.line(),
    -            error.column()
    -        ))
    -    })
    -}
    -
    -fn response_json Deserialize<'de>>(
    -    mut response: Response,
    -    deadline: Instant,
    -    max_bytes: u64,
    -    operation: &str,
    -    limit: &str,
    -) -> Result {
    -    remaining(deadline)?;
    -    if response.status() != StatusCode::OK {
    -        return Err(failure(&format!("API HTTP {}", response.status().as_u16())));
    -    }
    -    let oversized = || failure(&format!("{operation} response exceeds the {limit} limit"));
    -    if response
    -        .content_length()
    -        .is_some_and(|size| size > max_bytes)
    -    {
    -        return Err(oversized());
    -    }
    -    let mut bytes = Vec::new();
    -    let mut buffer = [0; 8192];
    -    loop {
    -        remaining(deadline)?;
    -        let available = (max_bytes - bytes.len() as u64 + 1).min(buffer.len() as u64) as usize;
    -        let count = response
    -            .read(&mut buffer[..available])
    -            .map_err(|error| failure(&format!("API body read failed ({:?})", error.kind())))?;
    -        if count == 0 {
    -            break;
    -        }
    -        if bytes.len() as u64 + count as u64 > max_bytes {
    -            return Err(oversized());
    -        }
    -        bytes.extend_from_slice(&buffer[..count]);
    -    }
    -    remaining(deadline)?;
    -    let result = decode_json(&bytes, operation);
    -    drop(bytes);
    -    remaining(deadline)?;
    -    result
    -}
    -
    -fn read_registration(path: &Path, deadline: Instant) -> Result, AppError> {
    -    remaining(deadline)?;
    -    let mut options = OpenOptions::new();
    -    options.read(true);
    -    #[cfg(unix)]
    -    // Do not wait for a FIFO writer before inspecting the opened handle.
    -    options.custom_flags(libc::O_NONBLOCK);
    -    let file = match options.open(path) {
    -        Ok(file) => file,
    -        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
    -        Err(error) => {
    -            return Err(failure(&format!(
    -                "registration read failed ({:?})",
    -                error.kind()
    -            )))
    -        }
    -    };
    -    if !file
    -        .metadata()
    -        .map_err(|error| failure(&format!("registration read failed ({:?})", error.kind())))?
    -        .is_file()
    -    {
    -        return Err(failure("invalid managed service registration file type"));
    -    }
    -    let mut bytes = Vec::new();
    -    file.take(REGISTRATION_MAX_BYTES + 1)
    -        .read_to_end(&mut bytes)
    -        .map_err(|error| failure(&format!("registration read failed ({:?})", error.kind())))?;
    -    remaining(deadline)?;
    -    if bytes.len() as u64 > REGISTRATION_MAX_BYTES {
    -        return Err(failure("registration exceeds the 8 KiB limit"));
    -    }
    -    let result = decode_json(&bytes, "managed service registration");
    -    drop(bytes);
    -    result.map(Some)
    -}
    -
    -fn connect_at(
    -    path: &Path,
    -    deadline: Instant,
    -    bootstrap: impl FnOnce(Instant) -> Result<(), AppError>,
    -) -> Result {
    -    remaining(deadline)?;
    -    let client = client()?;
    -    if let Some(registration) = read_registration(path, deadline)? {
    -        let connection = ManagedConnection::new(client.clone(), registration)?;
    -        if connection.healthy(deadline)? {
    -            return Ok(connection);
    -        }
    -    }
    -    bootstrap(deadline)?;
    -    let registration = read_registration(path, deadline)?.ok_or_else(|| {
    -        failure("managed local service registration unavailable after CLI bootstrap")
    -    })?;
    -    let connection = ManagedConnection::new(client, registration)?;
    -    if !connection.healthy(deadline)? {
    -        return Err(failure(
    -            "managed API unavailable or registration identity mismatch",
    -        ));
    -    }
    -    Ok(connection)
    -}
    -
    -fn client() -> Result {
    -    Client::builder()
    -        .no_proxy()
    -        .redirect(reqwest::redirect::Policy::none())
    -        .retry(reqwest::retry::never())
    -        .connect_timeout(HEALTH_TIMEOUT)
    -        .build()
    -        .map_err(|_| failure("managed API client initialization failed"))
    -}
    -
    -fn bootstrap_command(binary: &Path) -> Command {
    -    let mut command = Command::new(binary);
    -    command
    -        .args(["api", "get", "/api/info"])
    -        .stdin(Stdio::null())
    -        .stdout(Stdio::piped())
    -        .stderr(Stdio::null());
    -    command
    -}
    -
    -fn bootstrap_cli(candidates: &[PathBuf], deadline: Instant) -> Result<(), AppError> {
    -    let mut last_error = None;
    -    for (index, binary) in candidates.iter().enumerate() {
    -        remaining(deadline)?;
    -        if !binary.exists() && index + 1 < candidates.len() {
    -            continue;
    -        }
    -        let child = match bootstrap_command(binary).spawn() {
    -            Ok(child) => child,
    -            Err(error) => {
    -                last_error = Some(error.kind());
    -                continue;
    -            }
    -        };
    -        let output = collect_output(
    -            child,
    -            deadline
    -                .saturating_duration_since(Instant::now())
    -                .min(BOOTSTRAP_TIMEOUT),
    -            INFO_MAX_BYTES,
    -            "opencode managed API bootstrap",
    -            "32 KiB",
    -        )?;
    -        remaining(deadline)?;
    -        if !output.status.success() {
    -            return Err(failure(
    -                "CLI bootstrap failed; managed OpenCode V2 service is required",
    -            ));
    -        }
    -        let info: super::super::active::JsonObject =
    -            decode_json(&output.stdout, "CLI bootstrap health")?;
    -        validate_info(&info.0)?;
    -        return Ok(());
    -    }
    -    Err(failure(&format!(
    -        "CLI bootstrap spawn failed ({last_error:?})"
    -    )))
    -}
    -
    -pub(in crate::opencode_cli) fn connect(deadline: Instant) -> Result {
    -    connect_snapshot(target::snapshot()?, deadline)
    -}
    -
    -pub(in crate::opencode_cli) fn connect_snapshot(
    -    snapshot: Snapshot,
    -    deadline: Instant,
    -) -> Result {
    -    let registration = snapshot.registration()?.to_owned();
    -    connect_target(snapshot, ®istration, deadline, |deadline| {
    -        bootstrap_cli(&opencode_candidates(), deadline)
    -    })
    -}
    -
    -pub(in crate::opencode_cli) fn connect_target(
    -    snapshot: Snapshot,
    -    registration: &Path,
    -    deadline: Instant,
    -    bootstrap: impl FnOnce(Instant) -> Result<(), AppError>,
    -) -> Result {
    -    let mut connection = if snapshot.selection.url.is_some() {
    -        explicit_connection(&snapshot.selection, deadline)?
    -    } else {
    -        connect_at(registration, deadline, |deadline| {
    -            let _reservation = snapshot.reserve_side_effect(deadline)?;
    -            bootstrap(deadline)
    -        })?
    -    };
    -    snapshot.ensure_current()?;
    -    connection.snapshot = snapshot;
    -    Ok(connection)
    -}
    -
    -fn explicit_connection(
    -    selection: &Selection,
    -    deadline: Instant,
    -) -> Result {
    -    let url = selection
    -        .url
    -        .as_deref()
    -        .ok_or_else(|| failure("explicit endpoint unavailable"))?;
    -    let url = target::normalize_url(url).map_err(|_| failure("invalid explicit endpoint"))?;
    -    let url = Url::parse(&url).map_err(|_| failure("invalid explicit endpoint"))?;
    -    let client = client()?;
    -    let request = client
    -        .get(api_url(&url, &["info"])?)
    -        .timeout(remaining(deadline)?.min(HEALTH_TIMEOUT));
    -    let request = match &selection.password {
    -        Some(password) => request.basic_auth("opencode", Some(password)),
    -        None => request,
    -    };
    -    let response = request.send().map_err(request_failure)?;
    -    let info: super::super::active::JsonObject =
    -        response_json(response, deadline, INFO_MAX_BYTES, "health", "32 KiB")?;
    -    validate_info(&info.0)?;
    -    Ok(ManagedConnection {
    -        client,
    -        url: url.clone(),
    -        registration: Registration {
    -            id: uuid::Uuid::new_v4().to_string(),
    -            url: url.to_string(),
    -            pid: info.0.pid,
    -            version: info.0.version,
    -            password: selection.password.clone(),
    -        },
    -        snapshot: Snapshot::detached(selection.clone()),
    -    })
    -}
    -
    -pub(crate) fn service_status(
    -    snapshot: Snapshot,
    -    deadline: Instant,
    -) -> Result {
    -    let target = snapshot.selection.public();
    -    let result = (|| {
    -        if snapshot.selection.url.is_some() {
    -            explicit_connection(&snapshot.selection, deadline)
    -        } else {
    -            let registration = read_registration(snapshot.registration()?, deadline)?;
    -            match registration {
    -                Some(registration) => {
    -                    let connection = ManagedConnection::new(client()?, registration)?;
    -                    if connection.healthy(deadline)? {
    -                        Ok(connection)
    -                    } else {
    -                        Err(failure("shared service unavailable or identity mismatch"))
    -                    }
    -                }
    -                None => Err(failure("shared service registration unavailable")),
    -            }
    -        }
    -    })();
    -    snapshot.ensure_current()?;
    -    Ok(match result {
    -        Ok(connection) => OpenCodeServiceStatus {
    -            target,
    -            connected: true,
    -            url: Some(connection.url.as_str().trim_end_matches('/').into()),
    -            pid: Some(connection.registration.pid),
    -            version: Some(connection.registration.version),
    -            error: None,
    -        },
    -        Err(error) => OpenCodeServiceStatus {
    -            target,
    -            connected: false,
    -            url: None,
    -            pid: None,
    -            version: None,
    -            error: Some(error.message),
    -        },
    -    })
    -}
    -
    -#[cfg(test)]
    -pub(in crate::opencode_cli) fn test_connection(url: &str, deadline: Instant) -> ManagedConnection {
    -    let connection = ManagedConnection::new(
    -        client().unwrap(),
    -        Registration {
    -            id: uuid::Uuid::new_v4().to_string(),
    -            version: "2.0.22".into(),
    -            url: url.into(),
    -            pid: 42,
    -            password: Some("private".into()),
    -        },
    -    )
    -    .unwrap();
    -    assert!(connection.healthy(deadline).unwrap());
    -    connection
    -}
    -
    -#[cfg(test)]
    -mod tests;
    diff --git a/src-tauri/src/power/tests.rs b/src-tauri/src/power/tests.rs
    index dfd4e8f..fecbba1 100644
    --- a/src-tauri/src/power/tests.rs
    +++ b/src-tauri/src/power/tests.rs
    @@ -140,6 +140,153 @@ fn observe(engine: &mut Engine, result: Result, now: Inst
         engine.observation(generation, result, now);
     }
     
    +#[test]
    +fn worker_unwind_attempts_cleanup_without_panicking_or_recovering_poison() {
    +    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
    +
    +    struct UnwindMock {
    +        stops: Arc,
    +        unlocked: Arc,
    +        effects: Arc>,
    +        stop_failure: u8,
    +    }
    +
    +    impl PowerController for UnwindMock {
    +        fn prepare(&mut self) -> Result<(), AppError> {
    +            Ok(())
    +        }
    +        fn busy(&mut self, _: Duration) -> Result<(), AppError> {
    +            panic!("injected controller unwind");
    +        }
    +        fn idle(&mut self) -> Result<(), AppError> {
    +            Ok(())
    +        }
    +        fn stop(&mut self) -> Result<(), AppError> {
    +            self.stops.fetch_add(1, Ordering::SeqCst);
    +            self.unlocked.store(
    +                !matches!(
    +                    self.effects.try_lock(),
    +                    Err(std::sync::TryLockError::WouldBlock)
    +                ),
    +                Ordering::SeqCst,
    +            );
    +            match self.stop_failure {
    +                0 => Ok(()),
    +                1 => Err(AppError::configuration("injected cleanup failure")),
    +                _ => panic!("injected cleanup unwind"),
    +            }
    +        }
    +    }
    +
    +    for stop_failure in 0..3 {
    +        let fixture = engine(true);
    +        let stops = Arc::new(AtomicUsize::new(0));
    +        let unlocked = Arc::new(AtomicBool::new(false));
    +        let controller = UnwindMock {
    +            stops: Arc::clone(&stops),
    +            unlocked: Arc::clone(&unlocked),
    +            effects: Arc::clone(&fixture.effects),
    +            stop_failure,
    +        };
    +        let shared = Arc::clone(&fixture.shared);
    +        let effects = Arc::clone(&fixture.effects);
    +        let unwind = std::panic::catch_unwind(move || {
    +            let mut worker = Engine::new(controller, shared, effects);
    +            let now = Instant::now();
    +            worker.tick(now);
    +            let generation = worker.start_query(now).unwrap();
    +            worker.observation(generation, Ok(1), now);
    +        });
    +        assert!(unwind.is_err());
    +        assert_eq!(stops.load(Ordering::SeqCst), 1);
    +        assert!(unlocked.load(Ordering::SeqCst));
    +        assert!(fixture.effects.is_poisoned());
    +    }
    +
    +    let fixture = engine(true);
    +    let stops = Arc::new(AtomicUsize::new(0));
    +    let unlocked = Arc::new(AtomicBool::new(false));
    +    let controller = UnwindMock {
    +        stops: Arc::clone(&stops),
    +        unlocked: Arc::clone(&unlocked),
    +        effects: Arc::clone(&fixture.effects),
    +        stop_failure: 0,
    +    };
    +    let shared = Arc::clone(&fixture.shared);
    +    let effects = Arc::clone(&fixture.effects);
    +    let unwind = std::panic::catch_unwind(move || {
    +        let mut worker = Engine::new(controller, shared, effects);
    +        assert!(worker.stop().is_ok());
    +        panic!("injected post-stop unwind");
    +    });
    +    assert!(unwind.is_err());
    +    assert_eq!(stops.load(Ordering::SeqCst), 1);
    +    assert!(unlocked.load(Ordering::SeqCst));
    +}
    +
    +#[test]
    +fn disconnected_worker_final_cleanup_retries_before_completion_and_deduplicates_success() {
    +    struct StopMock {
    +        fail_initial: bool,
    +        stops: usize,
    +        effects: Arc>,
    +        completion: mpsc::Receiver<()>,
    +    }
    +
    +    impl PowerController for StopMock {
    +        fn prepare(&mut self) -> Result<(), AppError> {
    +            panic!("disabled worker must not prepare");
    +        }
    +        fn busy(&mut self, _: Duration) -> Result<(), AppError> {
    +            panic!("disabled worker must not protect");
    +        }
    +        fn idle(&mut self) -> Result<(), AppError> {
    +            Ok(())
    +        }
    +        fn stop(&mut self) -> Result<(), AppError> {
    +            assert!(matches!(
    +                self.effects.try_lock(),
    +                Err(std::sync::TryLockError::WouldBlock)
    +            ));
    +            assert!(matches!(
    +                self.completion.try_recv(),
    +                Err(mpsc::TryRecvError::Empty)
    +            ));
    +            self.stops += 1;
    +            if self.fail_initial && self.stops == 1 {
    +                Err(AppError::configuration("injected first stop failure"))
    +            } else {
    +                Ok(())
    +            }
    +        }
    +    }
    +
    +    for fail_initial in [true, false] {
    +        let fixture = engine(false);
    +        let (finished, completion) = mpsc::channel();
    +        let controller = StopMock {
    +            fail_initial,
    +            stops: 0,
    +            effects: Arc::clone(&fixture.effects),
    +            completion,
    +        };
    +        let mut worker = Engine::new(
    +            controller,
    +            Arc::clone(&fixture.shared),
    +            Arc::clone(&fixture.effects),
    +        );
    +        let (wake, events) = mpsc::channel();
    +        drop(wake);
    +        run_worker(&mut worker, events);
    +        assert_eq!(worker.controller.stops, 1);
    +        finish_worker(&mut worker, finished);
    +        assert_eq!(worker.controller.stops, if fail_initial { 2 } else { 1 });
    +        assert!(worker.stopped);
    +        assert_eq!(worker.controller.completion.try_recv(), Ok(()));
    +        assert!(fixture.effects.try_lock().is_ok());
    +    }
    +}
    +
     fn set(engine: &Engine, enabled: bool) {
         let mut shared = engine.shared.lock().unwrap();
         shared.desired.enabled = enabled;
    diff --git a/src-tauri/src/resource_file/tests.rs b/src-tauri/src/resource_file/tests.rs
    index cd83acc..83a14e7 100644
    --- a/src-tauri/src/resource_file/tests.rs
    +++ b/src-tauri/src/resource_file/tests.rs
    @@ -142,7 +142,7 @@ fn snapshot_rejects_final_mode_and_identity_drift_keeps_private_backup_and_remov
             let mut backups = 0;
             for entry in fs::read_dir(&fixture.0).unwrap() {
                 let path = entry.unwrap().path();
    -            assert!(!path.extension().is_some_and(|extension| extension == "tmp"));
    +            assert!(path.extension().is_none_or(|extension| extension != "tmp"));
                 if path.extension().is_some_and(|extension| extension == "bak") {
                     backups += 1;
                     assert_eq!(fs::read_to_string(&path).unwrap(), "private original body");
    @@ -226,7 +226,7 @@ fn failed_multi_file_publish_retains_all_private_complete_backups() {
             let mut originals = Vec::new();
             for entry in fs::read_dir(&fixture.0).unwrap() {
                 let path = entry.unwrap().path();
    -            assert!(!path.extension().is_some_and(|extension| extension == "tmp"));
    +            assert!(path.extension().is_none_or(|extension| extension != "tmp"));
                 if path.extension().is_some_and(|extension| extension == "bak") {
                     originals.push(fs::read_to_string(&path).unwrap());
                     assert_eq!(
    diff --git a/src-tauri/tests/app.rs b/src-tauri/tests/app.rs
    index 4219897..1944a31 100644
    --- a/src-tauri/tests/app.rs
    +++ b/src-tauri/tests/app.rs
    @@ -144,11 +144,10 @@ fn provider_crud_round_trip() {
             &providers::ModelRef::new("acme", "turbo").unwrap(),
         )
         .unwrap();
    -    assert!(providers::get_provider(&opencode, "acme")
    +    assert!(!providers::get_provider(&opencode, "acme")
             .unwrap()
             .models
    -        .get("turbo")
    -        .is_none());
    +        .contains_key("turbo"));
     
         // Delete with models is blocked.
         let error = providers::delete_provider(&opencode, "acme").unwrap_err();
    @@ -577,7 +576,7 @@ fn project_folder_lifecycle_preserves_files_and_normalizes_stale_selection() {
             added
         );
         let config = load_config(&home);
    -    assert_eq!(config.projects, [added.clone()]);
    +    assert_eq!(config.projects.as_slice(), std::slice::from_ref(&added));
         assert_eq!(config.state.selected_project_id, Some(added.id));
         models::select_project(&config_file, None).unwrap();
         assert_eq!(load_config(&home).state.selected_project_id, None);
    @@ -641,6 +640,7 @@ fn project_folder_symlink_dedup_unreadable_and_non_utf8_validation() {
         );
         let scoped = home.paths.for_project(Some(added.id)).unwrap();
         fs::set_permissions(&root, fs::Permissions::from_mode(0o0)).unwrap();
    +    // SAFETY: geteuid takes no pointers and only reads the effective UID for this fixture's permission check.
         let privileged = unsafe { libc::geteuid() } == 0;
         let unreadable_add = models::add_project(&home.paths.config_file(), root.to_str().unwrap());
         let unreadable_read = opencode_mom_tauri::agents::list(&scoped);
    @@ -679,7 +679,7 @@ fn group_lifecycle_preserves_selected_project_and_preferences() {
         groups::copy_group(&home.paths, saved.id, None).unwrap();
         groups::delete_group(&home.paths, saved.id).unwrap();
         let config = load_config(&home);
    -    assert_eq!(config.projects, [project.clone()]);
    +    assert_eq!(config.projects.as_slice(), std::slice::from_ref(&project));
         assert_eq!(config.state.selected_project_id, Some(project.id));
         assert_eq!(
             config.state.preferences.locale,
    @@ -706,8 +706,10 @@ fn global_agent_aliases_retain_paths_and_refuse_ambiguous_mutations() {
         .unwrap();
         assert_eq!(agents::list(&home.paths).unwrap().len(), 1);
         assert_eq!(
    -        agents::collect_markdown_files_in_roots(&home.paths).unwrap(),
    -        [singular.clone()]
    +        agents::collect_markdown_files_in_roots(&home.paths)
    +            .unwrap()
    +            .as_slice(),
    +        std::slice::from_ref(&singular)
         );
         assert_eq!(agents::model_references(&home.paths).unwrap().len(), 1);
         agents::update(
    diff --git a/src-tauri/tests/resources.rs b/src-tauri/tests/resources.rs
    index 02c1a5b..7d7fa02 100644
    --- a/src-tauri/tests/resources.rs
    +++ b/src-tauri/tests/resources.rs
    @@ -334,7 +334,7 @@ fn project_resource_ipc_mutations_are_refused_before_any_write() {
             serde_json::from_value(json!({"id":"new", "source":"inline", "storage":"inline"})).unwrap();
         for id in [project.id, uuid::Uuid::new_v4()] {
             let state = || app.state::();
    -        let errors = vec![
    +        let errors = [
                 commands::create_agent(state(), agent.clone(), Some(id)).unwrap_err(),
                 commands::update_agent(state(), agent.clone(), Some(id)).unwrap_err(),
                 commands::delete_agent(state(), "helper".to_owned(), None, Some(id)).unwrap_err(),
    @@ -409,7 +409,7 @@ fn project_resource_ipc_mutations_are_refused_before_any_write() {
         assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
         assert!(!paths.native_global_skills_dir().exists());
         let response = commands::load_app_state(app.state()).unwrap();
    -    assert_eq!(response.projects, [project.clone()]);
    +    assert_eq!(response.projects.as_slice(), std::slice::from_ref(&project));
         assert_eq!(response.selected_project_id, Some(project.id));
         assert_eq!(response.agents.len(), 1);
         assert_eq!(commands::list_agents(app.state(), None).unwrap().len(), 1);
    @@ -708,6 +708,7 @@ fn project_scope_rejects_pinned_fixed_config_but_not_an_unrelated_explicit_filen
     #[test]
     fn unrelated_unreadable_global_resources_do_not_block_project_browsing() {
         use std::os::unix::fs::PermissionsExt;
    +    // SAFETY: geteuid takes no pointers and only reads the effective UID to skip root's permission bypass.
         if unsafe { libc::geteuid() } == 0 {
             return;
         }
    
    From 9336eb9809cd754424dcb7c47f4717dbdecc506d Mon Sep 17 00:00:00 2001
    From: seho 
    Date: Fri, 23 Oct 2026 22:01:42 +0800
    Subject: [PATCH 62/97] fix: rename opencode_cli module
    
    ---
     src-tauri/src/commands/cli.rs | 113 ----------------------------------
     1 file changed, 113 deletions(-)
     delete mode 100644 src-tauri/src/commands/cli.rs
    
    diff --git a/src-tauri/src/commands/cli.rs b/src-tauri/src/commands/cli.rs
    deleted file mode 100644
    index 8a224e6..0000000
    --- a/src-tauri/src/commands/cli.rs
    +++ /dev/null
    @@ -1,113 +0,0 @@
    -use tauri::{AppHandle, Manager, State};
    -
    -use super::spawn_blocking_task;
    -use crate::error::AppError;
    -use crate::opencode_cli::{
    -    self, installation, ModelCatalogEntry, OpenCodeInstallation, OpenCodeServiceStatus,
    -    OpenCodeUpdateCheck, TokenUsageRecord,
    -};
    -use crate::paths::ConfigPaths;
    -
    -type CommandResult = Result;
    -
    -#[tauri::command]
    -pub async fn opencode_list_models(
    -    state: State<'_, ConfigPaths>,
    -    provider: Option,
    -) -> CommandResult> {
    -    let paths = state.inner().clone();
    -    spawn_blocking_task("OpenCode model catalog", move || {
    -        opencode_cli::list_models_via_cli(&paths, provider.as_deref())
    -    })
    -    .await
    -}
    -
    -#[tauri::command]
    -pub async fn opencode_get_service_status() -> CommandResult {
    -    spawn_blocking_task("OpenCode service status", opencode_cli::target::get_status).await
    -}
    -
    -#[tauri::command]
    -pub async fn opencode_set_service_target(
    -    app: AppHandle,
    -    url: Option,
    -    password: Option,
    -) -> CommandResult {
    -    spawn_blocking_task("OpenCode service target", move || {
    -        let power = app.state::();
    -        let island = app.state::();
    -        opencode_cli::target::set_target(
    -            url,
    -            password,
    -            || {
    -                Ok((
    -                    power.prepare_target_refresh()?,
    -                    island.prepare_target_refresh(),
    -                ))
    -            },
    -            |(power, island)| {
    -                power.invalidate();
    -                island.invalidate()
    -            },
    -            |generation| {
    -                power.finish_target_refresh();
    -                if island.finish_target_refresh(&app, generation).is_err() {
    -                    eprintln!("OpenCode target saved; Mini Island refresh unavailable");
    -                }
    -            },
    -        )
    -    })
    -    .await
    -}
    -
    -#[tauri::command]
    -pub fn opencode_resolve_binary() -> CommandResult {
    -    Ok(opencode_cli::resolve_opencode_binary()
    -        .display()
    -        .to_string())
    -}
    -
    -#[tauri::command]
    -pub async fn opencode_get_installation() -> CommandResult {
    -    spawn_blocking_task("OpenCode installation", installation::get_installation).await
    -}
    -
    -#[tauri::command]
    -pub async fn opencode_check_update() -> CommandResult {
    -    spawn_blocking_task("OpenCode update check", installation::check_for_update).await
    -}
    -
    -#[tauri::command]
    -pub async fn opencode_upgrade(
    -    expected_binary_path: String,
    -    expected_version: String,
    -    target_version: String,
    -) -> CommandResult {
    -    spawn_blocking_task("OpenCode upgrade", move || {
    -        installation::upgrade(&expected_binary_path, &expected_version, &target_version)
    -    })
    -    .await
    -}
    -
    -#[tauri::command]
    -pub async fn opencode_reload() -> CommandResult<()> {
    -    spawn_blocking_task("OpenCode reload", opencode_cli::reload_via_cli).await
    -}
    -
    -#[tauri::command]
    -pub async fn opencode_active_sessions() -> CommandResult {
    -    spawn_blocking_task(
    -        "OpenCode active sessions",
    -        opencode_cli::active_sessions_via_cli,
    -    )
    -    .await
    -}
    -
    -#[tauri::command]
    -pub async fn opencode_token_usage_records() -> CommandResult> {
    -    spawn_blocking_task(
    -        "OpenCode token usage",
    -        opencode_cli::token_usage_records_via_cli,
    -    )
    -    .await
    -}
    
    From 8b4ef5f163384b85c830d2751cb283ebd6c49723 Mon Sep 17 00:00:00 2001
    From: seho 
    Date: Fri, 23 Oct 2026 22:23:52 +0800
    Subject: [PATCH 63/97] refactor: optimizing Rust Code
    
    ---
     src-tauri/Cargo.lock                          | 323 ++++++-
     src-tauri/Cargo.toml                          |  30 +-
     src-tauri/native/macos-helper/Cargo.toml      |   8 +
     src-tauri/native/macos-helper/src/platform.rs |  40 +-
     src-tauri/native/macos-helper/src/protocol.rs |  12 +
     src-tauri/src/agents.rs                       |  11 +-
     src-tauri/src/agents_md.rs                    |   7 +-
     src-tauri/src/commands/island.rs              |   2 +-
     src-tauri/src/commands/opencode.rs            | 111 +++
     src-tauri/src/document.rs                     |  31 +-
     src-tauri/src/groups.rs                       |  13 +-
     src-tauri/src/island/hover.rs                 |  18 +
     src-tauri/src/island/mod.rs                   |  35 +-
     src-tauri/src/island/window.rs                |  22 +-
     src-tauri/src/jsonc.rs                        |  75 +-
     src-tauri/src/lib.rs                          | 106 ++-
     src-tauri/src/mcp.rs                          |  36 +-
     src-tauri/src/opencode/active.rs              | 118 +++
     src-tauri/src/opencode/binary.rs              | 141 +++
     src-tauri/src/opencode/command.rs             | 178 ++++
     src-tauri/src/opencode/installation.rs        | 886 ++++++++++++++++++
     src-tauri/src/opencode/island.rs              | 351 +++++++
     src-tauri/src/opencode/island/events.rs       | 190 ++++
     src-tauri/src/opencode/island/projection.rs   | 654 +++++++++++++
     src-tauri/src/opencode/mod.rs                 |  18 +
     src-tauri/src/opencode/models.rs              | 145 +++
     src-tauri/src/opencode/reload.rs              |  13 +
     src-tauri/src/opencode/target.rs              | 468 +++++++++
     src-tauri/src/opencode/target/windows.rs      | 113 +++
     src-tauri/src/opencode/token_usage.rs         | 554 +++++++++++
     src-tauri/src/power/macos.rs                  |  24 +-
     src-tauri/src/power/mod.rs                    |  92 +-
     src-tauri/src/power/settings.rs               |   2 +
     src-tauri/src/power/windows.rs                |  46 +-
     src-tauri/src/providers.rs                    |  38 +-
     src-tauri/src/refs.rs                         |   6 +-
     src-tauri/src/skills.rs                       |   4 +-
     37 files changed, 4762 insertions(+), 159 deletions(-)
     create mode 100644 src-tauri/src/commands/opencode.rs
     create mode 100644 src-tauri/src/opencode/active.rs
     create mode 100644 src-tauri/src/opencode/binary.rs
     create mode 100644 src-tauri/src/opencode/command.rs
     create mode 100644 src-tauri/src/opencode/installation.rs
     create mode 100644 src-tauri/src/opencode/island.rs
     create mode 100644 src-tauri/src/opencode/island/events.rs
     create mode 100644 src-tauri/src/opencode/island/projection.rs
     create mode 100644 src-tauri/src/opencode/mod.rs
     create mode 100644 src-tauri/src/opencode/models.rs
     create mode 100644 src-tauri/src/opencode/reload.rs
     create mode 100644 src-tauri/src/opencode/target.rs
     create mode 100644 src-tauri/src/opencode/target/windows.rs
     create mode 100644 src-tauri/src/opencode/token_usage.rs
    
    diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
    index 57adc37..e66d3e7 100644
    --- a/src-tauri/Cargo.lock
    +++ b/src-tauri/Cargo.lock
    @@ -232,6 +232,52 @@ dependencies = [
      "pkg-config",
     ]
     
    +[[package]]
    +name = "axum"
    +version = "0.8.9"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
    +dependencies = [
    + "axum-core",
    + "bytes",
    + "futures-util",
    + "http",
    + "http-body",
    + "http-body-util",
    + "hyper",
    + "hyper-util",
    + "itoa",
    + "matchit",
    + "memchr",
    + "mime",
    + "percent-encoding",
    + "pin-project-lite",
    + "serde_core",
    + "sync_wrapper",
    + "tokio",
    + "tower",
    + "tower-layer",
    + "tower-service",
    +]
    +
    +[[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",
    +]
    +
     [[package]]
     name = "base64"
     version = "0.21.7"
    @@ -244,6 +290,12 @@ version = "0.22.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
     
    +[[package]]
    +name = "base64"
    +version = "0.23.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
    +
     [[package]]
     name = "bit-set"
     version = "0.8.0"
    @@ -468,7 +520,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
     dependencies = [
      "cfg-if",
      "cpufeatures 0.3.1",
    - "rand_core",
    + "rand_core 0.10.1",
     ]
     
     [[package]]
    @@ -633,7 +685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
     dependencies = [
      "quote",
    - "syn 2.0.118",
    + "syn 3.0.9",
     ]
     
     [[package]]
    @@ -1053,11 +1105,26 @@ version = "1.3.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
     
    +[[package]]
    +name = "futures"
    +version = "0.3.34"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
    +dependencies = [
    + "futures-channel",
    + "futures-core",
    + "futures-executor",
    + "futures-io",
    + "futures-sink",
    + "futures-task",
    + "futures-util",
    +]
    +
     [[package]]
     name = "futures-channel"
    -version = "0.3.32"
    +version = "0.3.34"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
    +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
     dependencies = [
      "futures-core",
      "futures-sink",
    @@ -1065,15 +1132,15 @@ dependencies = [
     
     [[package]]
     name = "futures-core"
    -version = "0.3.32"
    +version = "0.3.34"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
    +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
     
     [[package]]
     name = "futures-executor"
    -version = "0.3.32"
    +version = "0.3.34"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
    +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
     dependencies = [
      "futures-core",
      "futures-task",
    @@ -1082,9 +1149,9 @@ dependencies = [
     
     [[package]]
     name = "futures-io"
    -version = "0.3.32"
    +version = "0.3.34"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
    +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
     
     [[package]]
     name = "futures-lite"
    @@ -1101,33 +1168,34 @@ dependencies = [
     
     [[package]]
     name = "futures-macro"
    -version = "0.3.32"
    +version = "0.3.34"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
    +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.118",
    + "syn 3.0.9",
     ]
     
     [[package]]
     name = "futures-sink"
    -version = "0.3.32"
    +version = "0.3.34"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
    +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
     
     [[package]]
     name = "futures-task"
    -version = "0.3.32"
    +version = "0.3.34"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
    +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
     
     [[package]]
     name = "futures-util"
    -version = "0.3.32"
    +version = "0.3.34"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
    +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
     dependencies = [
    + "futures-channel",
      "futures-core",
      "futures-io",
      "futures-macro",
    @@ -1255,7 +1323,7 @@ dependencies = [
      "js-sys",
      "libc",
      "r-efi 6.0.0",
    - "rand_core",
    + "rand_core 0.10.1",
      "wasm-bindgen",
     ]
     
    @@ -1492,6 +1560,12 @@ version = "1.10.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
     
    +[[package]]
    +name = "httpdate"
    +version = "1.0.3"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
    +
     [[package]]
     name = "hyper"
     version = "1.10.1"
    @@ -1505,6 +1579,7 @@ dependencies = [
      "http",
      "http-body",
      "httparse",
    + "httpdate",
      "itoa",
      "pin-project-lite",
      "smallvec",
    @@ -1989,6 +2064,12 @@ dependencies = [
      "web_atoms",
     ]
     
    +[[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.3"
    @@ -2088,6 +2169,18 @@ version = "1.0.6"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
     
    +[[package]]
    +name = "nix"
    +version = "0.31.3"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
    +dependencies = [
    + "bitflags 2.13.0",
    + "cfg-if",
    + "cfg_aliases",
    + "libc",
    +]
    +
     [[package]]
     name = "num-conv"
     version = "0.2.2"
    @@ -2332,18 +2425,26 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
     name = "opencode-mom"
     version = "2.0.0"
     dependencies = [
    + "axum",
      "block2",
      "dirs 6.0.0",
    + "futures",
      "jsonc-parser",
      "libc",
      "objc2",
      "objc2-app-kit",
      "objc2-foundation",
      "percent-encoding",
    + "process-wrap",
    + "rand 0.8.8",
      "reqwest",
    + "rmcp",
      "serde",
      "serde_json",
      "serde_yaml",
    + "sha2",
    + "sse-stream",
    + "subtle",
      "tauri",
      "tauri-build",
      "tauri-plugin-autostart",
    @@ -2352,6 +2453,7 @@ dependencies = [
      "tempfile",
      "time",
      "tokio",
    + "tokio-util",
      "uuid",
      "windows-sys 0.61.2",
     ]
    @@ -2432,6 +2534,12 @@ dependencies = [
      "windows-link",
     ]
     
    +[[package]]
    +name = "pastey"
    +version = "0.2.3"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
    +
     [[package]]
     name = "percent-encoding"
     version = "2.3.2"
    @@ -2582,6 +2690,15 @@ version = "0.2.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
     
    +[[package]]
    +name = "ppv-lite86"
    +version = "0.2.21"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
    +dependencies = [
    + "zerocopy",
    +]
    +
     [[package]]
     name = "precomputed-hash"
     version = "0.1.1"
    @@ -2650,6 +2767,19 @@ dependencies = [
      "unicode-ident",
     ]
     
    +[[package]]
    +name = "process-wrap"
    +version = "10.0.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "1f21b97672d2dc848e7b25701ab4535618b92f4861c13cc3f7f7bed52ad3c8da"
    +dependencies = [
    + "futures",
    + "indexmap 2.14.0",
    + "nix",
    + "tokio",
    + "windows",
    +]
    +
     [[package]]
     name = "quick-xml"
     version = "0.41.0"
    @@ -2689,7 +2819,7 @@ dependencies = [
      "bytes",
      "getrandom 0.4.3",
      "lru-slab",
    - "rand",
    + "rand 0.10.3",
      "rand_pcg",
      "ring",
      "rustc-hash",
    @@ -2737,6 +2867,17 @@ version = "6.0.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
     
    +[[package]]
    +name = "rand"
    +version = "0.8.8"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
    +dependencies = [
    + "libc",
    + "rand_chacha",
    + "rand_core 0.6.4",
    +]
    +
     [[package]]
     name = "rand"
     version = "0.10.3"
    @@ -2745,7 +2886,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
     dependencies = [
      "chacha20",
      "getrandom 0.4.3",
    - "rand_core",
    + "rand_core 0.10.1",
    +]
    +
    +[[package]]
    +name = "rand_chacha"
    +version = "0.3.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
    +dependencies = [
    + "ppv-lite86",
    + "rand_core 0.6.4",
    +]
    +
    +[[package]]
    +name = "rand_core"
    +version = "0.6.4"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
    +dependencies = [
    + "getrandom 0.2.17",
     ]
     
     [[package]]
    @@ -2760,7 +2920,7 @@ version = "0.10.2"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
     dependencies = [
    - "rand_core",
    + "rand_core 0.10.1",
     ]
     
     [[package]]
    @@ -2929,6 +3089,38 @@ dependencies = [
      "windows-sys 0.52.0",
     ]
     
    +[[package]]
    +name = "rmcp"
    +version = "3.6.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "ceb42e9e47443cd83a834dea57b5816d35481417bae72ade5dc5615c59122e26"
    +dependencies = [
    + "async-trait",
    + "base64 0.23.1",
    + "bytes",
    + "chrono",
    + "futures",
    + "http",
    + "http-body",
    + "http-body-util",
    + "indexmap 2.14.0",
    + "libc",
    + "pastey",
    + "pin-project-lite",
    + "rand 0.10.3",
    + "schemars 1.2.1",
    + "serde",
    + "serde_json",
    + "sse-stream",
    + "thiserror 2.0.18",
    + "tokio",
    + "tokio-stream",
    + "tokio-util",
    + "tower-service",
    + "tracing",
    + "uuid",
    +]
    +
     [[package]]
     name = "rustc-hash"
     version = "2.1.3"
    @@ -3070,7 +3262,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
     dependencies = [
      "dyn-clone",
      "indexmap 1.9.3",
    - "schemars_derive",
    + "schemars_derive 0.8.22",
      "serde",
      "serde_json",
      "url",
    @@ -3095,8 +3287,10 @@ version = "1.2.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
     dependencies = [
    + "chrono",
      "dyn-clone",
      "ref-cast",
    + "schemars_derive 1.2.1",
      "serde",
      "serde_json",
     ]
    @@ -3113,6 +3307,18 @@ dependencies = [
      "syn 2.0.118",
     ]
     
    +[[package]]
    +name = "schemars_derive"
    +version = "1.2.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
    +dependencies = [
    + "proc-macro2",
    + "quote",
    + "serde_derive_internals",
    + "syn 2.0.118",
    +]
    +
     [[package]]
     name = "scopeguard"
     version = "1.2.0"
    @@ -3479,6 +3685,19 @@ dependencies = [
      "system-deps",
     ]
     
    +[[package]]
    +name = "sse-stream"
    +version = "0.2.6"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4"
    +dependencies = [
    + "bytes",
    + "futures-util",
    + "http-body",
    + "http-body-util",
    + "pin-project-lite",
    +]
    +
     [[package]]
     name = "stable_deref_trait"
     version = "1.2.1"
    @@ -3553,6 +3772,17 @@ dependencies = [
      "unicode-ident",
     ]
     
    +[[package]]
    +name = "syn"
    +version = "3.0.9"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
    +dependencies = [
    + "proc-macro2",
    + "quote",
    + "unicode-ident",
    +]
    +
     [[package]]
     name = "sync_wrapper"
     version = "1.0.2"
    @@ -4065,10 +4295,23 @@ dependencies = [
      "libc",
      "mio",
      "pin-project-lite",
    + "signal-hook-registry",
      "socket2",
    + "tokio-macros",
      "windows-sys 0.61.2",
     ]
     
    +[[package]]
    +name = "tokio-macros"
    +version = "2.7.2"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
    +dependencies = [
    + "proc-macro2",
    + "quote",
    + "syn 3.0.9",
    +]
    +
     [[package]]
     name = "tokio-rustls"
     version = "0.26.6"
    @@ -4079,6 +4322,17 @@ dependencies = [
      "tokio",
     ]
     
    +[[package]]
    +name = "tokio-stream"
    +version = "0.1.19"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
    +dependencies = [
    + "futures-core",
    + "pin-project-lite",
    + "tokio",
    +]
    +
     [[package]]
     name = "tokio-util"
     version = "0.7.18"
    @@ -4088,6 +4342,7 @@ dependencies = [
      "bytes",
      "futures-core",
      "futures-sink",
    + "futures-util",
      "pin-project-lite",
      "tokio",
     ]
    @@ -5207,6 +5462,26 @@ dependencies = [
      "zvariant",
     ]
     
    +[[package]]
    +name = "zerocopy"
    +version = "0.8.63"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
    +dependencies = [
    + "zerocopy-derive",
    +]
    +
    +[[package]]
    +name = "zerocopy-derive"
    +version = "0.8.63"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
    +dependencies = [
    + "proc-macro2",
    + "quote",
    + "syn 2.0.118",
    +]
    +
     [[package]]
     name = "zerofrom"
     version = "0.1.8"
    diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
    index c206ef5..ab6ae4c 100644
    --- a/src-tauri/Cargo.toml
    +++ b/src-tauri/Cargo.toml
    @@ -5,9 +5,25 @@ description = "Cross-platform desktop model group switcher"
     authors = ["opencode-mom contributors"]
     license = "MIT"
     edition = "2021"
    -rust-version = "1.77.2"
    +rust-version = "1.90"
     default-run = "opencode-mom"
     
    +[lints.rust]
    +unsafe_op_in_unsafe_fn = "deny"
    +
    +[lints.clippy]
    +correctness = { level = "deny", priority = -1 }
    +undocumented_unsafe_blocks = "warn"
    +redundant_clone = "warn"
    +
    +[[bin]]
    +name = "opencode-mom"
    +path = "src/main.rs"
    +
    +[[bin]]
    +name = "mcp-test-peer"
    +path = "src/mcp_proxy/test_peer.rs"
    +
     [package.metadata]
     
     [lib]
    @@ -31,7 +47,15 @@ time = { version = "0.3", features = ["formatting", "parsing", "serde-well-known
     uuid = { version = "1.11", features = ["serde", "v4"] }
     reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "query", "rustls"] }
     percent-encoding = "2"
    -tokio = { version = "1", features = ["rt", "sync", "time"] }
    +tokio = { version = "1", features = ["rt", "sync", "time", "process", "io-util", "net"] }
    +process-wrap = { version = "=10.0.1", default-features = false, features = ["tokio1", "process-group", "job-object", "kill-on-drop"] }
    +rmcp = { version = "=3.6.0", default-features = false, features = ["client", "server", "transport-io", "transport-streamable-http-server"] }
    +axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
    +sha2 = "0.10"
    +subtle = "2.6"
    +rand = "0.8"
    +tokio-util = { version = "0.7", features = ["rt"] }
    +futures = "0.3"
     
     [target.'cfg(target_os = "macos")'.dependencies]
     block2 = "=0.6.1"
    @@ -42,6 +66,8 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
     [dev-dependencies]
     tauri = { version = "2.10.2", default-features = false, features = ["test"] }
     tempfile = "3"
    +sse-stream = "=0.2.6"
    +reqwest = { version = "0.13", default-features = false, features = ["json", "stream"] }
     
     [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
     tauri = { version = "2.10.2", default-features = false, features = ["tray-icon"] }
    diff --git a/src-tauri/native/macos-helper/Cargo.toml b/src-tauri/native/macos-helper/Cargo.toml
    index 9c00106..4adb4ee 100644
    --- a/src-tauri/native/macos-helper/Cargo.toml
    +++ b/src-tauri/native/macos-helper/Cargo.toml
    @@ -7,6 +7,14 @@ publish = false
     
     [workspace]
     
    +[lints.rust]
    +unsafe_op_in_unsafe_fn = "deny"
    +
    +[lints.clippy]
    +correctness = { level = "deny", priority = -1 }
    +undocumented_unsafe_blocks = "warn"
    +redundant_clone = "warn"
    +
     [dependencies]
     libc = "=0.2.186"
     
    diff --git a/src-tauri/native/macos-helper/src/platform.rs b/src-tauri/native/macos-helper/src/platform.rs
    index 9a8bd25..f1d073c 100644
    --- a/src-tauri/native/macos-helper/src/platform.rs
    +++ b/src-tauri/native/macos-helper/src/platform.rs
    @@ -83,7 +83,9 @@ fn pmset(arguments: &[&str]) -> Result> {
         let result = (|| {
             let mut stdout = child.stdout.take().ok_or("Missing power tool output")?;
             let fd = stdout.as_raw_fd();
    +        // SAFETY: stdout owns the live pipe fd; F_GETFL has no pointer argument.
             let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
    +        // SAFETY: stdout keeps fd alive; F_SETFL takes the integer flags read above.
             if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
                 return Err("Could not bound power tool output");
             }
    @@ -185,11 +187,14 @@ impl Backend for System {
     fn peer(stream: &UnixStream, uid: u32, pid: i32) -> Result<()> {
         let mut peer_uid = 0;
         let mut peer_gid = 0;
    +    // SAFETY: stream keeps its socket alive and both uid_t/gid_t outputs are writable.
         if unsafe { libc::getpeereid(stream.as_raw_fd(), &mut peer_uid, &mut peer_gid) } != 0 {
             return Err("Could not verify the peer user");
         }
         let mut peer_pid: libc::pid_t = 0;
         let mut length = std::mem::size_of_val(&peer_pid) as libc::socklen_t;
    +    // SAFETY: stream owns a live socket; peer_pid has exactly the advertised writable
    +    // pid_t capacity and length is a writable socklen_t, both live for this call.
         if unsafe {
             libc::getsockopt(
                 stream.as_raw_fd(),
    @@ -212,11 +217,15 @@ struct Parent(OwnedFd);
     
     impl Parent {
         fn new(pid: i32) -> Result {
    +        // SAFETY: kqueue takes no pointers or caller-provided resources.
             let fd = unsafe { libc::kqueue() };
             if fd < 0 {
                 return Err("Could not monitor the app lifetime");
             }
    +        // SAFETY: successful kqueue returned a fresh valid fd with no Rust owner;
    +        // ownership is transferred exactly once and closed by OwnedFd on every return.
             let owned = unsafe { OwnedFd::from_raw_fd(fd) };
    +        // SAFETY: owned keeps fd alive; F_SETFD accepts the integer FD_CLOEXEC flag.
             if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
                 return Err("Could not protect the lifetime monitor");
             }
    @@ -228,6 +237,8 @@ impl Parent {
                 data: 0,
                 udata: std::ptr::null_mut(),
             };
    +        // SAFETY: owned keeps the queue alive; event is one initialized kevent,
    +        // the output count is zero and the null timeout requests no timed wait.
             if unsafe { libc::kevent(fd, &event, 1, std::ptr::null_mut(), 0, std::ptr::null()) } < 0 {
                 return Err("The authorized app has already exited");
             }
    @@ -235,11 +246,15 @@ impl Parent {
         }
     
         fn alive(&self) -> bool {
    +        // SAFETY: kevent is a C struct of integer fields and a raw pointer; all-zero
    +        // is valid (including null udata) and is only used as the output buffer.
             let mut event: libc::kevent = unsafe { std::mem::zeroed() };
             let timeout = libc::timespec {
                 tv_sec: 0,
                 tv_nsec: 0,
             };
    +        // SAFETY: self owns the queue; there are no input events, event has capacity
    +        // for one output kevent, and timeout is a live initialized timespec.
             unsafe {
                 libc::kevent(
                     self.0.as_raw_fd(),
    @@ -262,6 +277,8 @@ fn set_timeouts(stream: &UnixStream, timeout: Duration) -> Result<()> {
     
     fn has_extra_bytes(stream: &UnixStream) -> bool {
         let mut byte = 0u8;
    +    // SAFETY: stream owns a live socket; byte is writable for exactly the supplied
    +    // one-byte capacity and recv does not retain its pointer.
         unsafe {
             libc::recv(
                 stream.as_raw_fd(),
    @@ -394,9 +411,11 @@ fn serve(
     }
     
     fn run_inner(ready: &mut File) -> Result<()> {
    +    // SAFETY: geteuid has no pointer arguments or caller preconditions.
         if unsafe { libc::geteuid() } != 0 {
             return Err("The helper requires administrator authorization");
         }
    +    // SAFETY: umask takes only mode bits; this helper is single-threaded before serve.
         unsafe { libc::umask(0o077) };
         let args: Vec = std::env::args().skip(1).collect();
         if args.len() != 3 {
    @@ -412,6 +431,13 @@ fn run_inner(ready: &mut File) -> Result<()> {
         if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o7777 != 0o700 {
             return Err("Unsafe root directory");
         }
    +    // SAFETY: fcntl accepts an arbitrary fd number (EBADF if absent) and an integer
    +    // F_SETFD flag. Validate the bootstrap's descriptor before taking ownership.
    +    if unsafe { libc::fcntl(3, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
    +        return Err("Missing root lock descriptor");
    +    }
    +    // SAFETY: fd 3 was verified open above and bootstrap transfers it to this
    +    // single-threaded process; no other Rust owner or concurrent closer exists.
         let lock = unsafe { File::from_raw_fd(3) };
         trusted_file(&lock, 0o600)?;
         let lock_path = fs::symlink_metadata(LOCK).map_err(|_| "Missing root lock")?;
    @@ -419,8 +445,8 @@ fn run_inner(ready: &mut File) -> Result<()> {
         if !lock_path.is_file()
             || lock_path.dev() != lock_fd.dev()
             || lock_path.ino() != lock_fd.ino()
    +        // SAFETY: lock owns the live fd; flock takes only integer operation flags.
             || unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0
    -        || unsafe { libc::fcntl(lock.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0
         {
             return Err("Another authorized helper is running");
         }
    @@ -444,6 +470,8 @@ fn run_inner(ready: &mut File) -> Result<()> {
             .map_err(|_| "Could not protect root socket")?;
         // Only the authorized uid can connect; the root-owned parent is never user-writable.
         let socket_c = std::ffi::CString::new(SOCKET).unwrap();
    +    // SAFETY: socket_c is a live NUL-terminated path; uid is the validated app uid
    +    // and gid_t::MAX is chown's documented sentinel for preserving the group.
         if unsafe { libc::chown(socket_c.as_ptr(), uid, u32::MAX) } != 0 {
             return Err("Could not grant bounded app socket access");
         }
    @@ -465,10 +493,14 @@ fn run_inner(ready: &mut File) -> Result<()> {
     }
     
     pub fn run() -> Result<()> {
    -    let mut ready = unsafe { File::from_raw_fd(4) };
    -    if unsafe { libc::fcntl(ready.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
    +    // SAFETY: fcntl accepts arbitrary fd numbers and integer F_SETFD flags; it fails
    +    // with EBADF if the bootstrap did not provide the acknowledgment descriptor.
    +    if unsafe { libc::fcntl(4, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
             return Err("Could not protect the launch acknowledgment");
         }
    +    // SAFETY: fd 4 was verified open above; bootstrap transfers sole ownership to
    +    // this single-threaded process, so File owns and closes it exactly once.
    +    let mut ready = unsafe { File::from_raw_fd(4) };
         let result = run_inner(&mut ready);
         if result.is_err() {
             let _ = ready.write_all(b"ERROR\n");
    @@ -540,6 +572,7 @@ mod tests {
                     let result = serve(
                         &listener,
                         || lifetime.load(Ordering::SeqCst),
    +                    // SAFETY: geteuid takes no pointers and has no preconditions.
                         unsafe { libc::geteuid() },
                         std::process::id() as i32,
                         &[7; 32],
    @@ -619,6 +652,7 @@ mod tests {
         fn kernel_peer_auth_checks_uid_pid_and_return_length() {
             let harness = Harness::new();
             let stream = UnixStream::connect(&harness.path).unwrap();
    +        // SAFETY: geteuid takes no pointers and has no preconditions.
             let uid = unsafe { libc::geteuid() };
             let pid = std::process::id() as i32;
             assert!(peer(&stream, uid, pid).is_ok());
    diff --git a/src-tauri/native/macos-helper/src/protocol.rs b/src-tauri/native/macos-helper/src/protocol.rs
    index 7219ed4..c970bb9 100644
    --- a/src-tauri/native/macos-helper/src/protocol.rs
    +++ b/src-tauri/native/macos-helper/src/protocol.rs
    @@ -13,6 +13,8 @@ pub enum Operation {
         Stop,
     }
     
    +// Client encoder: this shared module is also compiled into the parent controller.
    +#[cfg_attr(not(test), allow(dead_code))]
     pub fn hello(token: &[u8; 32]) -> [u8; HELLO_LEN] {
         let mut bytes = [0; HELLO_LEN];
         bytes[..8].copy_from_slice(HELLO_MAGIC);
    @@ -20,6 +22,7 @@ pub fn hello(token: &[u8; 32]) -> [u8; HELLO_LEN] {
         bytes
     }
     
    +// Server decoder: unused in the parent controller outside protocol tests.
     #[cfg_attr(not(test), allow(dead_code))]
     pub fn authenticate(bytes: &[u8], token: &[u8; 32]) -> bool {
         bytes.len() == HELLO_LEN
    @@ -31,6 +34,8 @@ pub fn authenticate(bytes: &[u8], token: &[u8; 32]) -> bool {
                 == 0
     }
     
    +// Client encoder: unused in the standalone helper outside protocol/socket tests.
    +#[cfg_attr(not(test), allow(dead_code))]
     pub fn request(seq: u64, operation: Operation) -> [u8; REQUEST_LEN] {
         let mut bytes = [0; REQUEST_LEN];
         bytes[0] = match operation {
    @@ -45,6 +50,7 @@ pub fn request(seq: u64, operation: Operation) -> [u8; REQUEST_LEN] {
         bytes
     }
     
    +// Server decoder: unused in the parent controller outside protocol tests.
     #[cfg_attr(not(test), allow(dead_code))]
     pub fn parse_request(bytes: &[u8], expected_seq: u64) -> Result {
         if bytes.len() != REQUEST_LEN || bytes[1..8] != [0; 7] {
    @@ -63,6 +69,7 @@ pub fn parse_request(bytes: &[u8], expected_seq: u64) -> Result, success: bool) -> [u8; ACK_LEN] {
         let mut bytes = [0; ACK_LEN];
    @@ -72,6 +79,8 @@ pub fn ack(seq: u64, flag: Option, success: bool) -> [u8; ACK_LEN] {
         bytes
     }
     
    +// Client validator: unused in the standalone helper outside protocol/socket tests.
    +#[cfg_attr(not(test), allow(dead_code))]
     pub fn valid_ack(bytes: &[u8], seq: u64) -> bool {
         bytes.len() == ACK_LEN
             && bytes[0] <= 1
    @@ -80,6 +89,8 @@ pub fn valid_ack(bytes: &[u8], seq: u64) -> bool {
             && u64::from_le_bytes(bytes[8..16].try_into().unwrap()) == seq
     }
     
    +// Client decoder: unused in the standalone helper outside protocol/socket tests.
    +#[cfg_attr(not(test), allow(dead_code))]
     pub fn parse_ack(bytes: &[u8], seq: u64) -> Result {
         if !valid_ack(bytes, seq) {
             return Err("Invalid helper acknowledgment");
    @@ -90,6 +101,7 @@ pub fn parse_ack(bytes: &[u8], seq: u64) -> Result {
         Ok(bytes[1] == 1)
     }
     
    +// Helper launch parser: unused in the parent controller outside protocol tests.
     #[cfg_attr(not(test), allow(dead_code))]
     pub fn decode_token(hex: &str) -> Result<[u8; 32], &'static str> {
         if hex.len() != 64 || !hex.bytes().all(|c| c.is_ascii_hexdigit()) {
    diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs
    index 206b439..e3c182c 100644
    --- a/src-tauri/src/agents.rs
    +++ b/src-tauri/src/agents.rs
    @@ -324,7 +324,7 @@ fn create_inner(
     ) -> Result {
         validate_agent_id(&request.id)?;
         validate_model(&request.fields)?;
    -    let (fields, prompt, _) = normalize_mutation(&request.fields, request.prompt, Vec::new())?;
    +    let (fields, prompt, _) = normalize_mutation(request.fields, request.prompt, Vec::new())?;
     
         match request.storage {
             AgentStorage::Inline => {
    @@ -384,7 +384,7 @@ fn update_inner(
             invalid("agent update requires an expected source path and content; reload before saving")
         })?;
         let (fields, prompt, clear_fields) =
    -        normalize_mutation(&mutation.fields, mutation.prompt, mutation.clear_fields)?;
    +        normalize_mutation(mutation.fields, mutation.prompt, mutation.clear_fields)?;
     
         match storage {
             AgentStorage::Inline => {
    @@ -893,12 +893,13 @@ fn invalid_model_ref(model: &str) -> AppError {
         ))
     }
     
    +type NormalizedMutation = (Map, Option, Vec);
    +
     fn normalize_mutation(
    -    fields: &Map,
    +    mut fields: Map,
         prompt: Option,
         clear_fields: Vec,
    -) -> Result<(Map, Option, Vec), AppError> {
    -    let mut fields = fields.clone();
    +) -> Result {
         let frontmatter_prompt = fields.remove("prompt");
         let prompt = match (prompt, frontmatter_prompt) {
             (Some(prompt), _) => Some(prompt),
    diff --git a/src-tauri/src/agents_md.rs b/src-tauri/src/agents_md.rs
    index 9d86e03..218e2d0 100644
    --- a/src-tauri/src/agents_md.rs
    +++ b/src-tauri/src/agents_md.rs
    @@ -120,12 +120,9 @@ impl MarkdownAgentDocument {
     }
     
     fn split_frontmatter(input: &str) -> Option<(&str, &str)> {
    -    let Some(after_open) = input
    +    let after_open = input
             .strip_prefix("---\n")
    -        .or_else(|| input.strip_prefix("---\r\n"))
    -    else {
    -        return None;
    -    };
    +        .or_else(|| input.strip_prefix("---\r\n"))?;
         let start_offset = input.len() - after_open.len();
         let mut offset = start_offset;
         for line in after_open.split_inclusive(['\n']) {
    diff --git a/src-tauri/src/commands/island.rs b/src-tauri/src/commands/island.rs
    index 050f5a3..6eef972 100644
    --- a/src-tauri/src/commands/island.rs
    +++ b/src-tauri/src/commands/island.rs
    @@ -3,7 +3,7 @@ use tauri::{AppHandle, Manager, State};
     
     use crate::error::AppError;
     use crate::island::{IslandService, IslandState, IslandView};
    -use crate::opencode_cli::island::IslandMessage;
    +use crate::opencode::island::IslandMessage;
     use crate::paths::ConfigPaths;
     
     #[tauri::command]
    diff --git a/src-tauri/src/commands/opencode.rs b/src-tauri/src/commands/opencode.rs
    new file mode 100644
    index 0000000..96ffeb8
    --- /dev/null
    +++ b/src-tauri/src/commands/opencode.rs
    @@ -0,0 +1,111 @@
    +use tauri::{AppHandle, Manager, State};
    +
    +use super::spawn_blocking_task;
    +use crate::error::AppError;
    +use crate::opencode::{
    +    self, installation, ModelCatalogEntry, OpenCodeInstallation, OpenCodeServiceStatus,
    +    OpenCodeUpdateCheck, TokenUsageRecord,
    +};
    +use crate::paths::ConfigPaths;
    +
    +type CommandResult = Result;
    +
    +#[tauri::command]
    +pub async fn opencode_list_models(
    +    state: State<'_, ConfigPaths>,
    +    provider: Option,
    +) -> CommandResult> {
    +    let paths = state.inner().clone();
    +    spawn_blocking_task("OpenCode model catalog", move || {
    +        opencode::list_models_via_service(&paths, provider.as_deref())
    +    })
    +    .await
    +}
    +
    +#[tauri::command]
    +pub async fn opencode_get_service_status() -> CommandResult {
    +    spawn_blocking_task("OpenCode service status", opencode::target::get_status).await
    +}
    +
    +#[tauri::command]
    +pub async fn opencode_set_service_target(
    +    app: AppHandle,
    +    url: Option,
    +    password: Option,
    +) -> CommandResult {
    +    spawn_blocking_task("OpenCode service target", move || {
    +        let power = app.state::();
    +        let island = app.state::();
    +        opencode::target::set_target(
    +            url,
    +            password,
    +            || {
    +                Ok((
    +                    power.prepare_target_refresh()?,
    +                    island.prepare_target_refresh(),
    +                ))
    +            },
    +            |(power, island)| {
    +                power.invalidate();
    +                island.invalidate()
    +            },
    +            |generation| {
    +                power.finish_target_refresh();
    +                if island.finish_target_refresh(&app, generation).is_err() {
    +                    eprintln!("OpenCode target saved; Mini Island refresh unavailable");
    +                }
    +            },
    +        )
    +    })
    +    .await
    +}
    +
    +#[tauri::command]
    +pub fn opencode_resolve_binary() -> CommandResult {
    +    Ok(opencode::resolve_opencode_binary().display().to_string())
    +}
    +
    +#[tauri::command]
    +pub async fn opencode_get_installation() -> CommandResult {
    +    spawn_blocking_task("OpenCode installation", installation::get_installation).await
    +}
    +
    +#[tauri::command]
    +pub async fn opencode_check_update() -> CommandResult {
    +    spawn_blocking_task("OpenCode update check", installation::check_for_update).await
    +}
    +
    +#[tauri::command]
    +pub async fn opencode_upgrade(
    +    expected_binary_path: String,
    +    expected_version: String,
    +    target_version: String,
    +) -> CommandResult {
    +    spawn_blocking_task("OpenCode upgrade", move || {
    +        installation::upgrade(&expected_binary_path, &expected_version, &target_version)
    +    })
    +    .await
    +}
    +
    +#[tauri::command]
    +pub async fn opencode_reload() -> CommandResult<()> {
    +    spawn_blocking_task("OpenCode reload", opencode::reload_via_service).await
    +}
    +
    +#[tauri::command]
    +pub async fn opencode_active_sessions() -> CommandResult {
    +    spawn_blocking_task(
    +        "OpenCode active sessions",
    +        opencode::active_sessions_via_service,
    +    )
    +    .await
    +}
    +
    +#[tauri::command]
    +pub async fn opencode_token_usage_records() -> CommandResult> {
    +    spawn_blocking_task(
    +        "OpenCode token usage",
    +        opencode::token_usage_records_via_service,
    +    )
    +    .await
    +}
    diff --git a/src-tauri/src/document.rs b/src-tauri/src/document.rs
    index 8dc024f..3c317ae 100644
    --- a/src-tauri/src/document.rs
    +++ b/src-tauri/src/document.rs
    @@ -130,7 +130,7 @@ pub struct JsoncDoc {
     impl JsoncDoc {
         pub fn parse(input: &str) -> Result {
             let raw = parse_jsonc_object(input)
    -            .map_err(|_| AppError::validation("malformed JSONC document"))?;
    +            .map_err(|error| AppError::validation(format!("malformed JSONC document: {error}")))?;
             Ok(Self {
                 content: input.to_owned(),
                 raw,
    @@ -147,14 +147,15 @@ impl JsoncDoc {
         /// document that is only persisted on the next write.
         pub fn read(path: &Path, schema: &str) -> Result {
             match read_text(path)? {
    -            Some(content) => JsoncDoc::parse(&content),
    +            Some(content) => JsoncDoc::parse(&content)
    +                .map_err(|error| AppError::validation(format!("{}: {error}", path.display()))),
                 None => Ok(Self::bootstrap(schema)),
             }
         }
     
         pub fn patch(&mut self, path: &[&str], value: Option) -> Result<(), AppError> {
             let (content, raw) = patch_with_effective(&self.content, path, value)
    -            .map_err(|_| AppError::validation("malformed JSONC document"))?;
    +            .map_err(|error| AppError::validation(format!("malformed JSONC document: {error}")))?;
             self.content = content;
             self.raw = raw;
             Ok(())
    @@ -178,6 +179,30 @@ mod tests {
         use super::*;
         use serde_json::json;
     
    +    #[test]
    +    fn jsonc_read_reports_safe_path_and_syntax_position_without_changing_wire_shape() {
    +        let directory = tempfile::tempdir().unwrap();
    +        let path = directory.path().join("config.jsonc");
    +        fs::write(
    +            &path,
    +            "{\n  \"apiKey\": \"PRIVATE_TOKEN\",\n  \"broken\" true\n}\n",
    +        )
    +        .unwrap();
    +
    +        let error = JsoncDoc::read(&path, OPENCODE_SCHEMA).unwrap_err();
    +        let wire = serde_json::to_value(&error).unwrap();
    +
    +        assert_eq!(wire["code"], "validation_failed");
    +        assert_eq!(wire["message"], error.message);
    +        assert_eq!(wire.as_object().unwrap().len(), 2);
    +        assert!(error.message.contains(&path.display().to_string()));
    +        assert!(error.message.contains("expected colon"), "{error}");
    +        assert!(error.message.contains("line 3"), "{error}");
    +        assert!(error.message.contains("column "), "{error}");
    +        assert!(!format!("{error:?}").contains("PRIVATE_TOKEN"));
    +        assert!(!wire.to_string().contains("PRIVATE_TOKEN"));
    +    }
    +
         #[test]
         fn write_file_atomically_creates_and_overwrites() {
             let dir = tempfile::tempdir().unwrap();
    diff --git a/src-tauri/src/groups.rs b/src-tauri/src/groups.rs
    index 352d242..36b4585 100644
    --- a/src-tauri/src/groups.rs
    +++ b/src-tauri/src/groups.rs
    @@ -13,10 +13,7 @@ fn load(paths: &ConfigPaths) -> Result {
         Ok(config)
     }
     
    -fn find_enabled_group<'a>(
    -    config: &'a AppConfig,
    -    group_id: Uuid,
    -) -> Result<&'a ModelGroup, AppError> {
    +fn find_enabled_group(config: &AppConfig, group_id: Uuid) -> Result<&ModelGroup, AppError> {
         let group = config
             .groups
             .iter()
    @@ -165,7 +162,7 @@ pub fn switch_group(paths: &ConfigPaths, group_id: Uuid) -> Result<(), AppError>
         }
     
         config.state.selected_group_id = Some(group.id);
    -    config.state.selected_group_name = Some(group.name.clone());
    +    config.state.selected_group_name = Some(group.name);
         models::save_config(&paths.config_file(), &config)
     }
     
    @@ -187,7 +184,7 @@ fn persist_group_lifecycle(
         if group.group_type == GroupType::Slim || switches_away_from_slim(previous, group) {
             let slim_file = paths.slim_file();
             let source = JsoncDoc::read(&slim_file, SLIM_SCHEMA)?;
    -        let mut document = source.clone();
    +        let mut document = source;
             if group.group_type == GroupType::Slim {
                 if let Some(previous) = previous.filter(|item| item.group_type == GroupType::Slim) {
                     if previous.name != group.name {
    @@ -220,7 +217,7 @@ fn persist_group_lifecycle(
             } else if let Some(previous) = previous {
                 projection::remove_omo_mappings(previous, &source)?
             } else {
    -            source.clone()
    +            source
             };
             omo_plan = Some((omo_file, document));
         }
    @@ -293,7 +290,7 @@ fn validate_group_drafts(
         Ok(())
     }
     
    -fn group_model_references<'a>(group: &'a ModelGroup) -> Vec<(&'static str, &'a str, &'a str)> {
    +fn group_model_references(group: &ModelGroup) -> Vec<(&'static str, &str, &str)> {
         let mut references = Vec::new();
         references.extend(group.open_code_agent_overrides.iter().map(|binding| {
             (
    diff --git a/src-tauri/src/island/hover.rs b/src-tauri/src/island/hover.rs
    index 809fcb7..7964da1 100644
    --- a/src-tauri/src/island/hover.rs
    +++ b/src-tauri/src/island/hover.rs
    @@ -20,25 +20,34 @@ struct HoverIvars {
     }
     
     define_class!(
    +    // SAFETY: NSObject is the actual superclass, all ivars are initialized before
    +    // init, and MainThreadOnly confines Cell/RefCell state and AppKit to one thread.
         #[unsafe(super = NSObject)]
         #[name = "MOMIslandHoverObserver"]
         #[thread_kind = MainThreadOnly]
         #[ivars = HoverIvars]
         struct HoverObserver;
     
    +    // SAFETY: the class inherits NSObject's protocol implementation without overrides.
         unsafe impl NSObjectProtocol for HoverObserver {}
     
         impl HoverObserver {
    +        // SAFETY: matches AppKit's mouseEntered: ABI (one borrowed NSEvent, void);
    +        // AppKit invokes this main-thread-only tracking owner while the event is live.
             #[unsafe(method(mouseEntered:))]
             fn mouse_entered(&self, _event: &NSEvent) {
                 self.notify(true, false);
             }
     
    +        // SAFETY: matches AppKit's mouseExited: ABI and borrows its live NSEvent
    +        // only during the main-thread callback, without storing it.
             #[unsafe(method(mouseExited:))]
             fn mouse_exited(&self, _event: &NSEvent) {
                 self.notify(false, false);
             }
     
    +        // SAFETY: matches AppKit's mouseMoved: ABI and borrows its live NSEvent
    +        // only during the main-thread callback, without storing it.
             #[unsafe(method(mouseMoved:))]
             fn mouse_moved(&self, _event: &NSEvent) {
                 self.notify(true, true);
    @@ -75,6 +84,8 @@ impl HoverObserver {
             let Ok(pointer) = window.ns_window() else {
                 return;
             };
    +        // SAFETY: Tauri returned this live window's NSWindow pointer; window stays
    +        // alive for the borrow and HoverObserver's MainThreadOnly confines this call.
             let native = unsafe { &*pointer.cast::() };
             if native.windowNumber() != ivars.window_number || !native.isVisible() {
                 return;
    @@ -143,6 +154,8 @@ pub(super) fn install(app: &AppHandle) -> Result<(), AppError> {
         let mtm = MainThreadMarker::new().ok_or_else(failure)?;
         let window = app.get_webview_window("island").ok_or_else(failure)?;
         let pointer = window.ns_window().map_err(|_| failure())?;
    +    // SAFETY: Tauri returns an NSWindow for this live handle; window outlives this
    +    // borrow and the MainThreadMarker above proves main-thread AppKit access.
         let native = unsafe { &*pointer.cast::() };
         let view = native.contentView().ok_or_else(failure)?;
         let service = app.state::();
    @@ -153,7 +166,12 @@ pub(super) fn install(app: &AppHandle) -> Result<(), AppError> {
             active: Cell::new(true),
             inside: Cell::new(false),
         });
    +    // SAFETY: this is a freshly allocated main-thread NSObject subclass with all
    +    // ivars initialized; inherited init returns the same owned +1 observer type.
         let owner: Retained = unsafe { msg_send![super(this), init] };
    +    // SAFETY: owner implements the tracking selectors with AppKit's ABI; Tracking
    +    // retains it and the area/view on the main thread, detaching the area before
    +    // releasing owner because NSTrackingArea itself does not retain that pointer.
         let area = unsafe {
             NSTrackingArea::initWithRect_options_owner_userInfo(
                 NSTrackingArea::alloc(),
    diff --git a/src-tauri/src/island/mod.rs b/src-tauri/src/island/mod.rs
    index 90b63bc..9470c02 100644
    --- a/src-tauri/src/island/mod.rs
    +++ b/src-tauri/src/island/mod.rs
    @@ -8,7 +8,7 @@ use tokio::sync::watch;
     
     use crate::error::AppError;
     use crate::models::{self, AppPreferences};
    -use crate::opencode_cli::island::{self as api, IslandApi, IslandChange, IslandSnapshot};
    +use crate::opencode::island::{self as api, IslandApi, IslandChange, IslandSnapshot};
     use crate::paths::ConfigPaths;
     
     #[cfg(any(target_os = "macos", test))]
    @@ -309,7 +309,7 @@ impl IslandService {
             };
             on_main(app, move |app| {
                 let service = app.state::();
    -            let _transition = crate::opencode_cli::target::transition()?;
    +            let _transition = crate::opencode::target::transition()?;
                 if !lock(&service.cache).current(generation) {
                     return Ok(());
                 }
    @@ -340,7 +340,7 @@ impl IslandService {
             paths: &ConfigPaths,
             enabled: bool,
         ) -> Result<(), AppError> {
    -        let _transition = crate::opencode_cli::target::transition()?;
    +        let _transition = crate::opencode::target::transition()?;
             if enabled && !cfg!(target_os = "macos") {
                 return Err(AppError::validation(
                     "Mini Island is only supported on macOS",
    @@ -467,7 +467,7 @@ impl IslandService {
                     {
                         let cache = Arc::downgrade(&self.cache);
                         let _ = app.run_on_main_thread(move || {
    -                        let retired = cache.upgrade().map_or(true, |cache| {
    +                        let retired = cache.upgrade().is_none_or(|cache| {
                                 let cache = lock(&cache);
                                 !cache.enabled && cache.generation == generation
                             });
    @@ -615,7 +615,7 @@ impl IslandService {
         }
     
         fn retry_connection(&self, app: &AppHandle, generation: u64) -> Result<(), AppError> {
    -        let _transition = crate::opencode_cli::target::transition()?;
    +        let _transition = crate::opencode::target::transition()?;
             if !lock(&self.cache).current(generation) {
                 return Err(disabled());
             }
    @@ -827,16 +827,13 @@ async fn connected(
         let mut pending = Coalescer::default();
         loop {
             if let Some(wake) = pending.deadline {
    -            match race(tokio::time::sleep_until(wake), events.next()).await {
    -                Err(event) => {
    -                    let state = lock(cache);
    -                    if !state.current(generation) || state.api_generation != api_generation {
    -                        return Err(disabled());
    -                    }
    -                    pending.record(event?, &state, tokio::time::Instant::now());
    -                    continue;
    +            if let Err(event) = race(tokio::time::sleep_until(wake), events.next()).await {
    +                let state = lock(cache);
    +                if !state.current(generation) || state.api_generation != api_generation {
    +                    return Err(disabled());
                     }
    -                Ok(_) => {}
    +                pending.record(event?, &state, tokio::time::Instant::now());
    +                continue;
                 }
                 if pending.snapshot {
                     let revision = next_revision(cache, generation)?;
    @@ -896,11 +893,11 @@ pub(crate) fn setup(
                 service.update_preferences(config.state.preferences);
                 #[cfg(target_os = "macos")]
                 if config.state.island_enabled {
    -                let _transition = crate::opencode_cli::target::transition();
    -                if service.apply_enabled(app, true).is_err() {
    -                    if persist_enabled(paths, false).is_err() {
    -                        eprintln!("failed to persist disabled Mini Island after startup failure");
    -                    }
    +                let _transition = crate::opencode::target::transition();
    +                if service.apply_enabled(app, true).is_err()
    +                    && persist_enabled(paths, false).is_err()
    +                {
    +                    eprintln!("failed to persist disabled Mini Island after startup failure");
                     }
                 }
             }
    diff --git a/src-tauri/src/island/window.rs b/src-tauri/src/island/window.rs
    index 7d8ee13..877c975 100644
    --- a/src-tauri/src/island/window.rs
    +++ b/src-tauri/src/island/window.rs
    @@ -46,6 +46,9 @@ fn native_window(app: &AppHandle) -> Result, AppError> {
         let _mtm = MainThreadMarker::new().ok_or_else(failure)?;
         let window = app.get_webview_window("island").ok_or_else(failure)?;
         let pointer = window.ns_window().map_err(|_| failure())?;
    +    // SAFETY: Tauri supplies a live NSWindow owned by window; the main-thread check
    +    // excludes concurrent AppKit destruction, and retain acquires our own +1 owner
    +    // before window is released. NSWindow remains main-thread-only in this module.
         unsafe { Retained::retain(pointer.cast::()) }.ok_or_else(failure)
     }
     
    @@ -251,6 +254,9 @@ fn start_transition(app: &AppHandle, token: Token) {
                 animation_completed(¬ifications, token, selected, target, view);
             });
             let changes = RcBlock::new(move |context: std::ptr::NonNull| {
    +            // SAFETY: runAnimationGroup invokes this block synchronously on the
    +            // calling main thread with its live nonnull NSAnimationContext; this
    +            // borrow is used only during the invocation and never retained.
                 unsafe { context.as_ref() }.setDuration(duration);
                 if current(app, token) {
                     native
    @@ -452,9 +458,14 @@ fn destroyed(window: WindowId) -> bool {
     }
     
     pub(super) fn event_is_current(window: &tauri::Window) -> bool {
    +    if MainThreadMarker::new().is_none() {
    +        return false;
    +    }
         let Ok(pointer) = window.ns_window() else {
             return false;
         };
    +    // SAFETY: Tauri's live window owns the returned NSWindow; the marker above
    +    // ensures main-thread access and this borrow ends after the identity read.
         let native = unsafe { &*pointer.cast::() };
         let window = identity(native);
         TRANSITIONS.with(|slot| slot.borrow().window == Some(window))
    @@ -549,6 +560,7 @@ fn open_new_window(
     }
     
     pub(super) fn open(app: &AppHandle, mode: IslandView) -> Result {
    +    MainThreadMarker::new().ok_or_else(failure)?;
         open_new_window(app.get_webview_window("island").is_some(), || {
             let target = placement(screen()?, mode);
             let window = WebviewWindowBuilder::new(app, "island", WebviewUrl::App("/island".into()))
    @@ -570,11 +582,11 @@ pub(super) fn open(app: &AppHandle, mode: IslandView) -> Result();
    -            native.orderFront(None);
    -            readback(native, screen()?, target, mode, "after-orderFront")?;
    -        }
    +        // SAFETY: this newly built Tauri window owns the live NSWindow; the marker
    +        // at entry proves main-thread access and window outlives this short borrow.
    +        let native = unsafe { &*pointer.cast::() };
    +        native.orderFront(None);
    +        readback(native, screen()?, target, mode, "after-orderFront")?;
             super::hover::install(app)?;
             let native = native_window(app)?;
             let identity = identity(&native);
    diff --git a/src-tauri/src/jsonc.rs b/src-tauri/src/jsonc.rs
    index 6ea8c84..dcca1d8 100644
    --- a/src-tauri/src/jsonc.rs
    +++ b/src-tauri/src/jsonc.rs
    @@ -1,20 +1,72 @@
     use jsonc_parser::cst::{CstInputValue, CstObject, CstRootNode};
    +use jsonc_parser::errors::{ParseError, ParseErrorKind};
     use jsonc_parser::ParseOptions;
     use serde_json::{Map, Value};
     
     #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     pub enum JsoncError {
         MalformedJson,
    +    Syntax {
    +        kind: &'static str,
    +        line: usize,
    +        column: usize,
    +    },
     }
     impl std::fmt::Display for JsoncError {
         fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    -        f.write_str("malformed JSONC")
    +        match self {
    +            Self::MalformedJson => f.write_str("malformed JSONC"),
    +            Self::Syntax { kind, line, column } => {
    +                write!(f, "malformed JSONC: {kind} at line {line}, column {column}")
    +            }
    +        }
         }
     }
     impl std::error::Error for JsoncError {}
     
    +impl From for JsoncError {
    +    fn from(error: ParseError) -> Self {
    +        use ParseErrorKind::*;
    +        // Parser string errors can contain input; retain only static error kinds.
    +        let kind = match error.kind() {
    +            CommentsNotAllowed => "comments not allowed",
    +            ExpectedColonAfterObjectKey => "expected colon after object key",
    +            ExpectedObjectValue => "expected object value",
    +            ExpectedDigit => "expected digit",
    +            ExpectedDigitFollowingNegativeSign => "expected digit following negative sign",
    +            ExpectedPlusMinusOrDigitInNumberLiteral => {
    +                "expected plus, minus, or digit in number literal"
    +            }
    +            ExpectedStringObjectProperty => "expected string object property",
    +            HexadecimalNumbersNotAllowed => "hexadecimal numbers not allowed",
    +            ExpectedComma => "expected comma",
    +            MultipleRootJsonValues => "multiple root JSON values",
    +            SingleQuotedStringsNotAllowed => "single-quoted strings not allowed",
    +            String(_) => "invalid string literal",
    +            TrailingCommasNotAllowed => "trailing commas not allowed",
    +            UnaryPlusNumbersNotAllowed => "unary plus numbers not allowed",
    +            UnexpectedCloseBrace => "unexpected close brace",
    +            UnexpectedCloseBracket => "unexpected close bracket",
    +            UnexpectedColon => "unexpected colon",
    +            UnexpectedComma => "unexpected comma",
    +            UnexpectedToken => "unexpected token",
    +            UnexpectedTokenInObject => "unexpected token in object",
    +            UnexpectedWord => "unexpected word",
    +            UnterminatedArray => "unterminated array",
    +            UnterminatedCommentBlock => "unterminated comment block",
    +            UnterminatedObject => "unterminated object",
    +            NestingDepthExceeded => "nesting depth exceeded",
    +        };
    +        Self::Syntax {
    +            kind,
    +            line: error.line_display(),
    +            column: error.column_display(),
    +        }
    +    }
    +}
    +
     pub fn parse_jsonc_object(input: &str) -> Result, JsoncError> {
    -    let root = CstRootNode::parse(input, &options()).map_err(|_| JsoncError::MalformedJson)?;
    +    let root = CstRootNode::parse(input, &options()).map_err(JsoncError::from)?;
         match root.object_value().and_then(|value| value.to_serde_value()) {
             Some(Value::Object(object)) => Ok(object),
             _ => Err(JsoncError::MalformedJson),
    @@ -33,7 +85,7 @@ pub fn patch_with_effective(
         if path.is_empty() || path.iter().any(|segment| segment.is_empty()) {
             return Err(JsoncError::MalformedJson);
         }
    -    let root = CstRootNode::parse(input, &options()).map_err(|_| JsoncError::MalformedJson)?;
    +    let root = CstRootNode::parse(input, &options()).map_err(JsoncError::from)?;
         let object = root.object_value().ok_or(JsoncError::MalformedJson)?;
         patch_object(&object, path, value.clone());
         let output = root.to_string();
    @@ -126,6 +178,23 @@ mod tests {
         use super::*;
         use serde_json::json;
     
    +    #[test]
    +    fn string_parse_diagnostics_do_not_include_input_fragments() {
    +        let input = r#"{"apiKey": "\uD800PRIVATE_TOKEN"}"#;
    +        let error = parse_jsonc_object(input).unwrap_err();
    +        let diagnostic = error.to_string();
    +
    +        assert!(
    +            diagnostic.contains("invalid string literal"),
    +            "{diagnostic}"
    +        );
    +        assert!(diagnostic.contains("line 1"), "{diagnostic}");
    +        assert!(diagnostic.contains("column "), "{diagnostic}");
    +        assert!(!diagnostic.contains("PRIVATE_TOKEN"));
    +        assert!(!diagnostic.contains("\\uD800"));
    +        assert!(!format!("{error:?}").contains("PRIVATE_TOKEN"));
    +    }
    +
         #[test]
         fn duplicate_key_rejects_shadowed_modification() {
             let input = r#"{"x": 1, "x": 2}"#;
    diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
    index 0fda993..46fbace 100644
    --- a/src-tauri/src/lib.rs
    +++ b/src-tauri/src/lib.rs
    @@ -4,14 +4,18 @@ pub mod commands;
     pub mod document;
     pub mod error;
     pub mod groups;
    +pub(crate) mod http_server;
     mod island;
     pub mod jsonc;
     pub mod mcp;
    +pub(crate) mod mcp_proxy;
     pub mod models;
    -pub mod opencode_cli;
    +pub mod opencode;
     pub mod paths;
     pub mod power;
    +pub(crate) mod private_file;
     pub mod projection;
    +pub(crate) mod protected_data;
     pub mod providers;
     pub mod refs;
     mod resource_file;
    @@ -24,6 +28,8 @@ use tauri::Manager;
     pub fn run() {
         let island_notifications = std::sync::Arc::new(std::sync::Mutex::new(None));
         let setup_notifications = island_notifications.clone();
    +    // 0: running, 1: shutting down, 2: ready to exit.
    +    let exit_flag = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0));
         let builder = tauri::Builder::default();
         let builder = builder.on_window_event(|window, event| {
             #[cfg(any(target_os = "macos", target_os = "windows"))]
    @@ -41,9 +47,26 @@ pub fn run() {
             }))
             .setup(move |app| {
                 let paths = paths::ConfigPaths::from_environment()?;
    -            opencode_cli::target::initialize(&paths)?;
    +            opencode::target::initialize(&paths)?;
    +            let proxy = std::sync::Arc::new(mcp_proxy::ProxyService::new(&paths).unwrap_or_else(
    +                |error| {
    +                    eprintln!("warning: MCP proxy initialization failed; disabling proxy: {error}");
    +                    mcp_proxy::ProxyService::new_disabled(&paths)
    +                },
    +            ));
    +            let http = std::sync::Arc::new(http_server::HttpServer::new(
    +                &paths,
    +                axum::Router::new().merge(proxy.router()),
    +            )?);
    +            app.manage(http.clone());
    +            app.manage(proxy);
                 app.manage(power::PowerService::new(paths.app_config_dir())?);
                 app.manage(paths.clone());
    +            tauri::async_runtime::spawn(async move {
    +                if let Err(error) = http.start().await {
    +                    eprintln!("warning: failed to start MOM HTTP service: {error}");
    +                }
    +            });
                 *setup_notifications
                     .lock()
                     .unwrap_or_else(|error| error.into_inner()) = Some(app.handle().clone());
    @@ -118,12 +141,61 @@ pub fn run() {
                 commands::copy_group,
                 commands::delete_group,
                 commands::switch_group,
    -            commands::save_preferences
    +            commands::save_preferences,
    +            commands::get_mom_http_settings,
    +            commands::set_mom_http_settings,
    +            commands::get_mcp_proxy_state,
    +            commands::get_mcp_proxy_settings,
    +            commands::save_mcp_proxy_settings,
    +            commands::rotate_mcp_proxy_key,
    +            commands::get_mcp_proxy_server,
    +            commands::create_mcp_proxy_server,
    +            commands::update_mcp_proxy_server,
    +            commands::delete_mcp_proxy_server,
    +            commands::import_mcp_proxy_server,
    +            commands::close_mcp_proxy_unit
             ])
             .build(tauri::generate_context!())
             .expect("failed to run omo-switch Tauri shell")
    -        .run(move |app, event| {
    -            if matches!(event, tauri::RunEvent::Exit) {
    +        .run(move |app, event| match event {
    +            tauri::RunEvent::ExitRequested { api, code, .. } => {
    +                if exit_flag.load(std::sync::atomic::Ordering::SeqCst) != 2 {
    +                    api.prevent_exit();
    +                }
    +                if exit_flag
    +                    .compare_exchange(
    +                        0,
    +                        1,
    +                        std::sync::atomic::Ordering::SeqCst,
    +                        std::sync::atomic::Ordering::SeqCst,
    +                    )
    +                    .is_ok()
    +                {
    +                    let app_handle = app.clone();
    +                    let exit_code = code.unwrap_or(0);
    +                    if let Some(proxy) = app.try_state::>()
    +                    {
    +                        let proxy = proxy.inner().clone();
    +                        let exit_flag = exit_flag.clone();
    +                        tauri::async_runtime::spawn(async move {
    +                            shutdown_http(&app_handle, &proxy, std::time::Duration::from_secs(8))
    +                                .await;
    +                            exit_flag.store(2, std::sync::atomic::Ordering::SeqCst);
    +                            app_handle.exit(exit_code);
    +                        });
    +                    } else {
    +                        exit_flag.store(2, std::sync::atomic::Ordering::SeqCst);
    +                        app_handle.exit(exit_code);
    +                    }
    +                }
    +            }
    +            tauri::RunEvent::Exit => {
    +                if let Some(proxy) = app.try_state::>() {
    +                    let proxy = proxy.inner().clone();
    +                    tauri::async_runtime::block_on(async move {
    +                        shutdown_http(app, &proxy, std::time::Duration::from_secs(1)).await;
    +                    });
    +                }
                     if let Some(service) = app.try_state::() {
                         service.shutdown();
                     }
    @@ -135,5 +207,29 @@ pub fn run() {
                         service.shutdown();
                     }
                 }
    +            _ => {}
             });
     }
    +
    +async fn shutdown_http(
    +    app: &tauri::AppHandle,
    +    proxy: &mcp_proxy::ProxyService,
    +    deadline: std::time::Duration,
    +) {
    +    let (http_result, proxy_result) = tokio::join!(
    +        async {
    +            if let Some(http) = app.try_state::>() {
    +                http.shutdown(deadline).await
    +            } else {
    +                Ok(())
    +            }
    +        },
    +        proxy.shutdown(deadline),
    +    );
    +    for error in [http_result, proxy_result]
    +        .into_iter()
    +        .filter_map(Result::err)
    +    {
    +        eprintln!("warning: failed to cleanly shut down MOM HTTP service: {error}");
    +    }
    +}
    diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs
    index 4e200b7..ee28faa 100644
    --- a/src-tauri/src/mcp.rs
    +++ b/src-tauri/src/mcp.rs
    @@ -257,7 +257,7 @@ fn validate_draft(name: &str, config: &Map) -> Result<(), AppErro
                     || command.iter().any(|value| !value.is_string())
                     || command[0]
                         .as_str()
    -                    .map_or(true, |value| value.trim().is_empty())
    +                    .is_none_or(|value| value.trim().is_empty())
                 {
                     return Err(invalid());
                 }
    @@ -292,7 +292,7 @@ fn validate_draft(name: &str, config: &Map) -> Result<(), AppErro
             for field in ["startup", "catalog", "execution"] {
                 if timeout
                     .get(field)
    -                .is_some_and(|value| value.as_u64().map_or(true, |value| value == 0))
    +                .is_some_and(|value| value.as_u64().is_none_or(|value| value == 0))
                 {
                     return Err(invalid());
                 }
    @@ -302,7 +302,7 @@ fn validate_draft(name: &str, config: &Map) -> Result<(), AppErro
             if let Some(value) = config.get(field) {
                 if value
                     .as_object()
    -                .map_or(true, |map| map.values().any(|value| !value.is_string()))
    +                .is_none_or(|map| map.values().any(|value| !value.is_string()))
                 {
                     return Err(invalid());
                 }
    @@ -325,7 +325,7 @@ fn validate_draft(name: &str, config: &Map) -> Result<(), AppErro
                 if oauth.get("callback_port").is_some_and(|value| {
                     value
                         .as_u64()
    -                    .map_or(true, |value| !(1..=65535).contains(&value))
    +                    .is_none_or(|value| !(1..=65535).contains(&value))
                 }) {
                     return Err(invalid());
                 }
    @@ -343,8 +343,8 @@ fn managed_documents(
             .map(|path| {
                 let snapshot = Snapshot::read(path)?;
                 let document = match &snapshot {
    -                Some(snapshot) => JsoncDoc::parse(&snapshot.content).map_err(|_| {
    -                    AppError::validation(format!("{}: malformed JSONC document", path.display()))
    +                Some(snapshot) => JsoncDoc::parse(&snapshot.content).map_err(|error| {
    +                    AppError::validation(format!("{}: {error}", path.display()))
                     })?,
                     None => JsoncDoc::bootstrap(crate::document::OPENCODE_SCHEMA),
                 };
    @@ -489,20 +489,20 @@ fn persist_document(
                 Ok(())
             })
         } else {
    +        let filename = path
    +            .file_name()
    +            .and_then(|name| name.to_str())
    +            .ok_or_else(|| AppError::validation("MCP config filename must be valid UTF-8"))?;
             let parent = Directory::ensure(path.parent().unwrap())?;
    -        parent.create_file_checked(
    -            path.file_name().unwrap().to_str().unwrap(),
    -            document.content(),
    -            || {
    -                for (path, snapshot, _) in documents {
    -                    match snapshot {
    -                        Some(snapshot) => snapshot.validate()?,
    -                        None => resource_file::absent(path)?,
    -                    }
    +        parent.create_file_checked(filename, document.content(), || {
    +            for (path, snapshot, _) in documents {
    +                match snapshot {
    +                    Some(snapshot) => snapshot.validate()?,
    +                    None => resource_file::absent(path)?,
                     }
    -                Ok(())
    -            },
    -        )
    +            }
    +            Ok(())
    +        })
         }
     }
     
    diff --git a/src-tauri/src/opencode/active.rs b/src-tauri/src/opencode/active.rs
    new file mode 100644
    index 0000000..70653d8
    --- /dev/null
    +++ b/src-tauri/src/opencode/active.rs
    @@ -0,0 +1,118 @@
    +use std::collections::HashSet;
    +use std::fmt;
    +use std::marker::PhantomData;
    +use std::time::{Duration, Instant};
    +
    +use serde::de::{self, MapAccess, Visitor};
    +use serde::{Deserialize, Deserializer};
    +
    +use crate::error::AppError;
    +
    +use super::transport::{self, ManagedConnection};
    +
    +const ACTIVE_SESSIONS_TIMEOUT: Duration = Duration::from_secs(5);
    +const ACTIVE_SESSIONS_MAX_BYTES: u64 = 1024 * 1024;
    +
    +#[derive(Deserialize)]
    +struct ActiveSessionsEnvelope {
    +    #[serde(deserialize_with = "count_running_sessions")]
    +    data: usize,
    +}
    +
    +#[derive(Deserialize)]
    +#[serde(tag = "type")]
    +enum ActiveSession {
    +    #[serde(rename = "running")]
    +    Running,
    +}
    +
    +pub(super) struct JsonObject(pub(super) T);
    +
    +impl<'de, T: Deserialize<'de>> Deserialize<'de> for JsonObject {
    +    fn deserialize>(deserializer: D) -> Result {
    +        struct ObjectVisitor(PhantomData);
    +
    +        impl<'de, T: Deserialize<'de>> Visitor<'de> for ObjectVisitor {
    +            type Value = JsonObject;
    +
    +            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
    +                formatter.write_str("a JSON object")
    +            }
    +
    +            fn visit_map>(self, map: M) -> Result {
    +                T::deserialize(de::value::MapAccessDeserializer::new(map)).map(JsonObject)
    +            }
    +        }
    +
    +        deserializer.deserialize_map(ObjectVisitor(PhantomData))
    +    }
    +}
    +
    +fn count_running_sessions<'de, D>(deserializer: D) -> Result
    +where
    +    D: Deserializer<'de>,
    +{
    +    running_sessions(deserializer).map(|ids| ids.len())
    +}
    +
    +pub(super) fn running_sessions<'de, D>(deserializer: D) -> Result, D::Error>
    +where
    +    D: Deserializer<'de>,
    +{
    +    struct RunningSessions;
    +
    +    impl<'de> Visitor<'de> for RunningSessions {
    +        type Value = HashSet;
    +
    +        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
    +            formatter.write_str("a map of running session IDs")
    +        }
    +
    +        fn visit_map(self, mut map: M) -> Result
    +        where
    +            M: MapAccess<'de>,
    +        {
    +            let mut ids = HashSet::new();
    +            while let Some(id) = map.next_key::()? {
    +                let valid = id.strip_prefix("ses_").is_some_and(|suffix| {
    +                    !suffix.is_empty()
    +                        && suffix.bytes().all(|byte| {
    +                            byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
    +                        })
    +                });
    +                if !valid || !ids.insert(id) {
    +                    return Err(de::Error::custom("invalid or duplicate session ID"));
    +                }
    +                map.next_value::>()?;
    +            }
    +            Ok(ids)
    +        }
    +    }
    +
    +    deserializer.deserialize_map(RunningSessions)
    +}
    +
    +/// Queries only the selected local service; Shared mode may bootstrap through the CLI.
    +/// This is not a machine-wide inventory of OpenCode processes or servers.
    +pub fn active_sessions_via_service() -> Result {
    +    let deadline = Instant::now() + ACTIVE_SESSIONS_TIMEOUT;
    +    active_sessions(&transport::connect(deadline)?, deadline)
    +}
    +
    +pub(super) fn active_sessions(
    +    connection: &ManagedConnection,
    +    deadline: Instant,
    +) -> Result {
    +    let envelope: JsonObject = connection.get_json(
    +        &["session", "active"],
    +        &[],
    +        deadline,
    +        ACTIVE_SESSIONS_MAX_BYTES,
    +        "opencode session.active",
    +        "1 MiB",
    +    )?;
    +    Ok(envelope.0.data)
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/src-tauri/src/opencode/binary.rs b/src-tauri/src/opencode/binary.rs
    new file mode 100644
    index 0000000..8c6a000
    --- /dev/null
    +++ b/src-tauri/src/opencode/binary.rs
    @@ -0,0 +1,141 @@
    +use std::env;
    +use std::ffi::OsStr;
    +use std::path::{Path, PathBuf};
    +
    +/// Candidate opencode binaries, in priority order:
    +/// 1. `$OPENCODE_BIN` explicit override (if the file exists)
    +/// 2. Known install locations — probed directly so the app works even when
    +///    launched with a minimal PATH (e.g. GUI launch on Windows)
    +/// 3. Plain `opencode` — resolved through PATH by `Command::new` at spawn time
    +pub(super) fn opencode_candidates() -> Vec {
    +    let mut candidates: Vec = Vec::new();
    +
    +    if let Ok(candidate) = env::var("OPENCODE_BIN") {
    +        let path = PathBuf::from(candidate.trim());
    +        if path.exists() {
    +            candidates.push(path);
    +        }
    +    }
    +
    +    // npm's Windows shim also supports the modern @opencode/cli package.
    +    #[cfg(windows)]
    +    if let Ok(appdata) = env::var("APPDATA") {
    +        let npm = PathBuf::from(appdata).join("npm");
    +        candidates.push(npm.join("opencode.exe"));
    +        candidates.push(npm.join("opencode.cmd"));
    +        candidates.push(npm.join("node_modules/opencode-ai/bin/opencode.exe"));
    +    }
    +    // Bundled CLI with the OpenChamber desktop app (Windows)
    +    if let Ok(local_appdata) = env::var("LOCALAPPDATA") {
    +        candidates.push(
    +            PathBuf::from(local_appdata)
    +                .join("Programs/@openchamberelectron/resources/opencode-cli/opencode.exe"),
    +        );
    +    }
    +    // Home directories (macOS / Linux)
    +    if let Some(home) = dirs::home_dir() {
    +        let name = if cfg!(windows) {
    +            "opencode.exe"
    +        } else {
    +            "opencode"
    +        };
    +        for directory in [
    +            ".opencode/bin",
    +            ".local/bin",
    +            ".npm-global/bin",
    +            ".bun/bin",
    +            ".cargo/bin",
    +        ] {
    +            candidates.push(home.join(directory).join(name));
    +        }
    +    }
    +    candidates.push(PathBuf::from("/opt/homebrew/bin/opencode"));
    +    candidates.push(PathBuf::from("/usr/local/bin/opencode"));
    +
    +    // Last resort: PATH resolution via `Command::new`.
    +    candidates.push(PathBuf::from("opencode"));
    +    candidates
    +}
    +
    +/// Resolve the opencode binary: `$OPENCODE_BIN`, then known install locations,
    +/// otherwise plain `opencode` (resolved through PATH by `Command::new`).
    +pub fn resolve_opencode_binary() -> PathBuf {
    +    opencode_candidates()
    +        .into_iter()
    +        .find(|p| p.exists())
    +        .unwrap_or_else(|| PathBuf::from("opencode"))
    +}
    +
    +/// Resolve the exact executable without canonicalizing its logical installation path.
    +pub(super) fn resolve_path_command(command: &Path) -> Option {
    +    resolve_path_command_in(
    +        command,
    +        env::var_os("PATH").as_deref(),
    +        &env::current_dir().ok()?,
    +    )
    +}
    +
    +pub(super) fn resolve_path_command_in(
    +    command: &Path,
    +    path: Option<&OsStr>,
    +    cwd: &Path,
    +) -> Option {
    +    if command.as_os_str().is_empty() {
    +        return None;
    +    }
    +    if command.is_absolute() || command.components().count() > 1 {
    +        let absolute = if command.is_absolute() {
    +            command.to_path_buf()
    +        } else {
    +            cwd.join(command)
    +        };
    +        return executable(&absolute).then_some(absolute);
    +    }
    +    for directory in env::split_paths(path?) {
    +        let directory = if directory.is_absolute() {
    +            directory
    +        } else {
    +            cwd.join(directory)
    +        };
    +        let candidate = directory.join(command);
    +        #[cfg(not(windows))]
    +        if executable(&candidate) {
    +            return Some(candidate);
    +        }
    +        #[cfg(windows)]
    +        {
    +            if candidate.extension().is_some() {
    +                if executable(&candidate) {
    +                    return Some(candidate);
    +                }
    +                continue;
    +            }
    +            // These are the native/shim extensions supported by Command on Windows.
    +            for extension in ["exe", "com", "cmd", "bat"] {
    +                let candidate = candidate.with_extension(extension);
    +                if executable(&candidate) {
    +                    return Some(candidate);
    +                }
    +            }
    +        }
    +    }
    +    None
    +}
    +
    +fn executable(path: &Path) -> bool {
    +    let Ok(metadata) = path.metadata() else {
    +        return false;
    +    };
    +    if !metadata.is_file() {
    +        return false;
    +    }
    +    #[cfg(unix)]
    +    {
    +        use std::os::unix::fs::PermissionsExt;
    +        metadata.permissions().mode() & 0o111 != 0
    +    }
    +    #[cfg(not(unix))]
    +    {
    +        true
    +    }
    +}
    diff --git a/src-tauri/src/opencode/command.rs b/src-tauri/src/opencode/command.rs
    new file mode 100644
    index 0000000..0174a3f
    --- /dev/null
    +++ b/src-tauri/src/opencode/command.rs
    @@ -0,0 +1,178 @@
    +use std::io::{self, Read};
    +use std::process::{Child, ChildStdout, Output};
    +use std::thread;
    +use std::time::{Duration, Instant};
    +
    +use crate::error::AppError;
    +
    +pub(super) fn collect_output(
    +    mut child: Child,
    +    timeout: Duration,
    +    max_bytes: u64,
    +    operation: &str,
    +    limit: &str,
    +) -> Result {
    +    let started = Instant::now();
    +    let mut stdout = child.stdout.take();
    +    let result = (|| {
    +        let stdout = stdout.as_mut().ok_or_else(|| {
    +            AppError::configuration(format!("failed to read {operation}: stdout is not piped"))
    +        })?;
    +        let read_failure = |error: io::Error| {
    +            AppError::configuration(format!("failed to read {operation}: {:?}", error.kind()))
    +        };
    +        prepare_stdout(stdout).map_err(read_failure)?;
    +        let mut bytes = Vec::new();
    +        let mut buffer = [0; 8192];
    +        let mut status = None;
    +        let mut eof = false;
    +        let mut read_error = None;
    +        loop {
    +            if status.is_none() {
    +                status = child.try_wait().map_err(|error| {
    +                    AppError::configuration(format!(
    +                        "failed to wait for {operation}: {:?}",
    +                        error.kind()
    +                    ))
    +                })?;
    +            }
    +            if let Some(status) = status {
    +                if !status.success() {
    +                    return Ok(Output {
    +                        status,
    +                        stdout: Vec::new(),
    +                        stderr: Vec::new(),
    +                    });
    +                }
    +                if let Some(error) = read_error.take() {
    +                    return Err(read_failure(error));
    +                }
    +                if eof {
    +                    return Ok(Output {
    +                        status,
    +                        stdout: bytes,
    +                        stderr: Vec::new(),
    +                    });
    +                }
    +            }
    +            if started.elapsed() >= timeout {
    +                return Err(AppError::configuration(if status.is_some() {
    +                    format!("{operation} output timed out.")
    +                } else {
    +                    format!("{operation} timed out. Check the OpenCode CLI and retry.")
    +                }));
    +            }
    +            if !eof && read_error.is_none() {
    +                let remaining = max_bytes
    +                    .saturating_sub(bytes.len() as u64)
    +                    .saturating_add(1)
    +                    .min(buffer.len() as u64) as usize;
    +                match read_available(stdout, &mut buffer[..remaining]) {
    +                    Ok(0) => eof = true,
    +                    Ok(count) => {
    +                        bytes.extend_from_slice(&buffer[..count]);
    +                        if bytes.len() as u64 > max_bytes {
    +                            return Err(AppError::configuration(format!(
    +                                "{operation} response exceeds the {limit} limit."
    +                            )));
    +                        }
    +                        continue;
    +                    }
    +                    Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
    +                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
    +                    Err(error) => read_error = Some(error),
    +                }
    +            }
    +            thread::sleep(Duration::from_millis(20).min(timeout.saturating_sub(started.elapsed())));
    +        }
    +    })();
    +    drop(stdout);
    +    if result.is_err() && child.try_wait().ok().flatten().is_none() {
    +        // Only reap this CLI child; a descendant may be the persistent OpenCode service.
    +        let _ = child.kill();
    +        let _ = child.wait();
    +    }
    +    result
    +}
    +
    +#[cfg(unix)]
    +fn prepare_stdout(stdout: &ChildStdout) -> io::Result<()> {
    +    use std::os::fd::AsRawFd;
    +
    +    let fd = stdout.as_raw_fd();
    +    // SAFETY: stdout keeps fd live; F_GETFL takes no additional arguments or pointers.
    +    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
    +    // SAFETY: fd remains live; F_SETFL receives integer flags only, after a successful F_GETFL.
    +    if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
    +        return Err(io::Error::last_os_error());
    +    }
    +    Ok(())
    +}
    +
    +#[cfg(unix)]
    +fn read_available(stdout: &mut ChildStdout, buffer: &mut [u8]) -> io::Result {
    +    stdout.read(buffer)
    +}
    +
    +#[cfg(windows)]
    +fn prepare_stdout(stdout: &ChildStdout) -> io::Result<()> {
    +    use std::os::windows::io::AsRawHandle;
    +    use windows_sys::Win32::Foundation::{ERROR_INVALID_HANDLE, INVALID_HANDLE_VALUE};
    +
    +    let handle = stdout.as_raw_handle();
    +    if handle.is_null() || handle == INVALID_HANDLE_VALUE {
    +        return Err(io::Error::from_raw_os_error(ERROR_INVALID_HANDLE as i32));
    +    }
    +    Ok(())
    +}
    +
    +#[cfg(windows)]
    +fn read_available(stdout: &mut ChildStdout, buffer: &mut [u8]) -> io::Result {
    +    use std::os::windows::io::AsRawHandle;
    +    use std::ptr::null_mut;
    +    use windows_sys::Win32::Foundation::ERROR_BROKEN_PIPE;
    +    use windows_sys::Win32::System::Pipes::PeekNamedPipe;
    +
    +    let mut available = 0;
    +    // This collector owns the sole reader; only read bytes already buffered in the pipe.
    +    // SAFETY: stdout owns the live pipe handle, available is writable, and unused outputs may be null.
    +    let success = unsafe {
    +        PeekNamedPipe(
    +            stdout.as_raw_handle(),
    +            null_mut(),
    +            0,
    +            null_mut(),
    +            &mut available,
    +            null_mut(),
    +        )
    +    };
    +    if success == 0 {
    +        let error = io::Error::last_os_error();
    +        return if error.raw_os_error() == Some(ERROR_BROKEN_PIPE as i32) {
    +            Ok(0)
    +        } else {
    +            Err(error)
    +        };
    +    }
    +    if available == 0 {
    +        return Err(io::ErrorKind::WouldBlock.into());
    +    }
    +    let count = buffer.len().min(available as usize);
    +    match stdout.read(&mut buffer[..count]) {
    +        Err(error) if error.raw_os_error() == Some(ERROR_BROKEN_PIPE as i32) => Ok(0),
    +        result => result,
    +    }
    +}
    +
    +#[cfg(not(any(unix, windows)))]
    +fn prepare_stdout(_: &ChildStdout) -> io::Result<()> {
    +    Err(io::ErrorKind::Unsupported.into())
    +}
    +
    +#[cfg(not(any(unix, windows)))]
    +fn read_available(_: &mut ChildStdout, _: &mut [u8]) -> io::Result {
    +    Err(io::ErrorKind::Unsupported.into())
    +}
    +
    +#[cfg(all(test, unix))]
    +mod tests;
    diff --git a/src-tauri/src/opencode/installation.rs b/src-tauri/src/opencode/installation.rs
    new file mode 100644
    index 0000000..7778554
    --- /dev/null
    +++ b/src-tauri/src/opencode/installation.rs
    @@ -0,0 +1,886 @@
    +use std::fs::{self, OpenOptions};
    +use std::io::Read;
    +use std::path::{Component, Path, PathBuf};
    +use std::process::{Command, Stdio};
    +use std::sync::Mutex;
    +use std::time::{Duration, Instant};
    +
    +use reqwest::blocking::Client;
    +use serde::{Deserialize, Serialize};
    +
    +use crate::error::{AppError, ErrorCode};
    +
    +use super::binary::{opencode_candidates, resolve_path_command};
    +use super::command::collect_output;
    +
    +const LATEST_URL: &str = "https://opencode.ai/update/api/latest/cli/npm";
    +const PACKAGE: &str = "@opencode/cli";
    +const FORMULA: &str = "anomalyco/tap/opencode-v2";
    +const LOCAL_TIMEOUT: Duration = Duration::from_secs(15);
    +const PROCESS_TIMEOUT: Duration = Duration::from_secs(3);
    +const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
    +// Verified native updater at 434f7b2a3670dd932794688bee5e34029e546630; earlier V2 builds stay manual.
    +const MIN_NATIVE_UPGRADE: [u32; 3] = [2, 0, 25];
    +// Native updater can wait 15 minutes for its flock, then run two five-minute curl operations.
    +const UPGRADE_TIMEOUT: Duration = Duration::from_secs(26 * 60);
    +static UPGRADE_LOCK: Mutex<()> = Mutex::new(());
    +
    +#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub struct OpenCodeInstallation {
    +    pub binary_path: Option,
    +    pub version: Option,
    +    pub method: String,
    +    pub can_upgrade: bool,
    +    pub upgrade_reason: Option,
    +    pub manual_command: Option,
    +}
    +
    +#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub struct OpenCodeUpdateCheck {
    +    pub installation: OpenCodeInstallation,
    +    pub latest_version: String,
    +    pub update_available: bool,
    +}
    +
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +struct Version {
    +    text: String,
    +    numbers: [u32; 3],
    +    unstable: bool,
    +}
    +
    +fn version(text: &str) -> Option {
    +    let text = text.trim().strip_prefix('v').unwrap_or(text.trim());
    +    if text.is_empty() || text.len() > 96 || !text.is_ascii() {
    +        return None;
    +    }
    +    let (core, suffix) = text
    +        .split_once('-')
    +        .map_or((text, None), |(core, suffix)| (core, Some(suffix)));
    +    let mut numbers = [0; 3];
    +    let mut parts = core.split('.');
    +    for number in &mut numbers {
    +        let part = parts.next()?;
    +        if part.is_empty()
    +            || !part.bytes().all(|byte| byte.is_ascii_digit())
    +            || (part.len() > 1 && part.starts_with('0'))
    +        {
    +            return None;
    +        }
    +        *number = part.parse().ok()?;
    +    }
    +    if parts.next().is_some() {
    +        return None;
    +    }
    +    if let Some(suffix) = suffix {
    +        if suffix.split('.').any(|part| {
    +            part.is_empty()
    +                || !part
    +                    .bytes()
    +                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
    +                || (part.len() > 1
    +                    && part.starts_with('0')
    +                    && part.bytes().all(|byte| byte.is_ascii_digit()))
    +        }) {
    +            return None;
    +        }
    +    }
    +    Some(Version {
    +        text: text.to_owned(),
    +        numbers,
    +        unstable: suffix.is_some(),
    +    })
    +}
    +
    +fn stable_v2(text: &str) -> Result {
    +    version(text)
    +        .filter(|version| version.numbers[0] == 2 && !version.unstable)
    +        .ok_or_else(|| AppError::validation("A stable OpenCode V2 version is required"))
    +}
    +
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +enum Source {
    +    Curl {
    +        bash: PathBuf,
    +        curl: PathBuf,
    +        home: PathBuf,
    +    },
    +    Manager {
    +        method: &'static str,
    +        manager: PathBuf,
    +        root: PathBuf,
    +        bin: PathBuf,
    +    },
    +}
    +
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +struct Detected {
    +    info: OpenCodeInstallation,
    +    logical: Option,
    +    canonical: Option,
    +    source: Option,
    +}
    +
    +fn manual_command(method: &str) -> Option {
    +    Some(
    +        match method {
    +            "curl" => "curl -fsSL https://opencode.ai/v2/install | bash",
    +            "npm" => "npm install -g @opencode/cli@2",
    +            "bun" => "bun install -g --trust @opencode/cli@2",
    +            "pnpm" => "pnpm add -g --allow-build=@opencode/cli @opencode/cli@2",
    +            "yarn" => "yarn global add @opencode/cli@2",
    +            "vite-plus" => "vp install -g @opencode/cli@2",
    +            "brew" => "brew upgrade anomalyco/tap/opencode-v2",
    +            _ => return None,
    +        }
    +        .to_owned(),
    +    )
    +}
    +
    +fn not_found() -> Detected {
    +    Detected {
    +        info: OpenCodeInstallation {
    +            binary_path: None,
    +            version: None,
    +            method: "not-found".to_owned(),
    +            can_upgrade: false,
    +            upgrade_reason: Some("not_found".to_owned()),
    +            manual_command: None,
    +        },
    +        logical: None,
    +        canonical: None,
    +        source: None,
    +    }
    +}
    +
    +fn selected_binary() -> Option {
    +    selected_binary_with(
    +        std::env::var_os("OPENCODE_BIN").as_deref(),
    +        opencode_candidates(),
    +        resolve_path_command,
    +    )
    +}
    +
    +fn selected_binary_with(
    +    explicit: Option<&std::ffi::OsStr>,
    +    candidates: impl IntoIterator,
    +    mut resolve: impl FnMut(&Path) -> Option,
    +) -> Option {
    +    if let Some(explicit) = explicit {
    +        // An invalid override must never redirect an upgrade to another installation.
    +        return resolve(Path::new(explicit.to_str()?.trim()));
    +    }
    +    candidates
    +        .into_iter()
    +        .find_map(|candidate| resolve(&candidate))
    +}
    +
    +fn sanitized(command: &mut Command) {
    +    command.env_clear();
    +    for key in [
    +        "PATH",
    +        "HOME",
    +        "USER",
    +        "LOGNAME",
    +        "TMPDIR",
    +        "TMP",
    +        "TEMP",
    +        "LANG",
    +        "LC_ALL",
    +        "SystemRoot",
    +        "COMSPEC",
    +        "APPDATA",
    +        "LOCALAPPDATA",
    +        "NPM_CONFIG_PREFIX",
    +        "BUN_INSTALL",
    +        "PNPM_HOME",
    +    ] {
    +        if let Some(value) = std::env::var_os(key) {
    +            command.env(key, value);
    +        }
    +    }
    +    command
    +        .env("OPENCODE_DISABLE_AUTOUPDATE", "1")
    +        .env("HOMEBREW_NO_AUTO_UPDATE", "1")
    +        .env("HOMEBREW_NO_INSTALL_FROM_API", "1")
    +        .env("HOMEBREW_NO_ANALYTICS", "1")
    +        .stdin(Stdio::null())
    +        .stdout(Stdio::piped())
    +        .stderr(Stdio::null());
    +}
    +
    +fn executable_command(path: &Path) -> Command {
    +    #[cfg(all(test, unix))]
    +    if tests::is_fake_executable(path) {
    +        let mut command = Command::new("/bin/sh");
    +        command.arg(path);
    +        return command;
    +    }
    +    Command::new(path)
    +}
    +
    +fn read_output(
    +    command: &mut Command,
    +    deadline: Instant,
    +    max_bytes: u64,
    +    operation: &str,
    +) -> Result, AppError> {
    +    let timeout = deadline
    +        .checked_duration_since(Instant::now())
    +        .filter(|duration| !duration.is_zero())
    +        .ok_or_else(|| AppError::configuration(format!("{operation} timed out")))?
    +        .min(PROCESS_TIMEOUT);
    +    sanitized(command);
    +    let child = command.spawn().map_err(|error| {
    +        AppError::configuration(format!("{operation} could not start: {:?}", error.kind()))
    +    })?;
    +    let output = collect_output(child, timeout, max_bytes, operation, "bounded output")?;
    +    if !output.status.success() {
    +        return Err(AppError::configuration(format!(
    +            "{operation} failed (exit {:?})",
    +            output.status.code()
    +        )));
    +    }
    +    Ok(output.stdout)
    +}
    +
    +fn read_version(path: &Path, deadline: Instant) -> Result, AppError> {
    +    let bytes = read_output(
    +        executable_command(path).arg("--version"),
    +        deadline,
    +        1024,
    +        "OpenCode version query",
    +    )?;
    +    Ok(std::str::from_utf8(&bytes).ok().and_then(version))
    +}
    +
    +fn query_path(manager: &Path, args: &[&str], deadline: Instant) -> Option {
    +    let bytes = read_output(
    +        executable_command(manager).args(args),
    +        deadline,
    +        8192,
    +        "OpenCode manager ownership query",
    +    )
    +    .ok()?;
    +    let text = std::str::from_utf8(&bytes).ok()?.trim();
    +    if text.is_empty() || text.contains(['\n', '\r', '\0']) {
    +        return None;
    +    }
    +    let path = PathBuf::from(text);
    +    path.is_absolute().then_some(path)
    +}
    +
    +fn bounded_file(path: &Path, max_bytes: u64) -> Option> {
    +    if !fs::symlink_metadata(path).ok()?.file_type().is_file() {
    +        return None;
    +    }
    +    let mut options = OpenOptions::new();
    +    options.read(true);
    +    #[cfg(unix)]
    +    {
    +        use std::os::unix::fs::OpenOptionsExt;
    +        options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW);
    +    }
    +    let file = options.open(path).ok()?;
    +    let metadata = file.metadata().ok()?;
    +    if !metadata.is_file() || metadata.len() > max_bytes {
    +        return None;
    +    }
    +    let mut bytes = Vec::new();
    +    file.take(max_bytes + 1).read_to_end(&mut bytes).ok()?;
    +    (bytes.len() as u64 <= max_bytes).then_some(bytes)
    +}
    +
    +fn package_bin(root: &Path) -> Option {
    +    let root = root.canonicalize().ok()?;
    +    let package = root.join("@opencode/cli");
    +    let bytes = bounded_file(&package.join("package.json"), 64 * 1024)?;
    +    let metadata: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    +    if metadata.get("name").and_then(|value| value.as_str()) != Some(PACKAGE) {
    +        return None;
    +    }
    +    let bin = metadata.get("bin");
    +    let bin = bin.and_then(|value| {
    +        value
    +            .as_str()
    +            .or_else(|| value.get("opencode").and_then(|value| value.as_str()))
    +    })?;
    +    let relative = Path::new(bin);
    +    if relative.as_os_str().is_empty()
    +        || relative
    +            .components()
    +            .any(|part| !matches!(part, Component::Normal(_) | Component::CurDir))
    +    {
    +        return None;
    +    }
    +    let path = package.join(relative).canonicalize().ok()?;
    +    path.starts_with(root).then_some(path)
    +}
    +
    +fn package_owns(root: &Path, binary: &Path) -> bool {
    +    package_bin(root).as_deref() == Some(binary)
    +}
    +
    +fn global_bin_owns(root: &Path, bin: &Path, logical: &Path, canonical: &Path) -> bool {
    +    if package_owns(root, canonical)
    +        && ["opencode", "opencode.exe"]
    +            .iter()
    +            .any(|name| bin.join(name).canonicalize().ok().as_deref() == Some(canonical))
    +    {
    +        return true;
    +    }
    +    // Windows npm shims are readable installations, never automatic upgrade candidates.
    +    if cfg!(windows) && logical == bin.join("opencode.cmd") && package_bin(root).is_some() {
    +        return npm_native_shim(logical);
    +    }
    +    false
    +}
    +
    +fn npm_native_shim(path: &Path) -> bool {
    +    bounded_file(path, 16 * 1024)
    +        .and_then(|bytes| String::from_utf8(bytes).ok())
    +        .is_some_and(|text| {
    +            text.contains("\"%dp0%\\node_modules\\@opencode\\cli\\bin\\opencode.exe\"")
    +        })
    +}
    +
    +fn bundled(path: &Path) -> bool {
    +    for ancestor in path.ancestors().skip(1) {
    +        let Some(name) = ancestor.file_name().and_then(|name| name.to_str()) else {
    +            continue;
    +        };
    +        if name == "Contents"
    +            && ancestor
    +                .parent()
    +                .is_some_and(|parent| parent.extension() == Some(std::ffi::OsStr::new("app")))
    +        {
    +            return true;
    +        }
    +        if name.eq_ignore_ascii_case("resources")
    +            && ancestor
    +                .parent()
    +                .and_then(|parent| parent.file_name())
    +                .and_then(|name| name.to_str())
    +                .is_some_and(|name| {
    +                    ["opencode", "openchamber", "@openchamberelectron"]
    +                        .iter()
    +                        .any(|app| name.eq_ignore_ascii_case(app))
    +                })
    +        {
    +            return true;
    +        }
    +    }
    +    false
    +}
    +
    +fn official_curl_path(logical: &Path, canonical: &Path, home: &Path) -> bool {
    +    let name = if cfg!(windows) {
    +        "opencode.exe"
    +    } else {
    +        "opencode"
    +    };
    +    let expected = home.join(".opencode/bin").join(name);
    +    logical == expected
    +        && fs::symlink_metadata(logical)
    +            .ok()
    +            .is_some_and(|metadata| metadata.file_type().is_file())
    +        && home
    +            .canonicalize()
    +            .ok()
    +            .is_some_and(|home| canonical == home.join(".opencode/bin").join(name))
    +}
    +
    +fn brew_formula(path: &Path, deadline: Instant) -> Option {
    +    let cellar = path.ancestors().find(|ancestor| {
    +        ancestor.file_name() == Some(std::ffi::OsStr::new("opencode-v2"))
    +            && ancestor
    +                .parent()
    +                .is_some_and(|parent| parent.file_name() == Some(std::ffi::OsStr::new("Cellar")))
    +    })?;
    +    let manager = cellar.parent()?.parent()?.join("bin/brew");
    +    let manager = resolve_path_command(&manager)?;
    +    let prefix = query_path(&manager, &["--prefix", FORMULA], deadline)?
    +        .canonicalize()
    +        .ok()?;
    +    if prefix.join("bin/opencode").canonicalize().ok().as_deref() != Some(path)
    +        || !prefix.starts_with(cellar)
    +    {
    +        return Some(false);
    +    }
    +    let bytes = read_output(
    +        executable_command(&manager).args(["info", "--json=v2", "--installed", FORMULA]),
    +        deadline,
    +        64 * 1024,
    +        "Homebrew ownership query",
    +    )
    +    .ok()?;
    +    let metadata: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    +    Some(metadata.get("formulae")?.as_array()?.iter().any(|formula| {
    +        formula.get("name").and_then(|value| value.as_str()) == Some("opencode-v2")
    +            && formula.get("full_name").and_then(|value| value.as_str()) == Some(FORMULA)
    +            && formula
    +                .get("installed")
    +                .and_then(|value| value.as_array())
    +                .is_some_and(|values| !values.is_empty())
    +    }))
    +}
    +
    +fn identify_source(
    +    logical: &Path,
    +    canonical: &Path,
    +    home: Option<&Path>,
    +    deadline: Instant,
    +) -> (String, Option, &'static str) {
    +    identify_source_with(logical, canonical, home, deadline, resolve_path_command)
    +}
    +
    +fn identify_source_with(
    +    logical: &Path,
    +    canonical: &Path,
    +    home: Option<&Path>,
    +    deadline: Instant,
    +    mut resolve: impl FnMut(&Path) -> Option,
    +) -> (String, Option, &'static str) {
    +    if bundled(logical) || bundled(canonical) {
    +        return ("bundled".to_owned(), None, "bundled");
    +    }
    +    if let Some(home) = home.filter(|home| official_curl_path(logical, canonical, home)) {
    +        #[cfg(unix)]
    +        if let (Some(bash), Some(curl)) = (resolve(Path::new("bash")), resolve(Path::new("curl"))) {
    +            return (
    +                "curl".to_owned(),
    +                Some(Source::Curl {
    +                    bash,
    +                    curl,
    +                    home: home.to_path_buf(),
    +                }),
    +                "",
    +            );
    +        }
    +        return (
    +            "curl".to_owned(),
    +            None,
    +            if cfg!(unix) {
    +                "manager_unavailable"
    +            } else {
    +                "unsupported"
    +            },
    +        );
    +    }
    +    if let Some(proven) = brew_formula(canonical, deadline) {
    +        // Homebrew has no verified version-pinned upgrade for this formula.
    +        return (
    +            if proven { "brew" } else { "unknown" }.to_owned(),
    +            None,
    +            if proven { "unsupported" } else { "ambiguous" },
    +        );
    +    }
    +    if !(canonical
    +        .ancestors()
    +        .any(|path| path.file_name() == Some(std::ffi::OsStr::new("node_modules")))
    +        || (cfg!(windows) && logical.file_name() == Some(std::ffi::OsStr::new("opencode.cmd"))))
    +    {
    +        return ("manual".to_owned(), None, "unsupported");
    +    }
    +    if let Some(manager) = resolve(Path::new("npm")) {
    +        if let (Some(root), Some(prefix)) = (
    +            query_path(&manager, &["root", "-g"], deadline),
    +            query_path(&manager, &["prefix", "-g"], deadline),
    +        ) {
    +            let expected_root = prefix.join(if cfg!(windows) {
    +                "node_modules"
    +            } else {
    +                "lib/node_modules"
    +            });
    +            let bin = if cfg!(windows) {
    +                prefix
    +            } else {
    +                prefix.join("bin")
    +            };
    +            if root.canonicalize().ok() == expected_root.canonicalize().ok()
    +                && global_bin_owns(&root, &bin, logical, canonical)
    +            {
    +                return (
    +                    "npm".to_owned(),
    +                    cfg!(unix).then_some(Source::Manager {
    +                        method: "npm",
    +                        manager,
    +                        root,
    +                        bin,
    +                    }),
    +                    if cfg!(unix) { "" } else { "unsupported" },
    +                );
    +            }
    +        }
    +    }
    +    // Other manager shims/layouts must match both their queried global root and package bin.
    +    for (name, method, root_args, bin_args) in [
    +        ("pnpm", "pnpm", &["root", "-g"][..], &["bin", "-g"][..]),
    +        (
    +            "yarn",
    +            "yarn",
    +            &["global", "dir"][..],
    +            &["global", "bin"][..],
    +        ),
    +        ("bun", "bun", &[][..], &["pm", "bin", "-g"][..]),
    +    ] {
    +        let Some(manager) = resolve(Path::new(name)) else {
    +            continue;
    +        };
    +        let Some(bin) = query_path(&manager, bin_args, deadline) else {
    +            continue;
    +        };
    +        let root = if method == "bun" {
    +            bin.parent()
    +                .map(|parent| parent.join("install/global/node_modules"))
    +        } else {
    +            query_path(&manager, root_args, deadline).map(|path| {
    +                if method == "yarn" {
    +                    path.join("node_modules")
    +                } else {
    +                    path
    +                }
    +            })
    +        };
    +        if let Some(root) = root.filter(|root| global_bin_owns(root, &bin, logical, canonical)) {
    +            return (
    +                method.to_owned(),
    +                cfg!(unix).then_some(Source::Manager {
    +                    method,
    +                    manager,
    +                    root,
    +                    bin,
    +                }),
    +                if cfg!(unix) { "" } else { "unsupported" },
    +            );
    +        }
    +    }
    +    ("unknown".to_owned(), None, "ambiguous")
    +}
    +
    +fn inspect_path(logical: PathBuf, home: Option<&Path>) -> Result {
    +    let deadline = Instant::now() + LOCAL_TIMEOUT;
    +    let canonical = logical.canonicalize().map_err(|error| {
    +        AppError::configuration(format!(
    +            "OpenCode binary is unavailable: {:?}",
    +            error.kind()
    +        ))
    +    })?;
    +    let current = read_version(&logical, deadline)?;
    +    let (method, source, source_reason) = identify_source(&logical, &canonical, home, deadline);
    +    let reason = if method == "bundled" {
    +        "bundled"
    +    } else {
    +        match ¤t {
    +            None => "unsupported",
    +            Some(current) if current.numbers[0] != 2 => "not_v2",
    +            Some(current) if current.unstable => "unstable",
    +            Some(current) if current.numbers < MIN_NATIVE_UPGRADE => "unsupported",
    +            Some(_) => source_reason,
    +        }
    +    };
    +    let binary_path = logical
    +        .to_str()
    +        .ok_or_else(|| AppError::configuration("OpenCode binary path is not valid UTF-8"))?
    +        .to_owned();
    +    Ok(Detected {
    +        info: OpenCodeInstallation {
    +            binary_path: Some(binary_path),
    +            version: current.map(|version| version.text),
    +            manual_command: manual_command(&method),
    +            method,
    +            can_upgrade: reason.is_empty(),
    +            upgrade_reason: (!reason.is_empty()).then(|| reason.to_owned()),
    +        },
    +        logical: Some(logical),
    +        canonical: Some(canonical),
    +        source,
    +    })
    +}
    +
    +fn inspect_selected() -> Result {
    +    match selected_binary() {
    +        Some(path) => inspect_path(path, dirs::home_dir().as_deref()),
    +        None => Ok(not_found()),
    +    }
    +}
    +
    +/// Local discovery only; never contacts or starts the OpenCode service.
    +pub fn get_installation() -> Result {
    +    Ok(inspect_selected()?.info)
    +}
    +
    +fn http_client() -> Result {
    +    Client::builder()
    +        .timeout(HTTP_TIMEOUT)
    +        .connect_timeout(Duration::from_secs(5))
    +        .no_proxy()
    +        .redirect(reqwest::redirect::Policy::none())
    +        .retry(reqwest::retry::never())
    +        .build()
    +        .map_err(|_| AppError::configuration("OpenCode update HTTP initialization failed"))
    +}
    +
    +fn fetch_bytes(client: &Client, url: &str, max_bytes: u64) -> Result, AppError> {
    +    let response = client
    +        .get(url)
    +        .header(reqwest::header::USER_AGENT, "opencode-mom")
    +        .send()
    +        .map_err(|_| AppError::configuration("OpenCode update request failed"))?;
    +    if !response.status().is_success() {
    +        return Err(AppError::configuration(format!(
    +            "OpenCode update request failed (HTTP {})",
    +            response.status().as_u16()
    +        )));
    +    }
    +    if response
    +        .content_length()
    +        .is_some_and(|length| length > max_bytes)
    +    {
    +        return Err(AppError::configuration(
    +            "OpenCode update response exceeds its size limit",
    +        ));
    +    }
    +    let mut bytes = Vec::new();
    +    response
    +        .take(max_bytes + 1)
    +        .read_to_end(&mut bytes)
    +        .map_err(|_| AppError::configuration("OpenCode update response could not be read"))?;
    +    if bytes.len() as u64 > max_bytes {
    +        return Err(AppError::configuration(
    +            "OpenCode update response exceeds its size limit",
    +        ));
    +    }
    +    Ok(bytes)
    +}
    +
    +#[derive(Deserialize)]
    +struct Latest {
    +    channel: String,
    +    name: String,
    +    distribution: String,
    +    version: String,
    +    metadata: LatestMetadata,
    +    active: bool,
    +}
    +
    +#[derive(Deserialize)]
    +struct LatestMetadata {
    +    package: String,
    +}
    +
    +fn latest_version(bytes: &[u8]) -> Result {
    +    let invalid = || {
    +        AppError::configuration("OpenCode update metadata is not an active stable V2 CLI release")
    +    };
    +    let latest: Latest = serde_json::from_slice(bytes).map_err(|_| invalid())?;
    +    if latest.channel != "latest"
    +        || latest.name != "cli"
    +        || latest.distribution != "npm"
    +        || !latest.active
    +        || latest.metadata.package != PACKAGE
    +    {
    +        return Err(invalid());
    +    }
    +    stable_v2(&latest.version).map_err(|_| invalid())
    +}
    +
    +fn fetch_latest() -> Result {
    +    latest_version(&fetch_bytes(&http_client()?, LATEST_URL, 16 * 1024)?)
    +}
    +
    +pub fn check_for_update() -> Result {
    +    let installation = get_installation()?;
    +    let latest = fetch_latest()?;
    +    let update_available = installation
    +        .version
    +        .as_deref()
    +        .and_then(version)
    +        .is_some_and(|current| {
    +            current.numbers[0] == 2 && !current.unstable && latest.numbers > current.numbers
    +        });
    +    Ok(OpenCodeUpdateCheck {
    +        installation,
    +        latest_version: latest.text,
    +        update_available,
    +    })
    +}
    +
    +fn upgrade_command(detected: &Detected, target: &str) -> Result {
    +    let target = stable_v2(target)?.text;
    +    let source = detected
    +        .source
    +        .as_ref()
    +        .ok_or_else(|| AppError::validation("OpenCode installation ownership is unproven"))?;
    +    let binary = detected
    +        .logical
    +        .as_ref()
    +        .ok_or_else(|| AppError::validation("OpenCode binary is unavailable"))?;
    +    let method = match source {
    +        Source::Curl { .. } => "curl",
    +        Source::Manager { method, .. } => *method,
    +    };
    +    let mut command = executable_command(binary);
    +    command.args(["upgrade", &target, "--method", method]);
    +    sanitized(&mut command);
    +    command.stdout(Stdio::null());
    +    if let Source::Curl { home, .. } = source {
    +        command.env("HOME", home);
    +    }
    +    Ok(command)
    +}
    +
    +#[cfg(unix)]
    +fn run_upgrade(command: &mut Command, timeout: Duration) -> Result<(), AppError> {
    +    use std::os::unix::process::CommandExt;
    +
    +    command.process_group(0);
    +    let mut child = command.spawn().map_err(|error| {
    +        AppError::configuration(format!(
    +            "OpenCode upgrade could not start: {:?}",
    +            error.kind()
    +        ))
    +    })?;
    +    let group = child.id() as libc::pid_t;
    +    let deadline = Instant::now() + timeout;
    +    loop {
    +        match child.try_wait() {
    +            Ok(Some(status)) if status.success() => return Ok(()),
    +            Ok(Some(status)) => {
    +                return Err(AppError::configuration(format!(
    +                    "OpenCode upgrade failed (exit {:?}); use the installation's manual command",
    +                    status.code()
    +                )))
    +            }
    +            Err(error) => {
    +                let _ = child.kill();
    +                let _ = child.wait();
    +                return Err(AppError::configuration(format!(
    +                    "OpenCode upgrade wait failed: {:?}",
    +                    error.kind()
    +                )));
    +            }
    +            Ok(None) => {}
    +        }
    +        if Instant::now() >= deadline {
    +            // SAFETY: kill takes scalar arguments; the negative spawned PID selects the group requested
    +            // by process_group(0). This Child is unreaped here, but external reaping/PID reuse is not excluded.
    +            let _ = unsafe { libc::kill(-group, libc::SIGKILL) };
    +            let _ = child.kill();
    +            let _ = child.wait();
    +            return Err(AppError::configuration(
    +                "OpenCode upgrade timed out; check the selected installation before trying again",
    +            ));
    +        }
    +        std::thread::sleep(
    +            Duration::from_millis(20).min(deadline.saturating_duration_since(Instant::now())),
    +        );
    +    }
    +}
    +
    +#[cfg(not(unix))]
    +fn run_upgrade(_: &mut Command, _: Duration) -> Result<(), AppError> {
    +    Err(AppError::configuration(
    +        "Automatic OpenCode upgrade is unsupported on this platform",
    +    ))
    +}
    +
    +fn execute_upgrade(detected: &Detected, target: &str) -> Result<(), AppError> {
    +    run_upgrade(&mut upgrade_command(detected, target)?, UPGRADE_TIMEOUT)
    +}
    +
    +fn verify_snapshot(
    +    detected: &Detected,
    +    expected_path: &str,
    +    expected_version: &str,
    +) -> Result<(), AppError> {
    +    if detected.info.binary_path.as_deref() != Some(expected_path)
    +        || detected.info.version.as_deref() != Some(expected_version)
    +    {
    +        return Err(AppError::validation(
    +            "OpenCode installation changed; refresh before upgrading",
    +        ));
    +    }
    +    if !detected.info.can_upgrade || detected.source.is_none() {
    +        return Err(AppError::validation(format!(
    +            "OpenCode automatic upgrade is unavailable ({}); use the installation's manual command",
    +            detected
    +                .info
    +                .upgrade_reason
    +                .as_deref()
    +                .unwrap_or("unsupported")
    +        )));
    +    }
    +    Ok(())
    +}
    +
    +fn guarded_upgrade(
    +    expected_path: &str,
    +    expected_version: &str,
    +    target: &str,
    +    mut inspect: impl FnMut(Option<&Path>) -> Result,
    +    latest: impl FnOnce() -> Result,
    +    execute: impl FnOnce(&Detected, &str) -> Result<(), AppError>,
    +) -> Result {
    +    let target = stable_v2(target)?;
    +    let current = stable_v2(expected_version)?;
    +    if current.numbers < MIN_NATIVE_UPGRADE {
    +        return Err(AppError::validation(
    +            "This OpenCode V2 version requires a manual upgrade",
    +        ));
    +    }
    +    if target.numbers <= current.numbers {
    +        return Err(AppError::validation(
    +            "OpenCode upgrades cannot downgrade or reinstall the current version",
    +        ));
    +    }
    +    let before = inspect(None)?;
    +    verify_snapshot(&before, expected_path, expected_version)?;
    +    if stable_v2(&latest()?.text)?.text != target.text {
    +        return Err(AppError::validation(
    +            "OpenCode target is no longer the latest stable V2 release; check again",
    +        ));
    +    }
    +    let refreshed = inspect(None)?;
    +    verify_snapshot(&refreshed, expected_path, expected_version)?;
    +    if before.logical != refreshed.logical
    +        || before.canonical != refreshed.canonical
    +        || before.source != refreshed.source
    +    {
    +        return Err(AppError::validation(
    +            "OpenCode installation source changed; refresh before upgrading",
    +        ));
    +    }
    +    execute(&refreshed, &target.text)?;
    +    let after = inspect(before.logical.as_deref())?;
    +    if after.logical != before.logical || after.info.version.as_deref() != Some(&target.text) {
    +        return Err(AppError::configuration("OpenCode upgrade finished, but the selected binary does not report the target version; no fallback installation was used"));
    +    }
    +    Ok(after.info)
    +}
    +
    +pub fn upgrade(
    +    expected_binary_path: &str,
    +    expected_version: &str,
    +    target_version: &str,
    +) -> Result {
    +    let _guard = UPGRADE_LOCK
    +        .try_lock()
    +        .map_err(|_| AppError::new(ErrorCode::Busy, "OpenCode upgrade is already in progress"))?;
    +    guarded_upgrade(
    +        expected_binary_path,
    +        expected_version,
    +        target_version,
    +        |path| match path {
    +            Some(path) => inspect_path(path.to_path_buf(), dirs::home_dir().as_deref()),
    +            None => inspect_selected(),
    +        },
    +        fetch_latest,
    +        execute_upgrade,
    +    )
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/src-tauri/src/opencode/island.rs b/src-tauri/src/opencode/island.rs
    new file mode 100644
    index 0000000..0fdaf5a
    --- /dev/null
    +++ b/src-tauri/src/opencode/island.rs
    @@ -0,0 +1,351 @@
    +use std::collections::HashSet;
    +use std::sync::Arc;
    +use std::time::{Duration, Instant};
    +
    +use reqwest::{Method, RequestBuilder, Response};
    +use serde::{Deserialize, Serialize};
    +use serde_json::{Map, Value};
    +
    +use crate::error::AppError;
    +
    +use super::transport::{self, ManagedAsyncConnection};
    +
    +mod events;
    +mod projection;
    +
    +pub(crate) use events::IslandChange;
    +pub(crate) type IslandEvents = events::IslandEvents;
    +
    +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
    +const MAX_BYTES: usize = 2 * 1024 * 1024;
    +const MAX_SESSIONS: usize = 100;
    +const MAX_PENDING: usize = 100;
    +const MAX_FIELDS: usize = 32;
    +const MAX_OPTIONS: usize = 100;
    +const MAX_TEXT: usize = 16 * 1024;
    +const JS_MAX_DATE: u64 = 8_640_000_000_000_000;
    +
    +#[derive(Default, Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub(crate) struct IslandSnapshot {
    +    pub sessions: Vec,
    +    pub permissions: Vec,
    +    pub questions: Vec,
    +}
    +
    +#[derive(Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub(crate) struct IslandSession {
    +    pub id: String,
    +    pub title: String,
    +    pub directory: String,
    +    pub updated_at: u64,
    +    pub parent_id: Option,
    +    pub status: String,
    +}
    +
    +#[derive(Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub(crate) struct IslandPermission {
    +    pub id: String,
    +    pub session_id: String,
    +    pub action: String,
    +    pub resources: Vec,
    +    pub save: Vec,
    +    pub message: Option,
    +}
    +
    +#[derive(Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub(crate) struct IslandQuestion {
    +    pub id: String,
    +    pub session_id: String,
    +    pub title: String,
    +    pub answerable: bool,
    +    pub fields: Vec,
    +}
    +
    +#[derive(Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub(crate) struct IslandQuestionField {
    +    pub key: String,
    +    #[serde(rename = "type")]
    +    pub kind: String,
    +    pub title: Option,
    +    pub description: Option,
    +    pub options: Vec,
    +    pub custom: bool,
    +    pub required: bool,
    +}
    +
    +#[derive(Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub(crate) struct IslandQuestionOption {
    +    pub value: String,
    +    pub label: String,
    +    pub description: Option,
    +}
    +
    +#[derive(Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub(crate) struct IslandMessage {
    +    pub id: String,
    +    pub role: String,
    +    pub text: String,
    +    pub time: u64,
    +    pub truncated: bool,
    +}
    +
    +#[derive(Clone)]
    +pub(crate) struct IslandApi {
    +    connection: Arc,
    +}
    +
    +pub(crate) async fn connect() -> Result {
    +    let snapshot = super::target::snapshot()?;
    +    let deadline = Instant::now() + Duration::from_secs(20);
    +    connect_snapshot(snapshot, deadline).await
    +}
    +
    +pub(super) async fn connect_snapshot(
    +    snapshot: super::target::Snapshot,
    +    deadline: Instant,
    +) -> Result {
    +    let connection = tauri::async_runtime::spawn_blocking(move || {
    +        // A dropped async caller cannot cancel a blocking bootstrap worker.
    +        let _reservation = snapshot.reserve_side_effect(deadline)?;
    +        transport::connect_snapshot(snapshot, deadline)?.into_async()
    +    })
    +    .await
    +    .map_err(|_| failure("connection worker failed"))??;
    +    Ok(IslandApi {
    +        connection: Arc::new(connection),
    +    })
    +}
    +
    +#[cfg(test)]
    +pub(crate) fn test_api(url: &str) -> IslandApi {
    +    IslandApi {
    +        connection: Arc::new(
    +            transport::test_connection(url, Instant::now() + REQUEST_TIMEOUT)
    +                .into_async()
    +                .unwrap(),
    +        ),
    +    }
    +}
    +
    +#[derive(Deserialize)]
    +struct Data {
    +    data: T,
    +}
    +
    +fn failure(kind: &str) -> AppError {
    +    AppError::configuration(format!("opencode island: {kind}."))
    +}
    +
    +fn invalid(kind: &str) -> AppError {
    +    AppError::validation(format!("opencode island: invalid {kind}."))
    +}
    +
    +fn identifier(value: &str, prefix: &str) -> Result<(), AppError> {
    +    if value.len() <= 256
    +        && value.strip_prefix(prefix).is_some_and(|suffix| {
    +            !suffix.is_empty()
    +                && suffix
    +                    .bytes()
    +                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
    +        })
    +    {
    +        Ok(())
    +    } else {
    +        Err(invalid("identifier"))
    +    }
    +}
    +
    +fn plain(value: &str, max: usize, multiline: bool) -> Result<(), AppError> {
    +    if value.len() > max
    +        || value
    +            .chars()
    +            .any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t')))
    +    {
    +        Err(invalid("plaintext"))
    +    } else {
    +        Ok(())
    +    }
    +}
    +
    +fn safe_time(value: u64) -> Result {
    +    if value <= JS_MAX_DATE {
    +        Ok(value)
    +    } else {
    +        Err(invalid("time"))
    +    }
    +}
    +
    +fn request_error(error: reqwest::Error) -> AppError {
    +    failure(if error.is_timeout() {
    +        "API request timed out"
    +    } else if error.is_connect() {
    +        "API connection failed"
    +    } else {
    +        "API request failed"
    +    })
    +}
    +
    +async fn body(mut response: Response) -> Result, AppError> {
    +    if response
    +        .content_length()
    +        .is_some_and(|size| size > MAX_BYTES as u64)
    +    {
    +        return Err(failure("response byte limit exceeded"));
    +    }
    +    let mut bytes = Vec::new();
    +    while let Some(chunk) = response.chunk().await.map_err(request_error)? {
    +        if bytes.len() + chunk.len() > MAX_BYTES {
    +            return Err(failure("response byte limit exceeded"));
    +        }
    +        bytes.extend_from_slice(&chunk);
    +    }
    +    Ok(bytes)
    +}
    +
    +impl IslandApi {
    +    fn request(&self, method: Method, path: &[&str]) -> Result {
    +        Ok(self
    +            .connection
    +            .request(method, path)?
    +            .timeout(REQUEST_TIMEOUT))
    +    }
    +
    +    async fn read Deserialize<'de>>(
    +        &self,
    +        path: &[&str],
    +        query: &[(&str, &str)],
    +    ) -> Result {
    +        let response = self
    +            .request(Method::GET, path)?
    +            .query(query)
    +            .send()
    +            .await
    +            .map_err(request_error)?;
    +        if response.status() != reqwest::StatusCode::OK {
    +            return Err(failure(&format!("API HTTP {}", response.status().as_u16())));
    +        }
    +        let value = transport::decode_json::>(
    +            &body(response).await?,
    +            "island response",
    +        )
    +        .map(|value| value.0)?;
    +        self.connection.ensure_current()?;
    +        Ok(value)
    +    }
    +
    +    async fn write(&self, request: RequestBuilder) -> Result<(), AppError> {
    +        let deadline = Instant::now() + REQUEST_TIMEOUT;
    +        let _reservation = self.connection.prepare_write(deadline).await?;
    +        let timeout = deadline
    +            .checked_duration_since(Instant::now())
    +            .filter(|timeout| !timeout.is_zero())
    +            .ok_or_else(|| failure("write timed out before dispatch"))?;
    +        let request = request.timeout(timeout);
    +        let response = request
    +            .send()
    +            .await
    +            .map_err(|_| failure("write outcome unknown; may have been accepted; do not retry"))?;
    +        let status = response.status();
    +        if !status.is_success() {
    +            if status.is_client_error() && status != reqwest::StatusCode::REQUEST_TIMEOUT {
    +                return Err(failure(&format!(
    +                    "API HTTP {}; write rejected",
    +                    status.as_u16()
    +                )));
    +            }
    +            return Err(failure(&format!(
    +                "API HTTP {}; write may have been accepted; do not retry",
    +                status.as_u16()
    +            )));
    +        }
    +        body(response)
    +            .await
    +            .map_err(|_| failure("write outcome unknown; may have been accepted; do not retry"))?;
    +        self.connection.ensure_current()?;
    +        Ok(())
    +    }
    +
    +    pub(crate) async fn send(
    +        &self,
    +        session_id: &str,
    +        text: &str,
    +        message_id: &str,
    +    ) -> Result<(), AppError> {
    +        identifier(session_id, "ses_")?;
    +        identifier(message_id, "msg_")?;
    +        plain(text, MAX_TEXT, true)?;
    +        if text.trim().is_empty() {
    +            return Err(invalid("message"));
    +        }
    +        self.write(
    +            self.request(Method::POST, &["session", session_id, "prompt"])?
    +                .json(&serde_json::json!({ "id": message_id, "text": text, "delivery": "queue" })),
    +        )
    +        .await
    +    }
    +
    +    pub(crate) async fn reply_permission(
    +        &self,
    +        session_id: &str,
    +        request_id: &str,
    +        decision: &str,
    +    ) -> Result<(), AppError> {
    +        identifier(session_id, "ses_")?;
    +        identifier(request_id, "per")?;
    +        if !matches!(decision, "once" | "always" | "reject") {
    +            return Err(invalid("permission decision"));
    +        }
    +        if decision == "always"
    +            && !self
    +                .permissions(session_id)
    +                .await
    +                .map_err(|_| invalid("pending permission"))?
    +                .iter()
    +                .any(|permission| {
    +                    permission.session_id == session_id
    +                        && permission.id == request_id
    +                        && !permission.save.is_empty()
    +                })
    +        {
    +            return Err(invalid("stale or unsavable permission"));
    +        }
    +        self.write(
    +            self.request(
    +                Method::POST,
    +                &["session", session_id, "permission", request_id, "reply"],
    +            )?
    +            .json(&serde_json::json!({ "decision": decision })),
    +        )
    +        .await
    +    }
    +
    +    pub(crate) async fn interrupt(&self, session_id: &str) -> Result<(), AppError> {
    +        identifier(session_id, "ses_")?;
    +        self.write(
    +            self.request(Method::POST, &["session", session_id, "interrupt"])?
    +                .query(&[("resume", "false")]),
    +        )
    +        .await
    +    }
    +
    +    pub(crate) async fn cancel_question(
    +        &self,
    +        session_id: &str,
    +        form_id: &str,
    +    ) -> Result<(), AppError> {
    +        identifier(session_id, "ses_")?;
    +        identifier(form_id, "frm_")?;
    +        self.write(self.request(Method::DELETE, &["session", session_id, "form", form_id])?)
    +            .await
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/src-tauri/src/opencode/island/events.rs b/src-tauri/src/opencode/island/events.rs
    new file mode 100644
    index 0000000..5ec5982
    --- /dev/null
    +++ b/src-tauri/src/opencode/island/events.rs
    @@ -0,0 +1,190 @@
    +use super::*;
    +
    +const MAX_FRAME: usize = 64 * 1024;
    +
    +#[derive(Debug, Clone, PartialEq, Eq)]
    +pub(crate) enum IslandChange {
    +    Snapshot,
    +    Messages { session_id: String },
    +}
    +
    +pub(crate) struct IslandEvents {
    +    response: Response,
    +    buffer: Vec,
    +    offset: usize,
    +    frame: Frame,
    +}
    +
    +#[derive(Default)]
    +pub(super) struct Frame {
    +    line: Vec,
    +    data: Vec,
    +    size: usize,
    +    skip_lf: bool,
    +}
    +
    +impl Frame {
    +    pub(super) fn byte(&mut self, byte: u8) -> Result>, AppError> {
    +        if self.skip_lf {
    +            self.skip_lf = false;
    +            if byte == b'\n' {
    +                return Ok(None);
    +            }
    +        }
    +        self.size += 1;
    +        if self.size > MAX_FRAME {
    +            return Err(failure("event frame byte limit exceeded"));
    +        }
    +        if !matches!(byte, b'\r' | b'\n') {
    +            self.line.push(byte);
    +            return Ok(None);
    +        }
    +        self.skip_lf = byte == b'\r';
    +        if self.line.is_empty() {
    +            self.size = 0;
    +            if self.data.last() == Some(&b'\n') {
    +                self.data.pop();
    +            }
    +            return Ok(Some(std::mem::take(&mut self.data)));
    +        }
    +        if let Some(data) = self.line.strip_prefix(b"data:") {
    +            self.data
    +                .extend_from_slice(data.strip_prefix(b" ").unwrap_or(data));
    +            self.data.push(b'\n');
    +        }
    +        self.line.clear();
    +        Ok(None)
    +    }
    +}
    +
    +impl IslandApi {
    +    pub(crate) async fn events(&self) -> Result {
    +        // Bound the handshake only; a quiet live stream must not become snapshot polling.
    +        let response = tokio::time::timeout(
    +            REQUEST_TIMEOUT,
    +            self.connection
    +                .request(Method::GET, &["event"])?
    +                .header(reqwest::header::ACCEPT, "text/event-stream")
    +                .send(),
    +        )
    +        .await
    +        .map_err(|_| failure("event connection timed out"))?
    +        .map_err(request_error)?;
    +        if response.status() != reqwest::StatusCode::OK {
    +            return Err(failure(&format!("API HTTP {}", response.status().as_u16())));
    +        }
    +        if response
    +            .headers()
    +            .get(reqwest::header::CONTENT_TYPE)
    +            .and_then(|value| value.to_str().ok())
    +            .and_then(|value| value.split(';').next())
    +            != Some("text/event-stream")
    +        {
    +            return Err(invalid("event content type"));
    +        }
    +        Ok(IslandEvents {
    +            response,
    +            buffer: Vec::new(),
    +            offset: 0,
    +            frame: Frame::default(),
    +        })
    +    }
    +}
    +
    +impl IslandEvents {
    +    pub(crate) async fn next(&mut self) -> Result {
    +        loop {
    +            while self.offset < self.buffer.len() {
    +                let byte = self.buffer[self.offset];
    +                self.offset += 1;
    +                if let Some(data) = self.frame.byte(byte)? {
    +                    if !data.is_empty() {
    +                        if let Some(change) = classify(&data)? {
    +                            return Ok(change);
    +                        }
    +                    }
    +                }
    +            }
    +            self.buffer.clear();
    +            self.offset = 0;
    +            let chunk = self
    +                .response
    +                .chunk()
    +                .await
    +                .map_err(request_error)?
    +                .ok_or_else(|| failure("event stream ended"))?;
    +            if chunk.len() > MAX_BYTES {
    +                return Err(failure("event chunk byte limit exceeded"));
    +            }
    +            self.buffer.extend_from_slice(&chunk);
    +        }
    +    }
    +}
    +
    +pub(super) fn classify(bytes: &[u8]) -> Result, AppError> {
    +    let event: Value = transport::decode_json(bytes, "island event")?;
    +    let kind = event
    +        .get("type")
    +        .and_then(Value::as_str)
    +        .ok_or_else(|| invalid("event type"))?;
    +    let snapshot = matches!(
    +        kind,
    +        "server.connected"
    +            | "session.created"
    +            | "session.deleted"
    +            | "session.forked"
    +            | "session.renamed"
    +            | "session.moved"
    +            | "session.status"
    +            | "session.idle"
    +            | "session.execution.started"
    +            | "session.execution.succeeded"
    +            | "session.execution.failed"
    +            | "session.execution.interrupted"
    +            | "permission.asked"
    +            | "permission.replied"
    +            | "form.created"
    +            | "form.replied"
    +            | "form.cancelled"
    +            | "location.shutdown"
    +    );
    +    let messages = matches!(
    +        kind,
    +        "session.text.started"
    +            | "session.text.delta"
    +            | "session.text.ended"
    +            | "session.message.content.updated"
    +            | "session.inbox.delivered"
    +    );
    +    if !snapshot && !messages {
    +        return Ok(None);
    +    }
    +    let data = event
    +        .get("data")
    +        .filter(|value| value.is_object())
    +        .ok_or_else(|| invalid("event data"))?;
    +    // Snapshot invalidation needs no routing ID; native lifecycle payloads differ by event.
    +    if snapshot && !kind.starts_with("form.") {
    +        return Ok(Some(IslandChange::Snapshot));
    +    }
    +    let data = if kind == "form.created" {
    +        data.get("form").ok_or_else(|| invalid("form event"))?
    +    } else {
    +        data
    +    };
    +    let session_id = data
    +        .get("sessionID")
    +        .and_then(Value::as_str)
    +        .ok_or_else(|| invalid("event session"))?;
    +    if session_id == "global" && kind.starts_with("form.") {
    +        return Ok(None);
    +    }
    +    identifier(session_id, "ses_")?;
    +    if snapshot {
    +        Ok(Some(IslandChange::Snapshot))
    +    } else {
    +        Ok(Some(IslandChange::Messages {
    +            session_id: session_id.to_owned(),
    +        }))
    +    }
    +}
    diff --git a/src-tauri/src/opencode/island/projection.rs b/src-tauri/src/opencode/island/projection.rs
    new file mode 100644
    index 0000000..c8894c0
    --- /dev/null
    +++ b/src-tauri/src/opencode/island/projection.rs
    @@ -0,0 +1,654 @@
    +use super::*;
    +
    +#[derive(Deserialize)]
    +struct Active {
    +    #[serde(deserialize_with = "super::super::active::running_sessions")]
    +    data: HashSet,
    +}
    +
    +#[derive(Deserialize)]
    +#[serde(rename_all = "camelCase")]
    +struct Session {
    +    id: String,
    +    title: Option,
    +    location: Location,
    +    outcome: Option,
    +    #[serde(rename = "parentID")]
    +    parent_id: Option,
    +    time: SessionTime,
    +}
    +
    +#[derive(Deserialize)]
    +struct Location {
    +    directory: String,
    +}
    +
    +#[derive(Deserialize)]
    +struct SessionTime {
    +    updated: u64,
    +    archived: Option,
    +}
    +
    +impl Session {
    +    fn public(self, running: bool) -> Result {
    +        identifier(&self.id, "ses_")?;
    +        if let Some(parent) = &self.parent_id {
    +            identifier(parent, "ses_")?;
    +        }
    +        let title = self.title.unwrap_or_default();
    +        plain(&title, 4096, false)?;
    +        plain(&self.location.directory, 4096, false)?;
    +        if self
    +            .outcome
    +            .as_deref()
    +            .is_some_and(|outcome| !matches!(outcome, "succeeded" | "failed" | "interrupted"))
    +        {
    +            return Err(invalid("session outcome"));
    +        }
    +        if let Some(time) = self.time.archived {
    +            safe_time(time)?;
    +        }
    +        Ok(IslandSession {
    +            id: self.id,
    +            title,
    +            directory: self.location.directory,
    +            updated_at: safe_time(self.time.updated)?,
    +            parent_id: self.parent_id,
    +            status: if running {
    +                "running"
    +            } else if self.outcome.as_deref() == Some("failed") {
    +                "error"
    +            } else {
    +                "idle"
    +            }
    +            .into(),
    +        })
    +    }
    +}
    +
    +async fn bounded>>(
    +    futures: impl IntoIterator,
    +    mut retain: impl FnMut(T) -> Result<(), AppError>,
    +) -> Result<(), AppError> {
    +    let mut input = futures.into_iter();
    +    let mut pending: Vec<_> = input.by_ref().take(4).map(Box::pin).collect();
    +    while !pending.is_empty() {
    +        let (index, result) = std::future::poll_fn(|context| {
    +            for (index, future) in pending.iter_mut().enumerate() {
    +                if let std::task::Poll::Ready(result) = future.as_mut().poll(context) {
    +                    return std::task::Poll::Ready((index, result));
    +                }
    +            }
    +            std::task::Poll::Pending
    +        })
    +        .await;
    +        retain(result?)?;
    +        if let Some(future) = input.next() {
    +            pending[index] = Box::pin(future);
    +        } else {
    +            drop(pending.swap_remove(index));
    +        }
    +    }
    +    Ok(())
    +}
    +
    +impl IslandApi {
    +    pub(super) async fn permissions(
    +        &self,
    +        session_id: &str,
    +    ) -> Result, AppError> {
    +        let pending: Data> = self
    +            .read(&["session", session_id, "permission"], &[])
    +            .await?;
    +        if pending.data.len() > MAX_PENDING {
    +            return Err(failure("permission display limit exceeded"));
    +        }
    +        let mut ids = HashSet::new();
    +        pending
    +            .data
    +            .into_iter()
    +            .map(|request| {
    +                let id = string(&request, "id")?;
    +                identifier(id, "per")?;
    +                if !ids.insert(id.to_owned()) || string(&request, "sessionID")? != session_id {
    +                    return Err(invalid("permission identity"));
    +                }
    +                let action = string(&request, "action")?;
    +                plain(action, 1024, false)?;
    +                if action.is_empty() {
    +                    return Err(invalid("permission action"));
    +                }
    +                Ok(IslandPermission {
    +                    id: id.to_owned(),
    +                    session_id: session_id.to_owned(),
    +                    action: action.to_owned(),
    +                    resources: bounded_strings(
    +                        request
    +                            .get("resources")
    +                            .ok_or_else(|| invalid("permission resources"))?,
    +                        MAX_OPTIONS,
    +                    )?,
    +                    save: request
    +                        .get("save")
    +                        .map(|value| bounded_strings(value, MAX_OPTIONS))
    +                        .transpose()?
    +                        .unwrap_or_default(),
    +                    message: optional_string(&request, "message", 4096)?,
    +                })
    +            })
    +            .collect()
    +    }
    +
    +    async fn questions(&self, session_id: &str) -> Result, AppError> {
    +        let pending: Data> = self.read(&["session", session_id, "form"], &[]).await?;
    +        if pending.data.len() > MAX_PENDING {
    +            return Err(failure("form display limit exceeded"));
    +        }
    +        let mut ids = HashSet::new();
    +        pending
    +            .data
    +            .into_iter()
    +            .map(|form| {
    +                let question = public_question(&form, session_id)?;
    +                if !ids.insert(question.id.clone()) {
    +                    return Err(invalid("duplicate form"));
    +                }
    +                Ok(question)
    +            })
    +            .collect()
    +    }
    +
    +    pub(crate) async fn reply_question(
    +        &self,
    +        session_id: &str,
    +        form_id: &str,
    +        mut answer: Map,
    +    ) -> Result<(), AppError> {
    +        identifier(session_id, "ses_")?;
    +        identifier(form_id, "frm_")?;
    +        if answer.len() > MAX_FIELDS {
    +            return Err(invalid("question answer limit"));
    +        }
    +        let current: Data = self
    +            .read(&["session", session_id, "form", form_id], &[])
    +            .await?;
    +        if current
    +            .data
    +            .get("state")
    +            .and_then(|state| state.get("status"))
    +            .and_then(Value::as_str)
    +            != Some("pending")
    +        {
    +            return Err(invalid("stale question"));
    +        }
    +        let question = public_question(¤t.data, session_id)?;
    +        if question.id != form_id {
    +            return Err(invalid("form identity"));
    +        }
    +        answer_valid(&question, &mut answer)?;
    +        self.write(
    +            self.request(
    +                Method::POST,
    +                &["session", session_id, "form", form_id, "reply"],
    +            )?
    +            .json(&serde_json::json!({"answer":answer})),
    +        )
    +        .await
    +    }
    +
    +    pub(crate) async fn snapshot(&self) -> Result {
    +        let snapshot = tokio::time::timeout(Duration::from_secs(15), self.snapshot_inner())
    +            .await
    +            .map_err(|_| failure("snapshot timed out"))??;
    +        self.connection.ensure_current()?;
    +        Ok(snapshot)
    +    }
    +
    +    async fn snapshot_inner(&self) -> Result {
    +        let recent: Data> = self
    +            .read(
    +                &["session"],
    +                &[("limit", "20"), ("order", "desc"), ("parentID", "null")],
    +            )
    +            .await?;
    +        if recent.data.len() > 20 {
    +            return Err(failure("recent session limit exceeded"));
    +        }
    +        let active: Active = self.read(&["session", "active"], &[]).await?;
    +        if active.data.len() > MAX_SESSIONS {
    +            return Err(failure("active session display limit exceeded"));
    +        }
    +        for id in &active.data {
    +            identifier(id, "ses_")?;
    +        }
    +        let mut seen = HashSet::new();
    +        let mut sessions = Vec::new();
    +        for session in recent.data {
    +            if session.parent_id.is_some() || !seen.insert(session.id.clone()) {
    +                return Err(invalid("recent root session"));
    +            }
    +            let archived = session.time.archived.is_some();
    +            let running = active.data.contains(&session.id);
    +            let session = session.public(running)?;
    +            if !archived || running {
    +                sessions.push(session);
    +            }
    +        }
    +        let missing: Vec<_> = active
    +            .data
    +            .iter()
    +            .filter(|id| !sessions.iter().any(|session| &session.id == *id))
    +            .cloned()
    +            .collect();
    +        if sessions.len() + missing.len() > MAX_SESSIONS {
    +            return Err(failure("session display limit exceeded"));
    +        }
    +        let details: Vec<_> = missing
    +            .into_iter()
    +            .map(|id| async move {
    +                let session: Data = self.read(&["session", &id], &[]).await?;
    +                if session.data.id != id {
    +                    return Err(invalid("session identity"));
    +                }
    +                session.data.public(true)
    +            })
    +            .collect();
    +        bounded(details, |session| {
    +            sessions.push(session);
    +            Ok(())
    +        })
    +        .await?;
    +        let mut snapshot = IslandSnapshot {
    +            sessions,
    +            ..IslandSnapshot::default()
    +        };
    +        let mut retained_bytes = serde_json::to_vec(&snapshot)
    +            .map_err(|_| failure("snapshot encoding failed"))?
    +            .len();
    +        if retained_bytes > MAX_BYTES {
    +            return Err(failure("snapshot retained byte limit exceeded"));
    +        }
    +        // Recent roots are checked once too: pending work is not inferred from connection state.
    +        let ids: Vec<_> = snapshot
    +            .sessions
    +            .iter()
    +            .map(|session| session.id.clone())
    +            .collect();
    +        let pending: Vec<_> = ids
    +            .into_iter()
    +            .map(|id| async move {
    +                let permissions = self.permissions(&id).await?;
    +                let questions = self.questions(&id).await?;
    +                Ok((permissions, questions))
    +            })
    +            .collect();
    +        bounded(pending, |(permissions, questions)| {
    +            if snapshot.permissions.len()
    +                + snapshot.questions.len()
    +                + permissions.len()
    +                + questions.len()
    +                > MAX_PENDING
    +            {
    +                return Err(failure("pending request display limit exceeded"));
    +            }
    +            retained_bytes += serde_json::to_vec(&(&permissions, &questions))
    +                .map_err(|_| failure("snapshot encoding failed"))?
    +                .len();
    +            if retained_bytes > MAX_BYTES {
    +                return Err(failure("snapshot retained byte limit exceeded"));
    +            }
    +            snapshot.permissions.extend(permissions);
    +            snapshot.questions.extend(questions);
    +            Ok(())
    +        })
    +        .await?;
    +        let pending_ids: HashSet<_> = snapshot
    +            .permissions
    +            .iter()
    +            .map(|permission| permission.session_id.as_str())
    +            .chain(
    +                snapshot
    +                    .questions
    +                    .iter()
    +                    .map(|question| question.session_id.as_str()),
    +            )
    +            .collect();
    +        snapshot.sessions.sort_by(|left, right| {
    +            let priority = |session: &IslandSession| {
    +                if pending_ids.contains(session.id.as_str()) {
    +                    0
    +                } else if session.status == "running" {
    +                    1
    +                } else {
    +                    2
    +                }
    +            };
    +            priority(left)
    +                .cmp(&priority(right))
    +                .then_with(|| right.updated_at.cmp(&left.updated_at))
    +                .then_with(|| left.id.cmp(&right.id))
    +        });
    +        Ok(snapshot)
    +    }
    +
    +    pub(crate) async fn messages(&self, session_id: &str) -> Result, AppError> {
    +        identifier(session_id, "ses_")?;
    +        let messages: Data> = self
    +            .read(
    +                &["session", session_id, "message"],
    +                &[("limit", "20"), ("order", "desc")],
    +            )
    +            .await?;
    +        public_messages(messages.data)
    +    }
    +}
    +
    +pub(super) fn public_question(form: &Value, session_id: &str) -> Result {
    +    let id = string(form, "id")?;
    +    identifier(id, "frm_")?;
    +    if string(form, "sessionID")? != session_id {
    +        return Err(invalid("form owner"));
    +    }
    +    let title = string(form, "title")?;
    +    plain(title, 4096, true)?;
    +    let fields = form
    +        .get("fields")
    +        .and_then(Value::as_array)
    +        .ok_or_else(|| invalid("form fields"))?;
    +    if fields.len() > MAX_FIELDS {
    +        return Err(failure("form field display limit exceeded"));
    +    }
    +    let mut question = IslandQuestion {
    +        id: id.to_owned(),
    +        session_id: session_id.to_owned(),
    +        title: title.to_owned(),
    +        answerable: !fields.is_empty()
    +            && form
    +                .get("metadata")
    +                .and_then(|metadata| metadata.get("kind"))
    +                .and_then(Value::as_str)
    +                == Some("question"),
    +        fields: Vec::new(),
    +    };
    +    let mut keys = HashSet::new();
    +    for field in fields {
    +        let key = string(field, "key")?;
    +        plain(key, 256, false)?;
    +        if key.is_empty() || !keys.insert(key.to_owned()) {
    +            return Err(invalid("form field key"));
    +        }
    +        let kind = string(field, "type")?;
    +        plain(kind, 64, false)?;
    +        let mut required = public_flag(field, "required")?;
    +        let conditional = match field.get("when") {
    +            None | Some(Value::Null) => false,
    +            Some(Value::Array(conditions)) => !conditions.is_empty(),
    +            _ => return Err(invalid("form conditions")),
    +        };
    +        let selection_minimum = match field.get("minItems") {
    +            None | Some(Value::Null) => false,
    +            Some(value) if kind == "multiselect" && value.as_u64() == Some(0) => false,
    +            Some(value) if kind == "multiselect" && value.as_u64() == Some(1) => {
    +                required = true;
    +                false
    +            }
    +            Some(_) => true,
    +        };
    +        // Constraints/conditions and non-question forms require the full OpenCode client.
    +        if !matches!(kind, "string" | "multiselect")
    +            || public_flag(field, "hidden")?
    +            || conditional
    +            || selection_minimum
    +            || ["format", "minLength", "maxLength", "pattern", "maxItems"]
    +                .iter()
    +                .any(|key| field.get(*key).is_some_and(|value| !value.is_null()))
    +        {
    +            question.answerable = false;
    +        }
    +        let mut options = Vec::new();
    +        let mut values = HashSet::new();
    +        if let Some(list) = field.get("options").filter(|value| !value.is_null()) {
    +            let list = list.as_array().ok_or_else(|| invalid("form options"))?;
    +            if list.len() > MAX_OPTIONS {
    +                return Err(failure("form option display limit exceeded"));
    +            }
    +            for option in list {
    +                let value = string(option, "value")?;
    +                let label = string(option, "label")?;
    +                plain(value, 4096, true)?;
    +                plain(label, 4096, true)?;
    +                if !values.insert(value.to_owned()) {
    +                    return Err(invalid("duplicate form option"));
    +                }
    +                options.push(IslandQuestionOption {
    +                    value: value.to_owned(),
    +                    label: label.to_owned(),
    +                    description: optional_string(option, "description", 4096)?,
    +                });
    +            }
    +        } else if kind == "multiselect" {
    +            question.answerable = false;
    +        }
    +        let custom = match field.get("custom") {
    +            None | Some(Value::Null) => options.is_empty(),
    +            Some(Value::Bool(custom)) => *custom,
    +            _ => return Err(invalid("form custom flag")),
    +        };
    +        question.fields.push(IslandQuestionField {
    +            key: key.to_owned(),
    +            kind: kind.to_owned(),
    +            title: optional_string(field, "title", 4096)?,
    +            description: optional_string(field, "description", 4096)?,
    +            options,
    +            custom,
    +            required,
    +        });
    +    }
    +    if !question.answerable {
    +        question.fields.clear();
    +    }
    +    Ok(question)
    +}
    +
    +pub(super) fn public_messages(messages: Vec) -> Result, AppError> {
    +    if messages.len() > 20 {
    +        return Err(failure("message display limit exceeded"));
    +    }
    +    let mut result = Vec::new();
    +    let mut ids = HashSet::new();
    +    for message in messages {
    +        let id = string(&message, "id")?;
    +        identifier(id, "msg_")?;
    +        if !ids.insert(id.to_owned()) {
    +            return Err(invalid("duplicate message"));
    +        }
    +        let role = string(&message, "type")?;
    +        plain(role, 64, false)?;
    +        if !matches!(role, "user" | "assistant") {
    +            continue;
    +        }
    +        if private_content(&message)? {
    +            continue;
    +        }
    +        let time = safe_time(
    +            message
    +                .get("time")
    +                .and_then(|time| time.get("created"))
    +                .and_then(Value::as_u64)
    +                .ok_or_else(|| invalid("message time"))?,
    +        )?;
    +        let mut text = String::new();
    +        let mut truncated = false;
    +        let mut append = |value: &str| -> Result<(), AppError> {
    +            plain(value, MAX_BYTES, true)?;
    +            let (piece, cut) = trim_text(value, MAX_TEXT - text.len());
    +            text.push_str(piece);
    +            truncated |= cut;
    +            Ok(())
    +        };
    +        if role == "user" {
    +            append(string(&message, "text")?)?;
    +        } else {
    +            let content = message
    +                .get("content")
    +                .and_then(Value::as_array)
    +                .ok_or_else(|| invalid("assistant content"))?;
    +            if content.len() > 256 {
    +                return Err(failure("message content item limit exceeded"));
    +            }
    +            for part in content {
    +                if part.get("type").and_then(Value::as_str) == Some("text")
    +                    && !private_content(part)?
    +                {
    +                    append(string(part, "text")?)?;
    +                }
    +            }
    +        }
    +        if !text.is_empty() {
    +            result.push(IslandMessage {
    +                id: id.to_owned(),
    +                role: role.to_owned(),
    +                text,
    +                time,
    +                truncated,
    +            });
    +        }
    +    }
    +    result.sort_by(|left, right| {
    +        left.time
    +            .cmp(&right.time)
    +            .then_with(|| left.id.cmp(&right.id))
    +    });
    +    Ok(result)
    +}
    +
    +fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str, AppError> {
    +    value
    +        .get(key)
    +        .and_then(Value::as_str)
    +        .ok_or_else(|| invalid("response field"))
    +}
    +
    +fn optional_string(value: &Value, key: &str, max: usize) -> Result, AppError> {
    +    match value.get(key) {
    +        None | Some(Value::Null) => Ok(None),
    +        Some(Value::String(text)) => {
    +            plain(text, max, true)?;
    +            Ok(Some(text.clone()))
    +        }
    +        _ => Err(invalid("response field")),
    +    }
    +}
    +
    +fn bounded_strings(value: &Value, max_items: usize) -> Result, AppError> {
    +    let values = value.as_array().ok_or_else(|| invalid("response list"))?;
    +    if values.len() > max_items {
    +        return Err(failure("response item limit exceeded"));
    +    }
    +    values
    +        .iter()
    +        .map(|value| {
    +            let text = value.as_str().ok_or_else(|| invalid("response field"))?;
    +            plain(text, 4096, true)?;
    +            Ok(text.to_owned())
    +        })
    +        .collect()
    +}
    +
    +fn trim_text(text: &str, remaining: usize) -> (&str, bool) {
    +    let mut end = text.len().min(remaining);
    +    while !text.is_char_boundary(end) {
    +        end -= 1;
    +    }
    +    (&text[..end], end < text.len())
    +}
    +
    +fn public_flag(value: &Value, key: &str) -> Result {
    +    match value.get(key) {
    +        None | Some(Value::Null) => Ok(false),
    +        Some(Value::Bool(flag)) => Ok(*flag),
    +        _ => Err(invalid("public content flag")),
    +    }
    +}
    +
    +fn private_content(value: &Value) -> Result {
    +    for flags in
    +        std::iter::once(value).chain(value.get("metadata").filter(|metadata| !metadata.is_null()))
    +    {
    +        if !flags.is_object() {
    +            return Err(invalid("content metadata"));
    +        }
    +        for key in ["hidden", "synthetic", "internal"] {
    +            if public_flag(flags, key)? {
    +                return Ok(true);
    +            }
    +        }
    +    }
    +    Ok(false)
    +}
    +
    +fn answer_valid(
    +    question: &IslandQuestion,
    +    answer: &mut Map,
    +) -> Result<(), AppError> {
    +    if !question.answerable || answer.len() > MAX_FIELDS {
    +        return Err(invalid("question answer"));
    +    }
    +    for key in answer.keys() {
    +        if !question.fields.iter().any(|field| field.key == *key) {
    +            return Err(invalid("question answer key"));
    +        }
    +    }
    +    for field in &question.fields {
    +        if !field.required
    +            && answer.get(&field.key).is_some_and(|value| {
    +                (field.kind == "string" && value.as_str() == Some(""))
    +                    || (field.kind == "multiselect" && value.as_array().is_some_and(Vec::is_empty))
    +            })
    +        {
    +            answer.remove(&field.key);
    +            continue;
    +        }
    +        let Some(value) = answer.get(&field.key) else {
    +            if field.required {
    +                return Err(invalid("required question answer"));
    +            }
    +            continue;
    +        };
    +        let values = match field.kind.as_str() {
    +            "string" => vec![value
    +                .as_str()
    +                .ok_or_else(|| invalid("question answer type"))?],
    +            "multiselect" => {
    +                let values = value
    +                    .as_array()
    +                    .ok_or_else(|| invalid("question answer type"))?;
    +                if values.len() > MAX_OPTIONS {
    +                    return Err(invalid("question answer limit"));
    +                }
    +                values
    +                    .iter()
    +                    .map(|value| {
    +                        value
    +                            .as_str()
    +                            .ok_or_else(|| invalid("question answer type"))
    +                    })
    +                    .collect::, _>>()?
    +            }
    +            _ => return Err(invalid("question answer type")),
    +        };
    +        if field.required && (values.is_empty() || values.iter().any(|v| v.trim().is_empty())) {
    +            return Err(invalid("required question answer"));
    +        }
    +        let mut unique = HashSet::new();
    +        for value in values {
    +            plain(value, 4096, true)?;
    +            if !unique.insert(value)
    +                || (!field.custom && !field.options.iter().any(|option| option.value == value))
    +            {
    +                return Err(invalid("question answer option"));
    +            }
    +        }
    +    }
    +    Ok(())
    +}
    diff --git a/src-tauri/src/opencode/mod.rs b/src-tauri/src/opencode/mod.rs
    new file mode 100644
    index 0000000..2d11ae6
    --- /dev/null
    +++ b/src-tauri/src/opencode/mod.rs
    @@ -0,0 +1,18 @@
    +mod active;
    +mod binary;
    +mod command;
    +pub(crate) mod installation;
    +pub(crate) mod island;
    +mod models;
    +mod reload;
    +pub(crate) mod target;
    +mod token_usage;
    +pub(crate) mod transport;
    +
    +pub use active::active_sessions_via_service;
    +pub use binary::resolve_opencode_binary;
    +pub use installation::{OpenCodeInstallation, OpenCodeUpdateCheck};
    +pub use models::{list_models_via_service, ModelCatalogEntry};
    +pub use reload::reload_via_service;
    +pub use target::{OpenCodeServiceStatus, OpenCodeServiceTarget};
    +pub use token_usage::{token_usage_records_via_service, TokenUsageRecord};
    diff --git a/src-tauri/src/opencode/models.rs b/src-tauri/src/opencode/models.rs
    new file mode 100644
    index 0000000..003676c
    --- /dev/null
    +++ b/src-tauri/src/opencode/models.rs
    @@ -0,0 +1,145 @@
    +use std::collections::BTreeSet;
    +use std::time::{Duration, Instant};
    +
    +use serde::{Deserialize, Serialize};
    +use serde_json::Value;
    +
    +use crate::error::AppError;
    +use crate::paths::ConfigPaths;
    +
    +use super::transport::{self, ManagedConnection};
    +
    +/// Frontend-facing catalog entry; local config remains authoritative for metadata.
    +#[derive(Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub struct ModelCatalogEntry {
    +    pub provider_id: String,
    +    pub model_id: String,
    +    #[serde(rename = "ref")]
    +    pub model_ref: String,
    +    pub name: String,
    +    pub is_custom: bool,
    +    pub limit: Option,
    +    pub variants: Option,
    +}
    +
    +#[derive(Deserialize)]
    +struct Catalog {
    +    data: Vec,
    +}
    +
    +#[derive(Deserialize)]
    +#[serde(rename_all = "camelCase")]
    +struct ApiModel {
    +    id: String,
    +    #[serde(rename = "providerID")]
    +    provider_id: String,
    +    #[serde(rename = "modelID")]
    +    model_id: String,
    +    enabled: bool,
    +}
    +
    +pub(super) fn model_refs(
    +    connection: &ManagedConnection,
    +    deadline: Instant,
    +) -> Result, AppError> {
    +    let directory = connection.directory().and_then(|path| path.to_str());
    +    let query: Vec<_> = directory
    +        .into_iter()
    +        .map(|directory| ("location[directory]", directory))
    +        .collect();
    +    let catalog: super::active::JsonObject = connection.get_json(
    +        &["model"],
    +        &query,
    +        deadline,
    +        8 * 1024 * 1024,
    +        "model catalog",
    +        "8 MiB",
    +    )?;
    +    let mut refs = BTreeSet::new();
    +    for model in catalog.0.data {
    +        if [&model.id, &model.provider_id, &model.model_id]
    +            .into_iter()
    +            .any(|value| {
    +                value.is_empty()
    +                    || value.len() > 4096
    +                    || value.contains(char::is_whitespace)
    +                    || value.chars().any(char::is_control)
    +            })
    +            || model.provider_id.contains('/')
    +        {
    +            return Err(AppError::configuration(
    +                "Invalid OpenCode model catalog identity",
    +            ));
    +        }
    +        if model.enabled {
    +            refs.insert(format!("{}/{}", model.provider_id, model.model_id));
    +        }
    +    }
    +    Ok(refs)
    +}
    +
    +/// Lists enabled models from the selected local service plus all configured models.
    +pub fn list_models_via_service(
    +    paths: &ConfigPaths,
    +    provider_filter: Option<&str>,
    +) -> Result, AppError> {
    +    let deadline = Instant::now() + Duration::from_secs(20);
    +    let connection = transport::connect(deadline)?;
    +    list_models(paths, provider_filter, &connection, deadline)
    +}
    +
    +fn list_models(
    +    paths: &ConfigPaths,
    +    provider_filter: Option<&str>,
    +    connection: &ManagedConnection,
    +    deadline: Instant,
    +) -> Result, AppError> {
    +    let mut refs = model_refs(connection, deadline)?;
    +
    +    let config_providers = crate::providers::list_providers(&paths.opencode_file())?;
    +    let custom_ids = crate::providers::custom_provider_ids(&paths.opencode_file())?;
    +
    +    // Config models are authoritative for metadata and must stay listed.
    +    for provider in &config_providers {
    +        for model_id in provider.models.keys() {
    +            refs.insert(format!("{}/{}", provider.name, model_id));
    +        }
    +    }
    +
    +    let mut catalog = Vec::with_capacity(refs.len());
    +    for model_ref in refs {
    +        let Some((provider_id, model_id)) = model_ref.split_once('/') else {
    +            continue;
    +        };
    +        if provider_filter.is_some_and(|filter| provider_id != filter) {
    +            continue;
    +        }
    +        let config_model = config_providers
    +            .iter()
    +            .find(|provider| provider.name == provider_id)
    +            .and_then(|provider| provider.models.get(model_id));
    +        let name = config_model
    +            .and_then(|model| model.name.clone())
    +            .unwrap_or_else(|| model_id.to_owned());
    +        let is_custom = custom_ids.contains(provider_id);
    +        catalog.push(ModelCatalogEntry {
    +            provider_id: provider_id.to_owned(),
    +            model_id: model_id.to_owned(),
    +            model_ref,
    +            name,
    +            is_custom,
    +            limit: config_model
    +                .and_then(|model| model.limit.clone())
    +                .and_then(|limit| serde_json::to_value(limit).ok()),
    +            variants: config_model
    +                .and_then(|model| model.variants.clone())
    +                .and_then(|variants| serde_json::to_value(variants).ok()),
    +        });
    +    }
    +    connection.ensure_current()?;
    +    Ok(catalog)
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/src-tauri/src/opencode/reload.rs b/src-tauri/src/opencode/reload.rs
    new file mode 100644
    index 0000000..6fdc6e1
    --- /dev/null
    +++ b/src-tauri/src/opencode/reload.rs
    @@ -0,0 +1,13 @@
    +use std::time::{Duration, Instant};
    +
    +use crate::error::AppError;
    +
    +use super::transport;
    +
    +pub fn reload_via_service() -> Result<(), AppError> {
    +    let deadline = Instant::now() + Duration::from_secs(20);
    +    transport::connect(deadline)?.reload(deadline)
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/src-tauri/src/opencode/target.rs b/src-tauri/src/opencode/target.rs
    new file mode 100644
    index 0000000..bc19c07
    --- /dev/null
    +++ b/src-tauri/src/opencode/target.rs
    @@ -0,0 +1,468 @@
    +use std::fs;
    +use std::path::{Path, PathBuf};
    +use std::sync::atomic::{AtomicUsize, Ordering};
    +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock};
    +
    +use serde::{Deserialize, Serialize};
    +
    +use crate::error::{AppError, ErrorCode};
    +use crate::paths::ConfigPaths;
    +use crate::private_file::{self, PublishError, PublishOutcome, ReadError};
    +use crate::protected_data::SecretBytes;
    +
    +#[cfg(not(windows))]
    +const MAX_BYTES: u64 = 8192;
    +#[cfg(windows)]
    +const MAX_BYTES: u64 = windows::MAX_FILE_BYTES as u64;
    +
    +#[cfg(any(windows, test))]
    +mod windows;
    +static RUNTIME: OnceLock> = OnceLock::new();
    +static TRANSITION: Mutex<()> = Mutex::new(());
    +
    +#[derive(Clone, Deserialize, Serialize, Default)]
    +#[serde(deny_unknown_fields)]
    +pub(super) struct Selection {
    +    #[serde(deserialize_with = "nullable_string")]
    +    pub(super) url: Option,
    +    #[serde(deserialize_with = "nullable_string")]
    +    pub(super) password: Option,
    +}
    +
    +fn nullable_string<'de, D: serde::Deserializer<'de>>(
    +    deserializer: D,
    +) -> Result, D::Error> {
    +    Option::deserialize(deserializer)
    +}
    +
    +#[derive(Debug, Clone, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub struct OpenCodeServiceTarget {
    +    pub mode: String,
    +    pub url: Option,
    +    pub has_password: bool,
    +}
    +
    +#[derive(Debug, Serialize)]
    +#[serde(rename_all = "camelCase")]
    +pub struct OpenCodeServiceStatus {
    +    pub target: OpenCodeServiceTarget,
    +    pub connected: bool,
    +    pub url: Option,
    +    pub pid: Option,
    +    pub version: Option,
    +    pub error: Option,
    +}
    +
    +impl Selection {
    +    pub(super) fn public(&self) -> OpenCodeServiceTarget {
    +        OpenCodeServiceTarget {
    +            mode: if self.url.is_some() {
    +                "explicit"
    +            } else {
    +                "shared"
    +            }
    +            .into(),
    +            url: self.url.clone(),
    +            has_password: self.password.is_some(),
    +        }
    +    }
    +}
    +
    +struct State {
    +    selection: Result,
    +    revision: u64,
    +}
    +
    +pub(crate) struct TargetRuntime {
    +    path: PathBuf,
    +    pub(super) registration: PathBuf,
    +    pub(super) directory: PathBuf,
    +    state: RwLock,
    +    in_flight: AtomicUsize,
    +}
    +
    +pub(super) struct SideEffectReservation(Option>);
    +
    +impl Drop for SideEffectReservation {
    +    fn drop(&mut self) {
    +        if let Some(runtime) = &self.0 {
    +            runtime.in_flight.fetch_sub(1, Ordering::SeqCst);
    +        }
    +    }
    +}
    +
    +#[derive(Clone)]
    +pub(super) struct Snapshot {
    +    pub(super) selection: Selection,
    +    pub(super) revision: u64,
    +    runtime: Option>,
    +}
    +
    +impl Snapshot {
    +    pub(super) fn reserve_side_effect(
    +        &self,
    +        deadline: std::time::Instant,
    +    ) -> Result {
    +        let _transition = transition_until(deadline)?;
    +        self.ensure_current()?;
    +        if let Some(runtime) = &self.runtime {
    +            runtime.in_flight.fetch_add(1, Ordering::SeqCst);
    +        }
    +        Ok(SideEffectReservation(self.runtime.clone()))
    +    }
    +
    +    pub(super) fn ensure_current(&self) -> Result<(), AppError> {
    +        if let Some(runtime) = &self.runtime {
    +            let state = runtime
    +                .state
    +                .read()
    +                .map_err(|_| failure("state unavailable"))?;
    +            if state.revision != self.revision {
    +                return Err(failure("target changed; retry the operation"));
    +            }
    +        }
    +        Ok(())
    +    }
    +
    +    pub(super) fn directory(&self) -> Option<&Path> {
    +        self.runtime
    +            .as_ref()
    +            .map(|runtime| runtime.directory.as_path())
    +    }
    +
    +    pub(super) fn registration(&self) -> Result<&Path, AppError> {
    +        self.runtime
    +            .as_ref()
    +            .map(|runtime| runtime.registration.as_path())
    +            .ok_or_else(|| failure("registration path unavailable"))
    +    }
    +
    +    pub(super) fn detached(selection: Selection) -> Self {
    +        Self {
    +            selection,
    +            revision: 0,
    +            runtime: None,
    +        }
    +    }
    +}
    +
    +pub(crate) fn transition() -> Result, AppError> {
    +    TRANSITION
    +        .lock()
    +        .map_err(|_| failure("transition unavailable"))
    +}
    +
    +pub(super) fn transition_until(
    +    deadline: std::time::Instant,
    +) -> Result, AppError> {
    +    loop {
    +        if std::time::Instant::now() >= deadline {
    +            return Err(failure("transition timed out"));
    +        }
    +        match TRANSITION.try_lock() {
    +            Ok(guard) => return Ok(guard),
    +            Err(std::sync::TryLockError::Poisoned(_)) => {
    +                return Err(failure("transition unavailable"))
    +            }
    +            Err(std::sync::TryLockError::WouldBlock) => {
    +                std::thread::sleep(std::time::Duration::from_millis(5))
    +            }
    +        }
    +    }
    +}
    +
    +fn failure(reason: &str) -> AppError {
    +    AppError::configuration(format!("OpenCode service target: {reason}."))
    +}
    +
    +pub(super) fn normalize_url(value: &str) -> Result {
    +    let invalid = || {
    +        AppError::validation("Use HTTP localhost, 127.0.0.1 or [::1] with an explicit positive port and no path, credentials, query or fragment")
    +    };
    +    if value.len() > 256 || value.chars().any(char::is_control) {
    +        return Err(invalid());
    +    }
    +    let authority = value.strip_prefix("http://").ok_or_else(invalid)?;
    +    let authority = authority.strip_suffix('/').unwrap_or(authority);
    +    let (host, port) = authority.rsplit_once(':').ok_or_else(invalid)?;
    +    if !matches!(host, "localhost" | "127.0.0.1" | "[::1]")
    +        || port.is_empty()
    +        || !port.bytes().all(|byte| byte.is_ascii_digit())
    +    {
    +        return Err(invalid());
    +    }
    +    let port = port
    +        .parse::()
    +        .ok()
    +        .filter(|port| *port != 0)
    +        .ok_or_else(invalid)?;
    +    Ok(format!(
    +        "http://{}:{port}",
    +        if host == "localhost" {
    +            "127.0.0.1"
    +        } else {
    +            host
    +        }
    +    ))
    +}
    +
    +fn password_valid(password: &str) -> bool {
    +    password.len() <= 4096 && !password.chars().any(char::is_control)
    +}
    +
    +fn updated(
    +    old: Option<&Selection>,
    +    url: Option,
    +    password: Option,
    +) -> Result {
    +    let Some(url) = url else {
    +        return Ok(Selection::default());
    +    };
    +    let url = normalize_url(&url)?;
    +    if password
    +        .as_deref()
    +        .is_some_and(|password| !password_valid(password))
    +    {
    +        return Err(AppError::validation(
    +            "Service password must be at most 4096 bytes without control characters",
    +        ));
    +    }
    +    let password = match password {
    +        Some(password) => (!password.is_empty()).then_some(password),
    +        None => old
    +            .filter(|old| old.url.as_deref() == Some(&url))
    +            .and_then(|old| old.password.clone()),
    +    };
    +    Ok(Selection {
    +        url: Some(url),
    +        password,
    +    })
    +}
    +
    +fn load(path: &Path) -> Result {
    +    let bytes = private_file::read(path, MAX_BYTES).map_err(|error| match error {
    +        ReadError::Unreadable => failure("private configuration unreadable"),
    +        ReadError::NotRegular | ReadError::MetadataTooLarge => {
    +            failure("configuration must be a bounded regular file")
    +        }
    +        ReadError::TooLarge => failure("configuration byte limit exceeded"),
    +        ReadError::UnsafePermissions => {
    +            failure("configuration must be account-owned with mode 0600")
    +        }
    +        #[cfg(windows)]
    +        ReadError::ReparsePoint => failure("configuration reparse points are not supported"),
    +    })?;
    +    let Some(bytes) = bytes else {
    +        return Ok(Selection::default());
    +    };
    +    let bytes = SecretBytes(bytes);
    +    #[cfg(windows)]
    +    return windows::decode(&bytes.0, crate::protected_data::unprotect);
    +    #[cfg(not(windows))]
    +    {
    +        let selection: super::active::JsonObject = serde_json::from_slice(&bytes.0)
    +            .map_err(|_| failure("invalid private configuration"))?;
    +        validate_selection(selection.0)
    +    }
    +}
    +
    +fn validate_selection(selection: Selection) -> Result {
    +    match &selection.url {
    +        Some(url) => {
    +            let url = normalize_url(url).map_err(|_| failure("invalid configured endpoint"))?;
    +            if selection
    +                .password
    +                .as_deref()
    +                .is_some_and(|password| password.is_empty() || !password_valid(password))
    +            {
    +                return Err(failure("invalid configured password"));
    +            }
    +            Ok(Selection {
    +                url: Some(url),
    +                password: selection.password,
    +            })
    +        }
    +        None if selection.password.is_none() => Ok(selection),
    +        None => Err(failure("shared target cannot contain a password")),
    +    }
    +}
    +
    +fn persist(path: &Path, selection: &Selection) -> Result<(), AppError> {
    +    let directory = path
    +        .parent()
    +        .ok_or_else(|| failure("configuration directory unavailable"))?;
    +    fs::create_dir_all(directory).map_err(|_| failure("configuration directory unavailable"))?;
    +    #[cfg(not(windows))]
    +    let bytes = SecretBytes(
    +        serde_json::to_vec(selection).map_err(|_| failure("configuration serialization failed"))?,
    +    );
    +    #[cfg(windows)]
    +    let bytes = SecretBytes(windows::encode(selection, crate::protected_data::protect)?);
    +    if bytes.0.len() as u64 > MAX_BYTES {
    +        return Err(failure("configuration byte limit exceeded"));
    +    }
    +    match private_file::publish(path, &bytes.0).map_err(|error| {
    +        failure(match error {
    +            PublishError::DirectoryUnavailable => "configuration directory unavailable",
    +            PublishError::TemporaryCreate => "private temporary file creation failed",
    +            PublishError::TemporaryWrite => "private configuration write failed",
    +            PublishError::Publication => "private configuration publication failed",
    +        })
    +    })? {
    +        PublishOutcome::Committed => {}
    +        PublishOutcome::CommittedWithWarning => {
    +            eprintln!("OpenCode service target saved, but directory sync failed");
    +        }
    +    }
    +    Ok(())
    +}
    +
    +impl TargetRuntime {
    +    pub(super) fn new(path: PathBuf, registration: PathBuf, directory: PathBuf) -> Arc {
    +        Arc::new(Self {
    +            state: RwLock::new(State {
    +                selection: load(&path),
    +                revision: 1,
    +            }),
    +            in_flight: AtomicUsize::new(0),
    +            path,
    +            registration,
    +            directory,
    +        })
    +    }
    +
    +    pub(super) fn snapshot(self: &Arc) -> Result {
    +        let state = self
    +            .state
    +            .read()
    +            .map_err(|_| failure("state unavailable"))?;
    +        Ok(Snapshot {
    +            selection: state.selection.clone()?,
    +            revision: state.revision,
    +            runtime: Some(self.clone()),
    +        })
    +    }
    +
    +    #[cfg(test)]
    +    pub(super) fn set(
    +        self: &Arc,
    +        url: Option,
    +        password: Option,
    +        preflight: impl FnOnce() -> Result<(), AppError>,
    +    ) -> Result {
    +        self.set_with_refresh(url, password, preflight, |()| ())
    +            .map(|(snapshot, ())| snapshot)
    +    }
    +
    +    pub(super) fn set_with_refresh(
    +        self: &Arc,
    +        url: Option,
    +        password: Option,
    +        prepare: impl FnOnce() -> Result,
    +        commit: impl FnOnce(G) -> R,
    +    ) -> Result<(Snapshot, R), AppError> {
    +        let _transition = transition()?;
    +        if self.in_flight.load(Ordering::SeqCst) != 0 {
    +            return Err(AppError::new(
    +                ErrorCode::Busy,
    +                "OpenCode service operation still in flight; wait before changing the target",
    +            ));
    +        }
    +        let selection = {
    +            let state = self
    +                .state
    +                .read()
    +                .map_err(|_| failure("state unavailable"))?;
    +            updated(state.selection.as_ref().ok(), url, password)?
    +        };
    +        let guards = prepare()?;
    +        let mut state = self
    +            .state
    +            .write()
    +            .map_err(|_| failure("state unavailable"))?;
    +        persist(&self.path, &selection)?;
    +        state.selection = Ok(selection.clone());
    +        state.revision += 1;
    +        let snapshot = Snapshot {
    +            selection,
    +            revision: state.revision,
    +            runtime: Some(self.clone()),
    +        };
    +        drop(state);
    +        // Only local invalidation is allowed here; native work/readers wait until finish.
    +        let refresh = commit(guards);
    +        Ok((snapshot, refresh))
    +    }
    +}
    +
    +pub(crate) fn initialize(paths: &ConfigPaths) -> Result<(), AppError> {
    +    let registration =
    +        registration_path(paths, std::env::var_os("XDG_STATE_HOME").map(PathBuf::from));
    +    let directory =
    +        std::env::current_dir().map_err(|_| failure("working directory unavailable"))?;
    +    RUNTIME
    +        .set(TargetRuntime::new(
    +            paths.app_config_dir().join("opencode-service.json"),
    +            registration,
    +            directory,
    +        ))
    +        .map_err(|_| failure("already initialized"))
    +}
    +
    +fn registration_path(paths: &ConfigPaths, state_home: Option) -> PathBuf {
    +    state_home
    +        .filter(|path| path.is_absolute())
    +        .unwrap_or_else(|| paths.home_dir().join(".local/state"))
    +        .join("opencode/service.json")
    +}
    +
    +pub(crate) fn runtime() -> Result<&'static Arc, AppError> {
    +    RUNTIME.get().ok_or_else(|| failure("not initialized"))
    +}
    +
    +pub(super) fn snapshot() -> Result {
    +    runtime()?.snapshot()
    +}
    +
    +pub(crate) fn get_status() -> Result {
    +    super::transport::service_status(
    +        snapshot()?,
    +        std::time::Instant::now() + std::time::Duration::from_secs(5),
    +    )
    +}
    +
    +pub(crate) fn set_target(
    +    url: Option,
    +    password: Option,
    +    prepare: impl FnOnce() -> Result,
    +    commit: impl FnOnce(G) -> R,
    +    finish: impl FnOnce(R),
    +) -> Result {
    +    let (snapshot, refresh) = runtime()?.set_with_refresh(url, password, prepare, commit)?;
    +    Ok(finish_target(snapshot, refresh, finish))
    +}
    +
    +fn finish_target(
    +    snapshot: Snapshot,
    +    refresh: R,
    +    finish: impl FnOnce(R),
    +) -> OpenCodeServiceStatus {
    +    let target = snapshot.selection.public();
    +    finish(refresh);
    +    super::transport::service_status(
    +        snapshot,
    +        std::time::Instant::now() + std::time::Duration::from_secs(5),
    +    )
    +    .unwrap_or_else(|_| OpenCodeServiceStatus {
    +        target,
    +        connected: false,
    +        url: None,
    +        pid: None,
    +        version: None,
    +        error: Some("OpenCode service target changed or connection status unavailable".into()),
    +    })
    +}
    +
    +#[cfg(test)]
    +mod tests;
    diff --git a/src-tauri/src/opencode/target/windows.rs b/src-tauri/src/opencode/target/windows.rs
    new file mode 100644
    index 0000000..c3f4387
    --- /dev/null
    +++ b/src-tauri/src/opencode/target/windows.rs
    @@ -0,0 +1,113 @@
    +use serde::de::{self, SeqAccess, Visitor};
    +use serde::{Deserialize, Deserializer, Serialize};
    +
    +use crate::protected_data::SecretBytes;
    +
    +use super::{failure, nullable_string, validate_selection, AppError, Selection};
    +
    +pub(super) const MAX_FILE_BYTES: usize = 32 * 1024;
    +const MAX_CIPHER_BYTES: usize = 8192;
    +const MAX_PASSWORD_BYTES: usize = 4096;
    +
    +#[derive(Deserialize, Serialize)]
    +#[serde(rename_all = "camelCase", deny_unknown_fields)]
    +struct WindowsSelection {
    +    #[serde(deserialize_with = "nullable_string")]
    +    url: Option,
    +    #[serde(deserialize_with = "encrypted_bytes")]
    +    encrypted_password: Option>,
    +}
    +
    +fn encrypted_bytes<'de, D: Deserializer<'de>>(
    +    deserializer: D,
    +) -> Result>, D::Error> {
    +    struct Bytes(Vec);
    +    impl<'de> Deserialize<'de> for Bytes {
    +        fn deserialize>(deserializer: D) -> Result {
    +            struct BoundedBytes;
    +            impl<'de> Visitor<'de> for BoundedBytes {
    +                type Value = Bytes;
    +                fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
    +                    formatter.write_str("a bounded nonempty encrypted byte array")
    +                }
    +                fn visit_seq>(self, mut sequence: A) -> Result {
    +                    let mut bytes = Vec::new();
    +                    while let Some(byte) = sequence.next_element::()? {
    +                        if bytes.len() == MAX_CIPHER_BYTES {
    +                            return Err(de::Error::custom(
    +                                "encrypted password byte limit exceeded",
    +                            ));
    +                        }
    +                        bytes.push(byte);
    +                    }
    +                    if bytes.is_empty() {
    +                        return Err(de::Error::custom("empty encrypted password"));
    +                    }
    +                    Ok(Bytes(bytes))
    +                }
    +            }
    +            deserializer.deserialize_seq(BoundedBytes)
    +        }
    +    }
    +    Option::::deserialize(deserializer).map(|bytes| bytes.map(|bytes| bytes.0))
    +}
    +
    +pub(super) fn encode(
    +    selection: &Selection,
    +    protect: impl FnOnce(&[u8]) -> Result, AppError>,
    +) -> Result, AppError> {
    +    let selection = validate_selection(selection.clone())?;
    +    let encrypted_password = selection
    +        .password
    +        .as_deref()
    +        .map(|password| {
    +            let bytes =
    +                protect(password.as_bytes()).map_err(|_| failure("password protection failed"))?;
    +            if bytes.is_empty() || bytes.len() > MAX_CIPHER_BYTES {
    +                return Err(failure("invalid protected password size"));
    +            }
    +            Ok(bytes)
    +        })
    +        .transpose()?;
    +    let bytes = serde_json::to_vec(&WindowsSelection {
    +        url: selection.url,
    +        encrypted_password,
    +    })
    +    .map_err(|_| failure("configuration serialization failed"))?;
    +    if bytes.len() > MAX_FILE_BYTES {
    +        return Err(failure("configuration byte limit exceeded"));
    +    }
    +    Ok(bytes)
    +}
    +
    +pub(super) fn decode(
    +    bytes: &[u8],
    +    unprotect: impl FnOnce(&[u8]) -> Result, AppError>,
    +) -> Result {
    +    if bytes.len() > MAX_FILE_BYTES {
    +        return Err(failure("configuration byte limit exceeded"));
    +    }
    +    let disk: super::super::active::JsonObject =
    +        serde_json::from_slice(bytes).map_err(|_| failure("invalid encrypted configuration"))?;
    +    let disk = disk.0;
    +    let mut selection = validate_selection(Selection {
    +        url: disk.url,
    +        password: None,
    +    })?;
    +    if let Some(ciphertext) = disk.encrypted_password {
    +        if selection.url.is_none() {
    +            return Err(failure("shared target cannot contain a password"));
    +        }
    +        let plaintext =
    +            SecretBytes(unprotect(&ciphertext).map_err(|_| failure("password decryption failed"))?);
    +        if plaintext.0.len() > MAX_PASSWORD_BYTES {
    +            return Err(failure("invalid decrypted password size"));
    +        }
    +        selection.password = Some(
    +            std::str::from_utf8(&plaintext.0)
    +                .map(str::to_owned)
    +                .map_err(|_| failure("invalid decrypted password"))?,
    +        );
    +    }
    +    validate_selection(selection)
    +}
    diff --git a/src-tauri/src/opencode/token_usage.rs b/src-tauri/src/opencode/token_usage.rs
    new file mode 100644
    index 0000000..cece0a1
    --- /dev/null
    +++ b/src-tauri/src/opencode/token_usage.rs
    @@ -0,0 +1,554 @@
    +use std::collections::{HashMap, HashSet};
    +use std::mem::size_of;
    +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
    +use std::thread;
    +use std::time::{Duration, Instant};
    +
    +use serde::{
    +    de::{self, IgnoredAny, SeqAccess, Visitor},
    +    Deserialize, Deserializer, Serialize,
    +};
    +
    +use crate::error::AppError;
    +
    +use super::transport::{self, ManagedConnection};
    +
    +pub(super) const SCAN_TIMEOUT: Duration = Duration::from_secs(60);
    +pub(super) const PAGE_MAX_BYTES: u64 = 32 * 1024 * 1024;
    +const MAX_RECORDS: usize = 100_000;
    +const MAX_METADATA_ITEMS: usize = 300_000;
    +const MAX_METADATA_BYTES: usize = 64 * 1024 * 1024;
    +const JS_MAX_INTEGER: u64 = 9_007_199_254_740_991;
    +const JS_MAX_DATE: u64 = 8_640_000_000_000_000;
    +const WORKERS: usize = 4;
    +
    +#[derive(Debug, PartialEq, Eq, Serialize)]
    +pub struct TokenUsageRecord {
    +    pub time: u64,
    +    pub input: u64,
    +    pub output: u64,
    +}
    +
    +#[derive(Deserialize)]
    +#[serde(bound(deserialize = "T: Deserialize<'de>"))]
    +pub(super) struct Page {
    +    #[serde(deserialize_with = "page_items")]
    +    pub(super) data: Vec,
    +    cursor: Cursor,
    +}
    +
    +fn page_items<'de, D, T>(deserializer: D) -> Result, D::Error>
    +where
    +    D: Deserializer<'de>,
    +    T: Deserialize<'de>,
    +{
    +    struct Items(std::marker::PhantomData);
    +    impl<'de, T: Deserialize<'de>> Visitor<'de> for Items {
    +        type Value = Vec;
    +
    +        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
    +            formatter.write_str("at most 1000 page items")
    +        }
    +
    +        fn visit_seq>(self, mut sequence: A) -> Result, A::Error> {
    +            let mut items = Vec::new();
    +            while let Some(item) = sequence.next_element()? {
    +                if items.len() == 1000 {
    +                    return Err(de::Error::custom("page item limit exceeded"));
    +                }
    +                items.push(item);
    +            }
    +            Ok(items)
    +        }
    +    }
    +    deserializer.deserialize_seq(Items(std::marker::PhantomData))
    +}
    +
    +#[derive(Deserialize)]
    +struct Cursor {
    +    next: Option,
    +}
    +
    +#[derive(Deserialize)]
    +pub(super) struct Session {
    +    id: String,
    +    fork: Option,
    +}
    +
    +#[derive(Deserialize)]
    +struct Fork {
    +    #[serde(rename = "sessionID")]
    +    session_id: String,
    +    boundary: Boundary,
    +}
    +
    +#[derive(Deserialize)]
    +struct Boundary {
    +    #[serde(rename = "type")]
    +    kind: BoundaryKind,
    +    #[serde(rename = "messageID")]
    +    message_id: String,
    +}
    +
    +#[derive(Deserialize)]
    +#[serde(rename_all = "lowercase")]
    +enum BoundaryKind {
    +    Before,
    +    Through,
    +}
    +
    +// No content, titles, model details, or error payloads enter the retained metadata.
    +#[derive(Deserialize)]
    +pub(super) struct Message {
    +    id: String,
    +    #[serde(rename = "type")]
    +    kind: String,
    +    time: MessageTime,
    +    tokens: Option,
    +    finish: Option,
    +    status: Option,
    +    #[serde(default, deserialize_with = "error_present")]
    +    error: bool,
    +}
    +
    +#[derive(Deserialize, PartialEq, Eq)]
    +struct MessageTime {
    +    created: u64,
    +    completed: Option,
    +    streamed: Option,
    +}
    +
    +#[derive(Deserialize, PartialEq, Eq)]
    +struct Tokens {
    +    input: u64,
    +    output: u64,
    +}
    +
    +fn error_present<'de, D: Deserializer<'de>>(deserializer: D) -> Result {
    +    struct ErrorValue;
    +    impl<'de> Visitor<'de> for ErrorValue {
    +        type Value = bool;
    +
    +        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
    +            formatter.write_str("an error payload or boolean")
    +        }
    +
    +        fn visit_bool(self, value: bool) -> Result {
    +            Ok(value)
    +        }
    +
    +        fn visit_none(self) -> Result {
    +            Ok(false)
    +        }
    +
    +        fn visit_unit(self) -> Result {
    +            Ok(false)
    +        }
    +
    +        fn visit_some>(self, deserializer: D) -> Result {
    +            deserializer.deserialize_any(ErrorValue)
    +        }
    +
    +        fn visit_map>(self, mut access: A) -> Result {
    +            while access.next_entry::()?.is_some() {}
    +            Ok(true)
    +        }
    +
    +        fn visit_seq>(self, mut access: A) -> Result {
    +            while access.next_element::()?.is_some() {}
    +            Ok(true)
    +        }
    +
    +        fn visit_str(self, value: &str) -> Result {
    +            Ok(!value.is_empty())
    +        }
    +
    +        fn visit_string(self, value: String) -> Result {
    +            self.visit_str(&value)
    +        }
    +    }
    +    deserializer.deserialize_option(ErrorValue)
    +}
    +
    +#[derive(Default)]
    +struct Budget {
    +    items: AtomicUsize,
    +    bytes: AtomicUsize,
    +}
    +
    +impl Budget {
    +    fn reserve(&self, items: usize, bytes: usize) -> Result<(), AppError> {
    +        for (counter, amount, max) in [
    +            (&self.items, items, MAX_METADATA_ITEMS),
    +            (&self.bytes, bytes, MAX_METADATA_BYTES),
    +        ] {
    +            counter
    +                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
    +                    value.checked_add(amount).filter(|value| *value <= max)
    +                })
    +                .map_err(|_| failure("metadata limit exceeded"))?;
    +        }
    +        Ok(())
    +    }
    +}
    +
    +pub(super) fn failure(reason: &str) -> AppError {
    +    AppError::configuration(format!("opencode token usage: {reason}."))
    +}
    +
    +pub(super) fn remaining(deadline: Instant) -> Result {
    +    deadline
    +        .checked_duration_since(Instant::now())
    +        .filter(|remaining| !remaining.is_zero())
    +        .ok_or_else(|| failure("scan timed out"))
    +}
    +
    +pub(super) fn valid_text(value: &str, max: usize) -> bool {
    +    !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
    +}
    +
    +fn inspect_session(session: &Session) -> Result<(&str, usize), AppError> {
    +    if !valid_text(&session.id, 4096) {
    +        return Err(failure("invalid session identifier"));
    +    }
    +    let mut bytes = size_of::() + 2 * session.id.len() + size_of::();
    +    if let Some(fork) = &session.fork {
    +        if !valid_text(&fork.session_id, 4096) || !valid_text(&fork.boundary.message_id, 4096) {
    +            return Err(failure("invalid fork provenance"));
    +        }
    +        bytes += fork.session_id.len() + fork.boundary.message_id.len();
    +    }
    +    Ok((&session.id, bytes))
    +}
    +
    +impl Message {
    +    fn usage(&self) -> Option<&Tokens> {
    +        if !matches!(self.kind.as_str(), "assistant" | "compaction") {
    +            return None;
    +        }
    +        let pending = match self.kind.as_str() {
    +            "compaction" => {
    +                self.status.as_deref() == Some("running")
    +                    && self.finish.is_none()
    +                    && !self.error
    +                    && self.time.completed.is_none()
    +            }
    +            "assistant" => {
    +                self.finish.is_none()
    +                    && !self.error
    +                    && self.time.completed.is_none()
    +                    && matches!(self.status.as_deref(), None | Some("running"))
    +            }
    +            _ => false,
    +        };
    +        if pending {
    +            None
    +        } else {
    +            // Absent recorded usage does not establish zero provider billing.
    +            self.tokens.as_ref()
    +        }
    +    }
    +
    +    fn copied_by_fork(&self) -> bool {
    +        match self.kind.as_str() {
    +            "assistant" => self.time.completed.is_some(),
    +            "shell" | "compaction" => self.status.as_deref() != Some("running"),
    +            _ => true,
    +        }
    +    }
    +
    +    fn same_projection(&self, other: &Self) -> bool {
    +        self.kind == other.kind
    +            && self.time == other.time
    +            && self.tokens == other.tokens
    +            && self.finish == other.finish
    +            && self.status == other.status
    +            && self.error == other.error
    +    }
    +}
    +
    +fn inspect_message(message: &Message) -> Result<(&str, usize), AppError> {
    +    if !valid_text(&message.id, 4096)
    +        || !valid_text(&message.kind, 256)
    +        || [&message.finish, &message.status]
    +            .into_iter()
    +            .flatten()
    +            .any(|value| !valid_text(value, 256))
    +        || [
    +            Some(message.time.created),
    +            message.time.completed,
    +            message.time.streamed,
    +        ]
    +        .into_iter()
    +        .flatten()
    +        .any(|time| time > JS_MAX_DATE)
    +    {
    +        return Err(failure("invalid message metadata"));
    +    }
    +    if let Some(tokens) = &message.tokens {
    +        safe_sum(tokens.input, tokens.output)?;
    +    }
    +    Ok((
    +        &message.id,
    +        size_of::()
    +            + size_of::()
    +            + 2 * message.id.len()
    +            + message.kind.len()
    +            + message.finish.as_ref().map_or(0, String::len)
    +            + message.status.as_ref().map_or(0, String::len),
    +    ))
    +}
    +
    +fn safe_sum(left: u64, right: u64) -> Result {
    +    left.checked_add(right)
    +        .filter(|sum| *sum <= JS_MAX_INTEGER)
    +        .ok_or_else(|| failure("token counts exceed safe integer bounds"))
    +}
    +
    +fn read_pages(
    +    mut fetch: impl FnMut(Option<&str>) -> Result, AppError>,
    +    inspect: impl Fn(&T) -> Result<(&str, usize), AppError>,
    +    budget: &Budget,
    +    deadline: Instant,
    +) -> Result, AppError> {
    +    let mut items = Vec::new();
    +    let mut ids = HashSet::new();
    +    let mut cursors = HashSet::new();
    +    let mut cursor: Option = None;
    +    loop {
    +        remaining(deadline)?;
    +        let page = fetch(cursor.as_deref())?;
    +        remaining(deadline)?;
    +        let empty = page.data.is_empty();
    +        for item in page.data {
    +            let (id, bytes) = inspect(&item)?;
    +            budget.reserve(1, bytes)?;
    +            if !ids.insert(id.to_owned()) {
    +                return Err(failure("duplicate identifier across pages"));
    +            }
    +            items.push(item);
    +        }
    +        remaining(deadline)?;
    +        if empty {
    +            break;
    +        }
    +        let Some(next) = page.cursor.next else {
    +            break;
    +        };
    +        if !valid_text(&next, 4096) {
    +            return Err(failure("invalid page cursor"));
    +        }
    +        budget.reserve(0, 2 * next.len() + size_of::())?;
    +        if !cursors.insert(next.clone()) {
    +            return Err(failure("page cursor loop"));
    +        }
    +        cursor = Some(next);
    +    }
    +    Ok(items)
    +}
    +
    +fn read_timelines(
    +    connection: &ManagedConnection,
    +    sessions: &[Session],
    +    budget: &Budget,
    +    deadline: Instant,
    +) -> Result>, AppError> {
    +    let index = AtomicUsize::new(0);
    +    let failed = AtomicBool::new(false);
    +    thread::scope(|scope| {
    +        let mut workers = Vec::new();
    +        for _ in 0..WORKERS.min(sessions.len()) {
    +            workers.push(scope.spawn(|| {
    +                let result = (|| {
    +                    let mut timelines = Vec::new();
    +                    loop {
    +                        remaining(deadline)?;
    +                        if failed.load(Ordering::Relaxed) {
    +                            return Err(failure("scan cancelled after a page failure"));
    +                        }
    +                        let index = index.fetch_add(1, Ordering::Relaxed);
    +                        let Some(session) = sessions.get(index) else {
    +                            break;
    +                        };
    +                        let messages = read_pages(
    +                            |cursor| {
    +                                if failed.load(Ordering::Relaxed) {
    +                                    return Err(failure("scan cancelled after a page failure"));
    +                                }
    +                                connection.fetch_page(Some(&session.id), cursor, deadline)
    +                            },
    +                            inspect_message,
    +                            budget,
    +                            deadline,
    +                        )?;
    +                        timelines.push((index, messages));
    +                    }
    +                    Ok(timelines)
    +                })();
    +                if result.is_err() {
    +                    failed.store(true, Ordering::Relaxed);
    +                }
    +                result
    +            }));
    +        }
    +        let mut timelines = Vec::new();
    +        let mut error = None;
    +        // Every in-flight request and body read shares the scan deadline.
    +        for worker in workers {
    +            let worker_error = match worker.join() {
    +                Ok(Ok(mut result)) => {
    +                    timelines.append(&mut result);
    +                    continue;
    +                }
    +                Ok(Err(error)) => error,
    +                Err(_) => {
    +                    failed.store(true, Ordering::Relaxed);
    +                    failure("timeline worker failed")
    +                }
    +            };
    +            if error.as_ref().is_none_or(|error: &AppError| {
    +                error.message == failure("scan cancelled after a page failure").message
    +            }) {
    +                error = Some(worker_error);
    +            }
    +        }
    +        if let Some(error) = error {
    +            return Err(error);
    +        }
    +        remaining(deadline)?;
    +        timelines.sort_unstable_by_key(|(index, _)| *index);
    +        Ok(timelines
    +            .into_iter()
    +            .map(|(_, messages)| messages)
    +            .collect())
    +    })
    +}
    +
    +fn fork_offsets(
    +    sessions: &[Session],
    +    timelines: &[Vec],
    +    deadline: Instant,
    +) -> Result, AppError> {
    +    let lookup: HashMap<_, _> = sessions
    +        .iter()
    +        .enumerate()
    +        .map(|(index, session)| (session.id.as_str(), index))
    +        .collect();
    +    let mut states = vec![0; sessions.len()];
    +    let mut offsets = vec![0; sessions.len()];
    +    for start in 0..sessions.len() {
    +        remaining(deadline)?;
    +        let mut path = Vec::new();
    +        let mut current = start;
    +        loop {
    +            remaining(deadline)?;
    +            match states[current] {
    +                2 => break,
    +                1 => return Err(failure("fork provenance unavailable: origin cycle")),
    +                _ => {}
    +            }
    +            states[current] = 1;
    +            path.push(current);
    +            let Some(fork) = &sessions[current].fork else {
    +                break;
    +            };
    +            let Some(&origin_idx) = lookup.get(fork.session_id.as_str()) else {
    +                break;
    +            };
    +            current = origin_idx;
    +        }
    +        for index in path.into_iter().rev() {
    +            remaining(deadline)?;
    +            if let Some(fork) = &sessions[index].fork {
    +                if let Some(&origin_idx) = lookup.get(fork.session_id.as_str()) {
    +                    let origin = &timelines[origin_idx];
    +                    let boundary = origin
    +                        .iter()
    +                        .position(|message| message.id == fork.boundary.message_id)
    +                        .ok_or_else(|| failure("fork provenance unavailable: missing boundary"))?;
    +                    let prefix = boundary
    +                        + match fork.boundary.kind {
    +                            BoundaryKind::Before => 0,
    +                            BoundaryKind::Through => 1,
    +                        };
    +                    let child = &timelines[index];
    +                    // Native forks copy eligible projected prefixes with new IDs, not a timestamp cutoff.
    +                    let expected = origin[..prefix]
    +                        .iter()
    +                        .filter(|message| message.copied_by_fork());
    +                    let copied = expected.clone().count();
    +                    if child.len() < copied
    +                        || !expected
    +                            .zip(&child[..copied])
    +                            .all(|(original, copied)| original.same_projection(copied))
    +                    {
    +                        return Err(failure(
    +                            "fork provenance unavailable: copied prefix mismatch",
    +                        ));
    +                    }
    +                    offsets[index] = copied;
    +                }
    +            }
    +            states[index] = 2;
    +        }
    +    }
    +    Ok(offsets)
    +}
    +
    +fn records(
    +    sessions: &[Session],
    +    timelines: &[Vec],
    +    deadline: Instant,
    +) -> Result, AppError> {
    +    let offsets = fork_offsets(sessions, timelines, deadline)?;
    +    let mut records = Vec::new();
    +    let mut input = 0;
    +    let mut output = 0;
    +    for (timeline, offset) in timelines.iter().zip(offsets) {
    +        for message in &timeline[offset..] {
    +            remaining(deadline)?;
    +            let Some(tokens) = message.usage() else {
    +                continue;
    +            };
    +            if records.len() == MAX_RECORDS {
    +                return Err(failure("100,000 record limit exceeded"));
    +            }
    +            input = safe_sum(input, tokens.input)?;
    +            output = safe_sum(output, tokens.output)?;
    +            safe_sum(input, output)?;
    +            records.push(TokenUsageRecord {
    +                time: message.time.created,
    +                input: tokens.input,
    +                output: tokens.output,
    +            });
    +        }
    +    }
    +    remaining(deadline)?;
    +    Ok(records)
    +}
    +
    +pub fn token_usage_records_via_service() -> Result, AppError> {
    +    let deadline = Instant::now() + SCAN_TIMEOUT;
    +    let connection = transport::connect(deadline)?;
    +    scan(&connection, deadline)
    +}
    +
    +pub(super) fn scan(
    +    connection: &ManagedConnection,
    +    deadline: Instant,
    +) -> Result, AppError> {
    +    let budget = Budget::default();
    +    let sessions = read_pages(
    +        |cursor| connection.fetch_page(None, cursor, deadline),
    +        inspect_session,
    +        &budget,
    +        deadline,
    +    )?;
    +    let timelines = read_timelines(connection, &sessions, &budget, deadline)?;
    +    let records = records(&sessions, &timelines, deadline)?;
    +    connection.ensure_current()?;
    +    Ok(records)
    +}
    +
    +#[cfg(test)]
    +pub(in crate::opencode) mod tests;
    diff --git a/src-tauri/src/power/macos.rs b/src-tauri/src/power/macos.rs
    index 63c9f45..12b49ea 100644
    --- a/src-tauri/src/power/macos.rs
    +++ b/src-tauri/src/power/macos.rs
    @@ -118,7 +118,9 @@ fn authorize(source: &Path, token: &[u8; 32], uid: u32, pid: u32) -> Result<(),
                 .take()
                 .ok_or_else(|| error("Missing authorization result"))?;
             let fd = stdout.as_raw_fd();
    +        // SAFETY: stdout owns the live pipe fd; F_GETFL has no pointer argument.
             let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
    +        // SAFETY: stdout keeps fd alive; F_SETFL takes the integer flags read above.
             if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
                 return Err(error("Could not bound the authorization result"));
             }
    @@ -194,11 +196,14 @@ fn verify_socket_path(uid: u32) -> Result<(), AppError> {
     fn verify_root_peer(stream: &UnixStream) -> Result {
         let mut uid = u32::MAX;
         let mut gid = u32::MAX;
    +    // SAFETY: stream owns a live socket; uid and gid are writable uid_t/gid_t outputs.
         if unsafe { libc::getpeereid(stream.as_raw_fd(), &mut uid, &mut gid) } != 0 || uid != 0 {
             return Err(error("The control peer is not the authorized root helper"));
         }
         let mut pid: libc::pid_t = 0;
         let mut length = std::mem::size_of_val(&pid) as libc::socklen_t;
    +    // SAFETY: stream keeps its fd alive; pid is writable for the advertised pid_t
    +    // capacity and length is a writable socklen_t. Neither pointer escapes the call.
         if unsafe {
             libc::getsockopt(
                 stream.as_raw_fd(),
    @@ -217,23 +222,31 @@ fn verify_root_peer(stream: &UnixStream) -> Result {
     }
     
     fn connect_bounded() -> Result {
    +    // SAFETY: socket takes only valid AF_UNIX/SOCK_STREAM integer constants.
         let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
         if fd < 0 {
             return Err(error("Could not create the helper control channel"));
         }
    +    // SAFETY: successful socket returned a fresh stream fd with no Rust owner;
    +    // this transfers ownership exactly once and closes it on all later errors.
         let stream = unsafe { UnixStream::from_raw_fd(fd) };
         stream
             .set_nonblocking(true)
             .map_err(|_| error("Could not bound the helper connection"))?;
    +    // SAFETY: stream owns the live fd; F_SETFD takes the integer FD_CLOEXEC flag.
         if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
             return Err(error("Could not protect the helper connection"));
         }
    +    // SAFETY: sockaddr_un consists solely of integer fields/bytes, so zero is valid
    +    // and leaves a NUL terminator after the fixed socket path copied below.
         let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
         address.sun_family = libc::AF_UNIX as libc::sa_family_t;
         address.sun_len = std::mem::size_of_val(&address) as u8;
         for (destination, source) in address.sun_path.iter_mut().zip(SOCKET.as_bytes()) {
             *destination = *source as libc::c_char;
         }
    +    // SAFETY: stream owns fd; address is a live initialized sockaddr_un of the exact
    +    // supplied size and SOCKET fits sun_path with a remaining NUL terminator.
         let connected = unsafe {
             libc::connect(
                 fd,
    @@ -252,11 +265,15 @@ fn connect_bounded() -> Result {
                 events: libc::POLLOUT,
                 revents: 0,
             };
    +        // SAFETY: descriptor is one writable initialized pollfd and stream keeps its
    +        // fd alive; the array count is one and the timeout is bounded.
             if unsafe { libc::poll(&mut descriptor, 1, 2000) } <= 0 {
                 return Err(error("The helper connection timed out"));
             }
             let mut status: libc::c_int = 0;
             let mut length = std::mem::size_of_val(&status) as libc::socklen_t;
    +        // SAFETY: stream owns fd; status has the advertised c_int output capacity
    +        // and length is a live writable socklen_t; neither pointer is retained.
             if unsafe {
                 libc::getsockopt(
                     fd,
    @@ -373,6 +390,7 @@ impl PowerController for NativeController {
                 ));
             }
             self.authorization_attempted = true;
    +        // SAFETY: geteuid has no pointer arguments or caller preconditions.
             let uid = unsafe { libc::geteuid() };
             if uid == 0 {
                 return Err(error("Run the desktop app as a regular user, not as root"));
    @@ -426,12 +444,6 @@ impl PowerController for NativeController {
         }
     }
     
    -impl Drop for NativeController {
    -    fn drop(&mut self) {
    -        let _ = self.stop();
    -    }
    -}
    -
     #[cfg(test)]
     mod tests {
         use super::*;
    diff --git a/src-tauri/src/power/mod.rs b/src-tauri/src/power/mod.rs
    index fc428a4..67d5602 100644
    --- a/src-tauri/src/power/mod.rs
    +++ b/src-tauri/src/power/mod.rs
    @@ -156,7 +156,7 @@ impl PowerService {
                         worker_effects,
                     );
                     run_worker(&mut engine, events);
    -                let _ = finished.send(());
    +                finish_worker(&mut engine, finished);
                 })
                 .map_err(|error| AppError::configuration(format!("Start power monitor: {error}")))?;
             Ok(Self {
    @@ -203,7 +203,7 @@ impl PowerService {
         }
     
         pub fn set(&self, enabled: bool) -> Result {
    -        let _transition = crate::opencode_cli::target::transition()?;
    +        let _transition = crate::opencode::target::transition()?;
             let mut shared = self
                 .shared
                 .lock()
    @@ -256,6 +256,7 @@ impl PowerService {
     
     struct Engine {
         controller: C,
    +    stopped: bool,
         shared: Arc>,
         effects: Arc>,
         generation: u64,
    @@ -271,10 +272,33 @@ struct Engine {
         inflight: Option,
     }
     
    +impl Drop for Engine {
    +    fn drop(&mut self) {
    +        if !thread::panicking() || self.stopped {
    +            return;
    +        }
    +        // The worker's guards have unwound; do not recover or reacquire poisoned locks.
    +        let result =
    +            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.controller.stop()));
    +        let message = match result {
    +            Ok(Ok(())) => return,
    +            Ok(Err(_)) => "Lid controller unwind cleanup failed; native recovery remains required",
    +            Err(payload) => {
    +                // A custom panic payload may itself panic on drop during this unwind.
    +                std::mem::forget(payload);
    +                "Lid controller unwind cleanup panicked; native recovery remains required"
    +            }
    +        };
    +        use std::io::Write;
    +        let _ = writeln!(std::io::stderr(), "{message}");
    +    }
    +}
    +
     impl Engine {
         fn new(controller: C, shared: Arc>, effects: Arc>) -> Self {
             Self {
                 controller,
    +            stopped: false,
                 shared,
                 effects,
                 generation: 0,
    @@ -291,6 +315,21 @@ impl Engine {
             }
         }
     
    +    fn controller(&mut self) -> &mut C {
    +        // A new effect can reacquire policy ownership even if it later fails or panics.
    +        self.stopped = false;
    +        &mut self.controller
    +    }
    +
    +    fn stop(&mut self) -> Result<(), AppError> {
    +        if self.stopped {
    +            return Ok(());
    +        }
    +        let result = self.controller.stop();
    +        self.stopped = result.is_ok();
    +        result
    +    }
    +
         fn current(&self) -> bool {
             self.shared
                 .lock()
    @@ -358,7 +397,7 @@ impl Engine {
             self.prepared = false;
             self.protection = false;
             self.last_busy = None;
    -        if let Err(error) = self.controller.stop() {
    +        if let Err(error) = self.stop() {
                 self.cleanup_failed = true;
                 eprintln!("Restore after canceled lid operation: {error}");
             }
    @@ -393,7 +432,7 @@ impl Engine {
             drop(guard);
             if let Some(preparation) = preparation {
                 // Interactive authorization holds neither transition, effects nor shared locks.
    -            let result = self.controller.prepare();
    +            let result = self.controller().prepare();
                 let _guard = effects.lock().expect("power effects lock poisoned");
                 let mut shared = self.shared.lock().expect("power monitor lock poisoned");
                 if shared.desired.control_generation != self.control_generation {
    @@ -439,11 +478,11 @@ impl Engine {
                 self.query_at = now;
                 // An ordinary opt-out restores policy but keeps this run's authorized helper reusable.
                 let restored = if shutdown {
    -                self.controller.stop()
    +                self.stop()
                 } else if !control_changed && !enabled && !self.cleanup_failed {
                     Ok(())
                 } else {
    -                self.controller.idle()
    +                self.controller().idle()
                 };
                 if self.stale_stop() {
                     return false;
    @@ -457,7 +496,7 @@ impl Engine {
             }
             if shutdown {
                 if self.cleanup_failed {
    -                if let Err(error) = self.controller.stop() {
    +                if let Err(error) = self.stop() {
                         eprintln!("Restore lid protection on exit: {error}");
                     }
                 }
    @@ -465,7 +504,7 @@ impl Engine {
             }
             if self.cleanup_failed {
                 if now >= self.renew_at {
    -                let result = self.controller.idle();
    +                let result = self.controller().idle();
                     if self.stale_stop() {
                         return false;
                     }
    @@ -473,7 +512,7 @@ impl Engine {
                     if let Err(error) = result {
                         self.error(error);
                     } else {
    -                    self.publish_ready(enabled, preference_error.clone());
    +                    self.publish_ready(enabled, preference_error);
                     }
                     self.renew_at = now + POLL;
                 }
    @@ -496,12 +535,12 @@ impl Engine {
                     .last_busy
                     .and_then(|busy| (busy + GRACE).checked_duration_since(now));
                 if let Some(remaining) = remaining.filter(|duration| !duration.is_zero()) {
    -                let result = self.controller.busy(LEASE.min(remaining));
    +                let result = self.controller().busy(LEASE.min(remaining));
                     if self.stale_stop() {
                         return false;
                     }
                     if let Err(error) = result {
    -                    let restored = self.controller.idle();
    +                    let restored = self.controller().idle();
                         if self.stale_stop() {
                             return false;
                         }
    @@ -514,7 +553,7 @@ impl Engine {
                         }
                     }
                 } else {
    -                let result = self.controller.idle();
    +                let result = self.controller().idle();
                     if self.stale_stop() {
                         return false;
                     }
    @@ -583,7 +622,7 @@ impl Engine {
                 Err(error) => self.publish(LidPhase::Unknown, None, Some(error.to_string())),
                 Ok(0) => {
                     self.last_busy = None;
    -                let result = self.controller.idle();
    +                let result = self.controller().idle();
                     if self.stale_stop() {
                         return;
                     }
    @@ -597,7 +636,7 @@ impl Engine {
                     self.renew_at = now + POLL;
                 }
                 Ok(count) => {
    -                let result = self.controller.busy(LEASE);
    +                let result = self.controller().busy(LEASE);
                     if self.stale_stop() {
                         return;
                     }
    @@ -610,7 +649,7 @@ impl Engine {
                         }
                         Err(error) => {
                             // A failed apply may have changed one field: restore, never claim protection.
    -                        let restored = self.controller.idle();
    +                        let restored = self.controller().idle();
                             if self.stale_stop() {
                                 return;
                             }
    @@ -630,6 +669,21 @@ impl Engine {
         }
     }
     
    +fn finish_worker(engine: &mut Engine, finished: mpsc::Sender<()>) {
    +    let effects = Arc::clone(&engine.effects);
    +    match effects.lock() {
    +        Ok(_guard) => {
    +            if engine.stop().is_err() {
    +                eprintln!("Lid controller final cleanup failed; native recovery remains required");
    +            }
    +        }
    +        Err(_) => {
    +            eprintln!("Lid controller final cleanup lock failed; native recovery remains required");
    +        }
    +    }
    +    let _ = finished.send(());
    +}
    +
     fn run_worker(engine: &mut Engine, wake: mpsc::Receiver<()>) {
         let (requests, queries) = mpsc::channel();
         let (results, observations) = mpsc::channel();
    @@ -637,7 +691,7 @@ fn run_worker(engine: &mut Engine, wake: mpsc::Receiver<(
             .name("lid-session-query".into())
             .spawn(move || {
                 while let Ok(generation) = queries.recv() {
    -                let result = crate::opencode_cli::active_sessions_via_cli();
    +                let result = crate::opencode::active_sessions_via_service();
                     if results.send((generation, result)).is_err() {
                         break;
                     }
    @@ -679,7 +733,11 @@ fn run_worker(engine: &mut Engine, wake: mpsc::Receiver<(
             ) {
                 let effects = Arc::clone(&engine.effects);
                 let _guard = effects.lock().expect("power effects lock poisoned");
    -            let _ = engine.controller.stop();
    +            if engine.stop().is_err() {
    +                eprintln!(
    +                    "Lid controller disconnected cleanup failed; native recovery remains required"
    +                );
    +            }
                 break;
             }
         }
    diff --git a/src-tauri/src/power/settings.rs b/src-tauri/src/power/settings.rs
    index e001413..9f64ab9 100644
    --- a/src-tauri/src/power/settings.rs
    +++ b/src-tauri/src/power/settings.rs
    @@ -50,6 +50,8 @@ pub(super) fn durable_write(path: &Path, value: &impl Serialize) -> Result<(), A
             };
             let source: Vec = temporary.as_os_str().encode_wide().chain(Some(0)).collect();
             let destination: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect();
    +        // SAFETY: both paths are NUL-terminated UTF-16 Vecs alive for this synchronous
    +        // call; MoveFileExW does not retain the pointers and the flags are valid.
             if unsafe {
                 MoveFileExW(
                     source.as_ptr(),
    diff --git a/src-tauri/src/power/windows.rs b/src-tauri/src/power/windows.rs
    index c0e037d..dc389e9 100644
    --- a/src-tauri/src/power/windows.rs
    +++ b/src-tauri/src/power/windows.rs
    @@ -282,11 +282,16 @@ impl Backend for NativeBackend {
         fn active_scheme(&mut self) -> Result {
             use windows_sys::Win32::{Foundation::LocalFree, System::Power::PowerGetActiveScheme};
             let mut pointer = std::ptr::null_mut();
    +        // SAFETY: null selects the default power store; pointer is a writable GUID*
    +        // output slot. Success returns a LocalAlloc GUID owned exclusively below.
             let code = unsafe { PowerGetActiveScheme(std::ptr::null_mut(), &mut pointer) };
             if code != 0 {
                 if !pointer.is_null() {
    -                unsafe {
    -                    LocalFree(pointer.cast());
    +                // SAFETY: any nonnull API output belongs to LocalAlloc and has not
    +                // been freed or exposed; match that allocator exactly once.
    +                if !unsafe { LocalFree(pointer.cast()) }.is_null() {
    +                    use std::io::Write;
    +                    let _ = writeln!(std::io::stderr(), "Windows power-scheme cleanup failed");
                     }
                 }
                 win_result(code, "Get active power scheme")?;
    @@ -296,9 +301,14 @@ impl Backend for NativeBackend {
                     "Windows returned no active power scheme",
                 ));
             }
    +        // SAFETY: success and the nonnull check establish a live initialized GUID;
    +        // copy it before releasing the API's allocation.
             let guid = unsafe { *pointer };
    -        unsafe {
    -            LocalFree(pointer.cast());
    +        // SAFETY: PowerGetActiveScheme allocated pointer with LocalAlloc; all reads
    +        // are complete and this is its sole matching release.
    +        if !unsafe { LocalFree(pointer.cast()) }.is_null() {
    +            use std::io::Write;
    +            let _ = writeln!(std::io::stderr(), "Windows power-scheme cleanup failed");
             }
             Ok(Uuid::from_fields(
                 guid.data1,
    @@ -311,8 +321,10 @@ impl Backend for NativeBackend {
             use windows_sys::Win32::System::Power::{PowerReadACValueIndex, PowerReadDCValueIndex};
             let guid = windows_sys::core::GUID::from_u128(scheme.as_u128());
             let mut value = 0;
    -        let code = unsafe {
    -            if battery {
    +        let code = if battery {
    +            // SAFETY: all three GUID references are live initialized inputs, value
    +            // is a writable DWORD output, and the reserved root key must be null.
    +            unsafe {
                     PowerReadDCValueIndex(
                         std::ptr::null_mut(),
                         &guid,
    @@ -320,7 +332,11 @@ impl Backend for NativeBackend {
                         &ffi::LID,
                         &mut value,
                     )
    -            } else {
    +            }
    +        } else {
    +            // SAFETY: all three GUID references are live initialized inputs, value
    +            // is a writable DWORD output, and the reserved root key must be null.
    +            unsafe {
                     PowerReadACValueIndex(
                         std::ptr::null_mut(),
                         &guid,
    @@ -336,10 +352,16 @@ impl Backend for NativeBackend {
         fn write(&mut self, scheme: Uuid, battery: bool, value: u32) -> Result<(), AppError> {
             use windows_sys::Win32::System::Power::{PowerWriteACValueIndex, PowerWriteDCValueIndex};
             let guid = windows_sys::core::GUID::from_u128(scheme.as_u128());
    -        let code = unsafe {
    -            if battery {
    +        let code = if battery {
    +            // SAFETY: all GUID references are live initialized inputs; value is a
    +            // DWORD, and the reserved root key is null. No pointer is retained.
    +            unsafe {
                     PowerWriteDCValueIndex(std::ptr::null_mut(), &guid, &ffi::BUTTONS, &ffi::LID, value)
    -            } else {
    +            }
    +        } else {
    +            // SAFETY: all GUID references are live initialized inputs; value is a
    +            // DWORD, and the reserved root key is null. No pointer is retained.
    +            unsafe {
                     PowerWriteACValueIndex(std::ptr::null_mut(), &guid, &ffi::BUTTONS, &ffi::LID, value)
                 }
             };
    @@ -351,6 +373,8 @@ impl Backend for NativeBackend {
             if self.active_scheme()? == scheme {
                 let guid = windows_sys::core::GUID::from_u128(scheme.as_u128());
                 win_result(
    +                // SAFETY: guid is an initialized live GUID; null selects the default
    +                // power store and PowerSetActiveScheme retains neither pointer.
                     unsafe { PowerSetActiveScheme(std::ptr::null_mut(), &guid) },
                     "Refresh active power scheme",
                 )?;
    @@ -362,6 +386,8 @@ impl Backend for NativeBackend {
                 SetThreadExecutionState, ES_CONTINUOUS, ES_SYSTEM_REQUIRED,
             };
             let flags = ES_CONTINUOUS | if busy { ES_SYSTEM_REQUIRED } else { 0 };
    +        // SAFETY: only documented flags are passed, with no pointers; Engine calls
    +        // both acquisition and release on its single owning controller thread.
             if unsafe { SetThreadExecutionState(flags) } == 0 {
                 Err(AppError::configuration(format!(
                     "Set idle execution request: {}",
    diff --git a/src-tauri/src/providers.rs b/src-tauri/src/providers.rs
    index bf057f3..e968889 100644
    --- a/src-tauri/src/providers.rs
    +++ b/src-tauri/src/providers.rs
    @@ -57,11 +57,7 @@ fn invalid_model_ref(value: &str) -> AppError {
     }
     
     pub fn list_providers(opencode_file: &Path) -> Result, AppError> {
    -    providers_from_root(
    -        &JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?
    -            .raw()
    -            .clone(),
    -    )
    +    providers_from_root(JsoncDoc::read(opencode_file, OPENCODE_SCHEMA)?.raw())
     }
     
     pub fn custom_provider_ids(
    @@ -89,7 +85,7 @@ fn ensure_custom_provider_in_doc(
         let is_custom = root
             .get("providers")
             .and_then(Value::as_object)
    -        .map_or(false, |providers| providers.contains_key(provider_id));
    +        .is_some_and(|providers| providers.contains_key(provider_id));
         if !is_custom {
             return Err(AppError::validation(format!(
                 "model operations are only allowed for custom providers: {provider_id}"
    @@ -114,7 +110,7 @@ pub fn create_provider(
         if provider
             .package
             .as_deref()
    -        .map_or(true, |package| package.trim().is_empty())
    +        .is_none_or(|package| package.trim().is_empty())
         {
             return Err(AppError::validation("provider package is required"));
         }
    @@ -342,12 +338,12 @@ fn provider_value(provider: &ProviderDef) -> Value {
         put_optional(
             &mut value,
             "settings",
    -        provider.settings.clone().and_then(to_value),
    +        provider.settings.as_ref().and_then(to_value),
         );
         put_optional(
             &mut value,
             "headers",
    -        provider.headers.clone().and_then(to_value),
    +        provider.headers.as_ref().and_then(to_value),
         );
         let models = provider
             .models
    @@ -376,24 +372,24 @@ pub fn model_value(model: &ModelDef) -> Value {
         put_optional(
             &mut value,
             "capabilities",
    -        model.capabilities.clone().and_then(to_value),
    +        model.capabilities.as_ref().and_then(to_value),
         );
    -    put_optional(&mut value, "cost", model.cost.clone().and_then(to_value));
    -    put_optional(&mut value, "limit", model.limit.clone().and_then(to_value));
    +    put_optional(&mut value, "cost", model.cost.as_ref().and_then(to_value));
    +    put_optional(&mut value, "limit", model.limit.as_ref().and_then(to_value));
         put_optional(
             &mut value,
             "settings",
    -        model.settings.clone().and_then(to_value),
    +        model.settings.as_ref().and_then(to_value),
         );
         put_optional(
             &mut value,
             "headers",
    -        model.headers.clone().and_then(to_value),
    +        model.headers.as_ref().and_then(to_value),
         );
         put_optional(
             &mut value,
             "variants",
    -        model.variants.clone().and_then(to_value),
    +        model.variants.as_ref().and_then(to_value),
         );
         Value::Object(value)
     }
    @@ -414,7 +410,7 @@ fn provider_field_patches(provider: &ProviderDef) -> Vec<(Vec, Option Result<(), AppError> {
         // V2 model IDs may contain `/` (e.g. openrouter/anthropic/claude) but never `#`.
    -    if !value.is_empty()
    -        && value.trim() == value
    -        && !value.contains('#')
    -        && !(matches!(kind, IdKind::Provider) && value.contains('/'))
    -        && !value.chars().any(char::is_whitespace)
    +    if !(value.is_empty()
    +        || value.trim() != value
    +        || value.contains('#')
    +        || (matches!(kind, IdKind::Provider) && value.contains('/'))
    +        || value.chars().any(char::is_whitespace))
         {
             return Ok(());
         }
    diff --git a/src-tauri/src/refs.rs b/src-tauri/src/refs.rs
    index 5f2acc0..480e3ed 100644
    --- a/src-tauri/src/refs.rs
    +++ b/src-tauri/src/refs.rs
    @@ -81,7 +81,7 @@ pub fn collect_model_references(paths: &ConfigPaths) -> Result Result Result Result<&'static Client, AppError> {
             .map_err(Clone::clone)
     }
     
    +type CatalogScan = Vec<(String, Result)>;
    +
     fn scan_catalog(
         source: &str,
         only: Option<&str>,
         cache: Option<&Mutex>,
         deadline: Instant,
    -) -> Result)>, AppError> {
    +) -> Result {
         if Instant::now() >= deadline {
             return Err(AppError::configuration("Skill scan deadline exceeded"));
         }
    
    From 1bfaad83dd471fbeb4fd6741b068db209bd0084f Mon Sep 17 00:00:00 2001
    From: seho 
    Date: Sun, 25 Oct 2026 14:14:58 +0800
    Subject: [PATCH 64/97] feat(frontend): add lazy MCP proxy boundary and
     localized copy
    
    ---
     src/config/AGENTS.md                          |   8 +-
     src/config/__tests__/config-refresh.test.mjs  |  10 +-
     .../__tests__/mcp-proxy-adapter.test.mjs      | 280 ++++++++++++++++
     src/config/adapter.ts                         |  33 ++
     src/config/store.svelte.ts                    |  46 ++-
     src/i18n/AGENTS.md                            |   8 +-
     src/i18n/__tests__/i18n.test.ts               |   5 +-
     src/i18n/dictionaries/en.ts                   | 315 ++++++++++++------
     src/i18n/dictionaries/zh.ts                   | 299 ++++++++++++-----
     src/types/AGENTS.md                           |   6 +
     src/types/mcp-proxy.ts                        |  65 ++++
     11 files changed, 892 insertions(+), 183 deletions(-)
     create mode 100644 src/config/__tests__/mcp-proxy-adapter.test.mjs
     create mode 100644 src/types/mcp-proxy.ts
    
    diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md
    index 42410b0..f98394b 100644
    --- a/src/config/AGENTS.md
    +++ b/src/config/AGENTS.md
    @@ -1,5 +1,7 @@
     # Configuration Boundary and State
     
    +`getMomHttpSettings` / `setMomHttpSettings` are lazy, untracked pass-throughs for application-wide `{ port, allowLan }`. MCP proxy settings exclude these fields and retain their own revision guard; neither HTTP nor proxy saves refresh global config or retain failed drafts.
    +
     ## Overview
     
     This directory connects typed command calls to shared application state and Svelte context. The adapter translates backend payloads; the store coordinates initialization, mutations, refreshes, preferences, and failed-draft recovery. Resource, app metadata, and manual-update facades remain lazy rather than becoming part of the global configuration snapshot.
    @@ -16,6 +18,7 @@ src/config/                         # Command translation and shared state
         ├── config-refresh.test.mjs     # Initialization, reload, races, selection, and drafts
         ├── projects.test.mjs           # Explicit resource scope, project persistence, and snapshot freshness
         ├── resources-bridge.test.mjs   # Lazy facades, secret drafts, pending-save guards and newest-target freshness
    +    ├── mcp-proxy-adapter.test.mjs  # Proxy/listener IPC payloads and lazy, secret-free store facades
         └── island.test.mjs             # Island IPC arguments and no-preload store behavior
     ```
     
    @@ -38,14 +41,15 @@ src/config/                         # Command translation and shared state
     - **Agent snapshots**: Store/adapter forward `mutation.expectedPath`/`expectedContent` unchanged from the form's frozen backend preview (`sources[].resolvedPath`/`content`). Never rebuild the content token from JavaScript raw JSON; update requires both fields, create is unchanged.
     - **Request ownership**: Preserve separate state/catalog sequence counters; only the newest response updates values or errors. State loading uses an outstanding-request count; catalog loading belongs to its latest request.
     - **Refresh failures**: Automatic refresh tolerates catalog failure after a successful mutation. `refreshAll` rejects catalog failure through `refreshAllError`; failed catalog loads retain the previous catalog.
    -- **Reload lifecycle**: `reloadOpencode` invokes the CLI before refreshing, prevents overlapping reloads, and holds the splash until completion. Initialization also waits for its catalog attempt.
    +- **Reload lifecycle**: `reloadOpencode` invokes the selected service configuration reload before refreshing, prevents overlapping reloads, and holds the splash until completion. Initialization also waits for its catalog attempt.
     - **Draft recovery**: Snapshot non-secret failed payloads with `structuredClone`; `reloadKeepingDraft` preserves recovery and form versions, while discard refresh clears recovery and increments `formResetVersion`.
     - **Secret drafts**: MCP/skill create/update failures retain no payload or error detail and use a generic message; leave sensitive content in its original form.
     - **Selection**: Apply `selectedGroupId` from app state, defaulting missing values to `null`; do not infer selection from `Group.isEnabled` or update it before refreshed state arrives.
    -- **Project browsing**: `projects` defaults to `[]`; `selectedProjectId` defaults to `null` and stale IDs normalize to Global. Picker calls are lazy pass-through; add/remove/select use independent `projectSaving` guarding and commit state only after persistence succeeds, without CLI/catalog refresh or draft recovery. Project versions protect local commits from older global snapshot reads.
    +- **Project browsing**: `projects` defaults to `[]`; `selectedProjectId` defaults to `null` and stale IDs normalize to Global. Picker calls are lazy pass-through; add/remove/select use independent `projectSaving` guarding and commit state only after persistence succeeds, without service reload/catalog refresh or draft recovery. Project versions protect local commits from older global snapshot reads.
     - **Resource scope**: Agent/MCP/skill facades take an optional explicit project ID; absent/null means Global and leaves existing IPC payloads unchanged. Never infer scope from shared selection or replace global `agents` with project reads. Resource writes may forward an explicit project ID for backend rejection; global writes retain their existing lifecycle.
     - **Preferences**: Apply theme/locale optimistically to state and the document; restore both when preference persistence fails.
     - **Regression harness**: Tests use `node:test`, injected adapters, and Vite `ssrLoadModule`; close each Vite server in `finally`. Pass `false` to `createConfigStore` when catalog work is intentionally excluded.
    +- **MCP Proxy facade**: MCP Proxy commands (`getMcpProxyState`, `getMcpProxySettings`, `saveMcpProxySettings`, `rotateMcpProxyKey`, `getMcpProxyServer`, `createMcpProxyServer`, `updateMcpProxyServer`, `deleteMcpProxyServer`, `importMcpProxyServer`, `closeMcpProxyUnit`) and MOM HTTP listener commands (`getMomHttpSettings`, `setMomHttpSettings`) stay lazy pass-throughs to the adapter. They never use `run()`, never join `AppState`, global `draftRecovery`, or localStorage, and do not trigger catalog or configuration refreshes. Listener writes carry only `{ settings: { port, allowLan } }`; proxy writes carry only enabled, idleTimeout, maxUnits, revision, and expectedRevision.
     - **Island facade**: Island commands remain lazy and do not use global config mutations; pass `initialize = false` to avoid startup app/catalog reads, and apply island-provided preferences without persistence. `setIslandView` forwards the shared `IslandView` (`compact`/`peek`/`expanded`) to `set_island_view` with `{ view }`. Permission decisions use the shared `once`/`always`/`reject` type; Always is explicit user consent, with pending identity/save-rule validation owned by the backend and no custom rule payload or MOM persistence. Propagate ambiguous/409 failures unchanged; never retry writes or convert conflicts to success.
     - **Island generation guard**: Messages/send/permission reply/question reply/cancel/interruption require the caller's displayed generation as their final numeric argument; adapter IPC forwards exact `expectedConnectionGeneration`, without defaults or current-state autofill. Backend rejects stale epochs before reading/mutating the selected target; propagate conflict unchanged and never retry against a new generation. Cache snapshot, refresh and view calls remain unchanged.
     - **Lid target readback**: A successful target version change immediately masks previous protection/counts and clears target-local errors while preserving the confirmed enabled preference; only current-version native readback can restore a protected claim.
    diff --git a/src/config/__tests__/config-refresh.test.mjs b/src/config/__tests__/config-refresh.test.mjs
    index 062adc8..42c44c1 100644
    --- a/src/config/__tests__/config-refresh.test.mjs
    +++ b/src/config/__tests__/config-refresh.test.mjs
    @@ -134,7 +134,7 @@ test('startup, save, and full refresh use the correct splash and reload paths',
       }
     });
     
    -test('OpenCode reload invokes CLI before full refresh and skips refresh on failure', async () => {
    +test('OpenCode reload invokes the selected service before full refresh and skips refresh on failure', async () => {
       const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
       try {
         const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts');
    @@ -180,8 +180,8 @@ test('OpenCode reload invokes CLI before full refresh and skips refresh on failu
         reload = deferred();
         const failure = store.reloadOpencode();
         assert.equal(store.splashLoading, true);
    -    reload.reject({ code: 'configuration_failed', message: 'CLI reload failed' });
    -    await assert.rejects(failure, { code: 'configuration_failed', message: 'CLI reload failed' });
    +    reload.reject({ code: 'configuration_failed', message: 'OpenCode reload failed' });
    +    await assert.rejects(failure, { code: 'configuration_failed', message: 'OpenCode reload failed' });
         assert.deepEqual(commands, [
           'opencode_list_models',
           'load_app_state',
    @@ -190,7 +190,7 @@ test('OpenCode reload invokes CLI before full refresh and skips refresh on failu
           'load_app_state',
           'opencode_reload',
         ]);
    -    assert.equal(store.refreshAllError?.message, 'CLI reload failed');
    +    assert.equal(store.refreshAllError?.message, 'OpenCode reload failed');
         assert.equal(store.splashLoading, false);
         assert.equal(store.reloading, false);
     
    @@ -492,7 +492,7 @@ test('latest catalog request owns catalog, loading, and errors despite stale com
       }
     });
     
    -test('tray configuration skips CLI catalog work and latest state read wins over a hidden startup response', async () => {
    +test('tray configuration skips service catalog work and latest state read wins over a hidden startup response', async () => {
       const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
       try {
         const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts');
    diff --git a/src/config/__tests__/mcp-proxy-adapter.test.mjs b/src/config/__tests__/mcp-proxy-adapter.test.mjs
    new file mode 100644
    index 0000000..30ed0f3
    --- /dev/null
    +++ b/src/config/__tests__/mcp-proxy-adapter.test.mjs
    @@ -0,0 +1,280 @@
    +import assert from 'node:assert/strict';
    +import test from 'node:test';
    +import { createServer } from 'vite';
    +
    +test('MCP proxy adapter maps commands and arguments accurately to Tauri IPC', async () => {
    +  const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
    +  try {
    +    const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts');
    +
    +    const calls = [];
    +    const adapter = createTauriAdapter(async (command, args) => {
    +      calls.push([command, args]);
    +      if (command === 'get_mcp_proxy_state') {
    +        return {
    +          actual: true,
    +          desired: true,
    +          actualState: 'running',
    +          desiredState: true,
    +          boundAddr: '127.0.0.1:23456',
    +          port: 23456,
    +          allowLan: false,
    +          totalServices: 1,
    +          enabledServices: 1,
    +          activeUnits: 0,
    +          activeSessions: 0,
    +          availableUnits: 32,
    +          maxUnits: 32,
    +          securityErrors: [],
    +          services: [],
    +          revision: 1,
    +          generation: 1,
    +        };
    +      }
    +      if (command === 'get_mom_http_settings') {
    +        return { port: 18765, allowLan: false };
    +      }
    +      if (command === 'set_mom_http_settings') {
    +        return undefined;
    +      }
    +      if (command === 'get_mcp_proxy_settings') {
    +        return {
    +          enabled: true,
    +          idleTimeout: 300,
    +          maxUnits: 32,
    +          revision: 1,
    +        };
    +      }
    +      if (command === 'save_mcp_proxy_settings') {
    +        return undefined;
    +      }
    +      if (command === 'rotate_mcp_proxy_key') {
    +        return 'test-secret-token-12345';
    +      }
    +      if (command === 'get_mcp_proxy_server') {
    +        return {
    +          id: args.id,
    +          name: 'Test Server',
    +          enabled: true,
    +          command: ['echo', 'hello'],
    +          cwd: '/tmp',
    +          env: {},
    +          protocol: 'auto',
    +          startupTimeout: 30,
    +          executionTimeout: 300,
    +        };
    +      }
    +      if (command === 'create_mcp_proxy_server' || command === 'update_mcp_proxy_server') {
    +        return args.server;
    +      }
    +      if (command === 'delete_mcp_proxy_server') {
    +        return undefined;
    +      }
    +      if (command === 'import_mcp_proxy_server') {
    +        return {
    +          id: 'imported',
    +          name: args.request.serverName,
    +          enabled: true,
    +          command: ['npx', 'server'],
    +          cwd: '',
    +          env: {},
    +          protocol: 'auto',
    +          startupTimeout: 30,
    +          executionTimeout: 300,
    +        };
    +      }
    +      if (command === 'close_mcp_proxy_unit') {
    +        return undefined;
    +      }
    +      throw new Error(`Unexpected command: ${command}`);
    +    });
    +
    +    // 1. getMcpProxyState
    +    const state = await adapter.getMcpProxyState();
    +    assert.equal(state.actualState, 'running');
    +    assert.equal(state.desiredState, true);
    +    assert.equal(state.activeUnits, 0);
    +    assert.equal(state.activeSessions, 0);
    +    assert.deepEqual(calls.at(-1), ['get_mcp_proxy_state', undefined]);
    +
    +    const httpSettings = await adapter.getMomHttpSettings();
    +    assert.deepEqual(httpSettings, { port: 18765, allowLan: false });
    +    assert.deepEqual(calls.at(-1), ['get_mom_http_settings', undefined]);
    +    await adapter.setMomHttpSettings({ port: 23456, allowLan: true });
    +    assert.deepEqual(calls.at(-1), ['set_mom_http_settings', { settings: { port: 23456, allowLan: true } }]);
    +
    +    // 2. getMcpProxySettings
    +    const settings = await adapter.getMcpProxySettings();
    +    assert.equal(settings.idleTimeout, 300);
    +    assert.equal('port' in settings, false);
    +    assert.deepEqual(calls.at(-1), ['get_mcp_proxy_settings', undefined]);
    +
    +    // 3. saveMcpProxySettings
    +    await adapter.saveMcpProxySettings(settings, 1);
    +    assert.deepEqual(calls.at(-1), ['save_mcp_proxy_settings', { settings, expectedRevision: 1 }]);
    +
    +    // 4. rotateMcpProxyKey
    +    const key = await adapter.rotateMcpProxyKey();
    +    assert.equal(key, 'test-secret-token-12345');
    +    assert.deepEqual(calls.at(-1), ['rotate_mcp_proxy_key', undefined]);
    +
    +    // 5. getMcpProxyServer
    +    const srv = await adapter.getMcpProxyServer('srv-1');
    +    assert.equal(srv.id, 'srv-1');
    +    assert.deepEqual(calls.at(-1), ['get_mcp_proxy_server', { id: 'srv-1' }]);
    +
    +    // 6. createMcpProxyServer
    +    const created = await adapter.createMcpProxyServer(srv, 2);
    +    assert.equal(created.id, 'srv-1');
    +    assert.deepEqual(calls.at(-1), ['create_mcp_proxy_server', { server: srv, expectedRevision: 2 }]);
    +
    +    // 7. updateMcpProxyServer
    +    const updated = await adapter.updateMcpProxyServer(srv, 3);
    +    assert.equal(updated.id, 'srv-1');
    +    assert.deepEqual(calls.at(-1), ['update_mcp_proxy_server', { server: srv, expectedRevision: 3 }]);
    +
    +    // 8. deleteMcpProxyServer
    +    await adapter.deleteMcpProxyServer('srv-1', 4);
    +    assert.deepEqual(calls.at(-1), ['delete_mcp_proxy_server', { id: 'srv-1', expectedRevision: 4 }]);
    +
    +    // 9. importMcpProxyServer
    +    const imported = await adapter.importMcpProxyServer({
    +      sourceScope: 'global',
    +      serverName: 'fs',
    +      expectedSourcePath: '/tmp/opencode.json',
    +    });
    +    assert.equal(imported.name, 'fs');
    +    assert.deepEqual(calls.at(-1), [
    +      'import_mcp_proxy_server',
    +      {
    +        request: {
    +          sourceScope: 'global',
    +          serverName: 'fs',
    +          expectedSourcePath: '/tmp/opencode.json',
    +        },
    +      },
    +    ]);
    +
    +    // 10. closeMcpProxyUnit
    +    await adapter.closeMcpProxyUnit({ unitId: 'unit-99', expectedGeneration: 5 });
    +    assert.deepEqual(calls.at(-1), ['close_mcp_proxy_unit', { request: { unitId: 'unit-99', expectedGeneration: 5 } }]);
    +  } finally {
    +    await vite.close();
    +  }
    +});
    +
    +test('ConfigStore MCP proxy facades are lazy pass-throughs and do not trigger run() or draftRecovery', async () => {
    +  const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
    +  try {
    +    const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts');
    +
    +    let failOnSave = false;
    +    const httpSettings = { port: 18765, allowLan: false };
    +    let savedHttpSettings;
    +    let failOnHttpSave = false;
    +    const adapter = {
    +      loadAppState: async () => ({
    +        groups: [],
    +        projects: [],
    +        selectedGroupId: null,
    +        selectedProjectId: null,
    +        preferences: { theme: 'dark', locale: 'en' },
    +        providers: [],
    +        agents: [],
    +      }),
    +      getMcpProxyState: async () => ({
    +        actual: false,
    +        desired: false,
    +        actualState: 'stopped',
    +        desiredState: false,
    +        boundAddr: null,
    +        port: 23456,
    +        allowLan: false,
    +        totalServices: 0,
    +        enabledServices: 0,
    +        activeUnits: 0,
    +        activeSessions: 0,
    +        availableUnits: 32,
    +        maxUnits: 32,
    +        securityErrors: [],
    +        services: [],
    +        revision: 1,
    +        generation: 1,
    +      }),
    +      getMomHttpSettings: async () => httpSettings,
    +      setMomHttpSettings: async (settings) => {
    +        if (failOnHttpSave) throw new Error('HTTP save failed');
    +        savedHttpSettings = settings;
    +      },
    +      getMcpProxySettings: async () => ({
    +        enabled: true,
    +        idleTimeout: 300,
    +        maxUnits: 32,
    +        revision: 1,
    +      }),
    +      saveMcpProxySettings: async () => {
    +        if (failOnSave) {
    +          const err = new Error('failed');
    +          err.code = 'validation_failed';
    +          throw err;
    +        }
    +      },
    +      rotateMcpProxyKey: async () => 'key-abc',
    +      getMcpProxyServer: async (id) => ({
    +        id,
    +        name: 'Srv',
    +        enabled: true,
    +        command: ['sh'],
    +        cwd: '',
    +        env: {},
    +        protocol: 'auto',
    +        startupTimeout: 30,
    +        executionTimeout: 300,
    +      }),
    +      createMcpProxyServer: async (s) => s,
    +      updateMcpProxyServer: async (s) => s,
    +      deleteMcpProxyServer: async () => undefined,
    +      importMcpProxyServer: async (req) => ({
    +        id: 'imp',
    +        name: req.serverName,
    +        enabled: true,
    +        command: ['sh'],
    +        cwd: '',
    +        env: {},
    +        protocol: 'auto',
    +        startupTimeout: 30,
    +        executionTimeout: 300,
    +      }),
    +      closeMcpProxyUnit: async () => undefined,
    +    };
    +
    +    const store = createConfigStore(adapter, false, false);
    +
    +    const state = await store.getMcpProxyState();
    +    assert.equal(state.actualState, 'stopped');
    +    assert.equal(state.desiredState, false);
    +    assert.equal(state.boundAddr, null);
    +
    +    assert.equal(await store.getMomHttpSettings(), httpSettings);
    +    const nextHttpSettings = { port: 23456, allowLan: true };
    +    await store.setMomHttpSettings(nextHttpSettings);
    +    assert.equal(savedHttpSettings, nextHttpSettings);
    +    failOnHttpSave = true;
    +    await assert.rejects(store.setMomHttpSettings(nextHttpSettings), /HTTP save failed/);
    +    assert.equal(store.draftRecovery, null);
    +    assert.equal(store.saving, false);
    +
    +    const settings = await store.getMcpProxySettings();
    +    assert.equal(settings.idleTimeout, 300);
    +
    +    // Save should not set store draftRecovery on failure
    +    failOnSave = true;
    +    await assert.rejects(store.saveMcpProxySettings(settings, 1));
    +    assert.equal(store.draftRecovery, null);
    +
    +    const key = await store.rotateMcpProxyKey();
    +    assert.equal(key, 'key-abc');
    +  } finally {
    +    await vite.close();
    +  }
    +});
    diff --git a/src/config/adapter.ts b/src/config/adapter.ts
    index d98604b..368c080 100644
    --- a/src/config/adapter.ts
    +++ b/src/config/adapter.ts
    @@ -20,6 +20,14 @@ import type {
       IslandView,
     } from '$src/types/island.js';
     import type { McpDraft, McpList, McpServer, McpUpdate } from '$src/types/mcp.js';
    +import type {
    +  CloseMcpProxyUnitRequest,
    +  ImportMcpProxyServerRequest,
    +  McpProxyServerDto,
    +  McpProxySettingsDto,
    +  McpProxyStateDto,
    +  MomHttpSettings,
    +} from '$src/types/mcp-proxy.js';
     import type { ModelCatalogEntry, ModelDef, ModelRef } from '$src/types/models.js';
     import type { LidState } from '$src/types/power.js';
     import type { ProviderDef } from '$src/types/providers.js';
    @@ -113,6 +121,18 @@ export interface CommandAdapter {
       ): Result;
       islandCancelQuestion(sessionId: string, formId: string, expectedConnectionGeneration: number): Result;
       islandInterrupt(sessionId: string, expectedConnectionGeneration: number): Result;
    +  getMcpProxyState(): Result;
    +  getMomHttpSettings(): Result;
    +  setMomHttpSettings(settings: MomHttpSettings): Result;
    +  getMcpProxySettings(): Result;
    +  saveMcpProxySettings(settings: McpProxySettingsDto, expectedRevision: number): Result;
    +  rotateMcpProxyKey(): Result;
    +  getMcpProxyServer(id: string): Result;
    +  createMcpProxyServer(server: McpProxyServerDto, expectedRevision: number): Result;
    +  updateMcpProxyServer(server: McpProxyServerDto, expectedRevision: number): Result;
    +  deleteMcpProxyServer(id: string, expectedRevision: number): Result;
    +  importMcpProxyServer(request: ImportMcpProxyServerRequest): Result;
    +  closeMcpProxyUnit(request: CloseMcpProxyUnitRequest): Result;
       saveGroup(value: Group): Result;
       copyGroup(id: string, name: string): Result;
       deleteGroup(id: string): Result;
    @@ -191,6 +211,19 @@ export function createTauriAdapter(invoke: TauriInvoke): CommandAdapter {
           invoke('island_cancel_question', { sessionId, formId, expectedConnectionGeneration }),
         islandInterrupt: (sessionId, expectedConnectionGeneration) =>
           invoke('island_interrupt', { sessionId, expectedConnectionGeneration }),
    +    getMcpProxyState: () => invoke('get_mcp_proxy_state'),
    +    getMomHttpSettings: () => invoke('get_mom_http_settings'),
    +    setMomHttpSettings: (settings) => invoke('set_mom_http_settings', { settings }),
    +    getMcpProxySettings: () => invoke('get_mcp_proxy_settings'),
    +    saveMcpProxySettings: (settings, expectedRevision) =>
    +      invoke('save_mcp_proxy_settings', { settings, expectedRevision }),
    +    rotateMcpProxyKey: () => invoke('rotate_mcp_proxy_key'),
    +    getMcpProxyServer: (id) => invoke('get_mcp_proxy_server', { id }),
    +    createMcpProxyServer: (server, expectedRevision) => invoke('create_mcp_proxy_server', { server, expectedRevision }),
    +    updateMcpProxyServer: (server, expectedRevision) => invoke('update_mcp_proxy_server', { server, expectedRevision }),
    +    deleteMcpProxyServer: (id, expectedRevision) => invoke('delete_mcp_proxy_server', { id, expectedRevision }),
    +    importMcpProxyServer: (request) => invoke('import_mcp_proxy_server', { request }),
    +    closeMcpProxyUnit: (request) => invoke('close_mcp_proxy_unit', { request }),
         saveGroup: async (value) => readGroup(await invoke('save_group', { group: writeGroup(value) })),
         copyGroup: async (id, name) => readGroup(await invoke('copy_group', { id, name })),
         deleteGroup: (id) => invoke('delete_group', { id }),
    diff --git a/src/config/store.svelte.ts b/src/config/store.svelte.ts
    index 82c0ce9..377f0a7 100644
    --- a/src/config/store.svelte.ts
    +++ b/src/config/store.svelte.ts
    @@ -22,6 +22,14 @@ import type {
       IslandView,
     } from '$src/types/island.js';
     import type { McpDraft, McpList, McpServer, McpUpdate } from '$src/types/mcp.js';
    +import type {
    +  CloseMcpProxyUnitRequest,
    +  ImportMcpProxyServerRequest,
    +  McpProxyServerDto,
    +  McpProxySettingsDto,
    +  McpProxyStateDto,
    +  MomHttpSettings,
    +} from '$src/types/mcp-proxy.js';
     import type { ModelCatalogEntry, ModelDef, ModelRef } from '$src/types/models.js';
     import type { LidState } from '$src/types/power.js';
     import type { ProviderDef } from '$src/types/providers.js';
    @@ -86,7 +94,7 @@ export function createConfigStore(adapter: CommandAdapter, catalogOnRefresh = tr
       let notice = $state(null);
       let draftRecovery = $state(null);
       let formResetVersion = $state(0);
    -  // New: opencode CLI model catalog (builtin + custom), fetched via wrapper
    +  // OpenCode service model catalog, fetched through the command adapter.
       let catalog = $state([]);
       let catalogLoading = $state(false);
       let catalogError = $state(null);
    @@ -546,6 +554,42 @@ export function createConfigStore(adapter: CommandAdapter, catalogOnRefresh = tr
         islandInterrupt(sessionId: string, expectedConnectionGeneration: number): Promise {
           return adapter.islandInterrupt(sessionId, expectedConnectionGeneration);
         },
    +    getMcpProxyState(): Promise {
    +      return adapter.getMcpProxyState();
    +    },
    +    getMomHttpSettings(): Promise {
    +      return adapter.getMomHttpSettings();
    +    },
    +    setMomHttpSettings(settings: MomHttpSettings): Promise {
    +      return adapter.setMomHttpSettings(settings);
    +    },
    +    getMcpProxySettings(): Promise {
    +      return adapter.getMcpProxySettings();
    +    },
    +    saveMcpProxySettings(settings: McpProxySettingsDto, expectedRevision: number): Promise {
    +      return adapter.saveMcpProxySettings(settings, expectedRevision);
    +    },
    +    rotateMcpProxyKey(): Promise {
    +      return adapter.rotateMcpProxyKey();
    +    },
    +    getMcpProxyServer(id: string): Promise {
    +      return adapter.getMcpProxyServer(id);
    +    },
    +    createMcpProxyServer(server: McpProxyServerDto, expectedRevision: number): Promise {
    +      return adapter.createMcpProxyServer(server, expectedRevision);
    +    },
    +    updateMcpProxyServer(server: McpProxyServerDto, expectedRevision: number): Promise {
    +      return adapter.updateMcpProxyServer(server, expectedRevision);
    +    },
    +    deleteMcpProxyServer(id: string, expectedRevision: number): Promise {
    +      return adapter.deleteMcpProxyServer(id, expectedRevision);
    +    },
    +    importMcpProxyServer(request: ImportMcpProxyServerRequest): Promise {
    +      return adapter.importMcpProxyServer(request);
    +    },
    +    closeMcpProxyUnit(request: CloseMcpProxyUnitRequest): Promise {
    +      return adapter.closeMcpProxyUnit(request);
    +    },
         async saveGroup(value: Group) {
           await run('saveGroup', value, () => adapter.saveGroup(value));
           await refresh();
    diff --git a/src/i18n/AGENTS.md b/src/i18n/AGENTS.md
    index 4a4d31a..24d6884 100644
    --- a/src/i18n/AGENTS.md
    +++ b/src/i18n/AGENTS.md
    @@ -33,15 +33,21 @@ i18n/                         # Translation data and context API
     - **Key schema**: Define new keys in `en.ts`; mirror every key in the typed `zh.ts` map rather than maintaining a separate key union.
     - **Project and update messages**: `header.*` covers the GitHub opener; `settings.*` covers manual version checks, macOS trust guidance, and optional Star support. Keep security steps and the quarantine command equivalent in both locales; Star support never gates downloads.
     - **CLI update messages**: `settings.opencode*` covers local installation details, every backend method/reason, explicit stable checks, exact path/version confirmation, and separate command/readback failures. Keep local-only, no-sudo/no-service-restart warnings equivalent in both locales; app updates remain separate.
    +- **Service operation messages**: Model catalog loading/errors and lid-monitor scope describe the selected local OpenCode service, not CLI transport. Keep CLI wording for installation, updates and Shared Service bootstrap only.
     - **Service target messages**: `settings.service*` distinguishes saved target, live backend identity and CLI version; explains direct V2 HTTP loopback APIs, Basic backend passwords, no-auth without a password, and no OpenChamber UI/Bearer fallback. Busy errors ask users to wait for the ongoing operation and retry; validation hints cover the direct HTTP loopback URL, positive port, and backend password without claiming authentication was checked. Never require manual Mini Island/lid-protection disabling or power restoration to switch targets; backend monitor refresh preserves enabled preferences. Keep errors free of private bodies. Keep Unix account-owned 0600 plaintext storage, Windows current-account DPAPI, dedicated backend-password guidance and no protection against account compromise equivalent in both locales. Never expose credential values or claim verified Windows ACL protection; Windows-native DPAPI acceptance is outside macOS host tests.
     - **Resource scope copy**: `scope.*` covers the list-only Global/project picker, exact-root read-only notices, separate Global comparison, and forgetting a folder without deleting files. It describes configuration files, never live MCP status or inherited runtime configuration.
    +- **MOM HTTP copy**: `settings.http.*` covers the General tab listener port (default 18765), LAN access/security warning, runtime status, and saves. `settings.proxy.httpHint` directs proxy users to General; proxy-only controls retain `settings.proxy.*`.
    +- **Settings search**: `settings.search*` labels the local setting finder, result count, navigation hint, clear action, and empty state. Keep both locales concise and action-oriented. `settings/SettingsSearch.svelte` explicitly indexes existing labels/descriptions and their technical keys in both dictionaries, never user-entered values; preserve those key identities during copy edits. `groups.enableAria` / `groups.disableAria` use matching `{name}` placeholders.
    +- **Dashboard copy**: `dashboard.*` covers runtime selection, Quick Actions, configured-resource counts, lazy MCP Proxy status, and independent usage periods. Keep input/output chart abbreviations translated; unavailable proxy readback must not claim zero services or a running gateway.
     - **Parameters**: Use matching `{name}` placeholders in both locales and pass string or number values to `t`; with a params object, missing values become empty strings, while omitting params leaves placeholders intact.
     - **Locale getter**: Keep `createI18n(() => config.preferences.locale)` in context providers so lookup follows preference changes; this module does not persist the locale.
     - **Context access**: Install the translator with `setI18n` in a parent and consume it with `getI18n` in descendants; `context.ts` owns the private Symbol key.
     - **Fallback**: Preserve lookup order of selected locale, English, then the key itself.
    -- **Existing coverage**: Keep interpolation, locale-change, and unknown-key cases in `__tests__/i18n.test.ts`; its `tray.*` loop checks matching placeholder names across locales.
    +- **Existing coverage**: Keep interpolation, locale-change, and unknown-key cases in `__tests__/i18n.test.ts`; its dictionary loop checks matching keys and placeholder names across both locales.
     - **Island copy**: `island.*` covers the compact native window, public message view, permissions, questions, unknown/disconnected status, and explicit ambiguous/409 retry states; `island.compact.*` supplies short status/count labels for non-notched screens, with full accessible counts retained through the existing keys. Every screen defaults to compact; notch geometry only changes information density. `settings.island*` covers the macOS-only switch and local V2 Shared Service explanation. Closing or disabling never means rejecting requests or interrupting sessions.
     
    +Dashboard MCP Proxy runtime labels reuse `mcp.proxy.running` / `mcp.proxy.stopped`; `settings.proxy.*` contains settings copy, not gateway state labels. Keep successful running/stopped rendering covered in both locales, alongside unavailable readback.
    +
     ## Sync
     
     This file is maintained as the directory scope changes. When files in this directory are added, modified, or deleted (components, logic units, utilities, constants, etc.), update this file's directory structure, key files, and conventions to keep the documentation in sync with the code.
    diff --git a/src/i18n/__tests__/i18n.test.ts b/src/i18n/__tests__/i18n.test.ts
    index e083ddb..f7c4c19 100644
    --- a/src/i18n/__tests__/i18n.test.ts
    +++ b/src/i18n/__tests__/i18n.test.ts
    @@ -16,8 +16,9 @@ test('interpolates translations, follows locale changes, and falls back for miss
       expect(i18n.t('missing.key' as MessageKey)).toBe('missing.key');
     });
     
    -test('tray dictionaries cover matching keys and interpolation parameters in both locales', () => {
    -  const keys = Object.keys(en).filter((key) => key.startsWith('tray.')) as MessageKey[];
    +test('dictionaries cover matching keys and interpolation parameters in both locales', () => {
    +  const keys = Object.keys(en) as MessageKey[];
    +  expect(Object.keys(zh).sort()).toEqual([...keys].sort());
       expect(keys.length).toBeGreaterThan(0);
       for (const key of keys) {
         expect(zh[key]).toBeTruthy();
    diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
    index a4a9201..e68eae4 100644
    --- a/src/i18n/dictionaries/en.ts
    +++ b/src/i18n/dictionaries/en.ts
    @@ -50,7 +50,7 @@ export const en = {
       'island.matchingRequestsHint': 'May also approve this Project’s matching pending requests.',
       'island.closeApprovalOptions': 'Close approval options',
       'island.alwaysProjectHint':
    -    'Saves this action and the server-provided save patterns in the request’s OpenCode Project, not MOM’s selected folder. May also approve matching pending requests in that Project.',
    +    'Saves this action and server-provided save rules in the request’s OpenCode Project, not MOM’s folder. May also approve matching pending requests there.',
       'island.reject': 'Reject',
       'island.previousRequest': 'Previous permission request',
       'island.nextRequest': 'Next permission request',
    @@ -69,7 +69,7 @@ export const en = {
         'Message ID conflict (409). Refresh and check the session before retrying. Your draft and message ID are kept.',
       'island.discardAttempt': 'Discard this attempt',
       'island.attemptNotFound':
    -    'This attempt was not found in recent messages. It may still have been accepted. Discard clears only your local draft.',
    +    'This attempt is not in recent messages. It may still have been accepted. Discarding clears only the local draft.',
       'island.closeHint': 'Collapsing, closing, or disabling the island does not reject requests or interrupt sessions.',
       'island.questionReadOnly': 'This question cannot be answered here. Use the OpenCode terminal, or skip it.',
       'island.required': 'Required',
    @@ -82,7 +82,7 @@ export const en = {
       'island.desktopOnly': 'The island is available only in the macOS desktop application.',
       'settings.island': 'OpenCode mini island',
       'settings.islandHint':
    -    'Off by default. Shows local OpenCode V2 sessions and pending requests; enabling may start that service.',
    +    'Local OpenCode V2 sessions and pending requests. Off by default; enabling may start Shared Service.',
       'settings.islandLoading': 'Reading status…',
       'settings.islandSaving': 'Updating…',
       'settings.islandFailed': 'Could not confirm island status; showing the last confirmed setting.',
    @@ -95,16 +95,16 @@ export const en = {
       'scope.manage': 'Manage folders',
       'scope.returnGlobal': 'Return to Global',
       'scope.projectReadOnly': 'Project files · read-only · selected root only',
    -  'scope.globalReadOnly': 'Global comparison · read-only. Switch the list to Global to edit.',
    +  'scope.globalReadOnly': 'Global comparison · read-only. Switch to Global to edit.',
       'scope.showGlobal': 'Show global configuration',
    -  'scope.removeHint': 'Removing a folder only forgets it in MOM. It does not delete or change any files.',
    +  'scope.removeHint': 'Only forgets this folder in MOM; files stay unchanged.',
       'scope.removeAria': 'Remove folder {path} from MOM',
       'scope.noProjects': 'No saved folders.',
       'scope.operationFailed': 'Folder operation failed.',
       'scope.globalComparisonFailed': 'Global comparison could not be loaded.',
       'scope.empty': 'No configuration entries in this project root.',
       'scope.notFound': 'This entry was not found in the selected project root.',
    -  'scope.globalDestination': 'This form writes to Global configuration, not the selected project.',
    +  'scope.globalDestination': 'Writes to Global, not the selected project.',
       'agents.detailMetaTitle': 'Agent details · opencode-mom',
       'agents.detailTitle': 'Agent details',
       'agents.loadFailed': 'Agent configuration could not be loaded.',
    @@ -192,7 +192,7 @@ export const en = {
       'tray.status.online': 'online',
       'tray.status.unavailable': 'unavailable',
       'tray.statusHelp':
    -    'Checking runtime status may start the local OpenCode service. Checked on opening and after Reload, never in the background.',
    +    'Checks may start the local OpenCode service. Only on open and after Reload; never in the background.',
       'tray.statusOnlineOne': 'OpenCode responded successfully. {count} active session. {help}',
       'tray.statusOnlineMany': 'OpenCode responded successfully. {count} active sessions. {help}',
       'tray.statusUnavailable':
    @@ -228,7 +228,7 @@ export const en = {
       'dashboard.category': 'Category',
       'dashboard.noMappings': 'This group has no model bindings.',
       'dashboard.noGroups': 'No groups configured for this runtime.',
    -  'dashboard.chooseGroup': 'Groups are available. Switch to an enabled group to select it.',
    +  'dashboard.chooseGroup': 'Switch to an enabled group to select it.',
       'dashboard.assignMappings': 'Edit bindings',
       'dashboard.createGroup': 'Create group',
       'dashboard.switchGroup': 'Switch group',
    @@ -248,6 +248,8 @@ export const en = {
       'dashboard.tokenUsage': 'Token usage',
       'dashboard.input': 'Input',
       'dashboard.output': 'Output',
    +  'dashboard.inputShort': 'IN',
    +  'dashboard.outputShort': 'OUT',
       'dashboard.tokenUsageUnavailable': 'Token usage unavailable',
       'dashboard.retryTokenUsage': 'Retry token usage',
       'dashboard.enabledGroups': 'Enabled groups',
    @@ -284,6 +286,18 @@ export const en = {
       'dashboard.less': 'Less',
       'dashboard.more': 'More',
       'dashboard.heatmapLabel': 'Daily Input + Output tokens for {range}',
    +  'dashboard.quickActions': 'Quick actions',
    +  'dashboard.quickActionGroup': 'New group',
    +  'dashboard.quickActionProvider': 'New provider',
    +  'dashboard.quickActionModel': 'New model',
    +  'dashboard.quickActionAgent': 'New agent',
    +  'dashboard.systemStatus': 'System status',
    +  'dashboard.mcpProxy': 'MCP proxy',
    +  'dashboard.proxyServicesCount': '{count} services',
    +  'dashboard.featuredRuntime': 'Runtime workbench',
    +  'dashboard.runtimeStandby': 'Standby',
    +  'dashboard.groupsConfigured': '{enabled} enabled / {total} groups',
    +  'dashboard.usageTrend': 'Period breakdown',
     
       // providers
       'providers.metaTitle': 'Providers · opencode-mom',
    @@ -317,8 +331,8 @@ export const en = {
       'providers.showApiKey': 'Show API key',
       'providers.hideApiKey': 'Hide API key',
       'providers.headersLabel': 'Headers',
    -  'providers.headersHint': 'Optional JSON object sent with every request.',
    -  'providers.keepKeyHint': 'Leave empty to keep the currently configured key.',
    +  'providers.headersHint': 'Optional request headers (JSON object).',
    +  'providers.keepKeyHint': 'Leave empty to clear the saved key.',
       'providers.saveProvider': 'Save provider',
       'providers.saveChanges': 'Save changes',
       'providers.notFound': 'The provider does not exist or has not been loaded yet.',
    @@ -337,7 +351,7 @@ export const en = {
       'models.colName': 'Name',
       'models.colContext': 'Context',
       'models.colActions': 'Actions',
    -  'models.loading': 'Loading models from opencode CLI...',
    +  'models.loading': 'Loading models from the OpenCode service...',
       'models.loadingConfiguration': 'Loading configuration...',
       'models.editTitle': 'Edit model',
       'models.deleteTitle': 'Delete model',
    @@ -349,7 +363,7 @@ export const en = {
       'models.noProvider': 'No provider available. Create a provider first.',
       'models.editMetaTitle': 'Edit model · opencode-mom',
       'models.notFound': 'The model does not exist or has not been loaded yet.',
    -  'models.catalogFailed': 'Failed to load models via opencode CLI: {message}',
    +  'models.catalogFailed': 'Failed to load models via the OpenCode service: {message}',
       'models.retry': 'Retry',
     
       // modelForm
    @@ -369,7 +383,7 @@ export const en = {
       'modelForm.toolCall': 'Tool call',
       'modelForm.attachment': 'Attachment',
       'modelForm.experimental': 'Experimental',
    -  'modelForm.capabilitiesHint': 'Unticked capabilities stay unset and inherit the provider or opencode defaults.',
    +  'modelForm.capabilitiesHint': 'Declare tool support and input/output formats.',
       'modelForm.temperature': 'Temperature',
       'modelForm.unset': 'Unset',
       'modelForm.temperatureHint': 'Whether the model accepts a temperature setting.',
    @@ -386,16 +400,15 @@ export const en = {
       'modelForm.output': 'Output',
       'modelForm.cacheRead': 'Cache read',
       'modelForm.cacheWrite': 'Cache write',
    -  'modelForm.costHint': 'Price in USD per 1M tokens; cache rates sit on top of the base input price.',
    +  'modelForm.costHint': 'USD per 1M tokens; cache rates add to input cost.',
       'modelForm.limits': 'Limits',
       'modelForm.context': 'Context',
    -  'modelForm.limitsHint':
    -    'Token counts: context is the full window, output caps generation, input optionally caps the prompt.',
    +  'modelForm.limitsHint': 'When setting token limits, context and output are required; input is optional.',
       'modelForm.runtime': 'Runtime',
       'modelForm.options': 'Options',
    -  'modelForm.optionsHint': 'Arbitrary provider options passed to the model client.',
    +  'modelForm.optionsHint': 'Provider settings as a JSON object.',
       'modelForm.headers': 'Headers',
    -  'modelForm.headersHint': 'Extra HTTP headers, e.g. { "x-api-key": "..." }.',
    +  'modelForm.headersHint': 'Extra HTTP headers as a JSON object.',
       'modelForm.variants': 'Variants',
       'modelForm.variantName': 'Variant name',
       'modelForm.variantNamePlaceholder': 'variant-name',
    @@ -404,8 +417,7 @@ export const en = {
       'modelForm.disabled': 'disabled',
       'modelForm.removeVariant': 'Remove variant',
       'modelForm.addVariant': 'Add variant',
    -  'modelForm.variantsHint':
    -    'Variant name → options expected by opencode, e.g. { "reasoningEffort": "high" }. Use the toggle to disable a variant.',
    +  'modelForm.variantsHint': 'Name each variant; JSON may contain settings, headers, or body.',
     
       // agents
       'agents.metaTitle': 'Agents · opencode-mom',
    @@ -428,7 +440,7 @@ export const en = {
       'agents.sourceToDelete': 'Source to delete',
       'agents.sourceInline': 'Inline',
       'agents.sourceGlobalMarkdown': 'Global Markdown',
    -  'agents.sourceDeleteHint': 'Only the selected source is deleted; the other one is kept.',
    +  'agents.sourceDeleteHint': 'Deletes only this source; keeps the other.',
       'agents.newMetaTitle': 'New agent · opencode-mom',
       'agents.newTitle': 'New agent',
       'agents.agentId': 'Name',
    @@ -452,36 +464,33 @@ export const en = {
       'agents.valuePlaceholder': 'Value (JSON supported)',
       'agents.removeOption': 'Remove option',
       'agents.addOption': 'Add option',
    -  'agents.kvHintNew':
    -    'Key/value rows are the only editing entry point; the JSON below is a synchronized preview only and never becomes a second save source.',
    -  'agents.kvHintEdit': 'Key/value rows are the only editing entry point; the JSON is a synchronized preview only.',
    +  'agents.kvHintNew': 'Edit key/value rows; JSON is preview-only.',
    +  'agents.kvHintEdit': 'Edit key/value rows; JSON is preview-only.',
       'agents.dangerZone': 'Danger zone',
    -  'agents.dangerHintNew':
    -    'Cleared fields are sent explicitly through clearFields. A new agent has no existing source fields to clear yet.',
    -  'agents.dangerClearNew': 'To clear fields after creation, use the danger zone on the edit page.',
    +  'agents.dangerHintNew': 'New agents have no saved fields to clear.',
    +  'agents.dangerClearNew': 'After creation, clear fields on the edit page.',
       'agents.create': 'Create agent',
    -  'agents.promptHintResolvedNew': 'File or environment references are used; they are resolved at runtime after saving.',
    -  'agents.promptHintResolved': 'File or environment references are used; they are resolved at runtime.',
    +  'agents.promptHintResolvedNew': 'File and environment references resolve at runtime after saving.',
    +  'agents.promptHintResolved': 'File and environment references resolve at runtime.',
       'agents.promptHintSupport': 'Supports {file:path/to/prompt.md} and {env:VARIABLE_NAME}.',
       'agents.editMetaTitle': 'Edit agent · opencode-mom',
       'agents.editTitle': 'Edit agent',
    -  'agents.editNotice': 'Editing writes only to the selected source; the effective view is never written back.',
    +  'agents.editNotice': 'Only edits this source; never writes back the effective view.',
       'agents.editSource': 'Edit source',
       'agents.pathLabel': 'Path: {path}.',
    -  'agents.noPath': 'no path provided by the DTO',
    -  'agents.pathNotProvided': 'not provided by the DTO',
    -  'agents.sourceMissing': 'The selected source is not provided by the DTO; it cannot be written safely.',
    -  'agents.sourceCannotEdit': 'The selected source was not provided in the DTO sources; it cannot be edited safely.',
    -  'agents.effectiveOverrides': 'Effective overrides: the effective view is read-only and never saved.',
    +  'agents.noPath': 'Path unavailable',
    +  'agents.pathNotProvided': 'Path unavailable',
    +  'agents.sourceMissing': 'Source unavailable; refresh before editing.',
    +  'agents.sourceCannotEdit': 'Source unavailable; refresh before editing.',
    +  'agents.effectiveOverrides': 'Effective values are read-only; never saved.',
       'agents.clearInherit': 'Clear / inherit',
       'agents.dangerClearTitle': 'Danger zone: clear fields of the selected source',
    -  'agents.dangerClearHint':
    -    'Checked fields are embedded into mutation.clearFields; effective values are never copied back into the source.',
    +  'agents.dangerClearHint': 'Clear selected source fields; effective values are not copied back.',
       'agents.clearField': 'Clear {field}',
       'agents.sourcePreview': 'Source preview',
       'agents.effectiveConfig': 'Effective configuration (read-only)',
    -  'agents.rawProvided': 'The following is the real raw content provided by the DTO.',
    -  'agents.rawNotProvided': 'The DTO provides no raw content; the following is the field snapshot of this source only.',
    +  'agents.rawProvided': 'Original source content.',
    +  'agents.rawNotProvided': 'Original content unavailable; showing source fields only.',
       'agents.notFound': 'The agent does not exist or has not been loaded yet.',
       'agents.saveChanges': 'Save changes',
       'agents.identity': 'Identity',
    @@ -496,8 +505,8 @@ export const en = {
       'agents.optionUnavailable': '{name} (unavailable)',
       'agents.descriptionRequired': 'Required by opencode.',
       'agents.colorPlaceholder': '#RRGGBB or theme name',
    -  'agents.hiddenHint': 'Only applies when Mode is subagent.',
    -  'agents.disableHint': 'Disabled agents are kept in config but not loaded by opencode.',
    +  'agents.hiddenHint': 'Subagents only.',
    +  'agents.disableHint': 'Kept in config; not loaded by OpenCode.',
       'agents.sourceTabLabel': 'Agent source',
       'agents.tabCustom': 'Custom ({count})',
       'agents.tabNative': 'Native ({count})',
    @@ -513,16 +522,13 @@ export const en = {
       'agents.variantSelectModel': 'Select a model to choose a variant.',
       'agents.variantNoVariants': 'This model reports no variants.',
       'agents.variantFromModel': 'Variants are read from the selected model.',
    -  'agents.promptHintMarkdown': 'For markdown agents the file body is the system prompt.',
    -  'agents.promptHintInline':
    -    'Inline agents support {file:path/to/prompt.md} and {env:VARIABLE_NAME} references, resolved at runtime.',
    +  'agents.promptHintMarkdown': 'The Markdown body is the system prompt.',
    +  'agents.promptHintInline': 'Supports {file:path/to/prompt.md} and {env:VARIABLE_NAME}; resolved at runtime.',
       'agents.permissions': 'Permissions',
       'agents.permissionsJson': 'Permissions JSON',
    -  'agents.permissionsGlobHint':
    -    'Some permission values are glob rules. Edit them with the JSON below so those rules are preserved.',
    +  'agents.permissionsGlobHint': 'Edit glob rules in the JSON below to preserve them.',
       'agents.permissionsInvalid': 'Permissions must be an array of {action, resource, effect} rules.',
    -  'agents.permissionsHint':
    -    'Values are allow, ask or deny. A key may map to a glob-to-action object; the last matching rule wins.',
    +  'agents.permissionsHint': 'Use allow, ask, or deny; the last matching rule wins.',
     
       // groups
       'groups.metaTitle': 'Groups · opencode-mom',
    @@ -543,13 +549,14 @@ export const en = {
       'groups.loading': 'Loading configuration...',
       'groups.enabled': 'Enabled',
       'groups.disabled': 'Disabled',
    +  'groups.enableAria': 'Enable {name}',
    +  'groups.disableAria': 'Disable {name}',
       'groups.switchTo': 'Switch to {name}',
       'groups.switchTitle': 'Switch group',
       'groups.reloadTitle': 'Reload opencode now?',
       'groups.reloadDescription':
    -    '{name} is active. Reload opencode to pick up this group’s settings. The switch is already applied; Cancel only closes this prompt.',
    -  'groups.reloadWarning':
    -    'Reloading may cancel pending permission or form requests. You can reload later from the sidebar.',
    +    '{name} is saved and active. Reload OpenCode to apply; Later or Cancel does not undo the switch.',
    +  'groups.reloadWarning': 'May cancel pending permission or form requests. Reload later from the sidebar.',
       'groups.reloadLater': 'Later',
       'groups.reloadNow': 'Reload now',
       'groups.reloading': 'Reloading…',
    @@ -592,17 +599,15 @@ export const en = {
       'groupForm.noModelCatalog': 'No model is available in the catalog. Add a provider model before mapping.',
     
       // feedback
    -  'feedback.conflictDescription': 'The local draft is kept in the current form and was not overwritten.',
    +  'feedback.conflictDescription': 'Your draft is kept in this form; nothing was overwritten.',
       'feedback.keepDraftReload': 'Keep draft and reload',
       'feedback.reviewMerge': 'Review and merge manually',
       'feedback.discardRefresh': 'Discard draft and refresh',
    -  'feedback.busyDescription':
    -    'Another configuration operation is still running. The local draft is kept; retry in the original form later.',
    +  'feedback.busyDescription': 'Another operation is running. Draft kept; retry in the original form later.',
       'feedback.continueEditing': 'Continue editing',
       'feedback.referencesDescription': 'Remove the references first.',
       'feedback.backToDraft': 'Back to draft',
    -  'feedback.preservedDescription':
    -    'The request content is preserved; no automatic merge was performed. Review it in the original form and retry.',
    +  'feedback.preservedDescription': 'Content kept without auto-merge. Review in the original form and retry.',
       'feedback.retryDescription': 'Review the current page and retry.',
     
       // toast
    @@ -613,7 +618,7 @@ export const en = {
       'toast.saveFailedAgent': 'Save failed. Check the configuration state.',
       'toast.saveFailed': 'Save failed.',
       'toast.fixFields': 'Fix the highlighted fields before saving.',
    -  'toast.catalogFailed': 'Failed to load models via opencode CLI: {message}',
    +  'toast.catalogFailed': 'Failed to load models via the OpenCode service: {message}',
       'toast.retry': 'Retry',
     
       // splash
    @@ -675,14 +680,13 @@ export const en = {
       'mcp.deleteAria': 'Delete {name}',
       'mcp.deleteTitle': 'Delete MCP server',
       'mcp.deleteConfirm': 'Delete {name} from all global configuration sources listed below? This cannot be undone.',
    -  'mcp.deleteHint':
    -    'Reload OpenCode manually after deletion to apply the change. Reloading may cancel pending requests.',
    +  'mcp.deleteHint': 'Reload OpenCode manually after deletion; this may cancel pending requests.',
       'mcp.deleteFailed': 'Deletion did not complete. Review the error, refresh the list and retry.',
       'mcp.removed': 'Removed from global configuration. Reload OpenCode to apply.',
       'mcp.nativeConfig': 'Native configuration',
       'mcp.secretsHidden': 'Header and environment values, client secrets and other secret fields are masked.',
       'mcp.credentialHint':
    -    'Header and environment values, known credential fields, URL credentials and explicit credential arguments are masked. Custom formats may still contain sensitive values.',
    +    'Headers, environment values, known credential fields, URL credentials, and explicit credential arguments are masked. Custom formats may still contain sensitive values.',
       'mcp.noConfig': 'No configuration fields.',
       'skills.metaTitle': 'Skills · opencode-mom',
       'skills.detailMetaTitle': 'Skill details · opencode-mom',
    @@ -702,15 +706,23 @@ export const en = {
       'settings.eyebrow': 'APPLICATION / SETTINGS',
       'settings.title': 'Settings',
       'settings.description': 'Desktop app settings.',
    +  'settings.searchLabel': 'Search settings',
    +  'settings.searchPlaceholder': 'Find a setting, e.g. port, CLI, password…',
    +  'settings.searchHint': 'Search settings; select a result to jump.',
    +  'settings.searchClear': 'Clear search',
    +  'settings.searchResults': 'Matching settings',
    +  'settings.searchCount': '{count} matching settings',
    +  'settings.searchEmpty': 'No settings match. Try another keyword or clear search.',
    +  'settings.appBehavior': 'App behavior',
       'settings.autostart': 'Launch at login',
    -  'settings.autostartHint': 'Start opencode-mom at sign-in. Desktop app only.',
    +  'settings.autostartHint': 'Start at sign-in (desktop only).',
       'settings.loading': 'Reading status…',
       'settings.saving': 'Updating…',
       'settings.loadFailed': 'Launch-at-login status unavailable; the switch stays disabled until it can be read.',
       'settings.saveFailed': 'Could not update launch at login; showing the last confirmed status.',
       'settings.autostartSaved': 'Launch at login updated.',
       'settings.opencodeTitle': 'OpenCode V2',
    -  'settings.opencodeHint': 'Updates the local OpenCode CLI; separate from MOM app updates.',
    +  'settings.opencodeHint': 'Local CLI updates, separate from MOM updates.',
       'settings.opencodeDesktopOnly': 'Available in the desktop app.',
       'settings.opencodeInstallation': 'Local CLI installation',
       'settings.opencodeVersion': 'CLI version',
    @@ -739,7 +751,7 @@ export const en = {
       'settings.opencodeReason.ambiguous': 'Install method could not be confirmed; update the binary above manually.',
       'settings.opencodeManualCommand': 'Run this in a terminal yourself, then refresh.',
       'settings.opencodeUpdates': 'CLI updates',
    -  'settings.opencodeCheckHint': 'Checks the latest stable release on click only; nothing installs automatically.',
    +  'settings.opencodeCheckHint': 'Check the latest stable release on click; no automatic install.',
       'settings.opencodeCheck': 'Check CLI updates',
       'settings.opencodeRetryCheck': 'Retry CLI update check',
       'settings.opencodeAvailable': 'OpenCode {version} is available.',
    @@ -754,15 +766,15 @@ export const en = {
       'settings.opencodeError.readback':
         'Upgrade command finished, but details could not be read back. Refresh to confirm; do not upgrade again yet.',
       'settings.opencodeUpgraded': 'Upgrade completed. CLI version: {version}.',
    -  'settings.opencodeRunningHint': 'A running service may still use the old version; this does not restart it.',
    +  'settings.opencodeRunningHint': 'Does not restart services; they may still use an older version.',
       'settings.opencodeConfirmTitle': 'Upgrade the local OpenCode CLI?',
    -  'settings.opencodeConfirmDescription': 'Review the binary and version change before continuing.',
    +  'settings.opencodeConfirmDescription': 'Confirm the exact binary path and version change.',
       'settings.opencodeVersionChange': 'Version change',
       'settings.opencodeUpgradeWarning':
         '🔒 Changes only the local CLI above. Does not update MOM, touch bundled CLIs, or restart services. No sudo.',
       'settings.opencodeConfirm': 'Confirm CLI upgrade',
       'settings.serviceTitle': 'Local service connection',
    -  'settings.serviceReadHint': 'Reads the selected endpoint only; refresh never starts or restarts a service.',
    +  'settings.serviceReadHint': 'Probes the saved target only; no service start or restart.',
       'settings.serviceRefresh': 'Refresh connection',
       'settings.serviceReading': 'Reading connection…',
       'settings.serviceConnected': 'Connected',
    @@ -774,34 +786,33 @@ export const en = {
       'settings.serviceVersion': 'Backend version',
       'settings.serviceMode': 'Connection target',
       'settings.serviceShared': 'Default Shared Service',
    -  'settings.serviceSharedHint': 'Uses OpenCode’s Shared Service; monitoring may start it when needed.',
    +  'settings.serviceSharedHint': 'Uses Shared Service; monitoring may start it when needed.',
       'settings.serviceExplicit': 'Fixed local endpoint',
    -  'settings.serviceExplicitHint': 'Uses one fixed OpenCode backend; no fallback or auto-start.',
    +  'settings.serviceExplicitHint': 'Fixed OpenCode backend; no fallback or auto-start.',
       'settings.serviceUrl': 'OpenCode backend URL',
       'settings.serviceUrlHint':
    -    'Direct OpenCode V2 API: http://127.0.0.1, [::1] or localhost with a port; no path or credentials. Not OpenChamber’s UI URL (usually port 3000).',
    +    'Direct OpenCode V2 API: http://127.0.0.1, [::1], or localhost, port 1–65535. No path, URL credentials, query, or fragment. Not OpenChamber UI (usually :3000).',
       'settings.servicePassword': 'Service password (optional)',
       'settings.servicePasswordHint':
    -    'Backend password for Basic auth, not an OpenChamber UI password. Leave blank to reuse the saved password for this URL.',
    +    'Backend Basic password, not an OpenChamber UI password; no Bearer fallback. Blank keeps this URL’s saved password; without one, no Authorization is sent.',
       'settings.servicePasswordSaved': 'A password is saved for this target; it is never shown.',
       'settings.serviceClearPassword': 'Clear the saved password when saving',
       'settings.serviceCredentialWarning':
    -    '🔒 Unix stores passwords in plaintext in a 0600 file; Windows uses DPAPI. Use a dedicated backend password.',
    +    '🔒 Unix stores passwords as plaintext in an account-owned 0600 file; Windows uses current-account DPAPI. Use a dedicated backend password; neither protects against account compromise.',
       'settings.serviceSave': 'Save connection target',
       'settings.serviceSaving': 'Saving connection target…',
       'settings.serviceSaveFailed': 'Could not save; the saved target is unchanged and your draft is kept.',
       'settings.serviceSaveBusy': 'A local-service operation is running; wait, then retry. Nothing was changed.',
       'settings.serviceSaveValidation':
    -    'Check the direct V2 loopback URL, port and password, then retry. Nothing was changed.',
    +    'Check the direct V2 HTTP loopback URL, port 1–65535, and backend password; retry. Nothing changed.',
       'settings.serviceReadFailed':
         'Could not confirm the connection. Check the backend is running and the password is correct, then refresh.',
       'settings.serviceSaved': 'Target saved; connected to the backend.',
    -  'settings.serviceSavedOffline': 'Target saved, but the backend is offline; nothing was started.',
    +  'settings.serviceSavedOffline': 'Target saved; connection not confirmed.',
       'settings.updates': 'App updates',
       'settings.installedVersion': 'Installed version: {version}',
       'settings.versionUnavailable': 'unavailable',
    -  'settings.updatesHint':
    -    'Checks GitHub on click only; downloads open in your browser and nothing installs automatically.',
    +  'settings.updatesHint': 'Check GitHub on click; downloads open in your browser, never auto-install.',
       'settings.appInfoFailed': 'Could not read app details.',
       'settings.retryAppInfo': 'Retry app details',
       'settings.checkUpdates': 'Check for updates',
    @@ -814,8 +825,7 @@ export const en = {
       'settings.releaseDownloads': 'View GitHub downloads',
       'settings.macInstallGuide': 'Installing an update on macOS',
       'settings.macSigning': 'This build is ad-hoc signed, not notarized by Apple. Gatekeeper may block the first launch.',
    -  'settings.macTrustWarning':
    -    '🔒 Only proceed with the official download. Never bypass warnings for unknown downloads.',
    +  'settings.macTrustWarning': '🔒 Use only the official download. Never bypass warnings for unknown downloads.',
       'settings.macDownloadOfficial': 'Download only from the official GitHub Releases page.',
       'settings.macReplaceApp':
         'Quit from the tray menu (closing the window only hides it), replace the app in Applications, then open it.',
    @@ -831,7 +841,7 @@ export const en = {
       'settings.starProject': 'Star on GitHub',
       'settings.lid': 'Stay awake with lid closed',
       'settings.lidHint':
    -    'Keeps your Mac awake while monitored sessions work. Off by default; needs macOS administrator authorization.',
    +    'Keeps Mac awake while monitored sessions work. Off by default; macOS admin authorization required.',
       'settings.lidLoading': 'Reading status…',
       'settings.lidDisabled': 'Disabled',
       'settings.lidIdle': 'Watching — no working sessions',
    @@ -851,7 +861,7 @@ export const en = {
       'settings.lidWait': 'Keep MOM running; wait for Protected before closing the lid.',
       'settings.lidDetails': 'Authorization and safety',
       'settings.lidAuthorization': 'macOS asks for administrator authorization once per app run for a native helper.',
    -  'settings.lidScope': 'Only the CLI-connected OpenCode service is monitored.',
    +  'settings.lidScope': 'Only the selected local OpenCode service is monitored.',
       'settings.lidRestore':
         'Original power settings are restored when work ends or you turn this off. This never forces sleep.',
       'settings.lidSafety': 'Closed and awake uses more battery and can get hot. Never use it in a closed bag.',
    @@ -863,18 +873,17 @@ export const en = {
       'configuration.saving': 'Saving…',
       'configuration.saved': 'Saved to global configuration. Reload OpenCode manually to apply.',
       'configuration.draftKept': 'Save failed. Your draft is kept.',
    -  'configuration.manualReload':
    -    'Saving does not reload OpenCode. Reload manually when ready; reloading may cancel active requests.',
    +  'configuration.manualReload': 'Saving does not reload OpenCode. Reload manually; pending requests may be cancelled.',
       'mcp.newTitle': 'New MCP',
       'mcp.newMetaTitle': 'New MCP · opencode-mom',
       'mcp.editTitle': 'Edit MCP',
    -  'mcp.newDescription': 'Add a server to global configuration. Local runs a command; Remote uses a URL.',
    -  'mcp.nameHint': 'The server name is its identifier and cannot be renamed after creation.',
    -  'mcp.typeHint': 'Changing type preserves existing fields. Review the full JSON before saving.',
    -  'mcp.editCredentials': 'Editing shows the saved configuration, including credentials.',
    +  'mcp.newDescription': 'Add to Global. Local runs a command; Remote uses a URL.',
    +  'mcp.nameHint': 'Unique server ID; cannot be renamed after creation.',
    +  'mcp.typeHint': 'Changing type keeps fields; review JSON before saving.',
    +  'mcp.editCredentials': 'Editing reveals the saved configuration, including credentials.',
       'mcp.jsonLabel': 'Native configuration (JSON)',
       'mcp.jsonHint':
    -    'Use a JSON object, not JSONC. Local: command is an array of strings; environment and cwd are optional. Remote: set url; headers and oauth are optional. Set disabled to true or false. Unknown fields are preserved.',
    +    'JSON object, not JSONC. Local: command string array; optional environment/cwd. Remote: url required; optional headers/oauth. disabled: boolean. Other fields are kept.',
       'mcp.jsonError': 'Enter a valid JSON object. Comments, arrays and trailing commas are not supported.',
       'mcp.nameRequired': 'Enter a server name.',
       'mcp.typeRequired': 'Set type to local or remote.',
    @@ -882,23 +891,143 @@ export const en = {
       'mcp.urlRequired': 'Remote configuration needs a URL.',
       'skills.local': 'Local',
       'skills.remote': 'Remote',
    -  'skills.remoteHint':
    -    'This Remote skill is read-only. Its content is managed by its source; it cannot be edited here.',
    +  'skills.remoteHint': 'Remote content is source-managed and read-only here.',
       'skills.newTitle': 'New skill',
       'skills.newMetaTitle': 'New skill · opencode-mom',
       'skills.editTitle': 'Edit skill',
    -  'skills.newDescription': 'Create a Local skill in the user-global skills directory, not the current project.',
    +  'skills.newDescription': 'Create a Local skill in the user-global directory, not this project.',
       'skills.idHint':
    -    'ID sets the folder: /SKILL.md. Use 1–128 ASCII letters, digits, hyphens or underscores. The frontmatter name is a separate display label.',
    -  'skills.idImmutable': 'ID and file path cannot be renamed here. Change the display name in YAML frontmatter.',
    +    'Folder: /SKILL.md. ID: 1–128 ASCII letters, digits, hyphens, or underscores. Frontmatter name is the display label.',
    +  'skills.idImmutable': 'ID/path cannot be renamed here; edit frontmatter name for the display label.',
       'skills.idRequired': 'Enter a skill ID.',
       'skills.documentLabel': 'Full Markdown document',
       'skills.documentHint':
         'Edit the full document, including optional YAML frontmatter. name and description are display metadata; metadata.opencode/autoinvoke controls autoinvoke. No HTML preview is rendered.',
       'mcp.v2ConfigHint':
    -    'Optional timeout is an object with startup, catalog and execution values in positive integer milliseconds, not a number. OAuth uses client_id, client_secret, scope, callback_port, redirect_uri and auth_server_metadata_url.',
    +    'timeout: optional object of positive integer milliseconds (startup/catalog/execution), not a number. OAuth: client_id, client_secret, scope, callback_port, redirect_uri, auth_server_metadata_url.',
       'skills.documentManagementHint':
    -    'Edit the full Markdown document, including optional YAML frontmatter. name is the display label; description helps the model discover the skill. Without invocation metadata, autoinvoke is allowed. Existing metadata.opencode/autoinvoke and disable-model-invocation settings are preserved as written. No HTML preview is rendered.',
    +    'Edit Markdown + optional YAML frontmatter. name: label; description: model discovery. Without invocation metadata, autoinvoke is allowed. metadata.opencode/autoinvoke and disable-model-invocation stay as written. No HTML preview.',
    +
    +  // MCP tabs & proxy
    +  'mcp.tabConfig': 'Configuration',
    +  'mcp.tabProxy': 'MCP Proxy',
    +  'mcp.proxy.eyebrow': 'CONFIG / MCP PROXY',
    +  'mcp.proxy.title': 'MCP Proxy',
    +  'mcp.proxy.description':
    +    'Built-in MCP HTTP gateway for running local and remote MCP servers through a managed proxy.',
    +  'mcp.proxy.statusTitle': 'Gateway Status',
    +  'mcp.proxy.running': 'Running',
    +  'mcp.proxy.stopped': 'Stopped',
    +  'mcp.proxy.actualState': 'Actual State',
    +  'mcp.proxy.desiredState': 'Desired State',
    +  'mcp.proxy.boundAddr': 'Bound Address',
    +  'mcp.proxy.port': 'Port',
    +  'mcp.proxy.allowLan': 'LAN Access',
    +  'mcp.proxy.lanEnabled': 'Enabled (Exposed to local network)',
    +  'mcp.proxy.lanDisabled': 'Disabled (Loopback only)',
    +  'mcp.proxy.activeUnits': 'Active Units',
    +  'mcp.proxy.availableUnits': 'Available Units',
    +  'mcp.proxy.maxUnits': 'Max Units',
    +  'mcp.proxy.activeSessions': 'Active Sessions',
    +  'mcp.proxy.revision': 'Revision',
    +  'mcp.proxy.generation': 'Generation',
    +  'mcp.proxy.securityErrors': 'Security Warnings',
    +  'mcp.proxy.serversTitle': 'Proxy Servers',
    +  'mcp.proxy.addServer': 'Add Server',
    +  'mcp.proxy.importServer': 'Import Server',
    +  'mcp.proxy.noServers': 'No proxy servers configured.',
    +  'mcp.proxy.serverName': 'Server Name',
    +  'mcp.proxy.protocol': 'Protocol',
    +  'mcp.proxy.status': 'Status',
    +  'mcp.proxy.sessions': 'Sessions',
    +  'mcp.proxy.actions': 'Actions',
    +  'mcp.proxy.editServer': 'Edit',
    +  'mcp.proxy.deleteServer': 'Delete',
    +  'mcp.proxy.deleteConfirm': 'Delete proxy server {name}? This ends its active sessions.',
    +  'mcp.proxy.deleteFailed': 'Failed to delete proxy server.',
    +  'mcp.proxy.closeSessions': 'Close Sessions',
    +  'mcp.proxy.closeUnit': 'Close Unit',
    +  'mcp.proxy.closeUnitPrompt': 'Enter unit or session ID to close:',
    +  'mcp.proxy.closeUnitFailed': 'Failed to close unit.',
    +  'mcp.proxy.toggleFailed': 'Failed to update server status.',
    +  'mcp.proxy.loadFailed': 'Failed to load MCP proxy state.',
    +  'mcp.proxy.refresh': 'Refresh',
    +
    +  // MCP proxy form / dialog
    +  'mcp.proxy.form.createTitle': 'Add Proxy Server',
    +  'mcp.proxy.form.editTitle': 'Edit Proxy Server',
    +  'mcp.proxy.form.id': 'Server ID',
    +  'mcp.proxy.form.idHint': 'Route ID: 1–64 ASCII letters, digits, hyphens, or underscores; unique.',
    +  'mcp.proxy.form.name': 'Display Name',
    +  'mcp.proxy.form.enabled': 'Enabled',
    +  'mcp.proxy.form.command': 'Command & Arguments',
    +  'mcp.proxy.form.commandHint': 'One argument per line; executable first. No shell parsing.',
    +  'mcp.proxy.form.cwd': 'Working Directory',
    +  'mcp.proxy.form.cwdHint': 'Required absolute working directory.',
    +  'mcp.proxy.form.env': 'Environment Variables',
    +  'mcp.proxy.form.envHint': 'One KEY=VALUE per line.',
    +  'mcp.proxy.form.protocol': 'Protocol',
    +  'mcp.proxy.form.protocolLegacy': 'Legacy (STDIO / JSON-RPC)',
    +  'mcp.proxy.form.protocolModern': 'Modern (HTTP / SSE)',
    +  'mcp.proxy.form.protocolAuto': 'Auto-detect',
    +  'mcp.proxy.form.startupTimeout': 'Startup Timeout (seconds)',
    +  'mcp.proxy.form.executionTimeout': 'Execution Timeout (seconds)',
    +  'mcp.proxy.form.save': 'Save Server',
    +  'mcp.proxy.form.saving': 'Saving…',
    +  'mcp.proxy.form.cancel': 'Cancel',
    +  'mcp.proxy.form.saveFailed': 'Failed to save proxy server.',
    +  'mcp.proxy.form.validationId': 'Server ID must be a non-empty alphanumeric slug.',
    +  'mcp.proxy.form.validationCommand': 'Command must not be empty.',
    +  'mcp.proxy.import.title': 'Import MCP Server',
    +  'mcp.proxy.import.select': 'Select existing MCP server to import',
    +  'mcp.proxy.import.empty': 'No existing MCP servers found to import.',
    +  'mcp.proxy.import.submit': 'Import',
    +  'mcp.proxy.import.failed': 'Failed to import MCP server.',
    +
    +  // Settings tabs & MCP Proxy settings
    +  'settings.tabGeneral': 'General',
    +  'settings.tabMcpProxy': 'MCP Proxy',
    +  'settings.http.title': 'MOM HTTP Service',
    +  'settings.http.description': 'MOM HTTP listener, also used by MCP Proxy.',
    +  'settings.http.port': 'Port',
    +  'settings.http.portHint': '1–65535; default 18765.',
    +  'settings.http.allowLan': 'Allow LAN',
    +  'settings.http.allowLanHint': 'Listen on 0.0.0.0 instead of loopback 127.0.0.1.',
    +  'settings.http.allowLanWarning':
    +    'SECURITY WARNING: LAN access exposes MOM HTTP to your local network. Devices with the secret token can execute MCP Proxy commands and tools. Enable only on trusted networks.',
    +  'settings.http.statusUnknown': 'Status unknown',
    +  'settings.http.refresh': 'Refresh HTTP Status',
    +  'settings.http.saveSettings': 'Save HTTP Settings',
    +  'settings.http.saveSuccess': 'MOM HTTP settings saved.',
    +  'settings.proxy.title': 'MCP Proxy Gateway',
    +  'settings.proxy.description': 'MCP Proxy capacity, timeouts, and token.',
    +  'settings.proxy.enableGateway': 'Enable MCP Gateway',
    +  'settings.proxy.enableGatewayHint': 'Enable MCP Proxy; the MOM HTTP listener stays separate.',
    +  'settings.proxy.httpHint': 'Port and LAN access: General settings.',
    +  'settings.proxy.idleTimeout': 'Session Idle Timeout (seconds)',
    +  'settings.proxy.idleTimeoutHint': 'Reclaim units and sessions after this idle time.',
    +  'settings.proxy.maxUnits': 'Maximum Concurrent Units',
    +  'settings.proxy.maxUnitsHint': 'Caps concurrent execution units.',
    +  'settings.proxy.saveSettings': 'Save Gateway Settings',
    +  'settings.proxy.saving': 'Saving…',
    +  'settings.proxy.saveSuccess': 'MCP Proxy settings saved.',
    +  'settings.proxy.saveFailed': 'Failed to save MCP Proxy settings.',
    +  'settings.proxy.loadFailed': 'Failed to load MCP Proxy settings.',
    +  'settings.proxy.rotateKey': 'Rotate Secret Token…',
    +  'settings.proxy.rotateKeyTitle': 'Rotate MCP Proxy Secret Token',
    +  'settings.proxy.rotateKeyDescription':
    +    'A new token invalidates the old one and immediately ends active client sessions. Update your clients.',
    +  'settings.proxy.rotateKeyConfirm': 'Generate New Token',
    +  'settings.proxy.rotateKeySuccess': 'New Secret Token Generated',
    +  'settings.proxy.rotateKeyWarning': 'Shown once. Copy now; unavailable after closing.',
    +  'settings.proxy.copyKey': 'Copy Token',
    +  'settings.proxy.copied': 'Copied!',
    +  'settings.proxy.close': 'Close',
    +  'settings.proxy.statusRunning': 'Running',
    +  'settings.proxy.statusStopped': 'Stopped',
    +  'settings.proxy.actualState': 'Actual state: {state}',
    +  'settings.proxy.boundAddr': 'Bound address: {addr}',
    +  'settings.proxy.revisionGen': 'Revision {revision} · Generation {generation}',
     } as const;
     
     export type MessageKey = keyof typeof en;
    diff --git a/src/i18n/dictionaries/zh.ts b/src/i18n/dictionaries/zh.ts
    index 7352284..83c706e 100644
    --- a/src/i18n/dictionaries/zh.ts
    +++ b/src/i18n/dictionaries/zh.ts
    @@ -51,7 +51,7 @@ export const zh: Record = {
       'island.matchingRequestsHint': '也可能批准此 Project 中匹配的待处理请求。',
       'island.closeApprovalOptions': '关闭授权选项',
       'island.alwaysProjectHint':
    -    '将当前操作和服务端提供的 save 匹配规则保存到请求所属的 OpenCode Project,而非 MOM 当前选择的文件夹。也可能批准该 Project 中匹配的待处理请求。',
    +    '将操作和服务端规则保存到请求所属的 OpenCode Project,而非 MOM 所选文件夹;也可能批准该 Project 中匹配的待处理请求。',
       'island.reject': '拒绝',
       'island.previousRequest': '上一项权限请求',
       'island.nextRequest': '下一项权限请求',
    @@ -68,7 +68,7 @@ export const zh: Record = {
       'island.sendUncertain': '消息可能已被接收。重试前请先刷新。',
       'island.sendConflict': '消息 ID 冲突(409)。重试前请先刷新并核对会话。草稿和消息 ID 已保留。',
       'island.discardAttempt': '放弃此次提交',
    -  'island.attemptNotFound': '最近消息中未找到此次提交,但它仍可能已被接收。放弃只会清空本地草稿。',
    +  'island.attemptNotFound': '最近消息中没有此次提交,但它仍可能已被接收。放弃只会清空本地草稿。',
       'island.closeHint': '收起、关闭或停用浮岛不会拒绝请求,也不会中断会话。',
       'island.questionReadOnly': '此问题无法在这里回答。请使用 OpenCode 终端,或跳过。',
       'island.required': '必填',
    @@ -80,7 +80,7 @@ export const zh: Record = {
       'island.skip': '跳过',
       'island.desktopOnly': '浮岛仅适用于 macOS 桌面应用。',
       'settings.island': 'OpenCode 迷你浮岛',
    -  'settings.islandHint': '默认关闭。仅显示本机 OpenCode V2 的会话和待处理请求;启用时可能启动该服务。',
    +  'settings.islandHint': '显示本地 OpenCode V2 会话和待处理请求。默认关闭;启用时可能启动 Shared Service。',
       'settings.islandLoading': '正在读取状态…',
       'settings.islandSaving': '正在更新…',
       'settings.islandFailed': '无法确认浮岛状态,当前显示上次确认的设置。',
    @@ -93,16 +93,16 @@ export const zh: Record = {
       'scope.manage': '管理文件夹',
       'scope.returnGlobal': '返回 Global',
       'scope.projectReadOnly': '项目文件 · 只读 · 仅所选根目录',
    -  'scope.globalReadOnly': 'Global 对照 · 只读。请在列表切换到 Global 后编辑。',
    +  'scope.globalReadOnly': 'Global 对照 · 只读。切换到 Global 后编辑。',
       'scope.showGlobal': '显示 Global 配置',
    -  'scope.removeHint': '移除文件夹只会让 MOM 忘记它,不会删除或修改任何文件。',
    +  'scope.removeHint': '仅从 MOM 移除记录,不删除或修改文件。',
       'scope.removeAria': '从 MOM 移除文件夹 {path}',
       'scope.noProjects': '没有已保存的文件夹。',
       'scope.operationFailed': '文件夹操作失败。',
       'scope.globalComparisonFailed': '未能加载 Global 对照。',
       'scope.empty': '此项目根目录中没有配置条目。',
       'scope.notFound': '在所选项目根目录中找不到此条目。',
    -  'scope.globalDestination': '此表单写入 Global 配置,而非所选项目。',
    +  'scope.globalDestination': '写入 Global,而非所选项目。',
       'agents.detailMetaTitle': 'Agent 详情 · opencode-mom',
       'agents.detailTitle': 'Agent 详情',
       'agents.loadFailed': '未能加载 Agent 配置。',
    @@ -189,7 +189,7 @@ export const zh: Record = {
       'tray.status.checking': '正在检查…',
       'tray.status.online': '在线',
       'tray.status.unavailable': '不可用',
    -  'tray.statusHelp': '检查运行时状态可能启动本地 OpenCode 服务。仅在打开托盘和重新加载后检查,不会在后台检查。',
    +  'tray.statusHelp': '仅打开托盘和 Reload 后检查,不在后台检查;可能启动本地 OpenCode 服务。',
       'tray.statusOnlineOne': 'OpenCode 响应成功。{count} 个活跃会话。{help}',
       'tray.statusOnlineMany': 'OpenCode 响应成功。{count} 个活跃会话。{help}',
       'tray.statusUnavailable': '无法检查运行时状态:{message} 这并不表示 OpenCode 已离线。{help}',
    @@ -224,7 +224,7 @@ export const zh: Record = {
       'dashboard.category': '分类',
       'dashboard.noMappings': '此分组没有模型绑定。',
       'dashboard.noGroups': '此运行时尚未配置分组。',
    -  'dashboard.chooseGroup': '已有可用分组。切换到已启用的分组以选中它。',
    +  'dashboard.chooseGroup': '切换到已启用的分组以选中它。',
       'dashboard.assignMappings': '编辑绑定',
       'dashboard.createGroup': '创建分组',
       'dashboard.switchGroup': '切换分组',
    @@ -244,6 +244,8 @@ export const zh: Record = {
       'dashboard.tokenUsage': 'Token 用量',
       'dashboard.input': '输入',
       'dashboard.output': '输出',
    +  'dashboard.inputShort': '输入',
    +  'dashboard.outputShort': '输出',
       'dashboard.tokenUsageUnavailable': 'Token 用量不可用',
       'dashboard.retryTokenUsage': '重试 Token 用量',
       'dashboard.enabledGroups': '已启用分组',
    @@ -280,6 +282,18 @@ export const zh: Record = {
       'dashboard.less': '少',
       'dashboard.more': '多',
       'dashboard.heatmapLabel': '{range} 的每日输入 + 输出 Token',
    +  'dashboard.quickActions': '快捷操作',
    +  'dashboard.quickActionGroup': '新建分组',
    +  'dashboard.quickActionProvider': '新建服务商',
    +  'dashboard.quickActionModel': '新建模型',
    +  'dashboard.quickActionAgent': '新建智能体',
    +  'dashboard.systemStatus': '系统状态',
    +  'dashboard.mcpProxy': 'MCP 代理',
    +  'dashboard.proxyServicesCount': '{count} 个服务',
    +  'dashboard.featuredRuntime': '运行时工作台',
    +  'dashboard.runtimeStandby': '待命',
    +  'dashboard.groupsConfigured': '{enabled} 已启用 / 共 {total} 个分组',
    +  'dashboard.usageTrend': '周期分布',
     
       // providers
       'providers.metaTitle': '服务商 · opencode-mom',
    @@ -313,8 +327,8 @@ export const zh: Record = {
       'providers.showApiKey': '显示 API 密钥',
       'providers.hideApiKey': '隐藏 API 密钥',
       'providers.headersLabel': '请求头',
    -  'providers.headersHint': '随每次请求发送的可选 JSON 对象。',
    -  'providers.keepKeyHint': '留空则保留当前已配置的密钥。',
    +  'providers.headersHint': '可选请求头,使用 JSON 对象。',
    +  'providers.keepKeyHint': '留空会清除已保存的密钥。',
       'providers.saveProvider': '保存服务商',
       'providers.saveChanges': '保存更改',
       'providers.notFound': '该服务商不存在或尚未加载。',
    @@ -333,7 +347,7 @@ export const zh: Record = {
       'models.colName': '名称',
       'models.colContext': '上下文',
       'models.colActions': '操作',
    -  'models.loading': '正在通过 opencode CLI 加载模型…',
    +  'models.loading': '正在通过 OpenCode 服务加载模型…',
       'models.loadingConfiguration': '正在加载配置…',
       'models.editTitle': '编辑模型',
       'models.deleteTitle': '删除模型',
    @@ -345,7 +359,7 @@ export const zh: Record = {
       'models.noProvider': '没有可用的服务商。请先创建服务商。',
       'models.editMetaTitle': '编辑模型 · opencode-mom',
       'models.notFound': '该模型不存在或尚未加载。',
    -  'models.catalogFailed': '通过 opencode CLI 加载模型失败:{message}',
    +  'models.catalogFailed': '通过 OpenCode 服务加载模型失败:{message}',
       'models.retry': '重试',
     
       // modelForm
    @@ -365,7 +379,7 @@ export const zh: Record = {
       'modelForm.toolCall': '工具调用',
       'modelForm.attachment': '附件',
       'modelForm.experimental': '实验性',
    -  'modelForm.capabilitiesHint': '未勾选的能力保持未设置,并继承服务商或 opencode 的默认值。',
    +  'modelForm.capabilitiesHint': '声明工具调用能力和输入/输出格式。',
       'modelForm.temperature': '温度',
       'modelForm.unset': '未设置',
       'modelForm.temperatureHint': '模型是否接受温度设置。',
    @@ -382,15 +396,15 @@ export const zh: Record = {
       'modelForm.output': '输出',
       'modelForm.cacheRead': '缓存读取',
       'modelForm.cacheWrite': '缓存写入',
    -  'modelForm.costHint': '价格以美元/百万 token 计;缓存费率在基础输入价格之上。',
    +  'modelForm.costHint': '美元/百万 Token;缓存费率另加到输入费用。',
       'modelForm.limits': '限制',
       'modelForm.context': '上下文',
    -  'modelForm.limitsHint': 'Token 数量:上下文为完整窗口,输出限制生成长度,输入可选地限制提示词。',
    +  'modelForm.limitsHint': '设置 Token 限制时,context/output 必填,input 可选。',
       'modelForm.runtime': '运行时',
       'modelForm.options': '选项',
    -  'modelForm.optionsHint': '传递给模型客户端的任意服务商选项。',
    +  'modelForm.optionsHint': '服务商设置,使用 JSON 对象。',
       'modelForm.headers': '请求头',
    -  'modelForm.headersHint': '额外的 HTTP 请求头,例如 { "x-api-key": "..." }。',
    +  'modelForm.headersHint': '额外 HTTP 请求头,使用 JSON 对象。',
       'modelForm.variants': '变体',
       'modelForm.variantName': '变体名称',
       'modelForm.variantNamePlaceholder': 'variant-name',
    @@ -399,8 +413,7 @@ export const zh: Record = {
       'modelForm.disabled': '已禁用',
       'modelForm.removeVariant': '移除变体',
       'modelForm.addVariant': '添加变体',
    -  'modelForm.variantsHint':
    -    '变体名称 → opencode 期望的选项,例如 { "reasoningEffort": "high" }。使用开关禁用某个变体。',
    +  'modelForm.variantsHint': '为变体命名;JSON 可含 settings、headers 或 body。',
     
       // agents
       'agents.metaTitle': '智能体 · opencode-mom',
    @@ -423,7 +436,7 @@ export const zh: Record = {
       'agents.sourceToDelete': '要删除的来源',
       'agents.sourceInline': '内联',
       'agents.sourceGlobalMarkdown': '全局 Markdown',
    -  'agents.sourceDeleteHint': '仅删除所选来源,另一个来源会保留。',
    +  'agents.sourceDeleteHint': '仅删除所选来源,保留另一来源。',
       'agents.newMetaTitle': '新建智能体 · opencode-mom',
       'agents.newTitle': '新建智能体',
       'agents.agentId': '名称',
    @@ -447,33 +460,33 @@ export const zh: Record = {
       'agents.valuePlaceholder': '值(支持 JSON)',
       'agents.removeOption': '移除选项',
       'agents.addOption': '添加选项',
    -  'agents.kvHintNew': '键值行是唯一的编辑入口;下方 JSON 仅为同步预览,绝不会成为第二个保存来源。',
    -  'agents.kvHintEdit': '键值行是唯一的编辑入口;JSON 仅为同步预览。',
    +  'agents.kvHintNew': '编辑键值行;JSON 仅供预览。',
    +  'agents.kvHintEdit': '编辑键值行;JSON 仅供预览。',
       'agents.dangerZone': '危险区域',
    -  'agents.dangerHintNew': '清除的字段会通过 clearFields 显式发送。新建智能体尚无来源字段可清除。',
    -  'agents.dangerClearNew': '创建后如需清除字段,请使用编辑页面的危险区域。',
    +  'agents.dangerHintNew': '新建智能体暂无已保存字段可清除。',
    +  'agents.dangerClearNew': '创建后可在编辑页清除字段。',
       'agents.create': '创建智能体',
    -  'agents.promptHintResolvedNew': '使用了文件或环境变量引用,保存后将在运行时解析。',
    -  'agents.promptHintResolved': '使用了文件或环境变量引用,将在运行时解析。',
    +  'agents.promptHintResolvedNew': '文件和环境变量引用在保存后运行时解析。',
    +  'agents.promptHintResolved': '文件和环境变量引用在运行时解析。',
       'agents.promptHintSupport': '支持 {file:path/to/prompt.md} 和 {env:VARIABLE_NAME}。',
       'agents.editMetaTitle': '编辑智能体 · opencode-mom',
       'agents.editTitle': '编辑智能体',
    -  'agents.editNotice': '编辑仅写入所选来源;生效视图绝不会被写回。',
    +  'agents.editNotice': '仅编辑所选来源,不回写生效视图。',
       'agents.editSource': '编辑来源',
       'agents.pathLabel': '路径:{path}。',
    -  'agents.noPath': 'DTO 未提供路径',
    -  'agents.pathNotProvided': 'DTO 未提供',
    -  'agents.sourceMissing': '所选来源未由 DTO 提供,无法安全写入。',
    -  'agents.sourceCannotEdit': '所选来源未包含在 DTO sources 中,无法安全编辑。',
    -  'agents.effectiveOverrides': '生效覆盖:生效视图为只读,绝不会被保存。',
    +  'agents.noPath': '路径不可用',
    +  'agents.pathNotProvided': '路径不可用',
    +  'agents.sourceMissing': '来源不可用;请刷新后再编辑。',
    +  'agents.sourceCannotEdit': '来源不可用;请刷新后再编辑。',
    +  'agents.effectiveOverrides': '生效值只读,不会保存。',
       'agents.clearInherit': '清除 / 继承',
       'agents.dangerClearTitle': '危险区域:清除所选来源的字段',
    -  'agents.dangerClearHint': '勾选的字段会写入 mutation.clearFields;生效值绝不会复制回来源。',
    +  'agents.dangerClearHint': '清除所选来源字段,不回写生效值。',
       'agents.clearField': '清除 {field}',
       'agents.sourcePreview': '来源预览',
       'agents.effectiveConfig': '生效配置(只读)',
    -  'agents.rawProvided': '以下为 DTO 提供的真实原始内容。',
    -  'agents.rawNotProvided': 'DTO 未提供原始内容;以下仅为该来源的字段快照。',
    +  'agents.rawProvided': '来源原文。',
    +  'agents.rawNotProvided': '原文不可用;仅显示来源字段。',
       'agents.notFound': '该智能体不存在或尚未加载。',
       'agents.saveChanges': '保存更改',
       'agents.identity': '基本信息',
    @@ -488,8 +501,8 @@ export const zh: Record = {
       'agents.optionUnavailable': '{name}(不可用)',
       'agents.descriptionRequired': 'opencode 必填。',
       'agents.colorPlaceholder': '#RRGGBB 或主题名',
    -  'agents.hiddenHint': '仅在模式为 subagent 时生效。',
    -  'agents.disableHint': '禁用的智能体仍保留在配置中,但不会由 opencode 加载。',
    +  'agents.hiddenHint': '仅 subagent 生效。',
    +  'agents.disableHint': '保留在配置中,但不由 OpenCode 加载。',
       'agents.sourceTabLabel': '智能体来源',
       'agents.tabCustom': '自定义 ({count})',
       'agents.tabNative': '原生 ({count})',
    @@ -505,13 +518,13 @@ export const zh: Record = {
       'agents.variantSelectModel': '请先选择模型以选择变体。',
       'agents.variantNoVariants': '该模型没有变体。',
       'agents.variantFromModel': '变体读取自所选模型。',
    -  'agents.promptHintMarkdown': '对于 Markdown 智能体,文件正文即系统提示词。',
    -  'agents.promptHintInline': '内联智能体支持 {file:path/to/prompt.md} 和 {env:VARIABLE_NAME} 引用,运行时解析。',
    +  'agents.promptHintMarkdown': 'Markdown 正文即系统提示词。',
    +  'agents.promptHintInline': '支持 {file:path/to/prompt.md} 和 {env:VARIABLE_NAME},运行时解析。',
       'agents.permissions': '权限',
       'agents.permissionsJson': '权限 JSON',
    -  'agents.permissionsGlobHint': '部分权限值是 glob 规则。请使用下方 JSON 编辑以保留这些规则。',
    +  'agents.permissionsGlobHint': '在下方 JSON 中编辑并保留 glob 规则。',
       'agents.permissionsInvalid': '权限必须是 {action, resource, effect} 规则数组。',
    -  'agents.permissionsHint': '取值为 allow、ask 或 deny。键可映射到 glob 到动作的对象;最后匹配的规则生效。',
    +  'agents.permissionsHint': '使用 allow、ask 或 deny;最后匹配的规则生效。',
     
       // groups
       'groups.metaTitle': '分组 · opencode-mom',
    @@ -532,12 +545,13 @@ export const zh: Record = {
       'groups.loading': '正在加载配置…',
       'groups.enabled': '已启用',
       'groups.disabled': '已禁用',
    +  'groups.enableAria': '启用 {name}',
    +  'groups.disableAria': '停用 {name}',
       'groups.switchTo': '切换到 {name}',
       'groups.switchTitle': '切换分组',
       'groups.reloadTitle': '现在重新加载 opencode?',
    -  'groups.reloadDescription':
    -    '已切换到 {name}。重新加载 opencode 后即可使用此分组的设置。切换已生效;“取消”只会关闭此提示。',
    -  'groups.reloadWarning': '重新加载可能取消待处理的权限或表单请求。稍后也可以从侧边栏手动重新加载。',
    +  'groups.reloadDescription': '已切换并保存 {name}。重新加载 OpenCode 后应用;“稍后”或“取消”不撤销切换。',
    +  'groups.reloadWarning': '可能取消待处理的权限或表单请求;也可稍后从侧边栏重新加载。',
       'groups.reloadLater': '稍后',
       'groups.reloadNow': '现在重新加载',
       'groups.reloading': '正在重新加载…',
    @@ -580,15 +594,15 @@ export const zh: Record = {
       'groupForm.noModelCatalog': '目录中没有可用模型。请先添加服务商模型再配置映射。',
     
       // feedback
    -  'feedback.conflictDescription': '本地草稿已保留在当前表单中,未被覆盖。',
    +  'feedback.conflictDescription': '草稿保留在当前表单中,未被覆盖。',
       'feedback.keepDraftReload': '保留草稿并重新加载',
       'feedback.reviewMerge': '手动审阅并合并',
       'feedback.discardRefresh': '丢弃草稿并刷新',
    -  'feedback.busyDescription': '另一个配置操作仍在运行。本地草稿已保留,请稍后在原表单中重试。',
    +  'feedback.busyDescription': '另一操作进行中,草稿已保留;请稍后在原表单重试。',
       'feedback.continueEditing': '继续编辑',
       'feedback.referencesDescription': '请先移除相关引用。',
       'feedback.backToDraft': '返回草稿',
    -  'feedback.preservedDescription': '请求内容已保留,未执行自动合并。请在原表单中审阅后重试。',
    +  'feedback.preservedDescription': '内容已保留,未自动合并;请在原表单检查后重试。',
       'feedback.retryDescription': '请检查当前页面后重试。',
     
       // toast
    @@ -599,7 +613,7 @@ export const zh: Record = {
       'toast.saveFailedAgent': '保存失败。请检查配置状态。',
       'toast.saveFailed': '保存失败。',
       'toast.fixFields': '请先修正高亮的字段再保存。',
    -  'toast.catalogFailed': '通过 opencode CLI 加载模型失败:{message}',
    +  'toast.catalogFailed': '通过 OpenCode 服务加载模型失败:{message}',
       'toast.retry': '重试',
     
       // splash
    @@ -661,12 +675,12 @@ export const zh: Record = {
       'mcp.deleteAria': '删除 {name}',
       'mcp.deleteTitle': '删除 MCP 服务器',
       'mcp.deleteConfirm': '从以下所有全局配置来源中删除 {name}?此操作无法撤销。',
    -  'mcp.deleteHint': '删除后请手动重新加载 OpenCode 以应用更改。重新加载可能取消待处理的请求。',
    +  'mcp.deleteHint': '删除后请手动重新加载 OpenCode;这可能取消待处理的请求。',
       'mcp.deleteFailed': '删除未完成。请查看错误,刷新列表后重试。',
       'mcp.removed': '已从全局配置移除。请重新加载 OpenCode 以应用更改。',
       'mcp.nativeConfig': '原生配置',
       'mcp.secretsHidden': '请求头、环境变量值、客户端密钥及其他密钥字段已隐藏。',
    -  'mcp.credentialHint': '请求头和环境变量值、已知密钥字段、URL 凭据及显式凭据参数已隐藏。自定义格式仍可能包含敏感值。',
    +  'mcp.credentialHint': '请求头、环境变量值、已知凭据字段、URL 凭据及显式凭据参数已隐藏。自定义格式仍可能包含敏感值。',
       'mcp.noConfig': '没有配置字段。',
       'skills.metaTitle': '技能 · opencode-mom',
       'skills.detailMetaTitle': '技能详情 · opencode-mom',
    @@ -686,15 +700,23 @@ export const zh: Record = {
       'settings.eyebrow': '应用 / 设置',
       'settings.title': '设置',
       'settings.description': '桌面应用设置。',
    +  'settings.searchLabel': '搜索设置',
    +  'settings.searchPlaceholder': '查找设置,例如端口、CLI、密码…',
    +  'settings.searchHint': '搜索设置;选择结果跳转。',
    +  'settings.searchClear': '清除搜索',
    +  'settings.searchResults': '匹配的设置',
    +  'settings.searchCount': '找到 {count} 项设置',
    +  'settings.searchEmpty': '没有匹配的设置,请换个关键词或清除搜索。',
    +  'settings.appBehavior': '应用行为',
       'settings.autostart': '登录时启动',
    -  'settings.autostartHint': '登录时启动 opencode-mom。仅桌面应用。',
    +  'settings.autostartHint': '登录时启动,仅桌面应用。',
       'settings.loading': '正在读取状态…',
       'settings.saving': '正在更新…',
       'settings.loadFailed': '无法读取登录启动状态,读取成功前开关保持禁用。',
       'settings.saveFailed': '无法更新登录启动,当前显示上次确认的状态。',
       'settings.autostartSaved': '登录启动已更新。',
       'settings.opencodeTitle': 'OpenCode V2',
    -  'settings.opencodeHint': '更新本地 OpenCode CLI,与 MOM 应用更新分开。',
    +  'settings.opencodeHint': '本地 CLI 更新,与 MOM 更新独立。',
       'settings.opencodeDesktopOnly': '请在桌面应用中使用。',
       'settings.opencodeInstallation': '本地 CLI 安装',
       'settings.opencodeVersion': 'CLI 版本',
    @@ -723,7 +745,7 @@ export const zh: Record = {
       'settings.opencodeReason.ambiguous': '无法确认安装方式,请手动更新上方可执行文件。',
       'settings.opencodeManualCommand': '请自行在终端执行,然后刷新。',
       'settings.opencodeUpdates': 'CLI 更新',
    -  'settings.opencodeCheckHint': '仅点击时检查最新稳定版,不会自动安装。',
    +  'settings.opencodeCheckHint': '点击检查最新稳定版,不自动安装。',
       'settings.opencodeCheck': '检查 CLI 更新',
       'settings.opencodeRetryCheck': '重试检查 CLI 更新',
       'settings.opencodeAvailable': '发现 OpenCode 新版本 {version}。',
    @@ -737,14 +759,14 @@ export const zh: Record = {
       'settings.opencodeError.upgrade': 'CLI 升级失败,当前显示上次确认的信息。请刷新后重试。',
       'settings.opencodeError.readback': '升级命令已完成,但无法重新读取信息。请刷新确认,暂时不要再次升级。',
       'settings.opencodeUpgraded': '升级已完成。CLI 版本:{version}。',
    -  'settings.opencodeRunningHint': '运行中的服务可能仍用旧版本;此操作不会重启服务。',
    +  'settings.opencodeRunningHint': '不会重启服务;运行中的服务可能仍用旧版。',
       'settings.opencodeConfirmTitle': '升级本地 OpenCode CLI?',
    -  'settings.opencodeConfirmDescription': '继续前请确认可执行文件和版本变化。',
    +  'settings.opencodeConfirmDescription': '请确认准确的可执行文件路径和版本变化。',
       'settings.opencodeVersionChange': '版本变化',
       'settings.opencodeUpgradeWarning': '🔒 仅修改上方本地 CLI。不会更新 MOM、修改应用内置 CLI 或重启服务。不使用 sudo。',
       'settings.opencodeConfirm': '确认升级 CLI',
       'settings.serviceTitle': '本地服务连接',
    -  'settings.serviceReadHint': '只读取所选端点;刷新不会启动或重启服务。',
    +  'settings.serviceReadHint': '仅探测已保存目标,不启动或重启服务。',
       'settings.serviceRefresh': '刷新连接',
       'settings.serviceReading': '正在读取连接…',
       'settings.serviceConnected': '已连接',
    @@ -756,31 +778,31 @@ export const zh: Record = {
       'settings.serviceVersion': 'Backend 版本',
       'settings.serviceMode': '连接目标',
       'settings.serviceShared': '默认 Shared Service',
    -  'settings.serviceSharedHint': '使用 OpenCode Shared Service;监控操作可能按需启动它。',
    +  'settings.serviceSharedHint': '使用 Shared Service;监测时可能按需启动。',
       'settings.serviceExplicit': '固定本地端点',
    -  'settings.serviceExplicitHint': '使用一个固定 OpenCode backend;不会回退或自动启动。',
    +  'settings.serviceExplicitHint': '固定 OpenCode backend,不回退或自动启动。',
       'settings.serviceUrl': 'OpenCode backend URL',
       'settings.serviceUrlHint':
    -    '直连 OpenCode V2 API:http://127.0.0.1、[::1] 或 localhost 加端口;不要带路径或凭据。不是 OpenChamber 的 UI URL(通常端口 3000)。',
    +    '直连 OpenCode V2 API:http://127.0.0.1、[::1] 或 localhost,端口 1–65535;不带路径、URL 凭据、查询或片段。不是 OpenChamber UI(通常为 :3000)。',
       'settings.servicePassword': '服务密码(可选)',
       'settings.servicePasswordHint':
    -    'OpenCode backend 的 Basic auth 密码,不是 OpenChamber UI 密码。留空则沿用此 URL 已保存的密码。',
    +    '用于 backend Basic auth,不是 OpenChamber UI 密码,不回退到 Bearer。留空沿用此 URL 的已存密码;无已存密码时不发送 Authorization。',
       'settings.servicePasswordSaved': '此目标已保存密码,内容不会显示。',
       'settings.serviceClearPassword': '保存时清除已保存密码',
       'settings.serviceCredentialWarning':
    -    '🔒 Unix 以明文 0600 文件保存密码;Windows 使用 DPAPI。请使用专用 backend 密码。',
    +    '🔒 Unix 将密码以明文保存在当前账户拥有的 0600 文件中;Windows 使用当前账户 DPAPI。请使用专用 backend 密码;两者都不能防止账户遭入侵。',
       'settings.serviceSave': '保存连接目标',
       'settings.serviceSaving': '正在保存连接目标…',
       'settings.serviceSaveFailed': '无法保存;已保存目标和草稿均未改变。',
       'settings.serviceSaveBusy': '本地服务操作进行中,请稍后重试。当前内容未改变。',
    -  'settings.serviceSaveValidation': '请检查直连 V2 loopback URL、端口和密码后重试。当前内容未改变。',
    +  'settings.serviceSaveValidation': '检查直连 V2 HTTP loopback URL、1–65535 端口及 backend 密码后重试。未作更改。',
       'settings.serviceReadFailed': '无法确认连接。请检查 backend 是否运行、密码是否正确,然后刷新。',
       'settings.serviceSaved': '目标已保存,已连接 backend。',
    -  'settings.serviceSavedOffline': '目标已保存,但 backend 未连接;没有启动任何服务。',
    +  'settings.serviceSavedOffline': '目标已保存,连接尚未确认。',
       'settings.updates': '应用更新',
       'settings.installedVersion': '已安装版本:{version}',
       'settings.versionUnavailable': '无法读取',
    -  'settings.updatesHint': '仅点击时访问 GitHub;下载在浏览器打开,不会自动安装。',
    +  'settings.updatesHint': '点击检查 GitHub;下载在浏览器打开,不自动安装。',
       'settings.appInfoFailed': '无法读取应用信息。',
       'settings.retryAppInfo': '重试读取应用信息',
       'settings.checkUpdates': '检查更新',
    @@ -793,7 +815,7 @@ export const zh: Record = {
       'settings.releaseDownloads': '查看 GitHub 下载',
       'settings.macInstallGuide': '在 macOS 上安装更新',
       'settings.macSigning': '此版本为 ad-hoc 签名,未经 Apple 公证,首次启动可能被 Gatekeeper 拦截。',
    -  'settings.macTrustWarning': '🔒 仅在信任官方下载时继续。切勿为来源不明的下载绕过安全提示。',
    +  'settings.macTrustWarning': '🔒 仅使用官方下载。切勿为来源不明的下载绕过安全提示。',
       'settings.macDownloadOfficial': '只从官方 GitHub Releases 页面下载。',
       'settings.macReplaceApp': '先从托盘菜单退出(关闭窗口只是隐藏),在 Applications 中替换应用,再打开。',
       'settings.macOpenAnyway': '如被拦截,先用 系统设置 → 隐私与安全性 → 仍要打开。通常无需下面的终端命令。',
    @@ -806,7 +828,7 @@ export const zh: Record = {
         '如果 MOM 帮到了你,欢迎点个 Star。达到 100 星后,我会自费购买 Apple Developer 证书,让安装更顺畅。感谢支持 🙏',
       'settings.starProject': '在 GitHub 点个 Star',
       'settings.lid': '合盖后保持唤醒',
    -  'settings.lidHint': '仅在监测到的会话工作时让 Mac 保持唤醒。默认关闭;需要 macOS 管理员授权。',
    +  'settings.lidHint': '监测到会话工作时保持 Mac 唤醒。默认关闭,需 macOS 管理员授权。',
       'settings.lidLoading': '正在读取状态…',
       'settings.lidDisabled': '已关闭',
       'settings.lidIdle': '正在监测 — 没有正在工作的会话',
    @@ -826,7 +848,7 @@ export const zh: Record = {
       'settings.lidWait': '请保持 MOM 运行,等到显示保护已生效再合盖。',
       'settings.lidDetails': '授权与安全',
       'settings.lidAuthorization': 'macOS 每次运行应用会为原生辅助程序请求一次管理员授权。',
    -  'settings.lidScope': '仅监测通过 CLI 连接的 OpenCode 服务。',
    +  'settings.lidScope': '仅监测选定的本地 OpenCode 服务。',
       'settings.lidRestore': '工作结束或关闭此功能时恢复原来的电源设置,不会强制睡眠。',
       'settings.lidSafety': '合盖保持唤醒会增加耗电和发热,切勿放入封闭的包内。',
       'settings.lidRecovery': '辅助程序被强制结束后,下次启动应用可能需要恢复电源设置。',
    @@ -837,17 +859,17 @@ export const zh: Record = {
       'configuration.saving': '正在保存…',
       'configuration.saved': '已保存到全局配置。请手动重新加载 OpenCode 以应用更改。',
       'configuration.draftKept': '保存失败。已保留草稿。',
    -  'configuration.manualReload': '保存不会重新加载 OpenCode。准备好后请手动重新加载;重新加载可能取消正在执行的请求。',
    +  'configuration.manualReload': '保存不会重新加载 OpenCode;请手动重新加载,可能取消待处理请求。',
       'mcp.newTitle': '新建 MCP',
       'mcp.newMetaTitle': '新建 MCP · opencode-mom',
       'mcp.editTitle': '编辑 MCP',
    -  'mcp.newDescription': '将服务器添加到全局配置。本地服务器运行命令;远程服务器使用 URL。',
    -  'mcp.nameHint': '服务器名称是其标识,创建后无法重命名。',
    -  'mcp.typeHint': '切换类型会保留已有字段。保存前请检查完整 JSON。',
    -  'mcp.editCredentials': '编辑会显示已保存的完整配置,包括凭据。',
    +  'mcp.newDescription': '添加到全局配置;本地运行命令,远程使用 URL。',
    +  'mcp.nameHint': '服务唯一标识,创建后不可重命名。',
    +  'mcp.typeHint': '切换类型保留字段;保存前检查 JSON。',
    +  'mcp.editCredentials': '编辑时会显示已保存的配置,包括凭据。',
       'mcp.jsonLabel': '原生配置(JSON)',
       'mcp.jsonHint':
    -    '使用 JSON 对象,不支持 JSONC。本地:command 为字符串数组,environment 和 cwd 可选。远程:设置 url,headers 和 oauth 可选。disabled 为 true 或 false。保留未知字段。',
    +    'JSON 对象,不支持 JSONC。本地需 command 字符串数组,environment/cwd 可选;远程需 url,headers/oauth 可选。disabled 为布尔值,其余字段保留。',
       'mcp.jsonError': '请输入有效的 JSON 对象。不支持注释、数组及尾随逗号。',
       'mcp.nameRequired': '请输入服务器名称。',
       'mcp.typeRequired': '请将 type 设置为 local 或 remote。',
    @@ -855,20 +877,139 @@ export const zh: Record = {
       'mcp.urlRequired': '远程配置需要 URL。',
       'skills.local': '本地',
       'skills.remote': '远程',
    -  'skills.remoteHint': '此远程技能为只读。内容由其来源管理,无法在此编辑。',
    +  'skills.remoteHint': '远程内容由来源管理,在此只读。',
       'skills.newTitle': '新建技能',
       'skills.newMetaTitle': '新建技能 · opencode-mom',
       'skills.editTitle': '编辑技能',
    -  'skills.newDescription': '在用户全局技能目录中创建本地技能,而非当前项目。',
    +  'skills.newDescription': '在用户全局目录创建本地技能,不写入当前项目。',
       'skills.idHint':
    -    'ID 决定目录:/SKILL.md。使用 1–128 个 ASCII 字母、数字、连字符或下划线。frontmatter 中的 name 是独立的显示名称。',
    -  'skills.idImmutable': '无法在此重命名 ID 或文件路径。请在 YAML frontmatter 中更改显示名称。',
    +    '目录为 /SKILL.md;ID 用 1–128 个 ASCII 字母、数字、连字符或下划线。frontmatter name 为显示名称。',
    +  'skills.idImmutable': '此处 ID/路径不可重命名;显示名称改 frontmatter name。',
       'skills.idRequired': '请输入技能 ID。',
       'skills.documentLabel': '完整 Markdown 文档',
       'skills.documentHint':
         '编辑完整文档,包括可选的 YAML frontmatter。name 和 description 为显示信息;metadata.opencode/autoinvoke 控制自动调用。不渲染 HTML 预览。',
       'mcp.v2ConfigHint':
    -    '可选的 timeout 为对象,startup、catalog 和 execution 的值为正整数毫秒,不是单个数值。OAuth 使用 client_id、client_secret、scope、callback_port、redirect_uri 和 auth_server_metadata_url。',
    +    'timeout 可选,为正整数毫秒对象(startup/catalog/execution),不是单个数值。OAuth:client_id、client_secret、scope、callback_port、redirect_uri、auth_server_metadata_url。',
       'skills.documentManagementHint':
    -    '编辑完整 Markdown 文档,包括可选的 YAML frontmatter。name 为显示名称;description 帮助模型发现技能。不设置调用 metadata 时允许自动调用。已有 metadata.opencode/autoinvoke 和 disable-model-invocation 设置按原文保留。不渲染 HTML 预览。',
    +    '编辑 Markdown 和可选 YAML frontmatter。name 为显示名称,description 供模型发现。无调用 metadata 时允许自动调用;metadata.opencode/autoinvoke 和 disable-model-invocation 按原文保留。不提供 HTML 预览。',
    +
    +  // MCP 标签页与代理
    +  'mcp.tabConfig': '配置列表',
    +  'mcp.tabProxy': 'MCP 代理',
    +  'mcp.proxy.eyebrow': '配置 / MCP 代理',
    +  'mcp.proxy.title': 'MCP 代理',
    +  'mcp.proxy.description': '内置 MCP HTTP 网关,用于通过托管代理运行本地与远程 MCP 服务器。',
    +  'mcp.proxy.statusTitle': '网关状态',
    +  'mcp.proxy.running': '运行中',
    +  'mcp.proxy.stopped': '已停止',
    +  'mcp.proxy.actualState': '实际状态',
    +  'mcp.proxy.desiredState': '目标状态',
    +  'mcp.proxy.boundAddr': '绑定地址',
    +  'mcp.proxy.port': '端口',
    +  'mcp.proxy.allowLan': '局域网访问',
    +  'mcp.proxy.lanEnabled': '已启用(向局域网公开)',
    +  'mcp.proxy.lanDisabled': '已禁用(仅本机回环)',
    +  'mcp.proxy.activeUnits': '活动单元',
    +  'mcp.proxy.availableUnits': '可用单元',
    +  'mcp.proxy.maxUnits': '最大单元数',
    +  'mcp.proxy.activeSessions': '活动会话',
    +  'mcp.proxy.revision': '配置版本',
    +  'mcp.proxy.generation': '运行代次',
    +  'mcp.proxy.securityErrors': '安全告警',
    +  'mcp.proxy.serversTitle': '代理服务列表',
    +  'mcp.proxy.addServer': '添加服务',
    +  'mcp.proxy.importServer': '导入服务',
    +  'mcp.proxy.noServers': '尚未配置代理服务。',
    +  'mcp.proxy.serverName': '服务名称',
    +  'mcp.proxy.protocol': '协议',
    +  'mcp.proxy.status': '状态',
    +  'mcp.proxy.sessions': '会话数',
    +  'mcp.proxy.actions': '操作',
    +  'mcp.proxy.editServer': '编辑',
    +  'mcp.proxy.deleteServer': '删除',
    +  'mcp.proxy.deleteConfirm': '删除代理服务 {name}?这会终止其活动会话。',
    +  'mcp.proxy.deleteFailed': '删除代理服务失败。',
    +  'mcp.proxy.closeSessions': '关闭会话',
    +  'mcp.proxy.closeUnit': '关闭单元',
    +  'mcp.proxy.closeUnitPrompt': '输入要关闭的单元或会话 ID:',
    +  'mcp.proxy.closeUnitFailed': '关闭单元失败。',
    +  'mcp.proxy.toggleFailed': '更新服务状态失败。',
    +  'mcp.proxy.loadFailed': '加载 MCP 代理状态失败。',
    +  'mcp.proxy.refresh': '刷新',
    +
    +  // MCP 代理表单 / 对话框
    +  'mcp.proxy.form.createTitle': '添加代理服务',
    +  'mcp.proxy.form.editTitle': '编辑代理服务',
    +  'mcp.proxy.form.id': '服务 ID',
    +  'mcp.proxy.form.idHint': '路由唯一 ID:1–64 个 ASCII 字母、数字、连字符或下划线。',
    +  'mcp.proxy.form.name': '显示名称',
    +  'mcp.proxy.form.enabled': '已启用',
    +  'mcp.proxy.form.command': '命令与参数',
    +  'mcp.proxy.form.commandHint': '每行一个参数,首行为可执行文件;不按 shell 解析。',
    +  'mcp.proxy.form.cwd': '工作目录',
    +  'mcp.proxy.form.cwdHint': '必填,使用绝对路径。',
    +  'mcp.proxy.form.env': '环境变量',
    +  'mcp.proxy.form.envHint': '每行一项 KEY=VALUE。',
    +  'mcp.proxy.form.protocol': '协议',
    +  'mcp.proxy.form.protocolLegacy': '传统模式 (STDIO / JSON-RPC)',
    +  'mcp.proxy.form.protocolModern': '现代模式 (HTTP / SSE)',
    +  'mcp.proxy.form.protocolAuto': '自动检测',
    +  'mcp.proxy.form.startupTimeout': '启动超时(秒)',
    +  'mcp.proxy.form.executionTimeout': '执行超时(秒)',
    +  'mcp.proxy.form.save': '保存服务',
    +  'mcp.proxy.form.saving': '保存中…',
    +  'mcp.proxy.form.cancel': '取消',
    +  'mcp.proxy.form.saveFailed': '保存代理服务失败。',
    +  'mcp.proxy.form.validationId': '服务 ID 必须为非空字母数字标识符。',
    +  'mcp.proxy.form.validationCommand': '执行命令不能为空。',
    +  'mcp.proxy.import.title': '导入 MCP 服务',
    +  'mcp.proxy.import.select': '选择要导入的已有 MCP 服务',
    +  'mcp.proxy.import.empty': '未找到可导入的已有 MCP 服务。',
    +  'mcp.proxy.import.submit': '导入',
    +  'mcp.proxy.import.failed': '导入 MCP 服务失败。',
    +
    +  // 设置标签页与代理设置
    +  'settings.tabGeneral': '常规设置',
    +  'settings.tabMcpProxy': 'MCP 代理',
    +  'settings.http.title': 'MOM HTTP 服务',
    +  'settings.http.description': 'MOM HTTP 监听,供 MCP 代理共用。',
    +  'settings.http.port': '端口',
    +  'settings.http.portHint': '1–65535;默认 18765。',
    +  'settings.http.allowLan': '允许局域网访问',
    +  'settings.http.allowLanHint': '监听 0.0.0.0,而非本机回环地址 127.0.0.1。',
    +  'settings.http.allowLanWarning':
    +    '安全告警:局域网访问会向本地网络公开 MOM HTTP。持有访问令牌的设备可执行 MCP 代理命令和工具。请仅在可信网络中启用。',
    +  'settings.http.statusUnknown': '状态未知',
    +  'settings.http.refresh': '刷新 HTTP 状态',
    +  'settings.http.saveSettings': '保存 HTTP 设置',
    +  'settings.http.saveSuccess': 'MOM HTTP 设置已保存。',
    +  'settings.proxy.title': 'MCP 代理网关',
    +  'settings.proxy.description': 'MCP 代理容量、超时和令牌。',
    +  'settings.proxy.enableGateway': '启用 MCP 网关',
    +  'settings.proxy.enableGatewayHint': '启用 MCP 代理;MOM HTTP 监听独立运行。',
    +  'settings.proxy.httpHint': '端口和局域网访问请到常规设置调整。',
    +  'settings.proxy.idleTimeout': '会话空闲超时(秒)',
    +  'settings.proxy.idleTimeoutHint': '空闲达到此时长后回收执行单元和会话。',
    +  'settings.proxy.maxUnits': '最大并发单元数',
    +  'settings.proxy.maxUnitsHint': '限制并发执行单元数。',
    +  'settings.proxy.saveSettings': '保存网关设置',
    +  'settings.proxy.saving': '保存中…',
    +  'settings.proxy.saveSuccess': 'MCP 代理设置已保存。',
    +  'settings.proxy.saveFailed': '保存 MCP 代理设置失败。',
    +  'settings.proxy.loadFailed': '加载 MCP 代理设置失败。',
    +  'settings.proxy.rotateKey': '轮换访问令牌…',
    +  'settings.proxy.rotateKeyTitle': '轮换 MCP 代理访问令牌',
    +  'settings.proxy.rotateKeyDescription': '新令牌会使旧令牌失效并立即终止活跃客户端会话;请更新客户端令牌。',
    +  'settings.proxy.rotateKeyConfirm': '生成新令牌',
    +  'settings.proxy.rotateKeySuccess': '已生成新访问令牌',
    +  'settings.proxy.rotateKeyWarning': '仅显示一次;请立即复制,关闭后无法再查看。',
    +  'settings.proxy.copyKey': '复制令牌',
    +  'settings.proxy.copied': '已复制!',
    +  'settings.proxy.close': '关闭',
    +  'settings.proxy.statusRunning': '运行中',
    +  'settings.proxy.statusStopped': '已停止',
    +  'settings.proxy.actualState': '实际状态:{state}',
    +  'settings.proxy.boundAddr': '绑定地址:{addr}',
    +  'settings.proxy.revisionGen': '配置版本 {revision} · 运行代次 {generation}',
     };
    diff --git a/src/types/AGENTS.md b/src/types/AGENTS.md
    index 5b95d05..c21dfd3 100644
    --- a/src/types/AGENTS.md
    +++ b/src/types/AGENTS.md
    @@ -1,5 +1,7 @@
     # Domain and Command Types
     
    +`mcp-proxy.ts` also exports `MomHttpSettings` (`port`, `allowLan`) for the shared application HTTP listener. These fields are absent from `McpProxySettingsDto`; `McpProxyStateDto` retains read-only HTTP network metadata alongside proxy state.
    +
     ## Overview
     
     This directory defines frontend domain records, command DTOs, and small UI result unions, with one file per domain area. It describes data exchanged by configuration actions and consumed by pages without implementing persistence, validation, or state management.
    @@ -12,6 +14,7 @@ src/types/            # Domain contracts without runtime implementations
     ├── app.ts            # App snapshot, projects, preferences, app/CLI/target metadata, updates, command errors
     ├── groups.ts         # Group records, agent bindings, and category mappings
     ├── mcp.ts            # MCP entries, lists, drafts, and guarded updates
    +├── mcp-proxy.ts      # MCP Proxy state, settings, server DTOs, and lifecycle requests
     ├── models.ts         # Model references, definitions, variants, and catalog entries
     ├── power.ts          # Lid-protection state and phases
     ├── providers.ts      # Provider settings and model dictionaries
    @@ -32,6 +35,7 @@ src/types/            # Domain contracts without runtime implementations
     | Model contract | `models.ts` | `ModelRef`, `ModelDef`, and `ModelCatalogEntry`; catalog limits and variants have distinct optional/nullable shapes. |
     | Agent contract | `agents.ts` | `AgentDefinition`, typed optional `AgentSourcePreview[]` and `AgentFieldOverride[]`, permissive source records, permission rules, and `AgentWrite` mutation metadata. |
     | Resource writes | `mcp.ts`, `skills.ts` | Separate saved entries, creation drafts, and updates carrying expected source/content snapshots. |
    +| MCP Proxy contract | `mcp-proxy.ts` | Gateway state, proxy-only settings, MOM HTTP listener settings, proxy server DTOs, import request, and unit close request. |
     
     ## Conventions
     
    @@ -44,6 +48,8 @@ src/types/            # Domain contracts without runtime implementations
     - **Manual updates**: `AppInfo.platform` is `macos`/`windows`/`linux`/`other`; `UpdateCheck.latestVersion` is required but nullable when no stable release exists. `ProjectPage` limits browser destinations to `repository`/`releases`; metadata/check results are not part of `AppState`.
     - **CLI updates**: Keep installation method and upgrade-reason unions wire-compatible. `OpenCodeInstallation.binaryPath`, `version`, `upgradeReason`, and `manualCommand` are required nullable fields; `OpenCodeUpdateCheck.latestVersion` is non-null. Backend values are authoritative; do not infer version ordering or upgrade capability in the frontend.
     - **Service target**: Target mode is `shared`/`explicit`, url is required nullable, and `hasPassword` is the only credential metadata. Status connected/url/pid/version/error remain separate from target and local CLI metadata; nullable identity/error fields are required. Never add passwords to returned DTOs or AppState. Preserve `busy`/`conflict` command error codes for guarded operations.
    +- **MCP Proxy state**: `actual`/`desired` and `desiredState` are booleans; `actualState` is `running`/`stopped`. Keep `boundAddr` required nullable and expose separate numeric `activeUnits` and `activeSessions` counters.
    +- **MOM HTTP settings**: `MomHttpSettings` contains port and allowLan only; `McpProxySettingsDto` contains enabled, idleTimeout, maxUnits, and revision. Keep listener settings separate from revision-guarded proxy writes.
     - **Guarded updates**: Keep `expectedConfig`/`expectedSourcePath` on `McpUpdate` and `expectedContent`/`expectedPath` on `SkillUpdate`. Agent `mutation.expectedPath`/`expectedContent` echo the frozen preview's `resolvedPath`/`content`; backend updates reject missing snapshots with `validation_failed`. Agent create payloads remain unchanged and need no expected fields.
     - **Open-ended data**: Retain `Record` for raw/config/settings objects, `unknown` for variant bodies and error detail, and open strings for agent modes and permission effects.
     - **Permission results**: Preserve the `ok` discriminator in `PermissionParse` and `mode` discriminator in `PermissionView`; only successful/rules branches carry rules.
    diff --git a/src/types/mcp-proxy.ts b/src/types/mcp-proxy.ts
    new file mode 100644
    index 0000000..c8f4a29
    --- /dev/null
    +++ b/src/types/mcp-proxy.ts
    @@ -0,0 +1,65 @@
    +export type McpProxyProtocol = 'legacy' | 'modern' | 'auto';
    +
    +export interface McpProxyServerSummaryDto {
    +  id: string;
    +  name: string;
    +  enabled: boolean;
    +  protocol: string;
    +  activeSessions: number;
    +}
    +
    +export interface McpProxyStateDto {
    +  actual: boolean;
    +  desired: boolean;
    +  actualState: 'running' | 'stopped';
    +  desiredState: boolean;
    +  boundAddr: string | null;
    +  port: number;
    +  allowLan: boolean;
    +  totalServices: number;
    +  enabledServices: number;
    +  activeUnits: number;
    +  activeSessions: number;
    +  availableUnits: number;
    +  maxUnits: number;
    +  securityErrors: string[];
    +  services: McpProxyServerSummaryDto[];
    +  revision: number;
    +  generation: number;
    +}
    +
    +export interface McpProxySettingsDto {
    +  enabled: boolean;
    +  idleTimeout: number;
    +  maxUnits: number;
    +  revision: number;
    +}
    +
    +export interface MomHttpSettings {
    +  port: number;
    +  allowLan: boolean;
    +}
    +
    +export interface McpProxyServerDto {
    +  id: string;
    +  name: string;
    +  enabled: boolean;
    +  command: string[];
    +  cwd: string;
    +  env: Record;
    +  protocol: string;
    +  startupTimeout: number;
    +  executionTimeout: number;
    +}
    +
    +export interface ImportMcpProxyServerRequest {
    +  sourceScope: string;
    +  projectId?: string | null;
    +  serverName: string;
    +  expectedSourcePath?: string | null;
    +}
    +
    +export interface CloseMcpProxyUnitRequest {
    +  unitId: string;
    +  expectedGeneration: number;
    +}
    
    From 7f94c5a006e7d9176ad92acb41f3e4e5d108a96c Mon Sep 17 00:00:00 2001
    From: seho 
    Date: Sun, 25 Oct 2026 15:06:14 +0800
    Subject: [PATCH 65/97] feat(frontend): unify Checkbox, disabled link styles,
     form/page docs and checkbox harness
    
    ---
     src/components/AGENTS.md                      |  23 +-
     src/components/FormPage.svelte                |   7 +
     src/components/ResourceScope.svelte           |  15 +-
     .../__tests__/resource-scope.test.ts          |   3 +-
     .../button/__tests__/button.test.ts           |  30 +++
     src/components/button/button.svelte           |   2 +-
     .../__tests__/checkbox-harness.svelte         |  66 ++++++
     .../checkbox/__tests__/checkbox.test.ts       | 166 ++++++++++++++
     src/components/checkbox/checkbox.svelte       |  25 +-
     src/components/checkbox/index.ts              |   4 +-
     src/routes/AGENTS.md                          |  23 +-
     src/routes/agents/AgentForm.svelte            |  24 +-
     src/routes/agents/PermissionEditor.svelte     |   4 +-
     src/routes/agents/[id]/+page.svelte           | 156 ++++++++-----
     src/routes/agents/[id]/edit/+page.svelte      |   9 +-
     .../agents/__tests__/agent-detail.test.ts     |  39 ++++
     src/routes/agents/new/+page.svelte            |   9 +-
     src/routes/groups/GroupForm.svelte            |  18 +-
     src/routes/groups/[id]/edit/+page.svelte      |   5 +-
     src/routes/groups/new/+page.svelte            |   7 +-
     src/routes/island/AGENTS.md                   |   3 +-
     src/routes/island/QuestionForm.svelte         |  65 ++++--
     src/routes/mcp/McpForm.svelte                 |  25 +-
     src/routes/mcp/[id]/+page.svelte              | 214 +++++++++---------
     src/routes/mcp/__tests__/mcp.test.ts          |   9 +-
     src/routes/mcp/new/+page.svelte               |  10 +-
     src/routes/models/ModelForm.svelte            |  14 +-
     src/routes/models/[id]/edit/+page.svelte      |  33 +--
     src/routes/models/new/+page.svelte            |  19 +-
     src/routes/providers/ProviderForm.svelte      |   6 +-
     src/routes/providers/[id]/edit/+page.svelte   |  18 +-
     src/routes/providers/new/+page.svelte         |   7 +-
     src/routes/skills/SkillForm.svelte            |  23 +-
     src/routes/skills/[id]/+page.svelte           | 205 +++++++++--------
     src/routes/skills/__tests__/skills.test.ts    |   9 +-
     src/routes/skills/new/+page.svelte            |  10 +-
     src/styles/AGENTS.md                          |   5 +-
     src/styles/forms.css                          |  61 ++++-
     src/styles/head.css                           |  12 +
     src/styles/layout.css                         |   1 +
     src/styles/responsive.css                     |  15 ++
     src/styles/scrollbar.css                      |  62 ++---
     42 files changed, 1003 insertions(+), 458 deletions(-)
     create mode 100644 src/components/FormPage.svelte
     create mode 100644 src/components/checkbox/__tests__/checkbox-harness.svelte
     create mode 100644 src/components/checkbox/__tests__/checkbox.test.ts
    
    diff --git a/src/components/AGENTS.md b/src/components/AGENTS.md
    index abe19f9..6f4478c 100644
    --- a/src/components/AGENTS.md
    +++ b/src/components/AGENTS.md
    @@ -16,6 +16,9 @@ components/                       # Shared UI library; no top-level barrel
     │   ├── index.ts                   # Root/Button aliases, types, and buttonVariants
     │   └── __tests__/button.test.ts    # DOM click smoke test
     ├── checkbox/                      # Bits UI Checkbox with checked/indeterminate indicators
    +│   ├── checkbox.svelte            # Control implementation and exported prop types
    +│   ├── index.ts                   # Root/Checkbox aliases and CheckboxProps types
    +│   └── __tests__/                 # checkbox.test.ts and checkbox-harness.svelte
     ├── combobox/                      # Free-text input with a Bits UI Popover option list
     │   └── __tests__/                 # dropdown.test.ts and dropdown-harness.svelte; also exercises Select
     ├── configuration/                 # Configuration feedback and error inspection
    @@ -25,7 +28,9 @@ components/                       # Shared UI library; no top-level barrel
     ├── dashboard/                     # Runtime groups and activity UI
     │   ├── ActivityHeatmap.svelte     # Prop-driven activity calendar with keyboard cell navigation
     │   ├── RuntimeCard.svelte         # Config-backed group summary for one runtime type
    -│   └── SwitchGroupDialog.svelte   # Config-backed switch/reload dialogs and trigger focus restoration
    +│   ├── TokenUsageChart.svelte     # Compact prop-driven token trend chart
    +│   ├── SwitchGroupDialog.svelte   # Config-backed switch/reload dialogs and trigger focus restoration
    +│   └── __tests__/                 # Token chart aggregation, ratios, and unavailable-state behavior
     ├── dialog/                        # Bits UI Dialog wrappers plus native header/footer
     ├── input/                         # Native input with separate file-input binding branch
     ├── separator/                     # Bits UI Separator
    @@ -38,11 +43,12 @@ components/                       # Shared UI library; no top-level barrel
     ├── DataTable.svelte               # Table snippet container and conditional Pagination
     ├── EmptyTableRow.svelte           # Empty-state tr/td with colspan and message
     ├── FormActions.svelte             # Shared form-actions snippet container
    +├── FormPage.svelte                # Opt-in available-height page frame; inactive details scroll normally
     ├── PageHead.svelte                # Eyebrow/title with actions or children snippet
     ├── ResourceScope.svelte           # Local shared project/Global picker and forget-folder dialog
     ├── ResourceScopeNotice.svelte     # Read-only detail destination and selected root path
     ├── GlobalDestination.svelte       # Global write destination notice on direct forms
    -├── __tests__/                     # Scope picker tests and local reactive config double
    +├── __tests__/                     # Scope picker, form layout contract and native submit tests
     ├── Pagination.svelte              # Bindable page and previous/next controls
     └── Select.svelte                  # Fixed-choice Bits UI Popover picker; search above eight options
     ```
    @@ -51,31 +57,36 @@ components/                       # Shared UI library; no top-level barrel
     
     | Responsibility | File | Description |
     | --- | --- | --- |
    -| Public family API | `button/index.ts`, `dialog/index.ts`, `tooltip/index.ts` | Component aliases; button also exposes prop and variant types. |
    +| Public family API | `button/index.ts`, `checkbox/index.ts`, `dialog/index.ts`, `tooltip/index.ts` | Component aliases; button and checkbox also expose prop types. |
     | Variants | `button/button.svelte`, `alert/alert.svelte`, `badge/badge.svelte`, `toast/toast.svelte` | Module-script `tv()` definitions and `VariantProps` types. |
     | Modal composition | `dialog/dialog-content.svelte` | Portal, overlay, children snippet, optional close control, destructive variant. |
     | Free-text selection | `combobox/combobox.svelte` | Bindable string, label/value filtering, keyboard highlight, portaled options. |
     | Fixed-choice selection | `Select.svelte` | Exports `SelectOption`; skips disabled choices and optionally focuses search. |
     | Resource browsing | `ResourceScope.svelte`, `ResourceScopeNotice.svelte`, `GlobalDestination.svelte` | Saved root selection shared only by MCP/Agent/Skill lists; read-only details and explicit Global forms. |
     | Table composition | `DataTable.svelte`, `Pagination.svelte`, `EmptyTableRow.svelte` | Table contents come from callers; pagination controls do not slice rows. |
    -| Page/form composition | `PageHead.svelte`, `FormActions.svelte` | Snippet-based heading actions and form footer. |
    +| Page/form composition | `PageHead.svelte`, `FormPage.svelte`, `FormActions.svelte` | Snippet-based heading, available-height page frame and in-flow footer. |
     | Notifications | `toast/toast.svelte` | Presentation only; actions and dismissal are supplied as callbacks. |
    -| Dashboard | `dashboard/RuntimeCard.svelte`, `dashboard/SwitchGroupDialog.svelte`, `dashboard/ActivityHeatmap.svelte` | Group state/switching and activity-calendar display. |
    +| Dashboard | `dashboard/RuntimeCard.svelte`, `dashboard/SwitchGroupDialog.svelte`, `dashboard/ActivityHeatmap.svelte`, `dashboard/TokenUsageChart.svelte` | Runtime switching, activity-calendar display, and token trend visualization. |
     | Configuration feedback | `configuration/Diagnostics.svelte`, `configuration/read-error.ts` | Diagnostic messages and inspection of unknown errors. |
    -| Interaction coverage | `combobox/__tests__/dropdown.test.ts`, `combobox/__tests__/dropdown-harness.svelte` | Combobox/Select portals, focus, selection, dismissal, and disabled options. |
    +| Interaction coverage | `checkbox/__tests__/checkbox.test.ts`, `combobox/__tests__/dropdown.test.ts` | Checkbox states/form inputs and Combobox/Select portals, focus, selection, dismissal, and disabled options. |
     
     ## Conventions
     
    +- **Dashboard hierarchy**: Keep runtime tiles compact and readable even when switching is unavailable; use the featured workbench for current-group identity and bindings. Telemetry panels preserve independent controls and align at the panel boundary; responsive refinements must retain Heatmap scrolling and roving keyboard focus.
    +
     - **Barrels**: Primitive-family folders expose `index.ts`; preserve existing short and prefixed aliases. Badge and spinner export named components without `Root`; dashboard/configuration and top-level components have no barrel.
     - **Class merging**: Class-based wrappers destructure `class` as `className` and use `cn(baseOrVariants, className)` from `../utils/index.ts`; it combines `clsx` and `tailwind-merge`.
     - **Prop contracts**: Native wrappers use `svelte/elements` attributes, often `WithElementRef`; Bits wrappers use primitive `*Props`. Use existing `WithoutChildren`/`WithoutChildrenOrChild` helpers when wrappers own child rendering.
     - **Binding and children**: Preserve exposed bindable refs, values, checked/indeterminate state, and open state. Native elements use `bind:this`; Bits primitives use `bind:ref`; snippet containers render their typed children.
     - **Bits composition**: Dialog, Tooltip, Checkbox, and Separator forward remaining props to Bits primitives. Dialog/Tooltip content wraps its portal internally; Popover pickers spread both `wrapperProps` and `props` in child snippets.
    +- **Checkbox semantics**: Associate labels by explicit `id`/`for`. Space toggles; Enter does not toggle or submit by default. Preserve Bits' real button ref without adding native-input properties; assert `aria-checked`/`data-state` and harness state in tests, never `button.checked`. Named checkboxes retain Bits' hidden form input; Switch retains its native input. Checkbox tests also cover QuestionForm option/custom keyboard behavior and field-key/option-value submission.
     - **Styling hooks**: Wrappers combine Tailwind classes with tokens from `../styles/tokens.css`; preserve `data-slot` hooks. Table, heading, form, and feedback classes are shared with `../styles/table.css`, `head.css`, `forms.css`, and `feedback.css`.
     - **Picker accessibility**: Preserve combobox/listbox IDs, expanded/selected state, arrow/Enter selection, Escape/Tab dismissal, and outside-focus handling. Combobox keeps input focus; Select restores trigger focus after selection and skips disabled options.
     - **Scope picker**: Select supports opt-in always-search, pinned choice, stacked path hints, and globe icons. ResourceScope persists selection only through config, restores the confirmed selection on failure, treats native picker cancellation as a no-op, and disables management during writes but not list reads. Forgetting a folder never deletes its files.
     - **Accessible names**: Switch consumers provide an external label or `aria-label`; Select accepts `ariaLabel` or a label targeting its `id`. Spinner is decorative; Toast uses alert for warning/error and status otherwise.
    +- **Disabled navigation**: Button anchors use `aria-disabled`, remove navigation and Tab entry, and share native disabled styling.
     - **Table ownership**: `DataTable` receives a table-content snippet and total count, with page size defaulting to five; callers own row selection. Keep PageHead's explicit actions snippet precedence over children.
    +- **Form ownership**: Use `FormPage` around the heading and editor; `active={false}` leaves read-only details in normal document flow without remounting their children. Editors keep `.form-body` and `FormActions` as siblings inside the native form so only fields/notices scroll, while submit ownership and keyboard behavior remain native. Dialog editors reuse the body/actions boundary within their available height. Ordinary help uses shared `.field-hint` or `.form-hint`; safety feedback keeps its separate semantics.
     - **Local tests**: Follow the button click test and dropdown harness: role/name queries, `fireEvent`, and `waitFor` for portaled state. The dropdown harness installs i18n; tests stub missing JSDOM layout/scroll APIs and restore mocks.
     
     ## Anti-patterns
    diff --git a/src/components/FormPage.svelte b/src/components/FormPage.svelte
    new file mode 100644
    index 0000000..b148054
    --- /dev/null
    +++ b/src/components/FormPage.svelte
    @@ -0,0 +1,7 @@
    +
    +
    +
    {@render children()}
    diff --git a/src/components/ResourceScope.svelte b/src/components/ResourceScope.svelte index df6c1f7..72680d9 100644 --- a/src/components/ResourceScope.svelte +++ b/src/components/ResourceScope.svelte @@ -1,6 +1,7 @@ + +
    { + event.preventDefault(); + onSubmitFormData?.(Array.from(new FormData(event.currentTarget).entries())); + }} +> +
    + + +
    + +
    diff --git a/src/components/checkbox/__tests__/checkbox.test.ts b/src/components/checkbox/__tests__/checkbox.test.ts new file mode 100644 index 0000000..27218e2 --- /dev/null +++ b/src/components/checkbox/__tests__/checkbox.test.ts @@ -0,0 +1,166 @@ +import { createEvent, fireEvent, render, screen } from '@testing-library/svelte'; +import { expect, test, vi } from 'vitest'; +import type { ConfigStore } from '$src/config/store.svelte.js'; +import QuestionForm from '$src/routes/island/QuestionForm.svelte'; +import ContextHarness from '$src/test-utils/harness.svelte'; +import type { IslandQuestion } from '$src/types/island.js'; +import { Checkbox, type CheckboxProps } from '../index.js'; +import Harness from './checkbox-harness.svelte'; + +test('toggles checked state via click, label association, and Space while ignoring Enter', async () => { + const onCheckedChange = vi.fn(); + const onSubmitFormData = vi.fn(); + const { component } = render(Harness, { onCheckedChange, onSubmitFormData }); + const harness = component as unknown as { + getControlRef: () => HTMLButtonElement | null; + getCheckedState: () => boolean; + }; + + const checkbox = screen.getByRole('checkbox', { name: 'Enable feature' }) as HTMLButtonElement; + expect(checkbox.type).toBe('button'); + expect(checkbox.getAttribute('data-slot')).toBe('checkbox'); + expect(checkbox.getAttribute('aria-checked')).toBe('false'); + expect(harness.getControlRef()).toBe(checkbox); + expect(harness.getCheckedState()).toBe(false); + + await fireEvent.click(checkbox); + expect(checkbox.getAttribute('aria-checked')).toBe('true'); + expect(checkbox.getAttribute('data-state')).toBe('checked'); + expect(harness.getCheckedState()).toBe(true); + expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true); + + await fireEvent.click(screen.getByText('Enable feature')); + expect(checkbox.getAttribute('aria-checked')).toBe('false'); + expect(harness.getCheckedState()).toBe(false); + expect(onCheckedChange).toHaveBeenLastCalledWith(false); + + checkbox.focus(); + const space = createEvent.keyDown(checkbox, { key: ' ' }); + await fireEvent(checkbox, space); + expect(space.defaultPrevented).toBe(true); + expect(checkbox.getAttribute('aria-checked')).toBe('true'); + expect(harness.getCheckedState()).toBe(true); + expect(onCheckedChange).toHaveBeenLastCalledWith(true); + + const enter = createEvent.keyDown(checkbox, { key: 'Enter' }); + await fireEvent(checkbox, enter); + expect(enter.defaultPrevented).toBe(true); + expect(checkbox.getAttribute('aria-checked')).toBe('true'); + expect(harness.getCheckedState()).toBe(true); + expect(onCheckedChange).toHaveBeenCalledTimes(3); + expect(onSubmitFormData).not.toHaveBeenCalled(); +}); + +test('supports indeterminate state and transitions to checked on user activation', async () => { + const onCheckedChange = vi.fn(); + const onIndeterminateChange = vi.fn(); + const { component } = render(Harness, { + initialChecked: false, + initialIndeterminate: true, + onCheckedChange, + onIndeterminateChange, + }); + const harness = component as unknown as { + getIndeterminateState: () => boolean; + }; + + const checkbox = screen.getByRole('checkbox', { name: 'Enable feature' }) as HTMLButtonElement; + expect(checkbox.getAttribute('aria-checked')).toBe('mixed'); + expect(checkbox.getAttribute('data-state')).toBe('indeterminate'); + expect(checkbox.querySelector('[data-slot="checkbox-indicator"] svg')).not.toBeNull(); + expect(harness.getIndeterminateState()).toBe(true); + + await fireEvent.click(checkbox); + expect(checkbox.getAttribute('aria-checked')).toBe('true'); + expect(checkbox.getAttribute('data-state')).toBe('checked'); + expect(harness.getIndeterminateState()).toBe(false); + expect(onIndeterminateChange).toHaveBeenCalledExactlyOnceWith(false); + expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true); +}); + +test('prevents pointer and keyboard changes when disabled', async () => { + const onCheckedChange = vi.fn(); + render(Harness, { initialChecked: true, disabled: true, onCheckedChange }); + + const checkbox = screen.getByRole('checkbox', { name: 'Enable feature' }) as HTMLButtonElement; + expect(checkbox.disabled).toBe(true); + expect(checkbox.getAttribute('aria-checked')).toBe('true'); + + await fireEvent.click(checkbox); + await fireEvent.keyDown(checkbox, { key: ' ' }); + expect(checkbox.getAttribute('aria-checked')).toBe('true'); + expect(onCheckedChange).not.toHaveBeenCalled(); +}); + +test('synchronizes hidden form input with name/value for FormData and omits it without name', async () => { + const onSubmitFormData = vi.fn(); + const { container } = render(Harness, { onSubmitFormData }); + + const hiddenInput = container.querySelector('input[type="checkbox"][name="featureFlag"]') as HTMLInputElement; + expect(hiddenInput).not.toBeNull(); + expect(hiddenInput.getAttribute('aria-hidden')).toBe('true'); + expect(hiddenInput.tabIndex).toBe(-1); + + await fireEvent.click(screen.getByRole('button', { name: 'Submit form' })); + expect(onSubmitFormData).toHaveBeenLastCalledWith([]); + + await fireEvent.click(screen.getByRole('checkbox', { name: 'Enable feature' })); + await fireEvent.click(screen.getByRole('button', { name: 'Submit form' })); + expect(onSubmitFormData).toHaveBeenLastCalledWith([['featureFlag', 'enabled']]); + + const props: CheckboxProps = { 'aria-label': 'Standalone' }; + const standalone = render(Checkbox, props); + expect(standalone.container.querySelector('input[type="checkbox"]')).toBeNull(); +}); + +test('QuestionForm uses Space for selection and Enter never submits option or custom checkboxes', async () => { + const onanswer = vi.fn(); + const onskip = vi.fn(); + const question: IslandQuestion = { + id: 'question_keyboard', + sessionId: 'session', + title: 'Choose targets', + answerable: true, + fields: [ + { + key: 'targets_key', + type: 'multiselect', + title: 'Targets', + options: [{ value: 'web_value', label: 'Web label' }], + custom: true, + required: true, + }, + ], + }; + const config = { preferences: { locale: 'en' } } as unknown as ConfigStore; + render( + QuestionForm, + { question, busy: false, onanswer, onskip }, + { wrapper: ContextHarness, wrapperProps: { config } }, + ); + + const option = screen.getByRole('checkbox', { name: 'Web label' }) as HTMLButtonElement; + option.focus(); + await fireEvent.keyDown(option, { key: ' ' }); + expect(option.getAttribute('aria-checked')).toBe('true'); + + await fireEvent.input(screen.getByRole('textbox', { name: 'Custom answer for Targets' }), { + target: { value: 'Custom value' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Add' })); + const custom = screen.getByRole('checkbox', { name: 'Custom value' }) as HTMLButtonElement; + + for (const checkbox of [option, custom]) { + expect(checkbox.type).toBe('button'); + checkbox.focus(); + const enter = createEvent.keyDown(checkbox, { key: 'Enter' }); + await fireEvent(checkbox, enter); + expect(enter.defaultPrevented).toBe(true); + expect(checkbox.getAttribute('aria-checked')).toBe('true'); + } + expect(onanswer).not.toHaveBeenCalled(); + expect(onskip).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole('button', { name: 'Submit answers' })); + expect(onanswer).toHaveBeenCalledExactlyOnceWith({ targets_key: ['web_value', 'Custom value'] }); +}); diff --git a/src/components/checkbox/checkbox.svelte b/src/components/checkbox/checkbox.svelte index 7871cca..f17fc8c 100644 --- a/src/components/checkbox/checkbox.svelte +++ b/src/components/checkbox/checkbox.svelte @@ -1,8 +1,15 @@ + + {#snippet children({ checked, indeterminate })} -
    - {#if checked} - - {:else if indeterminate} + {#if indeterminate} + {:else if checked} + {/if} -
    + {/snippet}
    diff --git a/src/components/checkbox/index.ts b/src/components/checkbox/index.ts index f52cb02..6354421 100644 --- a/src/components/checkbox/index.ts +++ b/src/components/checkbox/index.ts @@ -1,6 +1,8 @@ -import Root from './checkbox.svelte'; +import Root, { type CheckboxProps } from './checkbox.svelte'; export { + type CheckboxProps as Props, + type CheckboxProps, Root, // Root as Checkbox, diff --git a/src/routes/AGENTS.md b/src/routes/AGENTS.md index f47a2fd..3b6fcb7 100644 --- a/src/routes/AGENTS.md +++ b/src/routes/AGENTS.md @@ -10,7 +10,7 @@ Client-side pages for runtime switching, usage monitoring, and configuration man src/routes/ # SvelteKit page module ├── +layout.ts # Exports ssr = false ├── +layout.svelte # Config/i18n initialization and window-specific listeners -├── +page.svelte # /: runtime dashboard, metrics, and token usage +├── +page.svelte # /: runtime dashboard, quick actions, system status, and token usage ├── __tests__/ # Dashboard, layout preferences, and shared list refresh tests ├── providers/ # /providers: provider registry │ ├── +page.svelte # Searchable provider list and delete confirmation @@ -41,8 +41,10 @@ src/routes/ # SvelteKit page module │ ├── [id]/edit/+page.svelte # /groups/[id]/edit: group lookup and edit wrapper │ ├── GroupForm.svelte # Group identity, type, agent/category mappings │ └── __tests__/ # Validation, mappings, and type-switch tests -├── mcp/ # /mcp: Global management and exact project-root browsing -│ ├── +page.svelte # Scoped server list, optional Global comparison, diagnostics +├── mcp/ # /mcp: Global management, project-root scopes, and MCP Proxy +│ ├── +page.svelte # Tabbed container (config / proxy), scoped list, diagnostics +│ ├── ProxyPanel.svelte # MCP Proxy gateway status, server list, and unit management +│ ├── ProxyServerForm.svelte # Proxy server editor and MCP import dialog │ ├── new/+page.svelte # /mcp/new: creation wrapper │ ├── [id]/+page.svelte # /mcp/[id]: masked detail and inline edit mode │ ├── McpForm.svelte # Full native JSON creation/editing @@ -56,12 +58,15 @@ src/routes/ # SvelteKit page module │ ├── SkillForm.svelte # Full Markdown document creation/editing │ └── __tests__/ # List/detail/edit and safe content tests ├── settings/ # /settings: system settings, OpenCode CLI, and app updates -│ ├── +page.svelte # Autostart readback and independent lid/update sections +│ ├── +page.svelte # Persistent tabs ('general' | 'mcp-proxy'), active-only native lifecycles +│ ├── SettingsSearch.svelte # Static bilingual lookup, cross-tab navigation and non-action focus targets │ ├── LidProtection.svelte # Native phase display, polling, and restore actions │ ├── AppUpdates.svelte # Installed version, explicit release check, and native macOS guide +│ ├── McpProxySettings.svelte # General MOM HTTP / MCP Proxy sections sharing polling and guarded saves │ ├── MiniIsland.svelte # Independent macOS-only enable switch and readback │ ├── OpenCodeSettings.svelte # Native local CLI inspection, explicit checks, guarded upgrades │ ├── OpenCodeService.svelte # Saved Shared/fixed target, local probes, component-local secret draft +│ ├── RotateKeyDialog.svelte # Ephemeral token rotation dialog │ └── __tests__/ # Autostart, lid lifecycle, updates, openers, and navigation tests ├── island/ # /island: native mini island window │ ├── +page.svelte # Native-sized backdrop, CSS content motion, lazy sessions/messages and guarded actions @@ -77,7 +82,7 @@ src/routes/ # SvelteKit page module | Responsibility | File | Description | | :--- | :--- | :--- | | Bootstrap | `+layout.svelte` | Creates config/i18n contexts; disables global initialization for `/island`; registers main/tray listeners and disposes subscriptions. | -| Dashboard | `+page.svelte` | Shares one token-usage read between independently selected usage and heatmap periods. | +| Dashboard | `+page.svelte` | Composes runtime selectors, Quick Actions, System Status, Heatmap, and Token Usage while sharing one activity read between independent periods. | | Provider validation | `providers/schema.ts` | Requires package on creation; parses optional string-valued headers. | | Model mapping | `models/modelForm.ts` | Converts editor strings/variant rows to model definitions and back. | | Agent editing | `agents/agentForm.ts`, `agents/PermissionEditor.svelte` | Validates fields, joins model variants, and serializes ordered permission rules. | @@ -95,19 +100,23 @@ src/routes/ # SvelteKit page module ## Conventions -- **Page composition**: New/edit wrappers provide translated `PageHead` and `` titles, then reuse their route's form component; MCP/skill details host inline editors. +- **Dashboard status**: Runtime browsing/switch dialogs stay separate from automatic configuration writes. MCP Proxy status uses translated `mcp.proxy.running` / `mcp.proxy.stopped` text in a compact, wrap-safe status treatment rather than numeric-metric typography; failed reads remain unknown. Scope Heatmap assertions to its labelled region because Token Usage may share period captions. + +- **Page composition**: New/edit wrappers provide translated `PageHead` and `` titles inside `FormPage`, then reuse their route's form component. Editors keep `.form-body` and `FormActions` inside the native form so fields scroll independently. MCP/skill details activate the frame only while editing. - **Forms**: Provider/model/agent/group editors use TanStack Form with Zod; MCP/skill editors retain full JSON/Markdown drafts. `ModelForm` delegates persistence through `onSave`. - **Navigation**: Use links for route entry and `goto` from `$app/navigation` after successful saves; read IDs through `$app/state`. Model/agent/MCP/skill links encode identifiers with `encodeURIComponent`. - **List refresh**: Provider/model/agent/group lists call `config.refresh(true)` and guard loading/saving/local refresh; MCP/skill lists reload only their own data and diagnostics. Pagination uses five rows for the former and ten for the latter. - **Async reads**: MCP/skill lists and details invalidate request sequences on teardown or route changes; preserve these guards so stale responses cannot replace current data. - **Resource scopes**: Scope controls exist only on MCP/Agent/Skill lists, not app chrome or drafts. Exact project reads pass the saved project ID; list scope effects clear old data/search/page/delete state and invalidate stale requests. Agent project/comparison reads never replace config.agents; built-in tabs remain static. Optional Global comparison lazily reads Global, appends rows with scope-aware keys without merging equal names, and has independent request guards/errors. Project Refresh rereads both visible scopes. - **Read-only navigation**: MCP/skill details use `?project=`; project edit intent and Global saved banners are ignored, and retry/refresh preserve scope. Global comparison links use `?readonly=1`; Agent `[id]` detail is always read-only. Details never change shared scope. New and Agent edit routes remain Global and show a short destination notice when a project is selected. -- **Settings updates**: `AppUpdates` only calls local `getAppInfo` on mount; `checkForUpdates` requires an explicit click from an icon-only action with translated check/pending/retry accessible names and title tooltips. Release downloads and optional Stars use `openProjectPage`, never frontend URLs or automatic installation. Show the expandable macOS security guide only for native `platform === 'macos'` with an available update; quarantine commands remain selectable text, not executable controls. Pending operations are guarded and invalidated on teardown. +- **Settings updates**: Both tab panels stay mounted, preserving child-local drafts; hidden sections start no native work and clear polling timers without clearing pending operations. Search indexes static bilingual keys only and navigates to non-action focus targets. `AppUpdates` reads local `getAppInfo` only while active; `checkForUpdates` requires an explicit click from an icon-only action with translated check/pending/retry accessible names and title tooltips. Release downloads and optional Stars use `openProjectPage`, never frontend URLs or automatic installation. Show the expandable macOS security guide only for native `platform === 'macos'` with an available update; quarantine commands remain selectable text, not executable controls. Pending operations are guarded and invalidated on teardown. - **CLI updates**: `OpenCodeSettings` remains separate from app updates. Native mount confirms app info, reads local installation and independently probes the saved service without bootstrap; browser mode performs no calls. Installation metadata stays in background state; the update header groups accessible icon-only local refresh and explicit check actions, and inspection retry/service refresh controls are icon-only too. Explicit checks own installation/version results. Upgrade requires `canUpgrade`, an available checked update, and an exact path/version confirmation shown only in the dialog; CLI and service pending operations guard both panels. Failure preserves known details and requires explicit refresh before another check/upgrade. Success reads back installation and service status once, never reloads or restarts a service. - **Service target**: `OpenCodeService` keeps URL/password/clear drafts local across refresh/failure and commits only successful saves, including offline targets. Passwords never read back and clear on success/teardown. Saved target and connected URL/PID/backend version remain separate from the draft and CLI version; failed reads or status errors cannot advertise stale connection data. Render only safe generic or code-based busy/validation hints, never backend error bodies or credentials. - **Detail editing**: MCP/skill `?edit=1` opens editing after loading; keyed forms retain immutable expected snapshots, keep drafts on failure, and do not automatically reload OpenCode. Remote skills remain read-only. - **MCP display**: Read-only targets/search use `maskTarget`; detail fields use `maskConfig`. Only the explicit JSON editor receives unmasked configuration. +- **Scrollable previews**: Height-bounded Agent/MCP/Skill previews remain keyboard-focusable and use translated accessible names. - **Draft resets**: Agent/group forms initialize on identity changes and `config.formResetVersion`, not every reactive store update. Agent edits freeze the selected source's fields/clear-fields baseline plus `resolvedPath`/backend `content` string; background refresh and failed-save retry never replace it. Explicit source changes/reset reload it. Echo `expectedPath`/`expectedContent`, never stringify `raw` to build a numeric-lossy token; create is unchanged and missing update snapshots are rejected by the backend. +- **MCP Proxy**: General owns MOM HTTP port/LAN/status; the MCP Proxy tab retains enabled, timeouts, capacity, and token rotation. Both reuse `McpProxySettings`; HTTP saves use `setMomHttpSettings`, while proxy saves submit only proxy fields with their expected revision. Runtime polling preserves dirty drafts and their expected revision; save/read guards never overlap and polling resumes after writes. Proxy panel async completions are mounted-guarded. Server forms preserve literal command arguments and environment values. - **Tray mode**: `/tray` bypasses `AppShell`/`SplashScreen`, applies `tray-window`, and disables catalog prefetch. Main listens for `tray:navigate`/`tray:config-changed`; tray layout listens for `app:preferences-changed`. - **Island mode**: `/island` bypasses `AppShell`/`SplashScreen` and all global config preload. Every screen starts and collapses compact; native notch geometry changes information density, not the view. Non-notched compact adds short status/count text beside the same single activity indicator. Before its initial snapshot read, the native-targeted Island registers only `island:changed`, `island:collapsed`, `island:pointer-enter`, `island:pointer-leave`, and `app:preferences-changed`. Void pointer events update native hover alongside separate DOM hover using the shared 100 ms entry/180 ms protected leave timers, never focus/expand/read/mutate; expanded ignores hover, and disable/collapse/teardown clear both sources. It coalesces reads, fetches messages only while expanded, and cleans listeners/timers including late registrations on teardown. - **Island safety**: Ambiguous send/409 failures retain the Session draft and caller message ID; retries require explicit Refresh and never generate a replacement ID. Disconnected or failed snapshot reads show unknown session status, not Idle. Backend `answerable` owns metadata/constraint checks; UI accepts only string/multiselect fields and field-key/option-value answers. Collapse/disable/teardown never reply to requests or stop sessions. diff --git a/src/routes/agents/AgentForm.svelte b/src/routes/agents/AgentForm.svelte index 63dfd8e..a9dbbe4 100644 --- a/src/routes/agents/AgentForm.svelte +++ b/src/routes/agents/AgentForm.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { Button } from '$src/components/button/index.js'; import FormActions from '$src/components/FormActions.svelte'; +import GlobalDestination from '$src/components/GlobalDestination.svelte'; import Select from '$src/components/Select.svelte'; import { Switch } from '$src/components/switch/index.js'; import { getConfig } from '$src/config/context.js'; @@ -185,9 +186,6 @@ function changeModel(model: string) { {#if builtin}
    {i18n.t('agents.builtinReadOnly')}
    {:else if !edit || agent} - {#if edit} -

    {i18n.t('agents.editNotice')}

    - {/if}
    -
    +
    + + {#if edit} +

    {i18n.t('agents.editNotice')}

    + {/if} {#if edit}
    {i18n.t('agents.colSource')} @@ -213,14 +215,14 @@ function changeModel(model: string) { }))} /> {#if selectedSource} - {i18n.t('agents.pathLabel', { path: selectedSource.path ?? i18n.t('agents.noPath') })} {:else} - {i18n.t('agents.sourceMissing')} + {i18n.t('agents.sourceMissing')} {/if} {#if agent?.source === 'both'} - {i18n.t('agents.effectiveOverrides')} + {i18n.t('agents.effectiveOverrides')} {/if}
    @@ -336,7 +338,7 @@ function changeModel(model: string) { ]} /> {/snippet} - {variantHint} + {variantHint}
    @@ -399,7 +401,7 @@ function changeModel(model: string) { - {i18n.t('agents.hiddenHint')} + {i18n.t('agents.hiddenHint')} @@ -418,7 +420,7 @@ function changeModel(model: string) { - {i18n.t('agents.disableHint')} + {i18n.t('agents.disableHint')}
    {i18n.t('agents.legendPrompt')} @@ -433,7 +435,7 @@ function changeModel(model: string) { onblur={field.handleBlur} > {/snippet} - {promptHint} + {promptHint}
    diff --git a/src/routes/agents/PermissionEditor.svelte b/src/routes/agents/PermissionEditor.svelte index 58c5090..8d0a468 100644 --- a/src/routes/agents/PermissionEditor.svelte +++ b/src/routes/agents/PermissionEditor.svelte @@ -107,7 +107,7 @@ function add() { > {i18n.t('agents.addPermissionRule')} {:else if view.mode === 'json'} -

    +

    {i18n.t('agents.permissionsGlobHint')}

    {/if} @@ -125,6 +125,6 @@ function add() { >{error || i18n.t('agents.permissionsInvalid')}
    {/if} - {i18n.t('agents.permissionsHint')} + {i18n.t('agents.permissionsHint')} diff --git a/src/routes/agents/[id]/+page.svelte b/src/routes/agents/[id]/+page.svelte index a64d656..8ef79a8 100644 --- a/src/routes/agents/[id]/+page.svelte +++ b/src/routes/agents/[id]/+page.svelte @@ -3,6 +3,7 @@ import { ArrowLeft, RefreshCw } from '@lucide/svelte'; import { page } from '$app/state'; import { Button } from '$src/components/button/index.js'; import { isNotFound, readError } from '$src/components/configuration/read-error.js'; +import FormPage from '$src/components/FormPage.svelte'; import PageHead from '$src/components/PageHead.svelte'; import ResourceScopeNotice from '$src/components/ResourceScopeNotice.svelte'; import { getConfig } from '$src/config/context.js'; @@ -57,67 +58,98 @@ function text(value: unknown) { {i18n.t('agents.detailMetaTitle')} -
    - -
    - - -
    -
    -
    - -{#if loading} -
    {i18n.t('common.loadingConfiguration')}
    -{:else if error} - -{:else if agent} -
    -
    -
    -
    {i18n.t('common.mode')}
    -
    {agent.mode ?? i18n.t('common.unset')}
    -
    -
    -
    {i18n.t('common.model')}
    -
    {agent.modelRef ?? i18n.t('common.inherited')}
    + +
    + +
    + +
    -
    -
    {i18n.t('common.description')}
    -
    {agent.description || '—'}
    -
    -
    -
    - {#each agent.sources ?? [] as source, index (`${source.path ?? source.storage}:${index}`)} -
    -

    - {i18n.t('agents.sourcePreview')} - · {i18n.t(source.storage === 'inline' ? 'agents.sourceInline' : 'agents.markdown')} -

    -

    - {source.path ?? i18n.t('agents.pathNotProvided')} -

    -
    {text(source.raw ?? source.fields)}
    - {#if source.prompt && typeof source.raw !== 'string'} -

    {i18n.t('agents.prompt')}

    -
    {source.prompt}
    - {/if} -
    - {:else} -
    -

    {i18n.t('agents.sourcePreview')}

    -
    {text(agent.inline ?? agent.markdown ?? agent)}
    + + + + {#if loading} +
    {i18n.t('common.loadingConfiguration')}
    + {:else if error} + + {:else if agent} +
    +
    +
    +
    {i18n.t('common.mode')}
    +
    {agent.mode ?? i18n.t('common.unset')}
    +
    +
    +
    {i18n.t('common.model')}
    +
    {agent.modelRef ?? i18n.t('common.inherited')}
    +
    +
    +
    {i18n.t('common.description')}
    +
    {agent.description || '—'}
    +
    +
    - {/each} -{/if} + {#each agent.sources ?? [] as source, index (`${source.path ?? source.storage}:${index}`)} +
    +

    + {i18n.t('agents.sourcePreview')} + · {i18n.t(source.storage === 'inline' ? 'agents.sourceInline' : 'agents.markdown')} +

    +

    + {source.path ?? i18n.t('agents.pathNotProvided')} +

    + + +
    {text(source.raw ?? source.fields)}
    + + {#if source.prompt && typeof source.raw !== 'string'} +

    {i18n.t('agents.prompt')}

    + + +
    {source.prompt}
    + + {/if} +
    + {:else} +
    +

    {i18n.t('agents.sourcePreview')}

    + + +
    {text(agent.inline ?? agent.markdown ?? agent)}
    + +
    + {/each} + {/if} + diff --git a/src/routes/agents/[id]/edit/+page.svelte b/src/routes/agents/[id]/edit/+page.svelte index c082542..2935b76 100644 --- a/src/routes/agents/[id]/edit/+page.svelte +++ b/src/routes/agents/[id]/edit/+page.svelte @@ -1,6 +1,6 @@ {i18n.t('agents.editMetaTitle')} - - - + + + + diff --git a/src/routes/agents/__tests__/agent-detail.test.ts b/src/routes/agents/__tests__/agent-detail.test.ts index c8eda12..bc16ccd 100644 --- a/src/routes/agents/__tests__/agent-detail.test.ts +++ b/src/routes/agents/__tests__/agent-detail.test.ts @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { beforeEach, expect, test, vi } from 'vitest'; import { page as routePage } from '$app/state'; import { makeScopedConfig } from '$src/components/__tests__/scope-config.js'; +import { en } from '$src/i18n/dictionaries/en.js'; import Harness from '$src/test-utils/harness.svelte'; import type { AgentDefinition } from '$src/types/agents.js'; import AgentDetail from '../[id]/+page.svelte'; @@ -65,6 +66,12 @@ test('agent detail loads explicit project, shows source and safe text, and offer expect(getAgent).toHaveBeenCalledExactlyOnceWith('helper', 'project-a'); expect(screen.getByText('/work/alpha/.opencode/agents/helper.md')).toBeTruthy(); expect(container.querySelector('pre')?.textContent).toContain(''); + const preview = screen.getByRole('region', { + name: `${en['agents.sourcePreview']}: /work/alpha/.opencode/agents/helper.md`, + }); + expect(preview.tabIndex).toBe(0); + preview.focus(); + expect(document.activeElement).toBe(preview); expect(container.querySelector('script')).toBeNull(); expect(screen.queryByRole('button', { name: /Edit|Delete|Select configuration scope/ })).toBeNull(); expect(config.selectedProjectId).toBe('project-b'); @@ -95,3 +102,35 @@ test('same resource ID with another scope ignores late detail and Global compari expect(screen.getByText(/Global comparison · read-only/)).toBeTruthy(); expect(screen.queryByRole('link', { name: 'Edit helper' })).toBeNull(); }); + +test('source and prompt previews expose separate named keyboard entries without changing content', async () => { + const path = '/global/opencode.jsonc'; + const raw = { mode: 'subagent' }; + const prompt = 'Prompt body\n'.repeat(100); + const entry: AgentDefinition = { + ...agent, + sources: [{ storage: 'inline', path, raw, fields: raw, prompt }], + }; + const getAgent = vi.fn().mockResolvedValue(entry); + render(AgentDetail, {}, { wrapper: Harness, wrapperProps: { config: makeScopedConfig({ getAgent }) } }); + await screen.findByRole('heading', { name: agent.id }); + for (const [label, content] of [ + [en['agents.sourcePreview'], JSON.stringify(raw, null, 2)], + [en['agents.prompt'], prompt], + ]) { + const preview = screen.getByRole('region', { name: `${label}: ${path}` }); + expect(preview.textContent).toBe(content); + expect(preview.tabIndex).toBe(0); + preview.focus(); + expect(document.activeElement).toBe(preview); + } +}); + +test('legacy source preview keeps a named keyboard entry', async () => { + const getAgent = vi.fn().mockResolvedValue({ ...agent, sources: [] }); + render(AgentDetail, {}, { wrapper: Harness, wrapperProps: { config: makeScopedConfig({ getAgent }) } }); + const preview = await screen.findByRole('region', { name: `${en['agents.sourcePreview']}: ${agent.id}` }); + expect(preview.tabIndex).toBe(0); + preview.focus(); + expect(document.activeElement).toBe(preview); +}); diff --git a/src/routes/agents/new/+page.svelte b/src/routes/agents/new/+page.svelte index a4aa77e..a8ecaf4 100644 --- a/src/routes/agents/new/+page.svelte +++ b/src/routes/agents/new/+page.svelte @@ -1,5 +1,5 @@ {i18n.t('agents.newMetaTitle')} - - - + + + + diff --git a/src/routes/groups/GroupForm.svelte b/src/routes/groups/GroupForm.svelte index f3ae916..678e7d7 100644 --- a/src/routes/groups/GroupForm.svelte +++ b/src/routes/groups/GroupForm.svelte @@ -4,6 +4,7 @@ import { createForm } from '@tanstack/svelte-form'; import { z } from 'zod'; import { goto } from '$app/navigation'; import { Button } from '$src/components/button/index.js'; +import { Checkbox } from '$src/components/checkbox/index.js'; import { Combobox } from '$src/components/combobox/index.js'; import * as Dialog from '$src/components/dialog/index.js'; import FormActions from '$src/components/FormActions.svelte'; @@ -228,7 +229,7 @@ function remove(kind: MappingKind, index: number) { form.handleSubmit(); }} > -
    +
    {#snippet children(field)} @@ -266,15 +267,18 @@ function remove(kind: MappingKind, index: number) {
    {#snippet children(field)} - + /> + +
    {/snippet}
    diff --git a/src/routes/groups/[id]/edit/+page.svelte b/src/routes/groups/[id]/edit/+page.svelte index 968afff..6aa6842 100644 --- a/src/routes/groups/[id]/edit/+page.svelte +++ b/src/routes/groups/[id]/edit/+page.svelte @@ -1,6 +1,7 @@ {i18n.t('groups.newMetaTitle')} - - + + + + diff --git a/src/routes/island/AGENTS.md b/src/routes/island/AGENTS.md index 6be4387..5f81823 100644 --- a/src/routes/island/AGENTS.md +++ b/src/routes/island/AGENTS.md @@ -5,7 +5,7 @@ ```text island/ ├── +page.svelte # Three-shape notch island, CSS content motion, quick permission queue, lazy sessions -├── QuestionForm.svelte # Field-key/option-value answers and read-only fallback +├── QuestionForm.svelte # Field-key/option-value answers, shared Checkbox multiselect and read-only fallback └── __tests__/ ├── island.test.ts # Interactions, presentation lifecycle, send recovery, event bounds and cleanup └── layout.test.ts # Independent window without global preload @@ -30,6 +30,7 @@ island/ - Validation/not-found and HTTP 400/401/403/404/422 rejections leave drafts editable. Editing resets the ID; unchanged manual retry retains it. - Compact running counts require a connected snapshot. Writes also require `enabled === true`. Collapse, unmount and discard never reject requests or interrupt sessions. - All fixed copy uses `island.*` keys in both dictionaries; backend `answerable` owns form metadata and constraint validation. +- QuestionForm multiselect options and custom values use the shared Checkbox with explicit `id`/`for`, Island gold styling and explicit busy/read-only disabled props. Preserve field keys and option values. Space toggles; Enter on either checkbox must not submit the form. Keyboard, ARIA state and explicit answer submission regression coverage lives in `../../components/checkbox/__tests__/checkbox.test.ts`. ## Sync diff --git a/src/routes/island/QuestionForm.svelte b/src/routes/island/QuestionForm.svelte index 26abc1a..6156c82 100644 --- a/src/routes/island/QuestionForm.svelte +++ b/src/routes/island/QuestionForm.svelte @@ -1,4 +1,5 @@ {i18n.t('mcp.detailMetaTitle')} -
    - - {#if !editing} -
    - - - {#if server && !readonly} - +
    + + {#if !editing} +
    + + - - {/if} -
    - {/if} -
    -
    - -{#if editing} - -{/if} -{#if !readonly && (saved || page.url.searchParams.get('saved') === '1')} -
    {i18n.t('configuration.saved')}
    -{/if} -{#if loading} -
    {i18n.t('common.loadingConfiguration')}
    -{:else if error} - -{:else if server} - {#if editing && !readonly} - {#key identity} - { editing = false; }} - onSaved={(result) => { data = result; editing = false; saved = true; }} - /> - {/key} - {:else} -
    -
    -
    -
    {i18n.t('common.type')}
    -
    {i18n.t(server.type === 'local' ? 'mcp.local' : 'mcp.remote')}
    -
    -
    -
    {i18n.t('mcp.configuration')}
    -
    {i18n.t(server.disabled ? 'common.disabled' : 'common.enabled')}
    + {#if server && !readonly} + + + {/if}
    -
    -
    {i18n.t('mcp.target')}
    -
    - {maskTarget(server) || '—'} -
    -
    -
    -
    - {i18n.t(projectId !== null ? 'configuration.source' : 'configuration.globalSources')} -
    -
    -
      - {#each server.sourcePaths.length ? server.sourcePaths : [server.sourcePath] as path} -
    • {path}
    • - {/each} -
    -
    -
    -
    -
    -
    -

    - {i18n.t('mcp.nativeConfig')} -

    -

    {i18n.t('mcp.credentialHint')}

    -
    - {#each fields as [key, value]} -
    -
    {key}
    -
    -
    {typeof value === 'string' ? value : prettyJson(value)}
    + {/if} + +
    + + {#if !readonly && (saved || page.url.searchParams.get('saved') === '1')} +
    {i18n.t('configuration.saved')}
    + {/if} + {#if loading} +
    {i18n.t('common.loadingConfiguration')}
    + {:else if error} + + {:else if server} + {#if editing && !readonly} + {#key identity} + { editing = false; }} + onSaved={(result) => { data = result; editing = false; saved = true; }} + /> + {/key} + {:else} +
    +
    +
    +
    {i18n.t('common.type')}
    +
    {i18n.t(server.type === 'local' ? 'mcp.local' : 'mcp.remote')}
    +
    +
    +
    {i18n.t('mcp.configuration')}
    +
    {i18n.t(server.disabled ? 'common.disabled' : 'common.enabled')}
    +
    +
    +
    {i18n.t('mcp.target')}
    +
    + {maskTarget(server) || '—'}
    - {:else} -

    {i18n.t('mcp.noConfig')}

    - {/each} -
    -
    +
    +
    + {i18n.t(projectId !== null ? 'configuration.source' : 'configuration.globalSources')} +
    +
    +
      + {#each server.sourcePaths.length ? server.sourcePaths : [server.sourcePath] as path} +
    • {path}
    • + {/each} +
    +
    +
    +
    +
    +
    +

    + {i18n.t('mcp.nativeConfig')} +

    +

    {i18n.t('mcp.credentialHint')}

    +
    + {#each fields as [key, value]} +
    +
    {key}
    +
    + + +
    {typeof value === 'string' ? value : prettyJson(value)}
    + +
    +
    + {:else} +

    {i18n.t('mcp.noConfig')}

    + {/each} +
    +
    + {/if} {/if} -{/if} - goto('/mcp?removed=1')} /> + goto('/mcp?removed=1')} /> + diff --git a/src/routes/mcp/__tests__/mcp.test.ts b/src/routes/mcp/__tests__/mcp.test.ts index e783600..98cea0b 100644 --- a/src/routes/mcp/__tests__/mcp.test.ts +++ b/src/routes/mcp/__tests__/mcp.test.ts @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { page as routePage } from '$app/state'; import { makeScopedConfig } from '$src/components/__tests__/scope-config.js'; import type { ConfigStore } from '$src/config/store.svelte.js'; +import { en } from '$src/i18n/dictionaries/en.js'; import Harness from '$src/test-utils/harness.svelte'; import type { McpServer } from '$src/types/mcp.js'; import McpList from '../+page.svelte'; @@ -192,6 +193,12 @@ test('detail masks secrets, confirms sources, blocks dismissal while deleting, a expect(document.body.textContent).not.toContain('secret-env'); expect(document.body.textContent).not.toContain('secret-oauth'); expect(document.body.textContent).toContain('public-id'); + for (const key of Object.keys(server.config)) { + const preview = screen.getByRole('region', { name: `${en['mcp.nativeConfig']}: ${key}` }); + expect(preview.tabIndex).toBe(0); + preview.focus(); + expect(document.activeElement).toBe(preview); + } await fireEvent.click(screen.getByRole('button', { name: 'Delete' })); let dialog = await screen.findByRole('dialog'); await fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); @@ -588,7 +595,7 @@ test('MCP Global comparison detail rejects forged edit intent without selecting expect(screen.queryByRole('textbox')).toBeNull(); expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull(); - expect(screen.getByText(/Switch the list to Global to edit/)).toBeTruthy(); + expect(screen.getByText(en['scope.globalReadOnly'])).toBeTruthy(); expect(config.selectedProjectId).toBe('project-a'); }); diff --git a/src/routes/mcp/new/+page.svelte b/src/routes/mcp/new/+page.svelte index a4c8e19..9d9df13 100644 --- a/src/routes/mcp/new/+page.svelte +++ b/src/routes/mcp/new/+page.svelte @@ -1,5 +1,5 @@ {i18n.t('mcp.newMetaTitle')} - - -

    {i18n.t('mcp.newDescription')}

    - + + + + diff --git a/src/routes/models/ModelForm.svelte b/src/routes/models/ModelForm.svelte index 69ada7a..3267bb1 100644 --- a/src/routes/models/ModelForm.svelte +++ b/src/routes/models/ModelForm.svelte @@ -167,7 +167,7 @@ function removeVariant(index: number) { form.handleSubmit(); }} > -
    +
    {i18n.t('modelForm.identity')}
    @@ -276,7 +276,7 @@ function removeVariant(index: number) { >
    - {i18n.t('modelForm.capabilitiesHint')} + {i18n.t('modelForm.capabilitiesHint')}
    @@ -406,7 +406,7 @@ function removeVariant(index: number) { >
    - {i18n.t('modelForm.costHint')} + {i18n.t('modelForm.costHint')}

    {i18n.t('modelForm.limits')}

    @@ -477,7 +477,7 @@ function removeVariant(index: number) { >
    - {i18n.t('modelForm.limitsHint')} + {i18n.t('modelForm.limitsHint')}
    @@ -503,7 +503,7 @@ function removeVariant(index: number) { {/if} {/snippet} - {i18n.t('modelForm.optionsHint')} + {i18n.t('modelForm.optionsHint')}

    {i18n.t('modelForm.headers')}

    @@ -523,7 +523,7 @@ function removeVariant(index: number) { {/if} {/snippet} - {i18n.t('modelForm.headersHint')} + {i18n.t('modelForm.headersHint')}

    {i18n.t('modelForm.variants')}

    @@ -576,7 +576,7 @@ function removeVariant(index: number) { > {i18n.t('modelForm.addVariant')}
    - {i18n.t('modelForm.variantsHint')} + {i18n.t('modelForm.variantsHint')} diff --git a/src/routes/models/[id]/edit/+page.svelte b/src/routes/models/[id]/edit/+page.svelte index 976c8e3..e3dad5c 100644 --- a/src/routes/models/[id]/edit/+page.svelte +++ b/src/routes/models/[id]/edit/+page.svelte @@ -1,6 +1,7 @@ {i18n.t('models.editMetaTitle')} - -{#if found} - -{:else if !config.loading} - -{/if} + + + {#if found} + + {:else if !config.loading} + + {/if} + diff --git a/src/routes/models/new/+page.svelte b/src/routes/models/new/+page.svelte index 44edec8..22ec041 100644 --- a/src/routes/models/new/+page.svelte +++ b/src/routes/models/new/+page.svelte @@ -1,5 +1,6 @@ {i18n.t('models.newMetaTitle')} - -{#if !config.loading && config.providers.length === 0} - -{:else} - -{/if} + + + {#if !config.loading && config.providers.length === 0} + + {:else} + + {/if} + diff --git a/src/routes/providers/ProviderForm.svelte b/src/routes/providers/ProviderForm.svelte index 0df62e5..e3bd27b 100644 --- a/src/routes/providers/ProviderForm.svelte +++ b/src/routes/providers/ProviderForm.svelte @@ -92,7 +92,7 @@ $effect(() => { form.handleSubmit(); }} > -
    +
    {#if source} @@ -210,7 +210,7 @@ $effect(() => {
    {#if source} - {i18n.t('providers.keepKeyHint')} + {i18n.t('providers.keepKeyHint')} {/if}
    @@ -234,7 +234,7 @@ $effect(() => { {/if} {/snippet} - {i18n.t('providers.headersHint')} + {i18n.t('providers.headersHint')}
    diff --git a/src/routes/providers/[id]/edit/+page.svelte b/src/routes/providers/[id]/edit/+page.svelte index d8784d5..1e3a474 100644 --- a/src/routes/providers/[id]/edit/+page.svelte +++ b/src/routes/providers/[id]/edit/+page.svelte @@ -1,5 +1,6 @@ {i18n.t('providers.editMetaTitle')} - -{#if source} - -{:else if !config.loading} - -{/if} + + + {#if source} + + {:else if !config.loading} + + {/if} + diff --git a/src/routes/providers/new/+page.svelte b/src/routes/providers/new/+page.svelte index 1fdc74d..d54c527 100644 --- a/src/routes/providers/new/+page.svelte +++ b/src/routes/providers/new/+page.svelte @@ -1,4 +1,5 @@ {i18n.t('providers.newMetaTitle')} - - + + + + diff --git a/src/routes/skills/SkillForm.svelte b/src/routes/skills/SkillForm.svelte index b577c3d..770977a 100644 --- a/src/routes/skills/SkillForm.svelte +++ b/src/routes/skills/SkillForm.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { Button } from '$src/components/button/index.js'; import { readError } from '$src/components/configuration/read-error.js'; import FormActions from '$src/components/FormActions.svelte'; +import GlobalDestination from '$src/components/GlobalDestination.svelte'; import { getConfig } from '$src/config/context.js'; import { getI18n } from '$src/i18n/context.js'; import type { SkillEntry } from '$src/types/skills.js'; @@ -58,12 +59,16 @@ async function save() { aria-busy={pending} onsubmit={(event) => { event.preventDefault(); void save(); }} > - {#if error} - - {/if} -
    +
    + + {#if !initial} +

    {i18n.t('skills.newDescription')}

    + {/if} + {#if error} + + {/if}
    - {i18n.t(initial ? 'skills.idImmutable' : 'skills.idHint')} + {i18n.t(initial ? 'skills.idImmutable' : 'skills.idHint')}
    {#if initial}
    @@ -95,7 +98,7 @@ async function save() { autocomplete="off" aria-describedby="skill-document-hint" > - {i18n.t('skills.documentManagementHint')} + {i18n.t('skills.documentManagementHint')}

    {i18n.t('configuration.manualReload')}

    diff --git a/src/routes/skills/[id]/+page.svelte b/src/routes/skills/[id]/+page.svelte index cdc5acb..4750a4b 100644 --- a/src/routes/skills/[id]/+page.svelte +++ b/src/routes/skills/[id]/+page.svelte @@ -3,7 +3,7 @@ import { ArrowLeft, Pencil, RefreshCw } from '@lucide/svelte'; import { page } from '$app/state'; import { Button } from '$src/components/button/index.js'; import { isNotFound, readError } from '$src/components/configuration/read-error.js'; -import GlobalDestination from '$src/components/GlobalDestination.svelte'; +import FormPage from '$src/components/FormPage.svelte'; import PageHead from '$src/components/PageHead.svelte'; import ResourceScopeNotice from '$src/components/ResourceScopeNotice.svelte'; import { getConfig } from '$src/config/context.js'; @@ -61,103 +61,116 @@ $effect(() => { {i18n.t('skills.detailMetaTitle')} -
    - - {#if !editing} -
    - - - {#if skill?.kind === 'local' && !readonly} - +
    + + {#if !editing} +
    + + - {/if} -
    - {/if} -
    -
    - -{#if editing} - -{/if} -{#if !readonly && (saved || page.url.searchParams.get('saved') === '1')} -
    {i18n.t('configuration.saved')}
    -{/if} -{#if loading} -
    {i18n.t('common.loadingConfiguration')}
    -{:else if error} - -{:else if skill} - {#if editing && skill.kind === 'local' && !readonly} - {#key identity} - { editing = false; }} - onSaved={(result) => { data = result; editing = false; saved = true; }} - /> - {/key} - {:else} - {#if skill.kind === 'remote'} -
    {i18n.t('skills.remoteHint')}
    - {/if} -
    -
    -
    -
    {i18n.t('configuration.id')}
    -
    {skill.id}
    -
    -
    -
    {i18n.t('common.type')}
    -
    - {i18n.t(skill.kind === 'local' ? 'skills.local' : 'skills.remote')} { editing = true; saved = false; }} + >{i18n.t('configuration.edit')} -
    -
    -
    -
    {i18n.t('skills.autoinvoke')}
    -
    {i18n.t(skill.autoinvoke ? 'common.enabled' : 'common.disabled')}
    -
    -
    -
    {i18n.t('common.description')}
    -
    {skill.description || '—'}
    -
    -
    -
    {i18n.t('configuration.source')}
    -
    {skill.source}
    + {/if}
    -
    -
    {i18n.t('configuration.path')}
    -
    {skill.path}
    -
    -
    -
    -
    -
    -

    {i18n.t('skills.content')}

    - {i18n.t('skills.contentHint')} -
    - {#if skill.content.trim()} -
    {skill.content}
    - {:else} -

    {i18n.t('skills.emptyContent')}

    {/if} -
    + +
    + + {#if !readonly && (saved || page.url.searchParams.get('saved') === '1')} +
    {i18n.t('configuration.saved')}
    + {/if} + {#if loading} +
    {i18n.t('common.loadingConfiguration')}
    + {:else if error} + + {:else if skill} + {#if editing && skill.kind === 'local' && !readonly} + {#key identity} + { editing = false; }} + onSaved={(result) => { data = result; editing = false; saved = true; }} + /> + {/key} + {:else} + {#if skill.kind === 'remote'} +
    {i18n.t('skills.remoteHint')}
    + {/if} +
    +
    +
    +
    {i18n.t('configuration.id')}
    +
    {skill.id}
    +
    +
    +
    {i18n.t('common.type')}
    +
    + {i18n.t(skill.kind === 'local' ? 'skills.local' : 'skills.remote')} +
    +
    +
    +
    {i18n.t('skills.autoinvoke')}
    +
    {i18n.t(skill.autoinvoke ? 'common.enabled' : 'common.disabled')}
    +
    +
    +
    {i18n.t('common.description')}
    +
    + {skill.description || '—'} +
    +
    +
    +
    {i18n.t('configuration.source')}
    +
    {skill.source}
    +
    +
    +
    {i18n.t('configuration.path')}
    +
    {skill.path}
    +
    +
    +
    +
    +
    +

    {i18n.t('skills.content')}

    + {i18n.t('skills.contentHint')} +
    + {#if skill.content.trim()} + + +
    {skill.content}
    + + {:else} +

    {i18n.t('skills.emptyContent')}

    + {/if} +
    + {/if} {/if} -{/if} + diff --git a/src/routes/skills/__tests__/skills.test.ts b/src/routes/skills/__tests__/skills.test.ts index c9722ee..5fcaafa 100644 --- a/src/routes/skills/__tests__/skills.test.ts +++ b/src/routes/skills/__tests__/skills.test.ts @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { page as routePage } from '$app/state'; import { makeScopedConfig } from '$src/components/__tests__/scope-config.js'; import type { ConfigStore } from '$src/config/store.svelte.js'; +import { en } from '$src/i18n/dictionaries/en.js'; import Harness from '$src/test-utils/harness.svelte'; import type { SkillEntry } from '$src/types/skills.js'; import SkillList from '../+page.svelte'; @@ -99,6 +100,10 @@ test('detail displays full Markdown safely as plain text with metadata', async ( const { container } = render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config: makeConfig() } }); await screen.findByRole('heading', { name: skill.name }); expect(container.querySelector('pre')?.textContent).toBe(skill.content); + const preview = screen.getByRole('region', { name: `${en['skills.content']}: ${skill.name}` }); + expect(preview.tabIndex).toBe(0); + preview.focus(); + expect(document.activeElement).toBe(preview); expect(container.querySelector('script')).toBeNull(); expect(container.querySelector('img')).toBeNull(); expect(screen.getByText(skill.path)).toBeTruthy(); @@ -247,7 +252,7 @@ test('Remote detail rejects edit query and forged form entry without update IPC' const config = makeConfig({ getSkill: vi.fn().mockResolvedValue(remote), updateSkill }); page.url = new URL('http://localhost/skills/docs?edit=1'); const mounted = render(SkillDetail, {}, { wrapper: Harness, wrapperProps: { config } }); - await screen.findByText(/This Remote skill is read-only/); + await screen.findByText(en['skills.remoteHint']); expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); expect(screen.queryByRole('textbox')).toBeNull(); mounted.unmount(); @@ -435,7 +440,7 @@ test('Skill Global comparison detail ignores edit query and says switch Global t expect(getSkill).toHaveBeenCalledExactlyOnceWith(skill.id); expect(screen.queryByRole('textbox')).toBeNull(); expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); - expect(screen.getByText(/Switch the list to Global to edit/)).toBeTruthy(); + expect(screen.getByText(en['scope.globalReadOnly'])).toBeTruthy(); expect(config.selectedProjectId).toBe('project-a'); }); diff --git a/src/routes/skills/new/+page.svelte b/src/routes/skills/new/+page.svelte index dbbfdae..442122c 100644 --- a/src/routes/skills/new/+page.svelte +++ b/src/routes/skills/new/+page.svelte @@ -1,5 +1,5 @@ {i18n.t('skills.newMetaTitle')} - - -

    {i18n.t('skills.newDescription')}

    - + + + + diff --git a/src/styles/AGENTS.md b/src/styles/AGENTS.md index 06e99e9..08b591e 100644 --- a/src/styles/AGENTS.md +++ b/src/styles/AGENTS.md @@ -29,7 +29,7 @@ styles/ # Shared sheets imported by src/app.css | Global defaults | `base.css` | Applies app surface/text/fonts, form font inheritance, and visible focus styling. | | Shell geometry | `layout.css` | Keeps shell/workspace viewport-height and makes `.content` the scrolling region. | | Table behavior | `table.css` | Uses separate borders and sticky rightmost action cells with opaque panel backgrounds. | -| Form behavior | `forms.css` | Direct-child form selectors scroll `.form-grid` while keeping `.form-actions` pinned. | +| Form behavior | `forms.css` | Explicit `.form-page` frames keep headings visible; only `.form-body` scrolls above in-flow `.form-actions`. Shared `.field-hint` and `.form-hint` style ordinary help. | | Adaptive overrides | `responsive.css` | Adjusts navigation/forms/tables at 760 px and reduces animation/transition duration. | ## Conventions @@ -39,7 +39,8 @@ styles/ # Shared sheets imported by src/app.css - **Theme overrides**: Keep dark defaults in `:root` and light color overrides in the following `[data-theme="light"]` block; extend both when adding a color-bearing token. - **File ownership**: Extend the sheet matching the semantic selector's responsibility; keep page heading, search, feedback, form, table, and roster rules out of shell `layout.css`. - **Scroll boundaries**: Preserve `min-width: 0` on the workspace/content and `min-height: 0` on flexing scroll regions; shell/workspace hide overflow while `.content` scrolls vertically. -- **Pinned forms**: The pinned footer depends on `.content > .form-panel > .form-grid` and sibling `.form-actions`; preserve this direct-child structure when relying on these rules. +- **Pinned forms**: `.content > .form-page` fills the available workspace; `.form-panel` flexes between its heading and bottom edge. Its direct `.form-body` owns vertical scrolling and `.form-actions` stays in flow, never viewport-fixed. Nested `.form-grid` only arranges fields. Keep destination notices, help and validation feedback inside the body so short viewports retain usable field space. Dialog editors use the same body/actions boundary inside a height-constrained flex container. +- **Help hierarchy**: Ordinary field help uses `.field-hint`, standalone form introductions use `.form-hint`: 11 px, 18 px line height, `--text-secondary`. Keep warning/error banners, credential notices, read-only state and overwrite risks separate; do not mute safety feedback into ordinary help. Keep copy short and action-oriented. - **Table actions**: Retain `border-collapse: separate`, zero border spacing, and sticky `th.th-actions`/`td.row-actions`; action buttons are spaced with sibling margins inside a plain table cell. - **Responsive overrides**: Keep shared mobile changes in the 760 px media query. Preserve the `.mobile-menu` display overrides with `!important`, which counter the Button primitive's `inline-flex` utility. - **Scrollbar coverage**: Update standard `scrollbar-*` rules and matching WebKit track/thumb/state selectors together when adding a shared scrolling container; drawer and textarea tracks use `--scrollbar-track-raised`. diff --git a/src/styles/forms.css b/src/styles/forms.css index 64e38a1..a58e1fc 100644 --- a/src/styles/forms.css +++ b/src/styles/forms.css @@ -1,7 +1,14 @@ /* Forms: panels, grids, fields, key-value rows and fieldset groups. */ .form-panel { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + min-width: 0; + min-height: 0; max-width: 860px; padding: 24px; + overflow: hidden; } .form-grid { display: grid; @@ -65,37 +72,64 @@ } .form-actions { display: flex; + flex: 0 0 auto; + flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border-subtle); } -/* Form pages: keep the panel in the viewport and scroll only the field area, - leaving the `.form-actions` footer pinned. `:has(> .form-panel)` scopes this - to pages whose form panel is a direct child, so the PageHead and any notice - banners above the form stay outside the scroll region and remain visible. */ -.content:has(> .form-panel) { +/* Only the explicit form page owns the available workspace height. */ +.content:has(> .form-page) { display: flex; flex-direction: column; overflow: hidden; } -.content:has(> .form-panel) > .form-panel { +.form-page { display: flex; flex-direction: column; flex: 1; + min-width: 0; min-height: 0; } -.content:has(> .form-panel) > .form-panel > .form-grid { +.form-page > :not(.form-panel) { + flex-shrink: 0; +} +.form-body { flex: 1; min-height: 0; + min-width: 0; + align-content: start; overflow-y: auto; + overscroll-behavior-y: contain; + scrollbar-gutter: stable; + scroll-padding-block: 4px 12px; /* Breathing room so focus outlines on edge fields are not clipped and the last field clears the pinned footer. */ padding: 4px 4px 8px; } -.content:has(> .form-panel) > .form-panel > .form-actions { - flex: 0 0 auto; +.form-panel textarea { + resize: vertical; +} +.field-hint, +.form-hint, +.form-body :where(small.muted, p.muted) { + margin: 0; + color: var(--text-secondary); + font-size: 11px; + font-weight: 400; + line-height: 18px; + overflow-wrap: anywhere; +} +.field-hint { + display: block; +} +.form-hint { + grid-column: 1 / -1; +} +.form-body .field > .field-hint { + margin-top: -4px; } .group-submit { min-height: 32px; @@ -192,6 +226,15 @@ font-size: 11px; line-height: 16px; } +.form-body > .state-banner, +.form-body > .field-error, +.form-body > .form-hint { + grid-column: 1 / -1; + margin: 0; +} +.form-body > .state-banner { + overflow-wrap: anywhere; +} .variant-row { display: grid; grid-template-columns: minmax(0, 0.7fr) minmax(0, 1.3fr) 32px; diff --git a/src/styles/head.css b/src/styles/head.css index 5644882..2aaa17c 100644 --- a/src/styles/head.css +++ b/src/styles/head.css @@ -1,12 +1,24 @@ /* Page heading blocks and descriptive intro text. */ .page-head { display: flex; + flex: 0 0 auto; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; } +.page-head > div { + min-width: 0; +} +.page-head-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-left: auto; +} .page-head h1 { + overflow-wrap: anywhere; font-family: var(--font-heading); font-size: 28px; line-height: 36px; diff --git a/src/styles/layout.css b/src/styles/layout.css index f0c4411..6c1206f 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -63,6 +63,7 @@ overflow: hidden; } .header { + flex-shrink: 0; height: 56px; border-bottom: 1px solid var(--border-subtle); display: flex; diff --git a/src/styles/responsive.css b/src/styles/responsive.css index d425d99..6024f6c 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -81,6 +81,21 @@ grid-template-columns: 1fr; } } +@media (max-height: 600px) { + .content:has(> .form-page) { + padding-block: 12px; + } + .form-page .page-head { + margin-bottom: 12px; + } + .form-page .form-panel { + padding-block: 12px; + } + .form-page .form-actions { + margin-top: 12px; + padding-top: 12px; + } +} @media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; diff --git a/src/styles/scrollbar.css b/src/styles/scrollbar.css index 4d3297d..f461e36 100644 --- a/src/styles/scrollbar.css +++ b/src/styles/scrollbar.css @@ -1,24 +1,25 @@ -/* Custom scrollbar styling for scrollable containers. */ -html, -body, +/* Custom scrollbar styling: WebKit and Firefox standard. */ .workspace, .content, -.form-panel > .form-grid, +.form-body, .table-wrap, .drawer-panel, -textarea { +textarea, +pre { scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } +/* WebKit scrollbar styling. */ html::-webkit-scrollbar, body::-webkit-scrollbar, .workspace::-webkit-scrollbar, .content::-webkit-scrollbar, -.form-panel > .form-grid::-webkit-scrollbar, +.form-body::-webkit-scrollbar, .table-wrap::-webkit-scrollbar, .drawer-panel::-webkit-scrollbar, -textarea::-webkit-scrollbar { +textarea::-webkit-scrollbar, +pre::-webkit-scrollbar { width: 8px; height: 8px; } @@ -27,33 +28,37 @@ html::-webkit-scrollbar-track, body::-webkit-scrollbar-track, .workspace::-webkit-scrollbar-track, .content::-webkit-scrollbar-track, -.form-panel > .form-grid::-webkit-scrollbar-track, +.form-body::-webkit-scrollbar-track, .table-wrap::-webkit-scrollbar-track, .drawer-panel::-webkit-scrollbar-track, -textarea::-webkit-scrollbar-track { - background: var(--scrollbar-track); +textarea::-webkit-scrollbar-track, +pre::-webkit-scrollbar-track { + background-color: var(--scrollbar-track); } .drawer-panel::-webkit-scrollbar-track, -textarea::-webkit-scrollbar-track { - background: var(--scrollbar-track-raised); +textarea::-webkit-scrollbar-track, +pre::-webkit-scrollbar-track { + background-color: var(--scrollbar-track-raised); } html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb, .workspace::-webkit-scrollbar-thumb, .content::-webkit-scrollbar-thumb, -.form-panel > .form-grid::-webkit-scrollbar-thumb, +.form-body::-webkit-scrollbar-thumb, .table-wrap::-webkit-scrollbar-thumb, .drawer-panel::-webkit-scrollbar-thumb, -textarea::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border: 1px solid var(--scrollbar-track); - border-radius: 2px; +textarea::-webkit-scrollbar-thumb, +pre::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + border: 2px solid var(--scrollbar-track); + border-radius: 9999px; } .drawer-panel::-webkit-scrollbar-thumb, -textarea::-webkit-scrollbar-thumb { +textarea::-webkit-scrollbar-thumb, +pre::-webkit-scrollbar-thumb { border-color: var(--scrollbar-track-raised); } @@ -61,31 +66,34 @@ html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover, .workspace::-webkit-scrollbar-thumb:hover, .content::-webkit-scrollbar-thumb:hover, -.form-panel > .form-grid::-webkit-scrollbar-thumb:hover, +.form-body::-webkit-scrollbar-thumb:hover, .table-wrap::-webkit-scrollbar-thumb:hover, .drawer-panel::-webkit-scrollbar-thumb:hover, -textarea::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); +textarea::-webkit-scrollbar-thumb:hover, +pre::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); } html::-webkit-scrollbar-thumb:active, body::-webkit-scrollbar-thumb:active, .workspace::-webkit-scrollbar-thumb:active, .content::-webkit-scrollbar-thumb:active, -.form-panel > .form-grid::-webkit-scrollbar-thumb:active, +.form-body::-webkit-scrollbar-thumb:active, .table-wrap::-webkit-scrollbar-thumb:active, .drawer-panel::-webkit-scrollbar-thumb:active, -textarea::-webkit-scrollbar-thumb:active { - background: var(--scrollbar-thumb-active); +textarea::-webkit-scrollbar-thumb:active, +pre::-webkit-scrollbar-thumb:active { + background-color: var(--scrollbar-thumb-active); } html::-webkit-scrollbar-corner, body::-webkit-scrollbar-corner, .workspace::-webkit-scrollbar-corner, .content::-webkit-scrollbar-corner, -.form-panel > .form-grid::-webkit-scrollbar-corner, +.form-body::-webkit-scrollbar-corner, .table-wrap::-webkit-scrollbar-corner, .drawer-panel::-webkit-scrollbar-corner, -textarea::-webkit-scrollbar-corner { - background: var(--scrollbar-track); +textarea::-webkit-scrollbar-corner, +pre::-webkit-scrollbar-corner { + background-color: var(--scrollbar-track); } From 0746f5701a2e3d44039ce2d81ea70a0d968118b9 Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 25 Oct 2026 15:06:37 +0800 Subject: [PATCH 66/97] feat(frontend): MCP proxy guards, token rotation, settings lifecycle and translations --- src/components/__tests__/form-layout.test.ts | 51 ++ .../__tests__/form-page-harness.svelte | 26 + src/i18n/dictionaries/en.ts | 4 +- src/i18n/dictionaries/zh.ts | 3 +- src/routes/mcp/+page.svelte | 372 ++++++---- src/routes/mcp/AGENTS.md | 41 ++ src/routes/mcp/ProxyPanel.svelte | 473 +++++++++++++ src/routes/mcp/ProxyServerForm.svelte | 484 +++++++++++++ src/routes/mcp/__tests__/mcp-proxy.test.ts | 510 ++++++++++++++ src/routes/mcp/proxy/new/+page.svelte | 25 + src/routes/settings/+page.svelte | 230 +++++-- src/routes/settings/AGENTS.md | 21 +- src/routes/settings/AppUpdates.svelte | 245 ++++--- src/routes/settings/LidProtection.svelte | 26 +- src/routes/settings/McpProxySettings.svelte | 334 +++++++++ src/routes/settings/MiniIsland.svelte | 51 +- src/routes/settings/OpenCodeService.svelte | 63 +- src/routes/settings/OpenCodeSettings.svelte | 235 ++++--- src/routes/settings/RotateKeyDialog.svelte | 145 ++++ src/routes/settings/SettingsSearch.svelte | 229 ++++++ .../settings/__tests__/app-updates.test.ts | 42 ++ .../settings/__tests__/lid-protection.test.ts | 35 + .../__tests__/mcp-proxy-settings.test.ts | 432 ++++++++++++ .../settings/__tests__/mini-island.test.ts | 69 +- .../__tests__/opencode-settings.test.ts | 18 +- .../__tests__/rotate-key-dialog.test.ts | 43 ++ .../__tests__/settings-search.test.ts | 649 ++++++++++++++++++ .../settings/__tests__/settings.test.ts | 8 + 28 files changed, 4412 insertions(+), 452 deletions(-) create mode 100644 src/components/__tests__/form-layout.test.ts create mode 100644 src/components/__tests__/form-page-harness.svelte create mode 100644 src/routes/mcp/AGENTS.md create mode 100644 src/routes/mcp/ProxyPanel.svelte create mode 100644 src/routes/mcp/ProxyServerForm.svelte create mode 100644 src/routes/mcp/__tests__/mcp-proxy.test.ts create mode 100644 src/routes/mcp/proxy/new/+page.svelte create mode 100644 src/routes/settings/McpProxySettings.svelte create mode 100644 src/routes/settings/RotateKeyDialog.svelte create mode 100644 src/routes/settings/SettingsSearch.svelte create mode 100644 src/routes/settings/__tests__/mcp-proxy-settings.test.ts create mode 100644 src/routes/settings/__tests__/rotate-key-dialog.test.ts create mode 100644 src/routes/settings/__tests__/settings-search.test.ts diff --git a/src/components/__tests__/form-layout.test.ts b/src/components/__tests__/form-layout.test.ts new file mode 100644 index 0000000..af38829 --- /dev/null +++ b/src/components/__tests__/form-layout.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { expect, test, vi } from 'vitest'; +import FormPageHarness from './form-page-harness.svelte'; + +test('form page keeps heading and actions outside its body without detaching submit controls', async () => { + const onsubmit = vi.fn(); + const { container, rerender } = render(FormPageHarness, { onsubmit }); + const form = container.querySelector('form'); + const body = form?.querySelector(':scope > .form-body'); + const actions = form?.querySelector(':scope > .form-actions'); + expect(form?.parentElement?.classList.contains('form-page')).toBe(true); + expect(body?.contains(screen.getByRole('heading'))).toBe(false); + expect(body?.contains(actions ?? null)).toBe(false); + expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).form).toBe(form); + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onsubmit).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(onsubmit).toHaveBeenCalledOnce(); + await rerender({ active: false, onsubmit }); + expect(container.querySelector('.form-page')).toBeNull(); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeTruthy(); +}); + +test('every long editor has an explicit body and footer; route wrappers opt in without changing read-only pages', () => { + const source = (path: string) => readFileSync(resolve('src', path), 'utf8'); + for (const [route, form] of [ + ['providers', 'ProviderForm'], + ['models', 'ModelForm'], + ['agents', 'AgentForm'], + ['groups', 'GroupForm'], + ['mcp', 'McpForm'], + ['skills', 'SkillForm'], + ]) { + const editor = source(`routes/${route}/${form}.svelte`); + expect(editor).toContain('class="form-body form-grid"'); + expect(editor.indexOf(''); + if (route === 'mcp' || route === 'skills') { + expect(source(`routes/${route}/[id]/+page.svelte`)).toContain(''); + } + } + const proxy = source('routes/mcp/ProxyServerForm.svelte'); + expect(proxy).toContain('flex flex-col overflow-hidden'); + expect(proxy).toContain('
    '); + expect(proxy).toContain(''); + expect(proxy).not.toContain('overflow-y-auto'); +}); diff --git a/src/components/__tests__/form-page-harness.svelte b/src/components/__tests__/form-page-harness.svelte new file mode 100644 index 0000000..ddb6fa8 --- /dev/null +++ b/src/components/__tests__/form-page-harness.svelte @@ -0,0 +1,26 @@ + + + + + { + event.preventDefault(); + onsubmit(); + }} + > +
    + +
    + + + + + +
    diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index e68eae4..fb0b863 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -164,6 +164,7 @@ export const en = { 'common.search': 'Search', 'common.searchByName': 'Search by name', 'common.status': 'Status', + 'common.unknownStatus': 'Status unknown', 'common.type': 'Type', 'common.unnamed': 'unnamed', 'common.unset': 'unset', @@ -961,7 +962,8 @@ export const en = { 'mcp.proxy.form.name': 'Display Name', 'mcp.proxy.form.enabled': 'Enabled', 'mcp.proxy.form.command': 'Command & Arguments', - 'mcp.proxy.form.commandHint': 'One argument per line; executable first. No shell parsing.', + 'mcp.proxy.form.commandHint': + 'One argument per line; executable first. Blank lines are empty arguments; quotes are literal. No shell parsing.', 'mcp.proxy.form.cwd': 'Working Directory', 'mcp.proxy.form.cwdHint': 'Required absolute working directory.', 'mcp.proxy.form.env': 'Environment Variables', diff --git a/src/i18n/dictionaries/zh.ts b/src/i18n/dictionaries/zh.ts index 83c706e..e8a770a 100644 --- a/src/i18n/dictionaries/zh.ts +++ b/src/i18n/dictionaries/zh.ts @@ -162,6 +162,7 @@ export const zh: Record = { 'common.search': '搜索', 'common.searchByName': '按名称搜索', 'common.status': '状态', + 'common.unknownStatus': '状态未知', 'common.type': '类型', 'common.unnamed': '未命名', 'common.unset': '未设置', @@ -946,7 +947,7 @@ export const zh: Record = { 'mcp.proxy.form.name': '显示名称', 'mcp.proxy.form.enabled': '已启用', 'mcp.proxy.form.command': '命令与参数', - 'mcp.proxy.form.commandHint': '每行一个参数,首行为可执行文件;不按 shell 解析。', + 'mcp.proxy.form.commandHint': '每行一个参数,首行为可执行文件;空行表示空参数,引号原样保留,不按 shell 解析。', 'mcp.proxy.form.cwd': '工作目录', 'mcp.proxy.form.cwdHint': '必填,使用绝对路径。', 'mcp.proxy.form.env': '环境变量', diff --git a/src/routes/mcp/+page.svelte b/src/routes/mcp/+page.svelte index b916842..a008c4c 100644 --- a/src/routes/mcp/+page.svelte +++ b/src/routes/mcp/+page.svelte @@ -1,6 +1,7 @@ + +
    + +
    +
    + +
    +
    + + {i18n.t(loading ? 'common.loadingConfiguration' : error || !proxyState ? 'common.unknownStatus' : isRunning ? 'mcp.proxy.running' : 'mcp.proxy.stopped')} + + {#if proxyState?.boundAddr} + · + {proxyState.boundAddr} + {/if} +
    + {#if proxyState} +
    + {i18n.t('mcp.proxy.activeUnits')}: {proxyState.activeUnits} / {proxyState.maxUnits} + {i18n.t('mcp.proxy.activeSessions')}: {proxyState.activeSessions} + {i18n.t('mcp.proxy.revision')}: {proxyState.revision} +
    + {/if} +
    +
    + +
    +
    + + { + e.preventDefault(); + void toggleGateway(!(proxyState?.desiredState ?? false)); + }} + /> +
    + + + +
    +
    + + {#if proxyState?.securityErrors && proxyState.securityErrors.length > 0} + + {/if} + + {#if error} + + {/if} + + +
    + +
    + + + + + + {i18n.t('mcp.proxy.serverName')} + {i18n.t('mcp.proxy.protocol')} + {i18n.t('mcp.proxy.status')} + {i18n.t('mcp.proxy.sessions')} + {i18n.t('mcp.proxy.actions')} + + + + {#if loading} + + {:else if error} + + {:else if !filtered.length} + + {:else} + {#each rows as srv (srv.id)} + + + {srv.name} + {srv.id} + + + + {srv.protocol} + + + + + + {i18n.t(srv.enabled ? 'common.enabled' : 'common.disabled')} + + + + {srv.activeSessions} + + + + + + + + {/each} + {/if} + + +
    + + + readState(true)} +/> + + + { if (!open) { deletingServer = null; deleteError = ''; } }} +> + + + {i18n.t('mcp.proxy.deleteServer')} + + {i18n.t('mcp.proxy.deleteConfirm', { name: deletingServer?.name ?? '' })} + + + {#if deleteError} + + {/if} + + + + + + + + + { if (!open) { closeUnitOpen = false; closeUnitError = ''; } }} +> + + + {i18n.t('mcp.proxy.closeUnit')} + + {i18n.t('mcp.proxy.closeUnitPrompt')} + + +
    + + {#if closeUnitError} + + {/if} +
    + + + + +
    +
    diff --git a/src/routes/mcp/ProxyServerForm.svelte b/src/routes/mcp/ProxyServerForm.svelte new file mode 100644 index 0000000..4ffa7a1 --- /dev/null +++ b/src/routes/mcp/ProxyServerForm.svelte @@ -0,0 +1,484 @@ + + +{#snippet formFields()} + {#if error} + + {/if} + + {#if importMode} +
    +
    +

    {i18n.t('mcp.proxy.import.select')}

    + +
    + {#if loadingMcps} +

    {i18n.t('common.loadingConfiguration')}

    + {:else if availableMcps.length === 0} +

    {i18n.t('mcp.proxy.import.empty')}

    + {:else} + + + {i18n.t('mcp.proxy.form.idHint')} + +
    + +
    + + +
    +
    + + +
    + {i18n.t('mcp.proxy.form.enabled')} + +
    + + +
    + + + + {i18n.t('mcp.proxy.form.commandHint')} + +
    + + +
    + + + + {i18n.t('mcp.proxy.form.cwdHint')} + +
    + + +
    + + + + {i18n.t('mcp.proxy.form.envHint')} + +
    + + +
    +
    + + +
    + +
    + + +
    +
    +
    + {/if} +{/snippet} + +{#if dialog} + { + if (!val) handleCancel(); + }} + > + { + if (pending) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (pending) event.preventDefault(); + }} + > + +
    +
    + + {i18n.t(server ? 'mcp.proxy.form.editTitle' : 'mcp.proxy.form.createTitle')} + +
    + {#if !server && !importMode} + + {/if} +
    +
    + +
    + {@render formFields()} +
    + + + + + +
    +
    +{:else} +
    { + event.preventDefault(); + if (importMode) void performImport(); + else void save(); + }} + > +
    + {#if !server && !importMode} +
    + +
    + {/if} + {@render formFields()} +
    + + + + + +
    +{/if} diff --git a/src/routes/mcp/__tests__/mcp-proxy.test.ts b/src/routes/mcp/__tests__/mcp-proxy.test.ts new file mode 100644 index 0000000..b67436d --- /dev/null +++ b/src/routes/mcp/__tests__/mcp-proxy.test.ts @@ -0,0 +1,510 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import type { ConfigStore } from '$src/config/store.svelte.js'; +import { toast } from '$src/shell/toast.svelte.js'; +import Harness from '$src/test-utils/harness.svelte'; +import type { McpProxyServerDto, McpProxyStateDto } from '$src/types/mcp-proxy.js'; +import McpPage from '../+page.svelte'; +import ProxyPanel from '../ProxyPanel.svelte'; +import ProxyServerForm from '../ProxyServerForm.svelte'; +import ProxyNewPage from '../proxy/new/+page.svelte'; + +vi.mock('$src/shell/toast.svelte.js', () => ({ toast: vi.fn() })); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('$app/state', async () => { + const { createSubscriber } = await import('svelte/reactivity'); + let notify: () => void = () => {}; + const track = createSubscriber((update) => { + notify = update; + }); + const state = { + url: new URL('http://localhost/mcp'), + params: {}, + }; + return { + page: { + get url() { + track(); + return state.url; + }, + set url(value: URL) { + state.url = value; + notify(); + }, + get params() { + track(); + return state.params; + }, + set params(value: Record) { + state.params = value; + notify(); + }, + }, + }; +}); + +const defaultProxyState: McpProxyStateDto = { + actual: true, + desired: true, + actualState: 'running', + desiredState: true, + boundAddr: '127.0.0.1:23456', + port: 23456, + allowLan: false, + totalServices: 1, + enabledServices: 1, + activeUnits: 2, + activeSessions: 7, + availableUnits: 30, + maxUnits: 32, + securityErrors: [], + services: [ + { + id: 'fs-server', + name: 'Filesystem Server', + enabled: true, + protocol: 'legacy', + activeSessions: 2, + }, + ], + revision: 3, + generation: 1, +}; + +const fullServer: McpProxyServerDto = { + id: 'fs-server', + name: 'Filesystem Server', + enabled: true, + command: ['npx', '-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + cwd: '/tmp', + env: { DEBUG: '1' }, + protocol: 'legacy', + startupTimeout: 30, + executionTimeout: 300, +}; + +function makeConfig(overrides: Record = {}) { + return { + preferences: { locale: 'en', theme: 'dark' }, + selectedProjectId: null, + saving: false, + listMcps: vi.fn().mockResolvedValue({ data: [], diagnostics: [] }), + getMcpProxyState: vi.fn().mockResolvedValue(defaultProxyState), + getMcpProxySettings: vi.fn().mockResolvedValue({ + enabled: true, + idleTimeout: 300, + maxUnits: 16, + revision: 2, + }), + saveMcpProxySettings: vi.fn().mockResolvedValue(undefined), + getMcpProxyServer: vi.fn().mockResolvedValue(fullServer), + createMcpProxyServer: vi.fn().mockResolvedValue(fullServer), + updateMcpProxyServer: vi.fn().mockResolvedValue(fullServer), + deleteMcpProxyServer: vi.fn().mockResolvedValue(undefined), + importMcpProxyServer: vi.fn().mockResolvedValue(fullServer), + closeMcpProxyUnit: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as ConfigStore; +} + +beforeEach(() => { + page.url = new URL('http://localhost/mcp'); + vi.mocked(goto).mockClear(); + vi.mocked(toast).mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test.each(['edit', 'toggle'] as const)( + '%s keeps its lookup revision across polling and ignores duplicate clicks', + async (action) => { + let resolve!: (value: McpProxyServerDto) => void; + const pending = new Promise((yes) => { + resolve = yes; + }); + const getMcpProxyServer = vi.fn().mockReturnValue(pending); + const getMcpProxyState = vi + .fn() + .mockResolvedValueOnce(defaultProxyState) + .mockResolvedValueOnce({ ...defaultProxyState, revision: 4 }) + .mockResolvedValue({ ...defaultProxyState, revision: 5 }); + const config = makeConfig({ getMcpProxyServer, getMcpProxyState }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await vi.advanceTimersByTimeAsync(0); + await tick(); + const button = screen.getByRole('button', { name: action === 'edit' ? 'Edit' : 'Disabled' }); + await fireEvent.click(button); + await fireEvent.click(button); + expect(getMcpProxyServer).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2000); + await tick(); + expect(screen.getByText('Revision: 4')).toBeDefined(); + + resolve(fullServer); + await pending; + await tick(); + if (action === 'edit') { + await vi.advanceTimersByTimeAsync(2000); + await tick(); + expect(screen.getByText('Revision: 5')).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Save Server' })); + } + expect(config.updateMcpProxyServer).toHaveBeenCalledExactlyOnceWith( + { ...fullServer, enabled: action === 'edit' }, + 3, + ); + }, +); + +test('status stays loading until readback and becomes unknown after a failed refresh', async () => { + let resolve!: (value: McpProxyStateDto) => void; + const pending = new Promise((yes) => { + resolve = yes; + }); + const config = makeConfig({ + getMcpProxyState: vi.fn().mockReturnValueOnce(pending).mockRejectedValue(new Error('Read failed')), + }); + render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + expect(screen.getAllByText('Loading configuration...').length).toBeGreaterThan(0); + expect(screen.queryByText('Stopped')).toBeNull(); + resolve(defaultProxyState); + await screen.findByText('Running'); + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await screen.findByText('Read failed'); + expect(screen.getByText('Status unknown')).toBeDefined(); + expect(screen.queryByText('Running')).toBeNull(); + expect(screen.queryByText('Stopped')).toBeNull(); +}); + +test('failed initial state read stays unknown rather than stopped', async () => { + const config = makeConfig({ getMcpProxyState: vi.fn().mockRejectedValue(new Error('Read failed')) }); + render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByText('Read failed'); + expect(screen.getByText('Status unknown')).toBeDefined(); + expect(screen.queryByText('Stopped')).toBeNull(); +}); + +test('gateway does not save settings read after unmount', async () => { + const settings = { enabled: true, idleTimeout: 300, maxUnits: 16, revision: 2 }; + let resolve!: (value: typeof settings) => void; + const pending = new Promise((yes) => { + resolve = yes; + }); + const config = makeConfig({ getMcpProxySettings: vi.fn().mockReturnValue(pending) }); + const { unmount } = render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByText('Filesystem Server'); + await fireEvent.click(screen.getByRole('checkbox', { name: 'Enable MCP Gateway' })); + unmount(); + resolve(settings); + await pending; + await tick(); + expect(config.saveMcpProxySettings).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); +}); + +test.each(['edit', 'toggle'] as const)('%s ignores a server lookup completed after unmount', async (action) => { + let resolve!: (value: McpProxyServerDto) => void; + const pending = new Promise((yes) => { + resolve = yes; + }); + const getMcpProxyServer = vi.fn().mockReturnValue(pending); + const config = makeConfig({ getMcpProxyServer }); + const { unmount } = render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByText('Filesystem Server'); + expect(screen.getByText('Active Units: 2 / 32')).toBeDefined(); + expect(screen.getByText('Active Sessions: 7')).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: action === 'edit' ? 'Edit' : 'Disabled' })); + expect(getMcpProxyServer).toHaveBeenCalledWith('fs-server'); + unmount(); + resolve(fullServer); + await pending; + await tick(); + expect(config.updateMcpProxyServer).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(toast).not.toHaveBeenCalled(); +}); + +test('does not toast an edit lookup error after unmount', async () => { + let reject!: (reason: unknown) => void; + const pending = new Promise((_, no) => { + reject = no; + }); + const config = makeConfig({ getMcpProxyServer: vi.fn().mockReturnValue(pending) }); + const { unmount } = render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByText('Filesystem Server'); + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + unmount(); + reject(new Error('Lookup failed')); + await pending.catch(() => {}); + await tick(); + expect(toast).not.toHaveBeenCalled(); +}); + +test('server enabled switch binds its named checkbox to the saved draft', async () => { + const config = makeConfig(); + render( + ProxyServerForm, + { dialog: false, server: fullServer, expectedRevision: 3 }, + { wrapper: Harness, wrapperProps: { config } }, + ); + const checkbox = screen.getByRole('checkbox', { name: 'Enabled' }) as HTMLInputElement; + expect(checkbox.id).toBe('proxy-srv-enabled'); + expect(checkbox.name).toBe('proxy-srv-enabled'); + expect(checkbox.checked).toBe(true); + await fireEvent.click(checkbox); + expect(checkbox.checked).toBe(false); + await fireEvent.click(screen.getByRole('button', { name: 'Save Server' })); + expect(config.updateMcpProxyServer).toHaveBeenCalledExactlyOnceWith({ ...fullServer, enabled: false }, 3); +}); + +test('create does not mutate or navigate when its revision read finishes after unmount', async () => { + let resolve!: (value: McpProxyStateDto) => void; + const pending = new Promise((yes) => { + resolve = yes; + }); + const config = makeConfig({ getMcpProxyState: vi.fn().mockReturnValue(pending) }); + const { unmount } = render(ProxyNewPage, {}, { wrapper: Harness, wrapperProps: { config } }); + await fireEvent.input(screen.getByLabelText('Server ID'), { target: { value: 'custom-mcp' } }); + await fireEvent.input(screen.getByRole('textbox', { name: /Command/ }), { target: { value: 'npx' } }); + await fireEvent.submit(screen.getByRole('form')); + await fireEvent.submit(screen.getByRole('form')); + expect(config.getMcpProxyState).toHaveBeenCalledTimes(1); + unmount(); + resolve(defaultProxyState); + await pending; + await tick(); + expect(config.createMcpProxyServer).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); +}); + +test.each(['create', 'update'] as const)( + '%s completion after unmount does not call onSaved or navigate', + async (action) => { + let resolve!: (value: McpProxyServerDto) => void; + const pending = new Promise((yes) => { + resolve = yes; + }); + const mutation = vi.fn().mockReturnValue(pending); + const onSaved = vi.fn(); + const config = makeConfig({ [action === 'create' ? 'createMcpProxyServer' : 'updateMcpProxyServer']: mutation }); + const { unmount } = + action === 'create' + ? render(ProxyNewPage, {}, { wrapper: Harness, wrapperProps: { config } }) + : render( + ProxyServerForm, + { dialog: false, server: fullServer, expectedRevision: 3, onSaved }, + { wrapper: Harness, wrapperProps: { config } }, + ); + if (action === 'create') { + await fireEvent.input(screen.getByLabelText('Server ID'), { target: { value: 'custom-mcp' } }); + await fireEvent.input(screen.getByRole('textbox', { name: /Command/ }), { target: { value: 'npx' } }); + } + await fireEvent.submit(screen.getByRole('form')); + await waitFor(() => expect(mutation).toHaveBeenCalledTimes(1)); + await fireEvent.submit(screen.getByRole('form')); + expect(mutation).toHaveBeenCalledTimes(1); + unmount(); + resolve(fullServer); + await pending; + await tick(); + expect(onSaved).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); + }, +); + +test('import ignores duplicate submissions and late completion after unmount', async () => { + let resolve!: (value: McpProxyServerDto) => void; + const pending = new Promise((yes) => { + resolve = yes; + }); + const config = makeConfig({ + listMcps: vi.fn().mockResolvedValue({ + data: [{ name: 'local-fs', type: 'local', sourcePath: '/tmp/opencode.json' }], + diagnostics: [], + }), + importMcpProxyServer: vi.fn().mockReturnValue(pending), + }); + const { unmount } = render(ProxyNewPage, {}, { wrapper: Harness, wrapperProps: { config } }); + await fireEvent.click(screen.getByRole('button', { name: 'Import Server' })); + await screen.findByRole('button', { name: 'local-fs (local)' }); + await fireEvent.submit(screen.getByRole('form')); + await fireEvent.submit(screen.getByRole('form')); + expect(config.importMcpProxyServer).toHaveBeenCalledExactlyOnceWith({ + scope: 'global', + serverName: 'local-fs', + expectedSourcePath: '/tmp/opencode.json', + }); + unmount(); + resolve(fullServer); + await pending; + await tick(); + expect(config.createMcpProxyServer).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); +}); + +test('server form preserves argument boundaries and literal environment values', async () => { + const server: McpProxyServerDto = { + ...fullServer, + command: ['node', '', '""', "''", ' spaced ', '', ''], + env: Object.fromEntries([ + ['SPACED', ' value '], + ['EMPTY', ''], + ['EQUALS', 'a=b=c'], + ['QUOTED', '"literal value"'], + ['app.config-key', 'ok'], + ['__proto__', 'literal'], + ]), + }; + const config = makeConfig(); + render( + ProxyServerForm, + { + open: true, + server, + expectedRevision: 3, + onSaved: vi.fn(), + }, + { wrapper: Harness, wrapperProps: { config } }, + ); + await screen.findByRole('dialog'); + const env = screen.getByLabelText('Environment Variables') as HTMLTextAreaElement; + await fireEvent.input(env, { target: { value: `${env.value}\n # comment\n MORE = more ` } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save Server' })); + await waitFor(() => + expect(config.updateMcpProxyServer).toHaveBeenCalledWith( + { + ...server, + env: { ...server.env, MORE: ' more ' }, + }, + 3, + ), + ); +}); + +test('MCP page displays tabs and switches between config and proxy', async () => { + const config = makeConfig(); + render(McpPage, {}, { wrapper: Harness, wrapperProps: { config } }); + + expect(screen.getByRole('tab', { name: 'Configuration' })).toBeDefined(); + expect(screen.getByRole('tab', { name: 'MCP Proxy' })).toBeDefined(); + + // Click MCP Proxy tab + const proxyTab = screen.getByRole('tab', { name: 'MCP Proxy' }); + await fireEvent.click(proxyTab); + + expect(vi.mocked(goto)).toHaveBeenCalled(); + const calledUrl = vi.mocked(goto).mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('tab=proxy'); +}); + +test('ProxyPanel renders running status, server list and supports close unit', async () => { + page.url = new URL('http://localhost/mcp?tab=proxy'); + const closeMcpProxyUnit = vi.fn().mockResolvedValue(undefined); + const config = makeConfig({ closeMcpProxyUnit }); + + render(McpPage, {}, { wrapper: Harness, wrapperProps: { config } }); + + await waitFor(() => { + expect(config.getMcpProxyState).toHaveBeenCalled(); + }); + + expect(await screen.findByText('127.0.0.1:23456')).toBeDefined(); + expect(screen.getByText('Filesystem Server')).toBeDefined(); + expect(screen.getByText('fs-server')).toBeDefined(); + + // Click Close Unit button + const buttons = screen.getAllByRole('button', { name: /Close Unit/i }); + const triggerBtn = buttons[0]; + if (!triggerBtn) throw new Error('Trigger button not found'); + await fireEvent.click(triggerBtn); + + // Modal appears with input + const input = screen.getByRole('textbox', { name: 'Enter unit or session ID to close:' }); + await fireEvent.input(input, { target: { value: 'unit-abc' } }); + + const modalButtons = screen.getAllByRole('button', { name: /Close Unit/i }); + const confirmBtn = modalButtons[1]; + if (!confirmBtn) throw new Error('Confirm button not found'); + await fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(closeMcpProxyUnit).toHaveBeenCalledWith({ + unitId: 'unit-abc', + expectedGeneration: 1, + }); + }); +}); + +test('ProxyPanel master switch toggles gateway enablement', async () => { + page.url = new URL('http://localhost/mcp?tab=proxy'); + const getMcpProxySettings = vi.fn().mockResolvedValue({ + enabled: true, + idleTimeout: 300, + maxUnits: 16, + revision: 2, + }); + const saveMcpProxySettings = vi.fn().mockResolvedValue(undefined); + const config = makeConfig({ getMcpProxySettings, saveMcpProxySettings }); + + render(McpPage, {}, { wrapper: Harness, wrapperProps: { config } }); + await waitFor(() => expect(config.getMcpProxyState).toHaveBeenCalled()); + + const masterSwitch = screen.getByRole('checkbox', { name: 'Enable MCP Gateway' }); + expect((masterSwitch as HTMLInputElement).checked).toBe(true); + + await fireEvent.click(masterSwitch); + await waitFor(() => { + expect(saveMcpProxySettings).toHaveBeenCalledWith(expect.objectContaining({ enabled: false, revision: 2 }), 2); + }); +}); + +test('Add Server button links to /mcp/proxy/new', async () => { + page.url = new URL('http://localhost/mcp?tab=proxy'); + const config = makeConfig(); + render(McpPage, {}, { wrapper: Harness, wrapperProps: { config } }); + await waitFor(() => expect(config.getMcpProxyState).toHaveBeenCalled()); + + const addLink = screen.getByRole('link', { name: 'Add Server' }); + expect(addLink.getAttribute('href')).toBe('/mcp/proxy/new'); +}); + +test('ProxyNewPage creates proxy server and navigates on save', async () => { + const createMcpProxyServer = vi.fn().mockResolvedValue(fullServer); + const config = makeConfig({ createMcpProxyServer }); + + render(ProxyNewPage, {}, { wrapper: Harness, wrapperProps: { config } }); + + await screen.findByRole('heading', { name: 'Add Proxy Server' }); + const idInput = screen.getByLabelText('Server ID'); + const cmdInput = screen.getByLabelText('Command & Arguments'); + + await fireEvent.input(idInput, { target: { value: 'custom-mcp' } }); + await fireEvent.input(cmdInput, { target: { value: 'npx\n-y\ncustom-pkg' } }); + + const saveBtn = screen.getByRole('button', { name: 'Save Server' }); + await fireEvent.click(saveBtn); + + await waitFor(() => { + expect(createMcpProxyServer).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'custom-mcp', + command: ['npx', '-y', 'custom-pkg'], + }), + 3, + ); + }); + expect(goto).toHaveBeenCalledWith('/mcp?tab=proxy'); +}); diff --git a/src/routes/mcp/proxy/new/+page.svelte b/src/routes/mcp/proxy/new/+page.svelte new file mode 100644 index 0000000..f8ad8f1 --- /dev/null +++ b/src/routes/mcp/proxy/new/+page.svelte @@ -0,0 +1,25 @@ + + +{i18n.t('mcp.proxy.form.createTitle')} + + + + diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index c2be6a9..d644b6e 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,6 +1,8 @@ -
    -
    -
    -

    - {i18n.t('settings.updates')} -

    -

    - {i18n.t('settings.installedVersion', { +

    +

    + {i18n.t('settings.updates')} +

    +
    +
    +
    +

    + {i18n.t('settings.installedVersion', { version: appInfo?.version ?? result?.currentVersion ?? i18n.t(infoLoading ? 'common.loading' : 'settings.versionUnavailable'), })} -

    -

    {i18n.t('settings.updatesHint')}

    +

    +

    {i18n.t('settings.updatesHint')}

    +
    +
    - -
    - {#if infoError} - - {#if openError} - - {/if} -
    +
    diff --git a/src/routes/settings/LidProtection.svelte b/src/routes/settings/LidProtection.svelte index e7e9837..75ebf9e 100644 --- a/src/routes/settings/LidProtection.svelte +++ b/src/routes/settings/LidProtection.svelte @@ -10,6 +10,7 @@ import type { LidState } from '$src/types/power.js'; const config = getConfig(); const i18n = getI18n(); +let { active = true }: { active?: boolean } = $props(); let state = $state({ enabled: false, phase: 'disabled' }); let known = $state(false); let loading = $state(true); @@ -19,7 +20,7 @@ let saving = $state(false); let savingEnabled = $state(false); let queryError = $state(''); let saveError = $state(''); -let mounted = false; +let mounted = $state(false); let request = 0; let readGeneration = 0; let targetVersion = config.serviceTargetVersion; @@ -54,20 +55,20 @@ $effect(() => { saveError = ''; loading = true; reloadQueued = true; - void readStatus(); + if (active) void readStatus(); }); }); function schedule() { clearTimeout(timer); - if (reloadQueued && mounted && !querying && !saving) { + if (reloadQueued && mounted && active && !querying && !saving) { void readStatus(); return; } - if (mounted && !querying && !saving) timer = setTimeout(() => void readStatus(), 2000); + if (mounted && active && !querying && !saving) timer = setTimeout(() => void readStatus(), 2000); } async function readStatus(manual = false) { - if (!mounted || querying || saving) return; + if (!mounted || !active || querying || saving) return; clearTimeout(timer); const seq = ++request; const readSeq = ++readGeneration; @@ -96,7 +97,7 @@ async function readStatus(manual = false) { } } async function setEnabled(enabled: boolean) { - if (!mounted || saving) return; + if (!mounted || !active || saving) return; const control = document.getElementById('lid-closed'); const restoreFocus = document.activeElement === control; clearTimeout(timer); @@ -122,34 +123,39 @@ async function setEnabled(enabled: boolean) { schedule(); if (restoreFocus) { await tick(); - if (mounted && seq === request && document.activeElement === document.body) control?.focus(); + if (mounted && active && seq === request && document.activeElement === document.body) control?.focus(); } } } } onMount(() => { mounted = true; - void readStatus(); return () => { mounted = false; request += 1; clearTimeout(timer); }; }); +$effect(() => { + if (!mounted) return; + if (active) untrack(() => void readStatus()); + else clearTimeout(timer); +}); -
    +
    -

    +

    {i18n.t('settings.lidHint')}

    +import { KeyRound, RefreshCw, ShieldAlert } from '@lucide/svelte'; +import { onDestroy, onMount, untrack } from 'svelte'; +import { Button } from '$src/components/button/index.js'; +import { readError } from '$src/components/configuration/read-error.js'; +import { Input } from '$src/components/input/index.js'; +import { Switch } from '$src/components/switch/index.js'; +import { getConfig } from '$src/config/context.js'; +import { getI18n } from '$src/i18n/context.js'; +import { toast } from '$src/shell/toast.svelte.js'; +import type { McpProxySettingsDto, McpProxyStateDto, MomHttpSettings } from '$src/types/mcp-proxy.js'; +import RotateKeyDialog from './RotateKeyDialog.svelte'; + +let { active = true, http = false }: { active?: boolean; http?: boolean } = $props(); + +const config = getConfig(); +const i18n = getI18n(); + +let runtimeState = $state(null); +let settings = $state({ + enabled: false, + port: 18765, + allowLan: false, + idleTimeout: 300, + maxUnits: 16, + revision: 0, +}); + +let known = $state(false); +let savedSettings = $state<(McpProxySettingsDto & MomHttpSettings) | null>(null); +let loading = $state(true); +let querying = $state(false); +let refreshing = $state(false); +let saving = $state(false); +let error = $state(''); +let rotateDialogOpen = $state(false); + +let mounted = $state(false); +let request = 0; +let timer: ReturnType | undefined; + +const isDirty = $derived(savedSettings !== null && JSON.stringify(settings) !== JSON.stringify(savedSettings)); +const validPort = $derived(Number.isInteger(settings.port) && settings.port >= 1 && settings.port <= 65535); + +function schedule() { + clearTimeout(timer); + if (mounted && active && !querying && !saving) { + timer = setTimeout(() => void readStatus(), 2000); + } +} + +async function readStatus(manual = false) { + if (!mounted || !active || querying || saving) return; + clearTimeout(timer); + const seq = ++request; + querying = true; + refreshing = manual; + try { + const [fetchedSettings, fetchedState] = await Promise.all([ + http ? config.getMomHttpSettings() : config.getMcpProxySettings(), + config.getMcpProxyState(), + ]); + if (!mounted || seq !== request) return; + if (!isDirty) { + settings = { ...settings, ...fetchedSettings }; + savedSettings = { ...settings }; + } + runtimeState = fetchedState; + known = true; + error = ''; + } catch (cause) { + if (mounted && seq === request) { + error = readError(cause); + runtimeState = null; + } + } finally { + if (mounted) { + querying = false; + refreshing = false; + loading = false; + if (!saving && !querying) schedule(); + } + } +} + +async function save() { + if (!mounted || !active || saving || querying || !known || (http && !validPort)) return; + clearTimeout(timer); + const seq = ++request; + saving = true; + error = ''; + try { + if (http) { + await config.setMomHttpSettings({ port: settings.port, allowLan: settings.allowLan }); + } else { + const { enabled, idleTimeout, maxUnits, revision } = settings; + await config.saveMcpProxySettings({ enabled, idleTimeout, maxUnits, revision }, revision); + } + if (!mounted || seq !== request) return; + savedSettings = { ...settings }; + toast({ + variant: 'success', + description: i18n.t(http ? 'settings.http.saveSuccess' : 'settings.proxy.saveSuccess'), + }); + saving = false; + await readStatus(true); + } catch (cause) { + if (mounted && seq === request) { + error = readError(cause); + } + } finally { + if (mounted) { + saving = false; + schedule(); + } + } +} + +onMount(() => { + mounted = true; +}); + +$effect(() => { + if (!mounted) return; + if (active) { + untrack(() => void readStatus()); + } else { + clearTimeout(timer); + } +}); + +onDestroy(() => { + mounted = false; + request += 1; + clearTimeout(timer); +}); + +export function refresh() { + return readStatus(true); +} + + +{#if http} +
    +

    + {i18n.t('settings.http.title')} +

    +
    + {#if error} +
    + +
    + {/if} + + +
    +

    {i18n.t('settings.http.description')}

    + +

    {i18n.t('settings.http.portHint')}

    + +
    + + +
    +
    +
    + +

    {i18n.t('settings.http.allowLanHint')}

    +
    + +
    + + {#if settings.allowLan} + + {/if} +
    + + +
    + +
    +
    +
    +{:else} +
    + {#if error} + + {/if} + + {#if runtimeState?.securityErrors && runtimeState.securityErrors.length > 0} + + {/if} + +
    + +
    +
    +

    + {i18n.t('settings.proxy.title')} +

    +

    + {i18n.t('settings.proxy.description')} +

    +
    +
    + +
    +
    + + +
    +
    + +

    {i18n.t('settings.proxy.idleTimeoutHint')}

    + +
    + +
    + +

    {i18n.t('settings.proxy.maxUnitsHint')}

    + +
    +
    + + +
    +
    + + {i18n.t('settings.proxy.rotateKey')} + +

    + {i18n.t('settings.proxy.rotateKeyDescription')} +

    +
    + +
    + + +
    + +
    +
    +
    + + readStatus(true)} /> +{/if} diff --git a/src/routes/settings/MiniIsland.svelte b/src/routes/settings/MiniIsland.svelte index a4e263f..fe9c869 100644 --- a/src/routes/settings/MiniIsland.svelte +++ b/src/routes/settings/MiniIsland.svelte @@ -10,12 +10,14 @@ import type { IslandState } from '$src/types/island.js'; const config = getConfig(); const i18n = getI18n(); +let { active = true }: { active?: boolean } = $props(); let state = $state.raw(null); let macos = $state(false); let loading = $state(false); let saving = $state(false); let error = $state(''); -let mounted = false; +let mounted = $state(false); +let platformChecked = false; let request = 0; let targetVersion = config.serviceTargetVersion; let reloadQueued = false; @@ -26,16 +28,16 @@ $effect(() => { targetVersion = version; untrack(() => { reloadQueued = true; - if (macos) void load(); + if (active && macos) void load(); }); }); function reloadLatest() { - if (reloadQueued && macos) void load(); + if (reloadQueued && active && macos) void load(); } async function load() { - if (!mounted || loading || saving) return; + if (!mounted || !active || loading || saving) return; const seq = ++request; const version = config.serviceTargetVersion; reloadQueued = false; @@ -54,7 +56,7 @@ async function load() { } } async function toggle() { - if (!mounted || !state?.supported || loading || saving) return; + if (!mounted || !active || !state?.supported || loading || saving) return; const seq = ++request; const version = config.serviceTargetVersion; saving = true; @@ -73,34 +75,46 @@ async function toggle() { } onMount(() => { mounted = true; - if (isTauri()) { - void config - .getAppInfo() - .then((info) => { - if (!mounted || info.platform !== 'macos') return; - macos = true; - void load(); - }) - .catch(() => {}); - } return () => { mounted = false; request += 1; }; }); +$effect(() => { + if (!mounted || !active) return; + untrack(() => { + if (macos) { + reloadQueued = true; + void load(); + return; + } + if (platformChecked || !isTauri()) return; + platformChecked = true; + void config + .getAppInfo() + .then((info) => { + if (!mounted) return; + macos = info.platform === 'macos'; + if (macos && active) void load(); + }) + .catch(() => { + if (mounted) platformChecked = false; + }); + }); +}); {#if macos && state?.supported !== false} -
    +
    -

    +

    {i18n.t('settings.islandHint')}

    -

    {i18n.t('island.closeHint')}

    +

    {i18n.t('island.closeHint')}

    {#if loading || saving}

    {i18n.t(saving ? 'settings.islandSaving' : 'settings.islandLoading')} @@ -109,6 +123,7 @@ onMount(() => {

    (null); @@ -25,7 +27,7 @@ let pending = $state<'read' | 'save' | null>(null); let error = $state<'read' | 'save' | 'busy' | 'validation' | null>(null); let saved = $state(false); let connectionUnknown = $state(false); -let mounted = false; +let mounted = $state(false); let request = 0; const modes = ['shared', 'explicit'] as const; const disabled = $derived(!ready || blocked || pending !== null); @@ -36,7 +38,7 @@ function applyDraft(target: OpenCodeServiceTarget) { url = target.url ?? ''; } export async function refresh() { - if (!mounted || !ready || pending) return; + if (!mounted || !active || !ready || pending) return; const seq = ++request; pending = 'read'; busy = true; @@ -63,7 +65,7 @@ export async function refresh() { } async function save(event: SubmitEvent) { event.preventDefault(); - if (!mounted || disabled || (mode === 'explicit' && !url.trim())) return; + if (!mounted || !active || disabled || (mode === 'explicit' && !url.trim())) return; const seq = ++request; pending = 'save'; busy = true; @@ -105,19 +107,17 @@ onMount(() => { }; }); $effect(() => { - if (ready) untrack(() => void refresh()); + if (mounted && active && ready) untrack(() => void refresh()); }); -
    +
    -

    {i18n.t('settings.serviceTitle')}

    -

    {i18n.t('settings.serviceReadHint')}

    +

    + {i18n.t('settings.serviceTitle')} +

    +

    {i18n.t('settings.serviceReadHint')}

    -
    { edited = true; saved = false; }}> + { + edited = true; + saved = false; + }} + >
    {i18n.t('settings.serviceMode')}
    @@ -180,7 +187,7 @@ $effect(() => { {i18n.t(choice === 'shared' ? 'settings.serviceShared' : 'settings.serviceExplicit')}{i18n.t(choice === 'shared' ? 'settings.serviceSharedHint' : 'settings.serviceExplicitHint')} @@ -195,6 +202,7 @@ $effect(() => { > { spellcheck={false} aria-describedby="opencode-service-url-hint" /> -

    +

    {i18n.t('settings.serviceUrlHint')}

    @@ -214,23 +222,38 @@ $effect(() => { > -

    +

    {i18n.t('settings.servicePasswordHint')}

    {#if status?.target.hasPassword}

    {i18n.t('settings.servicePasswordSaved')}

    - +
    + { + edited = true; + saved = false; + }} + /> + +
    {/if}

    {i18n.t('settings.serviceCredentialWarning')} diff --git a/src/routes/settings/OpenCodeSettings.svelte b/src/routes/settings/OpenCodeSettings.svelte index 6d78b63..eb27bd9 100644 --- a/src/routes/settings/OpenCodeSettings.svelte +++ b/src/routes/settings/OpenCodeSettings.svelte @@ -1,7 +1,7 @@ -

    -

    +
    +

    {i18n.t('settings.opencodeTitle')}

    -

    {i18n.t('settings.opencodeHint')}

    - {#if !native} -

    {i18n.t('settings.opencodeDesktopOnly')}

    - {:else} -
    -
    -
    -

    - {i18n.t('settings.opencodeUpdates')} -

    -

    {i18n.t('settings.opencodeCheckHint')}

    +
    + {#if !native} +

    + {i18n.t('settings.opencodeDesktopOnly')} +

    + {:else} + +
    +
    +
    +

    + {i18n.t('settings.opencodeUpdates')} +

    +

    {i18n.t('settings.opencodeHint')}

    +

    {i18n.t('settings.opencodeCheckHint')}

    +
    +
    + + +
    -
    - - -
    -
    -
    - {#if pending} -

    {i18n.t(`settings.opencodePending.${pending}`)}

    - {:else if upgradedVersion} -

    - {i18n.t('settings.opencodeUpgraded', { version: upgradedVersion })} -

    -

    {i18n.t('settings.opencodeRunningHint')}

    - {:else if result} -

    - {i18n.t(result.updateAvailable ? 'settings.opencodeAvailable' : 'settings.opencodeCurrent', { version: result.latestVersion })} -

    - {#if result.updateAvailable && !result.installation.canUpgrade} -

    {i18n.t('settings.opencodeReason.unsupported')}

    {/if} - {/if} -
    - {#if canUpgrade} - - {/if} -
    - {#if error} - + {#if canUpgrade} + {/if} -

    + {#if error} + + {/if} + {/if} - - {/if} +
    - { if (!open) closeConfirmation(); }}> + { if (!open) closeConfirmation(); }}> { if (pending) event.preventDefault(); }} diff --git a/src/routes/settings/RotateKeyDialog.svelte b/src/routes/settings/RotateKeyDialog.svelte new file mode 100644 index 0000000..6f6991e --- /dev/null +++ b/src/routes/settings/RotateKeyDialog.svelte @@ -0,0 +1,145 @@ + + + { if (!val) close(); }}> + { if (pending) event.preventDefault(); }} + onInteractOutside={(event) => { if (pending) event.preventDefault(); }} + > + + {i18n.t('settings.proxy.rotateKeyTitle')} + + {i18n.t('settings.proxy.rotateKeyDescription')} + + + + {#if error} + + {/if} + + {#if newKey} +
    + +
    + + +
    +
    + {/if} + + + {#if newKey} + + {:else} + + + {/if} + +
    +
    diff --git a/src/routes/settings/SettingsSearch.svelte b/src/routes/settings/SettingsSearch.svelte new file mode 100644 index 0000000..87ccafa --- /dev/null +++ b/src/routes/settings/SettingsSearch.svelte @@ -0,0 +1,229 @@ + + + + + +
    +
    +

    + {i18n.t('settings.searchHint')} +

    + {#if terms.length} +
    +

    + {i18n.t(results.length ? 'settings.searchCount' : 'settings.searchEmpty', { count: results.length })} +

    + {#if results.length} +
      + {#each results as entry (entry.keys[0])} +
    • + +
    • + {/each} +
    + {:else} + + {/if} +
    + {/if} +
    diff --git a/src/routes/settings/__tests__/app-updates.test.ts b/src/routes/settings/__tests__/app-updates.test.ts index 2e937e4..639474b 100644 --- a/src/routes/settings/__tests__/app-updates.test.ts +++ b/src/routes/settings/__tests__/app-updates.test.ts @@ -22,6 +22,48 @@ function mount(overrides: Record = {}) { return { config, ...view }; } +test('inactive updates do not start operations and a pending app-info read survives a tab round trip without overlap', async () => { + let finish!: (info: AppInfo) => void; + const config = { + preferences: { locale: 'en', theme: 'dark' }, + getAppInfo: vi + .fn() + .mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue({ version: '0.2.0', platform: 'macos' }), + checkForUpdates: vi.fn(), + openProjectPage: vi.fn(), + }; + const view = render( + AppUpdates, + { active: false }, + { + wrapper: Harness, + wrapperProps: { config: config as unknown as ConfigStore }, + }, + ); + await tick(); + expect(config.getAppInfo).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Check for updates' })); + await fireEvent.click(screen.getByRole('button', { name: 'Star on GitHub' })); + expect(config.checkForUpdates).not.toHaveBeenCalled(); + expect(config.openProjectPage).not.toHaveBeenCalled(); + await view.rerender({ active: true }); + await view.rerender({ active: false }); + await view.rerender({ active: true }); + expect(config.getAppInfo).toHaveBeenCalledOnce(); + await view.rerender({ active: false }); + finish({ version: '0.1.0', platform: 'macos' }); + await screen.findByText('Installed version: 0.1.0'); + expect(config.getAppInfo).toHaveBeenCalledOnce(); + await view.rerender({ active: true }); + await screen.findByText('Installed version: 0.2.0'); + expect(config.getAppInfo).toHaveBeenCalledTimes(2); +}); + test('reads installed version locally, checks only on click, guards pending and reports up to date', async () => { let finish: (value: UpdateCheck) => void = () => {}; const checkForUpdates = vi.fn().mockImplementation( diff --git a/src/routes/settings/__tests__/lid-protection.test.ts b/src/routes/settings/__tests__/lid-protection.test.ts index 7e57c88..fa14620 100644 --- a/src/routes/settings/__tests__/lid-protection.test.ts +++ b/src/routes/settings/__tests__/lid-protection.test.ts @@ -511,3 +511,38 @@ test.each(['read', 'save'] as const)('unmount drops a queued target reload behin expect(vi.getTimerCount()).toBe(0); expect(screen.queryByRole('status')).toBeNull(); }); + +test.each(['read', 'save'] as const)( + 'inactive completion of a pending %s keeps target reload queued until re-entry', + async (operation) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const pending = deferred(); + const getLidProtection = vi.fn().mockResolvedValueOnce(protectedState); + if (operation === 'read') getLidProtection.mockReturnValueOnce(pending.promise); + getLidProtection.mockResolvedValue(disabled); + const view = mountTarget({ getLidProtection, setLidProtection: vi.fn().mockReturnValue(pending.promise) }); + await settle(); + if (operation === 'read') await vi.advanceTimersByTimeAsync(2000); + else await fireEvent.click(view.toggle); + await view.rerender({ active: false }); + await view.config.setOpenCodeServiceTarget(null); + await settle(); + const reads = getLidProtection.mock.calls.length; + pending.resolve(protectedState); + await settle(); + await vi.advanceTimersByTimeAsync(6000); + expect(getLidProtection).toHaveBeenCalledTimes(reads); + expect(vi.getTimerCount()).toBe(0); + expect(screen.getByRole('status').textContent).not.toContain('Protected'); + await fireEvent.click(view.toggle); + expect(view.adapter.setLidProtection).toHaveBeenCalledTimes(operation === 'save' ? 1 : 0); + await view.rerender({ active: true }); + await settle(); + expect(getLidProtection).toHaveBeenCalledTimes(reads + 1); + expect(screen.getByRole('checkbox')).toBe(view.toggle); + expect(screen.getByRole('status').textContent).toBe('Disabled'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(2000); + expect(getLidProtection).toHaveBeenCalledTimes(reads + 2); + }, +); diff --git a/src/routes/settings/__tests__/mcp-proxy-settings.test.ts b/src/routes/settings/__tests__/mcp-proxy-settings.test.ts new file mode 100644 index 0000000..faf982e --- /dev/null +++ b/src/routes/settings/__tests__/mcp-proxy-settings.test.ts @@ -0,0 +1,432 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import type { ConfigStore } from '$src/config/store.svelte.js'; +import Harness from '$src/test-utils/harness.svelte'; +import type { McpProxySettingsDto, McpProxyStateDto, MomHttpSettings } from '$src/types/mcp-proxy.js'; +import SettingsPage from '../+page.svelte'; +import McpProxySettings from '../McpProxySettings.svelte'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('$src/shell/toast.svelte.js', () => ({ + toast: vi.fn(), +})); + +vi.mock('$app/state', async () => { + const { createSubscriber } = await import('svelte/reactivity'); + let notify: () => void = () => {}; + const track = createSubscriber((update) => { + notify = update; + }); + const state = { + url: new URL('http://localhost/settings'), + params: {}, + }; + return { + page: { + get url() { + track(); + return state.url; + }, + set url(value: URL) { + state.url = value; + notify(); + }, + get params() { + track(); + return state.params; + }, + set params(value: Record) { + state.params = value; + notify(); + }, + }, + }; +}); + +const defaultSettings: McpProxySettingsDto = { + enabled: true, + idleTimeout: 300, + maxUnits: 32, + revision: 5, +}; + +const defaultHttpSettings: MomHttpSettings = { port: 23456, allowLan: false }; + +const defaultProxyState: McpProxyStateDto = { + actual: true, + desired: true, + actualState: 'running', + desiredState: true, + boundAddr: '127.0.0.1:23456', + port: 23456, + allowLan: false, + totalServices: 1, + enabledServices: 1, + activeUnits: 0, + activeSessions: 7, + availableUnits: 32, + maxUnits: 32, + securityErrors: [], + services: [], + revision: 5, + generation: 2, +}; + +function makeConfig(overrides: Record = {}) { + return { + preferences: { locale: 'en', theme: 'dark' }, + getAutostart: vi.fn().mockResolvedValue(false), + setAutostart: vi.fn().mockResolvedValue(true), + getLidProtection: vi.fn().mockResolvedValue({ enabled: false, phase: 'disabled' }), + getAppInfo: vi.fn().mockResolvedValue({ version: '2.0.0', platform: 'macos' }), + checkForUpdates: vi.fn(), + getMomHttpSettings: vi.fn().mockResolvedValue(defaultHttpSettings), + setMomHttpSettings: vi.fn().mockResolvedValue(undefined), + getMcpProxySettings: vi.fn().mockResolvedValue(defaultSettings), + getMcpProxyState: vi.fn().mockResolvedValue(defaultProxyState), + saveMcpProxySettings: vi.fn().mockResolvedValue(undefined), + rotateMcpProxyKey: vi.fn().mockResolvedValue('test-rotated-key-xyz'), + ...overrides, + } as unknown as ConfigStore; +} + +beforeEach(() => { + page.url = new URL('http://localhost/settings'); + vi.mocked(goto).mockClear(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +async function settle() { + await tick(); + await Promise.resolve(); + await tick(); +} + +test.each([false, true])( + 'HTTP=%s retains in-flight read/save guards across activation and never polls while inactive', + async (http) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + let finishRead!: (value: McpProxySettingsDto | MomHttpSettings) => void; + let finishSave!: () => void; + const pendingRead = new Promise((resolve) => { + finishRead = resolve; + }); + const pendingSave = new Promise((resolve) => { + finishSave = resolve; + }); + const defaults = http ? defaultHttpSettings : defaultSettings; + const getSettings = vi.fn().mockReturnValueOnce(pendingRead).mockResolvedValue(defaults); + const setSettings = vi.fn().mockReturnValue(pendingSave); + const config = makeConfig({ + [http ? 'getMomHttpSettings' : 'getMcpProxySettings']: getSettings, + [http ? 'setMomHttpSettings' : 'saveMcpProxySettings']: setSettings, + }); + const view = render(McpProxySettings, { http, active: false }, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + expect(getSettings).not.toHaveBeenCalled(); + expect(config.getMcpProxyState).not.toHaveBeenCalled(); + await view.rerender({ active: true }); + await view.rerender({ active: false }); + await view.rerender({ active: true }); + expect(getSettings).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + await view.rerender({ active: false }); + finishRead(defaults); + await settle(); + await vi.advanceTimersByTimeAsync(6000); + expect(getSettings).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + await fireEvent.click(screen.getByRole('button', { name: http ? 'Save HTTP Settings' : 'Save Gateway Settings' })); + expect(setSettings).not.toHaveBeenCalled(); + await view.rerender({ active: true }); + await settle(); + const input = screen.getByLabelText(http ? 'Port' : 'Session Idle Timeout (seconds)') as HTMLInputElement; + await fireEvent.input(input, { target: { value: '24567' } }); + await fireEvent.click(screen.getByRole('button', { name: http ? 'Save HTTP Settings' : 'Save Gateway Settings' })); + expect(setSettings).toHaveBeenCalledOnce(); + await view.rerender({ active: false }); + await view.rerender({ active: true }); + expect(getSettings).toHaveBeenCalledTimes(2); + await view.rerender({ active: false }); + finishSave(); + await settle(); + await vi.advanceTimersByTimeAsync(6000); + expect(getSettings).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + await view.rerender({ active: true }); + await settle(); + expect(screen.getByLabelText(http ? 'Port' : 'Session Idle Timeout (seconds)')).toBe(input); + expect(getSettings).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(2000); + expect(getSettings).toHaveBeenCalledTimes(4); + }, +); + +test.each([false, true])('HTTP=%s polls without replacing a dirty draft or its expected revision', async (http) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const baseline = http ? defaultHttpSettings : defaultSettings; + const getSettings = vi.fn().mockResolvedValue({ ...baseline }); + const getMcpProxyState = vi.fn().mockResolvedValue(defaultProxyState); + const config = makeConfig({ + [http ? 'getMomHttpSettings' : 'getMcpProxySettings']: getSettings, + getMcpProxyState, + }); + render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + + const field = http ? 'port' : 'idleTimeout'; + const input = screen.getByLabelText(http ? 'Port' : 'Session Idle Timeout (seconds)') as HTMLInputElement; + getSettings.mockResolvedValue({ ...baseline, [field]: 23457, ...(!http && { revision: 6 }) }); + await vi.advanceTimersByTimeAsync(2000); + expect(input.value).toBe('23457'); + + await fireEvent.input(input, { target: { value: '24567' } }); + getSettings.mockResolvedValue({ ...baseline, [field]: 23458, ...(!http && { revision: 7 }) }); + getMcpProxyState.mockResolvedValue({ ...defaultProxyState, activeSessions: 9 }); + await vi.advanceTimersByTimeAsync(2000); + expect(input.value).toBe('24567'); + + await fireEvent.click(screen.getByRole('button', { name: http ? 'Save HTTP Settings' : 'Save Gateway Settings' })); + await settle(); + if (http) { + expect(config.setMomHttpSettings).toHaveBeenCalledExactlyOnceWith({ ...defaultHttpSettings, port: 24567 }); + expect(config.saveMcpProxySettings).not.toHaveBeenCalled(); + } else { + expect(config.saveMcpProxySettings).toHaveBeenCalledExactlyOnceWith( + { ...defaultSettings, idleTimeout: 24567, revision: 6 }, + 6, + ); + expect(config.setMomHttpSettings).not.toHaveBeenCalled(); + } +}); + +test.each([ + [false, false], + [false, true], + [true, false], + [true, true], +])('HTTP=%s save failure=%s preserves polling and refreshes successful saves', async (http, fails) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + let resolve!: () => void; + let reject!: (reason: unknown) => void; + const pending = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + const baseline = http ? defaultHttpSettings : defaultSettings; + const getSettings = vi.fn().mockResolvedValue({ ...baseline }); + const saveSettings = vi.fn().mockReturnValue(pending); + const config = makeConfig({ + [http ? 'getMomHttpSettings' : 'getMcpProxySettings']: getSettings, + [http ? 'setMomHttpSettings' : 'saveMcpProxySettings']: saveSettings, + }); + const { unmount } = render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + + const field = http ? 'port' : 'idleTimeout'; + const input = screen.getByLabelText(http ? 'Port' : 'Session Idle Timeout (seconds)') as HTMLInputElement; + const save = screen.getByRole('button', { name: http ? 'Save HTTP Settings' : 'Save Gateway Settings' }); + await fireEvent.input(input, { target: { value: '24567' } }); + await fireEvent.click(save); + await vi.advanceTimersByTimeAsync(6000); + expect(getSettings).toHaveBeenCalledTimes(1); + + if (fails) { + reject(new Error('Save failed')); + } else { + getSettings.mockResolvedValue({ ...baseline, [field]: 24568, ...(!http && { revision: 6 }) }); + resolve(); + } + await settle(); + expect((save as HTMLButtonElement).disabled).toBe(false); + expect(getSettings).toHaveBeenCalledTimes(fails ? 1 : 2); + expect(input.value).toBe(fails ? '24567' : '24568'); + + await vi.advanceTimersByTimeAsync(2000); + expect(getSettings).toHaveBeenCalledTimes(fails ? 2 : 3); + expect(input.value).toBe(fails ? '24567' : '24568'); + unmount(); + await vi.advanceTimersByTimeAsync(4000); + expect(getSettings).toHaveBeenCalledTimes(fails ? 2 : 3); +}); + +test.each([false, true])('HTTP=%s does not start a save while a status read is in flight', async (http) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const baseline = http ? defaultHttpSettings : defaultSettings; + let resolve!: (value: McpProxySettingsDto | MomHttpSettings) => void; + const pending = new Promise((yes) => { + resolve = yes; + }); + const getSettings = vi + .fn() + .mockResolvedValueOnce({ ...baseline }) + .mockReturnValueOnce(pending); + const config = makeConfig({ [http ? 'getMomHttpSettings' : 'getMcpProxySettings']: getSettings }); + render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + await vi.advanceTimersByTimeAsync(2000); + const save = screen.getByRole('button', { + name: http ? 'Save HTTP Settings' : 'Save Gateway Settings', + }) as HTMLButtonElement; + expect(save.disabled).toBe(true); + await fireEvent.click(save); + expect(config.saveMcpProxySettings).not.toHaveBeenCalled(); + expect(config.setMomHttpSettings).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(6000); + expect(getSettings).toHaveBeenCalledTimes(2); + resolve({ ...baseline }); + await settle(); + expect(save.disabled).toBe(false); +}); + +test('settings switches to MCP Proxy tab and does not read autostart on proxy tab', async () => { + page.url = new URL('http://localhost/settings?tab=mcp-proxy'); + const getAutostart = vi.fn().mockResolvedValue(false); + const config = makeConfig({ getAutostart }); + + render(SettingsPage, {}, { wrapper: Harness, wrapperProps: { config } }); + + expect(getAutostart).not.toHaveBeenCalled(); + await waitFor(() => { + expect(config.getMcpProxySettings).toHaveBeenCalled(); + }); + + const proxy = within(screen.getByRole('tabpanel', { name: 'MCP Proxy' })); + expect(proxy.getByRole('region', { name: 'MCP Proxy Gateway' })).toBeDefined(); + expect(proxy.getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' })).toBeDefined(); + expect(proxy.getByRole('spinbutton', { name: 'Maximum Concurrent Units' })).toBeDefined(); + const generalPanel = document.getElementById('settings-tabpanel-general') as HTMLElement; + expect(generalPanel.hidden).toBe(true); + expect(within(generalPanel).getByRole('spinbutton', { name: 'Port', hidden: true })).toBeDefined(); + expect(screen.queryByRole('spinbutton', { name: 'Port' })).toBeNull(); + expect(screen.queryByRole('checkbox', { name: 'Allow LAN' })).toBeNull(); +}); + +test('General owns HTTP settings without proxy writes and stops polling on tab switch', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const config = makeConfig(); + const { unmount } = render(SettingsPage, {}, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + const generalPanel = screen.getByRole('tabpanel', { name: 'General' }); + const general = within(generalPanel); + expect((document.getElementById('settings-tabpanel-mcp-proxy') as HTMLElement).hidden).toBe(true); + expect(screen.queryByRole('checkbox', { name: 'Enable MCP Gateway' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Rotate Secret Token…' })).toBeNull(); + + const port = general.getByRole('spinbutton', { name: 'Port' }) as HTMLInputElement; + const save = general.getByRole('button', { name: 'Save HTTP Settings' }) as HTMLButtonElement; + for (const value of ['', '0', '65536', '18765.5']) { + await fireEvent.input(port, { target: { value } }); + expect(save.disabled).toBe(true); + } + expect(config.saveMcpProxySettings).not.toHaveBeenCalled(); + expect(config.setMomHttpSettings).not.toHaveBeenCalled(); + await fireEvent.input(port, { target: { value: '18765' } }); + await fireEvent.click(general.getByRole('checkbox', { name: 'Allow LAN' })); + expect(general.getByRole('alert').textContent).toContain('Enable only on trusted networks.'); + await fireEvent.click(save); + await settle(); + expect(config.setMomHttpSettings).toHaveBeenCalledExactlyOnceWith({ port: 18765, allowLan: true }); + expect(config.saveMcpProxySettings).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await settle(); + expect(config.getMomHttpSettings).toHaveBeenCalledTimes(3); + expect(config.getMcpProxySettings).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('tab', { name: 'MCP Proxy' })); + await settle(); + expect(generalPanel.hidden).toBe(true); + expect(general.getByRole('spinbutton', { name: 'Port', hidden: true })).toBe(port); + const proxy = within(screen.getByRole('tabpanel', { name: 'MCP Proxy' })); + expect(proxy.getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' })).toBeDefined(); + await vi.advanceTimersByTimeAsync(2000); + expect(config.getMomHttpSettings).toHaveBeenCalledTimes(3); + expect(config.getMcpProxySettings).toHaveBeenCalledTimes(2); + unmount(); + await vi.advanceTimersByTimeAsync(4000); + expect(config.getMomHttpSettings).toHaveBeenCalledTimes(3); + expect(config.getMcpProxySettings).toHaveBeenCalledTimes(2); +}); + +test('HTTP settings displays error banner after a failed read', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const getMomHttpSettings = vi + .fn() + .mockResolvedValueOnce(defaultHttpSettings) + .mockRejectedValue(new Error('Read failed')); + const config = makeConfig({ getMomHttpSettings }); + render(McpProxySettings, { http: true }, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + expect(screen.queryByRole('alert')).toBeNull(); + await vi.advanceTimersByTimeAsync(2000); + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByRole('alert').textContent).toContain('Read failed'); +}); + +test('McpProxySettings saves gateway settings with expected revision', async () => { + page.url = new URL('http://localhost/settings?tab=mcp-proxy'); + const saveMcpProxySettings = vi.fn().mockResolvedValue(undefined); + const config = makeConfig({ saveMcpProxySettings }); + + render(SettingsPage, {}, { wrapper: Harness, wrapperProps: { config } }); + + await waitFor(() => { + expect(config.getMcpProxySettings).toHaveBeenCalled(); + }); + + const idleTimeoutInput = screen.getByLabelText('Session Idle Timeout (seconds)'); + await fireEvent.input(idleTimeoutInput, { target: { value: '450' } }); + const saveBtn = screen.getByRole('button', { name: 'Save Gateway Settings' }); + await fireEvent.click(saveBtn); + + await waitFor(() => { + expect(saveMcpProxySettings).toHaveBeenCalledWith( + expect.objectContaining({ + idleTimeout: 450, + revision: 5, + }), + 5, + ); + }); +}); + +test('RotateKeyDialog confirms and displays rotated secret token once', async () => { + page.url = new URL('http://localhost/settings?tab=mcp-proxy'); + const rotateMcpProxyKey = vi.fn().mockResolvedValue('secret-key-super-safe'); + const config = makeConfig({ rotateMcpProxyKey }); + + render(SettingsPage, {}, { wrapper: Harness, wrapperProps: { config } }); + + await waitFor(() => { + expect(config.getMcpProxySettings).toHaveBeenCalled(); + }); + + const rotateBtn = screen.getByRole('button', { name: /Rotate Secret Token/i }); + await fireEvent.click(rotateBtn); + + // Dialog opens + const confirmBtn = screen.getByRole('button', { name: 'Generate New Token' }); + await fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(rotateMcpProxyKey).toHaveBeenCalled(); + }); + + // Displays key input + const keyInput = (await screen.findByLabelText('New Secret Token Generated')) as HTMLInputElement; + expect(keyInput.value).toBe('secret-key-super-safe'); + + // Close dialog + const closeBtn = screen.getByRole('button', { name: 'Close' }); + await fireEvent.click(closeBtn); +}); diff --git a/src/routes/settings/__tests__/mini-island.test.ts b/src/routes/settings/__tests__/mini-island.test.ts index 0ea961f..002fed4 100644 --- a/src/routes/settings/__tests__/mini-island.test.ts +++ b/src/routes/settings/__tests__/mini-island.test.ts @@ -21,7 +21,7 @@ const initial: IslandState = { notchWidth: 180, topInset: 32, }; -function mount(overrides: Record = {}) { +function mount(overrides: Record = {}, active = true) { const config = { preferences: { theme: 'dark', locale: 'en' }, getAppInfo: vi.fn().mockResolvedValue({ version: '2.0.0', platform: 'macos' }), @@ -29,7 +29,11 @@ function mount(overrides: Record = {}) { setIslandEnabled: vi.fn().mockResolvedValue({ ...initial, enabled: true, connection: 'connecting' }), ...overrides, }; - const view = render(MiniIsland, {}, { wrapper: Harness, wrapperProps: { config: config as unknown as ConfigStore } }); + const view = render( + MiniIsland, + { active }, + { wrapper: Harness, wrapperProps: { config: config as unknown as ConfigStore } }, + ); return { ...view, config }; } function mountTarget(overrides: Record = {}) { @@ -47,6 +51,63 @@ function mountTarget(overrides: Record = {}) { } beforeEach(() => native.isTauri.mockReturnValue(true)); +test('inactive mount defers platform detection and a late macOS result waits until re-entry to read state', async () => { + let finish!: (info: { version: string; platform: string }) => void; + const getAppInfo = vi.fn().mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = mount({ getAppInfo }, false); + await tick(); + expect(getAppInfo).not.toHaveBeenCalled(); + await view.rerender({ active: true }); + expect(getAppInfo).toHaveBeenCalledOnce(); + await view.rerender({ active: false }); + finish({ version: '2.0.0', platform: 'macos' }); + await tick(); + expect(view.config.getIslandState).not.toHaveBeenCalled(); + await view.rerender({ active: true }); + await waitFor(() => expect((screen.getByRole('checkbox') as HTMLInputElement).disabled).toBe(false)); + const control = screen.getByRole('checkbox'); + expect(view.config.getIslandState).toHaveBeenCalledOnce(); + await view.rerender({ active: false }); + await fireEvent.click(control); + await view.rerender({ active: true }); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('checkbox')).toBe(control); + expect(getAppInfo).toHaveBeenCalledOnce(); + expect(view.config.setIslandEnabled).not.toHaveBeenCalled(); +}); + +test('platform failure retries only on re-entry and teardown prevents late platform readback', async () => { + const getAppInfo = vi.fn().mockRejectedValueOnce(new Error('Platform unavailable')).mockResolvedValueOnce({ + version: '2.0.0', + platform: 'macos', + }); + const view = mount({ getAppInfo }); + await tick(); + await tick(); + expect(getAppInfo).toHaveBeenCalledOnce(); + expect(view.config.getIslandState).not.toHaveBeenCalled(); + await view.rerender({ active: false }); + await view.rerender({ active: true }); + await waitFor(() => expect(view.config.getIslandState).toHaveBeenCalledOnce()); + view.unmount(); + let finish!: (info: { version: string; platform: string }) => void; + const pending = mount({ + getAppInfo: vi.fn().mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ), + }); + pending.unmount(); + finish({ version: '2.0.0', platform: 'macos' }); + await tick(); + expect(pending.config.getIslandState).not.toHaveBeenCalled(); +}); + test('macOS switch reads independently, defaults off, guards pending and uses native readback immediately', async () => { let finish!: (value: IslandState) => void; const setIslandEnabled = vi.fn().mockImplementationOnce( @@ -59,8 +120,8 @@ test('macOS switch reads independently, defaults off, guards pending and uses na const control = (await screen.findByRole('checkbox', { name: 'OpenCode mini island' })) as HTMLInputElement; await waitFor(() => expect(control.checked).toBe(false)); await waitFor(() => expect(control.hasAttribute('disabled')).toBe(false)); - expect(screen.getByText(/Shows local OpenCode V2 sessions and pending requests/).textContent).toContain( - 'enabling may start that service', + expect(screen.getByText(/Local OpenCode V2 sessions and pending requests/).textContent).toContain( + 'enabling may start Shared Service', ); await fireEvent.click(control); await fireEvent.click(control); diff --git a/src/routes/settings/__tests__/opencode-settings.test.ts b/src/routes/settings/__tests__/opencode-settings.test.ts index 8e6e8d0..b8c65d0 100644 --- a/src/routes/settings/__tests__/opencode-settings.test.ts +++ b/src/routes/settings/__tests__/opencode-settings.test.ts @@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/sve import { tick } from 'svelte'; import { beforeEach, expect, test, vi } from 'vitest'; import type { ConfigStore } from '$src/config/store.svelte.js'; +import { en } from '$src/i18n/dictionaries/en.js'; +import { zh } from '$src/i18n/dictionaries/zh.js'; import Harness from '$src/test-utils/harness.svelte'; import type { OpenCodeInstallation, OpenCodeServiceStatus, OpenCodeUpdateCheck } from '$src/types/app.js'; import OpenCodeSettings from '../OpenCodeSettings.svelte'; @@ -384,7 +386,7 @@ test('upgrade is confirmed once, blocks dismissal and refresh, and reads CLI bac expect(config.getOpenCodeServiceStatus).toHaveBeenCalledTimes(2); expect(config.checkOpenCodeUpdate).toHaveBeenCalledOnce(); expect(config.reloadOpencode).not.toHaveBeenCalled(); - expect(screen.getByText(/A running service may still use the old version/)).toBeTruthy(); + expect(screen.getByText(en['settings.opencodeRunningHint'])).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Upgrade CLI…' })).toBeNull(); }); @@ -551,7 +553,9 @@ test('read-only refresh keeps URL, password and clear-password drafts and tests await fireEvent.click(screen.getByLabelText('Clear the saved password when saving')); await fireEvent.click(screen.getByRole('button', { name: 'Refresh connection' })); await waitFor(() => expect(getOpenCodeServiceStatus).toHaveBeenCalledTimes(3)); - expect((screen.getByLabelText('Clear the saved password when saving') as HTMLInputElement).checked).toBe(true); + expect( + screen.getByRole('checkbox', { name: 'Clear the saved password when saving' }).getAttribute('aria-checked'), + ).toBe('true'); }); test('failed save preserves confirmed target and local secret draft without displaying backend payloads', async () => { @@ -602,8 +606,12 @@ test.each([ const save = screen.getByRole('button', { name: locale === 'en' ? 'Save connection target' : '保存连接目标' }); await fireEvent.click(save); const alert = await screen.findByRole('alert'); - expect(alert.textContent).toContain(hint); - expect(alert.textContent).toContain(action); + if (code === 'validation_failed') { + expect(alert.textContent).toBe((locale === 'en' ? en : zh)['settings.serviceSaveValidation']); + } else { + expect(alert.textContent).toContain(hint); + expect(alert.textContent).toContain(action); + } expect(alert.textContent).not.toContain('private-password'); expect(alert.textContent).not.toMatch(/Mini Island|lid protection|power|合盖保护|电源|关闭/); expect((url as HTMLInputElement).value).toBe('http://[::1]:5096'); @@ -639,7 +647,7 @@ test('offline save commits the returned normalized target, clears password and n const { config } = mount({ setOpenCodeServiceTarget: vi.fn().mockResolvedValue(offline) }); await explicitDraft('http://localhost:5096', 'new-secret'); await fireEvent.click(screen.getByRole('button', { name: 'Save connection target' })); - await screen.findByText(/Target saved, but the backend is offline/); + await screen.findByText(en['settings.serviceSavedOffline']); expect(screen.getByText('Disconnected')).toBeTruthy(); expect(screen.queryByText('4242')).toBeNull(); expect(screen.queryByText(/secret inaccessible/)).toBeNull(); diff --git a/src/routes/settings/__tests__/rotate-key-dialog.test.ts b/src/routes/settings/__tests__/rotate-key-dialog.test.ts new file mode 100644 index 0000000..c6932dc --- /dev/null +++ b/src/routes/settings/__tests__/rotate-key-dialog.test.ts @@ -0,0 +1,43 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { expect, test, vi } from 'vitest'; +import type { ConfigStore } from '$src/config/store.svelte.js'; +import Harness from '$src/test-utils/harness.svelte'; +import RotateKeyDialog from '../RotateKeyDialog.svelte'; + +function mount(rotateMcpProxyKey = vi.fn().mockResolvedValue('private-rotated-token')) { + const config = { preferences: { locale: 'en' }, rotateMcpProxyKey } as unknown as ConfigStore; + return render(RotateKeyDialog, { open: true }, { wrapper: Harness, wrapperProps: { config } }); +} + +test('externally closing the token dialog clears its one-time secret before reopening', async () => { + const { rerender } = mount(); + await fireEvent.click(await screen.findByRole('button', { name: 'Generate New Token' })); + expect(((await screen.findByLabelText('New Secret Token Generated')) as HTMLInputElement).value).toBe( + 'private-rotated-token', + ); + + await rerender({ open: false }); + await rerender({ open: true }); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); + expect(await screen.findByRole('button', { name: 'Generate New Token' })).toBeTruthy(); +}); + +test('leaving and re-entering the proxy tab discards a pending one-time token', async () => { + let resolve!: (value: string) => void; + const rotateMcpProxyKey = vi.fn().mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const { rerender } = mount(rotateMcpProxyKey); + await fireEvent.click(await screen.findByRole('button', { name: 'Generate New Token' })); + + await rerender({ open: true, active: false }); + await rerender({ open: true, active: true }); + resolve('late-private-token'); + await waitFor(() => + expect((screen.getByRole('button', { name: 'Generate New Token' }) as HTMLButtonElement).disabled).toBe(false), + ); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); + expect(rotateMcpProxyKey).toHaveBeenCalledOnce(); +}); diff --git a/src/routes/settings/__tests__/settings-search.test.ts b/src/routes/settings/__tests__/settings-search.test.ts new file mode 100644 index 0000000..0f76552 --- /dev/null +++ b/src/routes/settings/__tests__/settings-search.test.ts @@ -0,0 +1,649 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import type { CommandAdapter } from '$src/config/adapter.js'; +import { createConfigStore } from '$src/config/store.svelte.js'; +import { en } from '$src/i18n/dictionaries/en.js'; +import { zh } from '$src/i18n/dictionaries/zh.js'; +import Harness from '$src/test-utils/harness.svelte'; +import Settings from '../+page.svelte'; +import SettingsSearch from '../SettingsSearch.svelte'; + +const native = vi.hoisted(() => ({ isTauri: vi.fn(() => true) })); +vi.mock('@tauri-apps/api/core', () => ({ isTauri: native.isTauri })); +vi.mock('$app/navigation', () => ({ goto: vi.fn().mockResolvedValue(undefined) })); +vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/settings'), params: {} } })); +vi.mock('$src/shell/toast.svelte.js', () => ({ toast: vi.fn() })); + +const service = { + target: { mode: 'explicit', url: 'http://127.0.0.1:4096', hasPassword: true }, + connected: true, + url: 'http://127.0.0.1:4096', + pid: 42, + version: '2.0.0', + error: null, +}; +const installation = { + binaryPath: '/Users/test/.opencode/bin/opencode', + version: '2.0.0', + method: 'curl', + canUpgrade: true, + upgradeReason: null, + manualCommand: null, +}; + +function makeAdapter() { + return { + savePreferences: vi.fn().mockResolvedValue(undefined), + getAutostart: vi.fn().mockResolvedValue(false), + setAutostart: vi.fn(), + getLidProtection: vi.fn().mockResolvedValue({ enabled: true, phase: 'protected', activeSessions: 2 }), + setLidProtection: vi.fn(), + getIslandState: vi.fn().mockResolvedValue({ supported: true, enabled: true }), + setIslandEnabled: vi.fn(), + getAppInfo: vi.fn().mockResolvedValue({ version: '2.0.0', platform: 'macos' }), + getOpenCodeInstallation: vi.fn().mockResolvedValue(installation), + getOpenCodeServiceStatus: vi.fn().mockResolvedValue(service), + setOpenCodeServiceTarget: vi.fn(), + checkOpenCodeUpdate: vi.fn().mockResolvedValue({ + installation, + currentVersion: '2.0.0', + latestVersion: '2.1.0', + updateAvailable: true, + }), + upgradeOpenCode: vi.fn(), + checkForUpdates: vi.fn(), + getMomHttpSettings: vi.fn().mockResolvedValue({ port: 18765, allowLan: false }), + setMomHttpSettings: vi.fn(), + getMcpProxySettings: vi.fn().mockResolvedValue({ enabled: true, idleTimeout: 300, maxUnits: 16, revision: 1 }), + getMcpProxyState: vi.fn().mockResolvedValue({ + actualState: 'running', + boundAddr: '127.0.0.1:18765', + securityErrors: [], + activeUnits: 0, + activeSessions: 0, + maxUnits: 16, + revision: 1, + generation: 1, + }), + saveMcpProxySettings: vi.fn(), + rotateMcpProxyKey: vi.fn(), + }; +} + +function mountPage(tab: 'general' | 'mcp-proxy' = 'general', overrides: Partial> = {}) { + page.url = new URL(`http://localhost/settings${tab === 'mcp-proxy' ? '?tab=mcp-proxy' : ''}`); + const adapter = { ...makeAdapter(), ...overrides }; + const config = createConfigStore(adapter as unknown as CommandAdapter, false, false); + const view = render(Settings, {}, { wrapper: Harness, wrapperProps: { config } }); + return { ...view, config, adapter }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function settle() { + for (let step = 0; step < 8; step++) { + await Promise.resolve(); + await tick(); + } +} + +function results() { + return screen.getByRole('list', { name: 'Matching settings' }); +} + +function activePanel() { + return within(screen.getByRole('tabpanel')); +} + +function expectNoWrites(adapter: ReturnType) { + for (const call of [ + adapter.setAutostart, + adapter.setLidProtection, + adapter.setIslandEnabled, + adapter.setOpenCodeServiceTarget, + adapter.upgradeOpenCode, + adapter.checkForUpdates, + adapter.setMomHttpSettings, + adapter.saveMcpProxySettings, + adapter.rotateMcpProxyKey, + ]) + expect(call).not.toHaveBeenCalled(); +} + +beforeEach(() => { + page.url = new URL('http://localhost/settings'); + native.isTauri.mockReturnValue(true); + vi.mocked(goto).mockClear(); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); +}); + +test('matches bilingual labels, descriptions and technical names locally, and follows live locale changes', async () => { + const adapter = makeAdapter(); + const config = createConfigStore(adapter as unknown as CommandAdapter, false, false); + const onselect = vi.fn(); + render(SettingsSearch, { onselect }, { wrapper: Harness, wrapperProps: { config } }); + const search = screen.getByRole('searchbox', { name: 'Search settings' }) as HTMLInputElement; + expect(search.name).toBe('settings-search'); + expect(search.autocomplete).toBe('off'); + + for (const [query, label] of [ + [' CLI ', en['settings.opencodeUpdates']], + ['18765', en['settings.http.port']], + ['allowLan', en['settings.http.allowLan']], + ['MCP maxUnits', en['settings.proxy.maxUnits']], + ['idleTimeout', en['settings.proxy.idleTimeout']], + ['局域网', en['settings.http.allowLan']], + ['登录', en['settings.autostart']], + ['password', en['settings.servicePassword']], + ] as const) { + await fireEvent.input(search, { target: { value: query } }); + expect(within(results()).getByText(label)).toBeTruthy(); + } + await config.setLocale('zh'); + await tick(); + expect(search).toBe(screen.getByRole('searchbox', { name: '搜索设置' })); + expect(search.value).toBe('password'); + expect(screen.getByText(zh['settings.servicePassword'])).toBeTruthy(); + await fireEvent.input(search, { target: { value: 'MCP maxUnits' } }); + const chineseResults = screen.getByRole('list', { name: '匹配的设置' }); + await fireEvent.click(within(chineseResults).getByRole('button', { name: /最大并发/ })); + expect(onselect).toHaveBeenCalledWith( + expect.objectContaining({ tab: 'mcp-proxy', target: 'settings-proxy-capacity' }), + ); + await config.setLocale('en'); + await tick(); + expect(within(results()).getByText(en['settings.proxy.maxUnits'])).toBeTruthy(); +}); + +test('empty, whitespace, no-match and Escape clear return to the unchanged settings view', async () => { + const { adapter } = mountPage(); + const port = await activePanel().findByRole('spinbutton', { name: 'Port' }); + const search = screen.getByRole('searchbox', { name: 'Search settings' }) as HTMLInputElement; + expect(screen.queryByRole('list', { name: 'Matching settings' })).toBeNull(); + await fireEvent.input(search, { target: { value: 'not-a-real-setting-xyz' } }); + expect(screen.getByText(en['settings.searchEmpty'])).toBeTruthy(); + expect(activePanel().getByRole('spinbutton', { name: 'Port' })).toBe(port); + await fireEvent.click(screen.getAllByRole('button', { name: 'Clear search' }).at(-1) as HTMLButtonElement); + expect(search.value).toBe(''); + expect(document.activeElement).toBe(search); + expect(screen.queryByRole('list', { name: 'Matching settings' })).toBeNull(); + await fireEvent.input(search, { target: { value: ' ' } }); + expect(screen.queryByRole('list', { name: 'Matching settings' })).toBeNull(); + await fireEvent.input(search, { target: { value: 'port' } }); + await fireEvent.keyDown(search, { key: 'Escape' }); + expect(search.value).toBe(''); + expect(activePanel().getByRole('spinbutton', { name: 'Port' })).toBe(port); + expectNoWrites(adapter); + expect(adapter.checkOpenCodeUpdate).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); +}); + +test('search and same-tab navigation retain URL, password, clear-password and HTTP drafts without remounting native panels', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const { adapter, config, unmount } = mountPage(); + await settle(); + const url = activePanel().getByRole('textbox', { name: 'OpenCode backend URL' }) as HTMLInputElement; + const password = activePanel().getByLabelText('Service password (optional)') as HTMLInputElement; + const clear = activePanel().getByRole('checkbox', { + name: 'Clear the saved password when saving', + }); + const port = activePanel().getByRole('spinbutton', { name: 'Port' }) as HTMLInputElement; + const lid = screen.getByRole('checkbox', { name: 'Stay awake with lid closed' }); + const island = screen.getByRole('checkbox', { name: 'OpenCode mini island' }); + await fireEvent.input(url, { target: { value: 'http://127.0.0.1:54321/draft-only-url' } }); + await fireEvent.input(password, { target: { value: 'draft-only-private-password' } }); + await fireEvent.click(clear); + await fireEvent.input(port, { target: { value: '23456' } }); + const search = screen.getByRole('searchbox', { name: 'Search settings' }); + const counts = [ + adapter.getAppInfo, + adapter.getOpenCodeInstallation, + adapter.getOpenCodeServiceStatus, + adapter.getIslandState, + ].map((call) => call.mock.calls.length); + for (const query of ['password', 'draft-only-private-password', 'draft-only-url', '23456']) { + await fireEvent.input(search, { target: { value: query } }); + if (query !== 'password') expect(screen.queryByRole('list', { name: 'Matching settings' })).toBeNull(); + } + await fireEvent.input(search, { target: { value: 'password' } }); + await fireEvent.click(within(results()).getByRole('button', { name: /Service password/ })); + expect(document.activeElement?.id).toBe('opencode-service-title'); + await fireEvent.keyDown(search, { key: 'Escape' }); + expect(activePanel().getByRole('textbox', { name: 'OpenCode backend URL' })).toBe(url); + expect(url.value).toBe('http://127.0.0.1:54321/draft-only-url'); + expect(password.value).toBe('draft-only-private-password'); + expect(clear.getAttribute('aria-checked')).toBe('true'); + expect(port.value).toBe('23456'); + expect(screen.getByRole('checkbox', { name: 'Stay awake with lid closed' })).toBe(lid); + expect(screen.getByRole('checkbox', { name: 'OpenCode mini island' })).toBe(island); + expect( + [adapter.getAppInfo, adapter.getOpenCodeInstallation, adapter.getOpenCodeServiceStatus, adapter.getIslandState].map( + (call) => call.mock.calls.length, + ), + ).toEqual(counts); + expect(adapter.getAutostart).toHaveBeenCalledOnce(); + expect(adapter.getMomHttpSettings).toHaveBeenCalledOnce(); + expect(adapter.getMcpProxySettings).not.toHaveBeenCalled(); + expect(adapter.checkOpenCodeUpdate).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); + expectNoWrites(adapter); + expect(config.draftRecovery).toBeNull(); + await vi.advanceTimersByTimeAsync(2000); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(2); + expect(port.value).toBe('23456'); + expect(password.value).toBe('draft-only-private-password'); + await fireEvent.click(clear); + expect(password.disabled).toBe(false); + expect(password.value).toBe('draft-only-private-password'); + for (const input of document.querySelectorAll('input')) expect(input.name).not.toBe(''); + unmount(); + expect(password.isConnected).toBe(false); + await vi.advanceTimersByTimeAsync(4000); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(2); +}); + +test('proxy drafts and revisions survive search, empty results, locale changes and polling; header refresh remains active-tab only', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const { adapter, config, unmount } = mountPage('mcp-proxy'); + await settle(); + const timeout = activePanel().getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' }) as HTMLInputElement; + const units = activePanel().getByRole('spinbutton', { name: 'Maximum Concurrent Units' }) as HTMLInputElement; + await fireEvent.input(timeout, { target: { value: '7654' } }); + await fireEvent.input(units, { target: { value: '77' } }); + adapter.getMcpProxySettings.mockResolvedValue({ enabled: false, idleTimeout: 500, maxUnits: 32, revision: 2 }); + const search = screen.getByRole('searchbox', { name: 'Search settings' }); + for (const query of ['token', '7654', 'maxUnits', '']) { + await fireEvent.input(search, { target: { value: query } }); + if (query === '7654') expect(screen.queryByRole('list', { name: 'Matching settings' })).toBeNull(); + } + await config.setLocale('zh'); + await tick(); + expect(timeout.value).toBe('7654'); + await config.setLocale('en'); + await tick(); + expect(activePanel().getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' })).toBe(timeout); + await fireEvent.click( + within(document.querySelector('.page-head') as HTMLElement).getByRole('button', { name: 'Refresh' }), + ); + await settle(); + expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(2000); + expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(3); + expect(timeout.value).toBe('7654'); + expect(units.value).toBe('77'); + expect(adapter.getMomHttpSettings).not.toHaveBeenCalled(); + expect(adapter.getAutostart).not.toHaveBeenCalled(); + expect(adapter.getLidProtection).not.toHaveBeenCalled(); + expect(adapter.getAppInfo).not.toHaveBeenCalled(); + expectNoWrites(adapter); + expect(adapter.checkOpenCodeUpdate).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); + adapter.saveMcpProxySettings.mockResolvedValue(undefined); + await fireEvent.click(screen.getByRole('button', { name: 'Save Gateway Settings' })); + await settle(); + expect(adapter.saveMcpProxySettings).toHaveBeenCalledExactlyOnceWith( + { enabled: true, idleTimeout: 7654, maxUnits: 77, revision: 1 }, + 1, + ); + unmount(); + const reads = adapter.getMcpProxySettings.mock.calls.length; + await vi.advanceTimersByTimeAsync(4000); + expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(reads); +}); + +test('cross-tab results navigate and focus without activating controls; only explicit navigation changes lifecycles', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const { adapter, unmount } = mountPage(); + await settle(); + const search = screen.getByRole('searchbox', { name: 'Search settings' }) as HTMLInputElement; + await fireEvent.input(search, { target: { value: 'rotate' } }); + expect(adapter.getMcpProxySettings).not.toHaveBeenCalled(); + await fireEvent.click(within(results()).getByRole('button', { name: /Rotate Secret Token/ })); + await settle(); + expect(screen.getByRole('tab', { name: 'MCP Proxy' }).getAttribute('aria-selected')).toBe('true'); + expect(document.activeElement?.id).toBe('settings-proxy-token'); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(adapter.getMcpProxySettings).toHaveBeenCalledOnce(); + const lidReads = adapter.getLidProtection.mock.calls.length; + const httpReads = adapter.getMomHttpSettings.mock.calls.length; + await vi.advanceTimersByTimeAsync(2000); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(lidReads); + expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(httpReads); + expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(2); + await fireEvent.input(search, { target: { value: '18765' } }); + await fireEvent.click(within(results()).getByRole('button', { name: /Port/ })); + await settle(); + expect(document.activeElement?.id).toBe('settings-http-port'); + expect(screen.getByRole('tab', { name: 'General' }).getAttribute('aria-selected')).toBe('true'); + expect(search.value).toBe('18765'); + expect(goto).toHaveBeenCalledTimes(2); + expect(vi.mocked(goto).mock.calls[0]?.[0]).toBe('http://localhost/settings?tab=mcp-proxy'); + expectNoWrites(adapter); + expect(adapter.checkOpenCodeUpdate).not.toHaveBeenCalled(); + unmount(); +}); + +test('cross-tab search retains both panels and all drafts while inactive polling stays paused', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const confirm = vi.spyOn(window, 'confirm'); + const { adapter } = mountPage(); + await settle(); + const url = activePanel().getByRole('textbox', { name: 'OpenCode backend URL' }) as HTMLInputElement; + const password = activePanel().getByLabelText('Service password (optional)') as HTMLInputElement; + const clear = activePanel().getByRole('checkbox', { + name: 'Clear the saved password when saving', + }); + const port = activePanel().getByRole('spinbutton', { name: 'Port' }) as HTMLInputElement; + await fireEvent.input(url, { target: { value: 'http://127.0.0.1:54321/keep-me' } }); + await fireEvent.input(password, { target: { value: 'keep-private-password' } }); + await fireEvent.click(clear); + await fireEvent.input(port, { target: { value: '23456' } }); + const search = screen.getByRole('searchbox', { name: 'Search settings' }); + await fireEvent.input(search, { target: { value: 'idleTimeout' } }); + await fireEvent.click(within(results()).getByRole('button', { name: /Session Idle Timeout/ })); + await settle(); + expect(screen.getByRole('tab', { name: 'MCP Proxy' }).getAttribute('aria-selected')).toBe('true'); + expect((document.getElementById('settings-tabpanel-general') as HTMLElement).hidden).toBe(true); + expect(url.isConnected).toBe(true); + expect(password.value).toBe('keep-private-password'); + expect(clear.getAttribute('aria-checked')).toBe('true'); + expect(adapter.getMcpProxySettings).toHaveBeenCalledOnce(); + const timeout = activePanel().getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' }) as HTMLInputElement; + const units = activePanel().getByRole('spinbutton', { name: 'Maximum Concurrent Units' }) as HTMLInputElement; + await fireEvent.input(timeout, { target: { value: '7654' } }); + await fireEvent.input(units, { target: { value: '77' } }); + const generalReads = [ + adapter.getAutostart, + adapter.getLidProtection, + adapter.getMomHttpSettings, + adapter.getAppInfo, + adapter.getIslandState, + adapter.getOpenCodeInstallation, + adapter.getOpenCodeServiceStatus, + ]; + const generalCounts = generalReads.map((call) => call.mock.calls.length); + await vi.advanceTimersByTimeAsync(4000); + expect(generalReads.map((call) => call.mock.calls.length)).toEqual(generalCounts); + + await fireEvent.input(search, { target: { value: 'password' } }); + await fireEvent.click(within(results()).getByRole('button', { name: /Service password/ })); + await settle(); + expect(document.activeElement?.id).toBe('opencode-service-title'); + expect(activePanel().getByRole('textbox', { name: 'OpenCode backend URL' })).toBe(url); + expect(activePanel().getByLabelText('Service password (optional)')).toBe(password); + expect(activePanel().getByRole('checkbox', { name: 'Clear the saved password when saving' })).toBe(clear); + expect(activePanel().getByRole('spinbutton', { name: 'Port' })).toBe(port); + expect(url.value).toBe('http://127.0.0.1:54321/keep-me'); + expect(password.value).toBe('keep-private-password'); + expect(clear.getAttribute('aria-checked')).toBe('true'); + expect(port.value).toBe('23456'); + expect(adapter.getIslandState).toHaveBeenCalledTimes(2); + expect(adapter.getOpenCodeServiceStatus).toHaveBeenCalledTimes(2); + expect(adapter.getOpenCodeInstallation).toHaveBeenCalledOnce(); + const proxyReads = adapter.getMcpProxySettings.mock.calls.length; + await vi.advanceTimersByTimeAsync(4000); + expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(proxyReads); + + await fireEvent.input(search, { target: { value: 'maxUnits' } }); + await fireEvent.click(within(results()).getByRole('button', { name: /Maximum Concurrent Units/ })); + await settle(); + expect(document.activeElement?.id).toBe('settings-proxy-capacity'); + expect(activePanel().getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' })).toBe(timeout); + expect(activePanel().getByRole('spinbutton', { name: 'Maximum Concurrent Units' })).toBe(units); + expect(timeout.value).toBe('7654'); + expect(units.value).toBe('77'); + expect(confirm).not.toHaveBeenCalled(); + expect(goto).toHaveBeenCalledTimes(3); + expectNoWrites(adapter); +}); + +test('inactive native info completion cannot start CLI installation or Island reads until re-entry', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const info = deferred<{ version: string; platform: string }>(); + const { adapter } = mountPage('general', { getAppInfo: vi.fn().mockReturnValue(info.promise) }); + await settle(); + expect(adapter.getAppInfo).toHaveBeenCalledTimes(3); + await fireEvent.click(screen.getByRole('tab', { name: 'MCP Proxy' })); + info.resolve({ version: '2.0.0', platform: 'macos' }); + await settle(); + await vi.advanceTimersByTimeAsync(4000); + expect(adapter.getOpenCodeInstallation).not.toHaveBeenCalled(); + expect(adapter.getOpenCodeServiceStatus).not.toHaveBeenCalled(); + expect(adapter.getIslandState).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('tab', { name: 'General' })); + await settle(); + expect(adapter.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(adapter.getOpenCodeServiceStatus).toHaveBeenCalledOnce(); + expect(adapter.getIslandState).toHaveBeenCalledOnce(); + expect(adapter.getAppInfo).toHaveBeenCalledTimes(5); + expectNoWrites(adapter); +}); + +test('HTTP in-flight guards survive fast tab round trips and completion while inactive never resumes polling', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const read = deferred<{ port: number; allowLan: boolean }>(); + const save = deferred(); + const { adapter } = mountPage('general', { + getMomHttpSettings: vi.fn().mockReturnValueOnce(read.promise).mockResolvedValue({ port: 23456, allowLan: false }), + setMomHttpSettings: vi.fn().mockReturnValue(save.promise), + }); + await settle(); + const port = activePanel().getByRole('spinbutton', { name: 'Port' }) as HTMLInputElement; + const general = screen.getByRole('tab', { name: 'General' }); + const proxy = screen.getByRole('tab', { name: 'MCP Proxy' }); + await fireEvent.click(proxy); + await fireEvent.click(general); + await settle(); + expect(adapter.getMomHttpSettings).toHaveBeenCalledOnce(); + await fireEvent.click(proxy); + read.resolve({ port: 18765, allowLan: false }); + await settle(); + await vi.advanceTimersByTimeAsync(6000); + expect(adapter.getMomHttpSettings).toHaveBeenCalledOnce(); + const inactiveGeneral = within(document.getElementById('settings-tabpanel-general') as HTMLElement); + await fireEvent.click(inactiveGeneral.getByRole('button', { name: 'Save HTTP Settings', hidden: true })); + await fireEvent.click(inactiveGeneral.getByRole('checkbox', { name: 'Stay awake with lid closed', hidden: true })); + await fireEvent.click(inactiveGeneral.getByRole('checkbox', { name: 'OpenCode mini island', hidden: true })); + await fireEvent.submit( + inactiveGeneral + .getByRole('button', { name: 'Save connection target', hidden: true }) + .closest('form') as HTMLFormElement, + ); + expect(adapter.getMomHttpSettings).toHaveBeenCalledOnce(); + expectNoWrites(adapter); + await fireEvent.click(general); + await settle(); + expect(activePanel().getByRole('spinbutton', { name: 'Port' })).toBe(port); + expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(2); + await fireEvent.input(port, { target: { value: '24567' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Save HTTP Settings' })); + await fireEvent.click(proxy); + save.resolve(); + await settle(); + await vi.advanceTimersByTimeAsync(6000); + expect(adapter.setMomHttpSettings).toHaveBeenCalledExactlyOnceWith({ port: 24567, allowLan: false }); + expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(2); + await fireEvent.click(general); + await settle(); + expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(3); +}); + +test('checked CLI updates survive tab re-entry and a pending upgrade is not cleared or read back while inactive', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const upgrade = deferred(); + const { adapter } = mountPage('general', { upgradeOpenCode: vi.fn().mockReturnValue(upgrade.promise) }); + await settle(); + await fireEvent.click(screen.getByRole('button', { name: 'Check CLI updates' })); + await settle(); + const general = screen.getByRole('tab', { name: 'General' }); + const proxy = screen.getByRole('tab', { name: 'MCP Proxy' }); + await fireEvent.click(proxy); + await fireEvent.click(general); + await settle(); + const upgradeButton = screen.getByRole('button', { name: 'Upgrade CLI…' }); + expect(adapter.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(adapter.checkOpenCodeUpdate).toHaveBeenCalledOnce(); + await fireEvent.click(upgradeButton); + await fireEvent.click(screen.getByRole('button', { name: 'Confirm CLI upgrade' })); + await fireEvent.click(proxy); + expect(screen.queryByRole('dialog')).toBeNull(); + await fireEvent.click(general); + await settle(); + expect((screen.getByRole('button', { name: 'Upgrading CLI…' }) as HTMLButtonElement).disabled).toBe(true); + expect(adapter.getOpenCodeInstallation).toHaveBeenCalledOnce(); + await fireEvent.click(proxy); + upgrade.resolve(); + await settle(); + expect(adapter.getOpenCodeInstallation).toHaveBeenCalledOnce(); + const serviceReads = adapter.getOpenCodeServiceStatus.mock.calls.length; + await vi.advanceTimersByTimeAsync(4000); + expect(adapter.getOpenCodeServiceStatus).toHaveBeenCalledTimes(serviceReads); + await fireEvent.click(general); + await settle(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(adapter.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(adapter.getOpenCodeServiceStatus).toHaveBeenCalledTimes(serviceReads + 1); + expect(adapter.upgradeOpenCode).toHaveBeenCalledExactlyOnceWith(installation.binaryPath, '2.0.0', '2.1.0'); +}); + +test.each(['read', 'save'] as const)( + 'service pending %s and local drafts survive tab round trips without overlapping operations', + async (operation) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const pending = deferred(); + const { adapter } = mountPage( + 'general', + operation === 'read' + ? { + getOpenCodeServiceStatus: vi + .fn() + .mockResolvedValueOnce(service) + .mockReturnValueOnce(pending.promise) + .mockResolvedValue(service), + } + : { setOpenCodeServiceTarget: vi.fn().mockReturnValue(pending.promise) }, + ); + await settle(); + const url = activePanel().getByRole('textbox', { name: 'OpenCode backend URL' }) as HTMLInputElement; + const password = activePanel().getByLabelText('Service password (optional)') as HTMLInputElement; + await fireEvent.input(url, { target: { value: 'http://127.0.0.1:54321/retained' } }); + await fireEvent.input(password, { target: { value: 'retained-secret' } }); + await fireEvent.click( + screen.getByRole('button', { + name: operation === 'read' ? 'Refresh connection' : 'Save connection target', + }), + ); + const reads = adapter.getOpenCodeServiceStatus.mock.calls.length; + const general = screen.getByRole('tab', { name: 'General' }); + const proxy = screen.getByRole('tab', { name: 'MCP Proxy' }); + await fireEvent.click(proxy); + await fireEvent.click(general); + await settle(); + expect(adapter.getOpenCodeServiceStatus).toHaveBeenCalledTimes(reads); + expect((screen.getByRole('button', { name: 'Check CLI updates' }) as HTMLButtonElement).disabled).toBe(true); + expect(url.value).toBe('http://127.0.0.1:54321/retained'); + expect(password.value).toBe('retained-secret'); + await fireEvent.click(proxy); + pending.resolve({ ...service, target: { ...service.target, url: 'http://127.0.0.1:54321/retained' } }); + await settle(); + await vi.advanceTimersByTimeAsync(4000); + expect(adapter.getOpenCodeServiceStatus).toHaveBeenCalledTimes(reads); + await fireEvent.click(general); + await settle(); + expect(adapter.getOpenCodeServiceStatus).toHaveBeenCalledTimes(reads + 1); + expect(activePanel().getByRole('textbox', { name: 'OpenCode backend URL' })).toBe(url); + expect(activePanel().getByLabelText('Service password (optional)')).toBe(password); + expect(url.value).toBe('http://127.0.0.1:54321/retained'); + expect(password.value).toBe(operation === 'save' ? '' : 'retained-secret'); + expect(adapter.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expect(adapter.setOpenCodeServiceTarget).toHaveBeenCalledTimes(operation === 'save' ? 1 : 0); + }, +); + +test('search never opens a checked CLI upgrade or restarts its installation inspection', async () => { + const { adapter } = mountPage(); + const check = await screen.findByRole('button', { name: 'Check CLI updates' }); + await waitFor(() => expect((check as HTMLButtonElement).disabled).toBe(false)); + await fireEvent.click(check); + await screen.findByRole('button', { name: 'Upgrade CLI…' }); + const search = screen.getByRole('searchbox', { name: 'Search settings' }); + await fireEvent.input(search, { target: { value: 'upgrade' } }); + await fireEvent.click(within(results()).getByRole('button', { name: /CLI updates/ })); + expect(document.activeElement?.id).toBe('opencode-settings-title'); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(adapter.checkOpenCodeUpdate).toHaveBeenCalledOnce(); + expect(adapter.getOpenCodeInstallation).toHaveBeenCalledOnce(); + expectNoWrites(adapter); +}); + +test('browser search still locates desktop-only settings without fetching native data', async () => { + native.isTauri.mockReturnValue(false); + const { adapter } = mountPage('mcp-proxy'); + const search = screen.getByRole('searchbox', { name: 'Search settings' }); + await fireEvent.input(search, { target: { value: 'password' } }); + await fireEvent.click(within(results()).getByRole('button', { name: /Service password/ })); + expect(document.activeElement?.id).toBe('opencode-settings-title'); + expect(screen.getByText(en['settings.opencodeDesktopOnly'])).toBeTruthy(); + expect(adapter.getOpenCodeServiceStatus).not.toHaveBeenCalled(); + expect(adapter.getOpenCodeInstallation).not.toHaveBeenCalled(); + expect(adapter.getIslandState).not.toHaveBeenCalled(); + expectNoWrites(adapter); +}); + +test('settings four regions name locale', async () => { + native.isTauri.mockReturnValue(true); + const { config } = mountPage(); + await settle(); + + const appRegion = screen.getByRole('region', { name: en['settings.appBehavior'] }); + const opencodeRegion = screen.getByRole('region', { name: en['settings.opencodeTitle'] }); + const httpRegion = screen.getByRole('region', { name: en['settings.http.title'] }); + const updatesRegion = screen.getByRole('region', { name: en['settings.updates'] }); + + expect(appRegion.compareDocumentPosition(opencodeRegion)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(opencodeRegion.compareDocumentPosition(httpRegion)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(httpRegion.compareDocumentPosition(updatesRegion)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + + const autostart = within(appRegion).getByRole('checkbox', { name: en['settings.autostart'] }); + const island = within(appRegion).getByRole('checkbox', { name: en['settings.island'] }); + const lid = within(appRegion).getByRole('checkbox', { name: en['settings.lid'] }); + expect(autostart.compareDocumentPosition(island)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(island.compareDocumentPosition(lid)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + + const urlField = within(opencodeRegion).getByRole('textbox', { name: en['settings.serviceUrl'] }); + const cliBtn = within(opencodeRegion).getByRole('button', { name: en['settings.opencodeCheck'] }); + expect(urlField.compareDocumentPosition(cliBtn)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + + const port = within(httpRegion).getByRole('spinbutton', { name: en['settings.http.port'] }); + const allowLan = within(httpRegion).getByRole('checkbox', { name: en['settings.http.allowLan'] }); + expect(port).toBeTruthy(); + expect(allowLan).toBeTruthy(); + + const updatesBtn = within(updatesRegion).getByRole('button', { name: en['settings.checkUpdates'] }); + expect(updatesBtn).toBeTruthy(); + expect(screen.getByText(en['settings.supportProject'])).toBeTruthy(); + + config.setLocale('zh'); + await tick(); + expect(screen.getByRole('region', { name: zh['settings.appBehavior'] })).toBeTruthy(); + config.setLocale('en'); + await tick(); +}); diff --git a/src/routes/settings/__tests__/settings.test.ts b/src/routes/settings/__tests__/settings.test.ts index 617b043..5d129b1 100644 --- a/src/routes/settings/__tests__/settings.test.ts +++ b/src/routes/settings/__tests__/settings.test.ts @@ -16,6 +16,14 @@ function mount(overrides: Record = {}) { getAppInfo: vi.fn().mockResolvedValue({ version: '0.1.0', platform: 'other' }), checkForUpdates: vi.fn(), openProjectPage: vi.fn(), + getMomHttpSettings: vi.fn().mockResolvedValue({ port: 18765, allowLan: false }), + getMcpProxySettings: vi.fn().mockResolvedValue({ + enabled: false, + idleTimeout: 300, + maxUnits: 16, + revision: 1, + }), + getMcpProxyState: vi.fn().mockResolvedValue({ actualState: 'stopped', boundAddr: null, securityErrors: [] }), ...overrides, } as unknown as ConfigStore; render(Settings, {}, { wrapper: Harness, wrapperProps: { config } }); From 2b62c9c33d4cabd90f8e71e3b99ec6b5214a5b8d Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 25 Oct 2026 15:06:58 +0800 Subject: [PATCH 67/97] feat(frontend): dashboard token chart, runtime card and list refresh updates --- .../dashboard/ActivityHeatmap.svelte | 4 +- src/components/dashboard/RuntimeCard.svelte | 421 ++++++++++---- .../dashboard/TokenUsageChart.svelte | 163 ++++++ .../__tests__/token-usage-chart.test.ts | 113 ++++ src/routes/+page.svelte | 541 +++++++++++++----- src/routes/__tests__/dashboard.test.ts | 73 ++- src/routes/agents/+page.svelte | 13 +- src/routes/groups/+page.svelte | 109 ++-- .../groups/__tests__/group-list.test.ts | 264 +++++++++ src/routes/models/+page.svelte | 159 +++-- src/routes/providers/+page.svelte | 10 +- src/routes/skills/+page.svelte | 16 +- 12 files changed, 1519 insertions(+), 367 deletions(-) create mode 100644 src/components/dashboard/TokenUsageChart.svelte create mode 100644 src/components/dashboard/__tests__/token-usage-chart.test.ts create mode 100644 src/routes/groups/__tests__/group-list.test.ts diff --git a/src/components/dashboard/ActivityHeatmap.svelte b/src/components/dashboard/ActivityHeatmap.svelte index 932e10e..892c0cb 100644 --- a/src/components/dashboard/ActivityHeatmap.svelte +++ b/src/components/dashboard/ActivityHeatmap.svelte @@ -76,7 +76,7 @@ function navigate(event: KeyboardEvent, index: number) {
    @@ -280,7 +280,7 @@ function navigate(event: KeyboardEvent, index: number) {
    -
    +
    {i18n.t('dashboard.less')} diff --git a/src/routes/settings/__tests__/behavior.test.ts b/src/routes/settings/__tests__/behavior.test.ts index 2b6a77a..9126556 100644 --- a/src/routes/settings/__tests__/behavior.test.ts +++ b/src/routes/settings/__tests__/behavior.test.ts @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { tick } from 'svelte'; import { beforeEach, expect, test, vi } from 'vitest'; import { beforeNavigate } from '$app/navigation'; import type { ConfigStore } from '$src/config/store.svelte.js'; @@ -20,6 +21,18 @@ function mount(overrides: Record = {}, active = true) { selectedProjectId: 'project-a', getGlobalBehavior: vi.fn().mockResolvedValue(document), saveGlobalBehavior: vi.fn().mockResolvedValue(document), + generatePrompt: vi.fn().mockResolvedValue('Generated preview content'), + catalogModels: vi.fn().mockReturnValue([ + { + ref: 'demo/fast', + providerId: 'demo', + modelId: 'fast', + name: 'Fast', + isCustom: true, + variants: [{ id: 'quick' }], + }, + ]), + models: vi.fn().mockReturnValue([]), ...overrides, } as unknown as ConfigStore; return { ...render(Behavior, { active }, { wrapper: Harness, wrapperProps: { config } }), config }; @@ -131,3 +144,175 @@ test('route leave protects dirty local text but switching settings tabs never pr expect(cancel).toHaveBeenCalledOnce(); confirm.mockRestore(); }); + +test('behavior PromptGenerator closing discards late results and reopen is not contaminated', async () => { + let resolve!: (value: string) => void; + const pendingPromise = new Promise((res) => { + resolve = res; + }); + const { config } = mount({ generatePrompt: vi.fn().mockImplementation(() => pendingPromise) }, true); + await waitFor(() => + expect((screen.getByLabelText(en['settings.behaviorContent']) as HTMLTextAreaElement).disabled).toBe(false), + ); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.open'] })); + await fireEvent.input(screen.getByLabelText(en['promptGenerator.instructions']), { + target: { value: 'Instructions for session 1' }, + }); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.generate'] })); + expect(config.generatePrompt).toHaveBeenCalledTimes(1); + await fireEvent.click(screen.getByRole('button', { name: en['common.close'] })); + expect(screen.queryByRole('dialog')).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.open'] })); + expect(screen.getByRole('heading', { name: en['promptGenerator.behaviorTitle'] })).toBeTruthy(); + expect((screen.getByLabelText(en['promptGenerator.instructions']) as HTMLTextAreaElement).value).toBe(''); + resolve('Late arriving result from session 1'); + await pendingPromise; + await tick(); + expect(screen.queryByLabelText(en['promptGenerator.preview'])).toBeNull(); + expect((screen.getByLabelText(en['settings.behaviorContent']) as HTMLTextAreaElement).value).toBe(document.content); + expect(config.saveGlobalBehavior).not.toHaveBeenCalled(); +}); + +test('behavior PromptGenerator integration happy: edited draft, lazy catalog, preview-only, explicit Save', async () => { + const catalogModels = vi.fn().mockReturnValue([ + { + ref: 'demo/fast', + providerId: 'demo', + modelId: 'fast', + name: 'Fast', + isCustom: true, + variants: [{ id: 'quick' }], + }, + ]); + const models = vi.fn().mockReturnValue([]); + const { config } = mount( + { + catalogModels, + models, + generatePrompt: vi.fn().mockResolvedValue('Generated behavior preview'), + saveGlobalBehavior: vi.fn().mockResolvedValue({ ...document, content: 'Generated behavior preview' }), + }, + true, + ); + const input = screen.getByLabelText(en['settings.behaviorContent']) as HTMLTextAreaElement; + await waitFor(() => expect(input.disabled).toBe(false)); + expect(catalogModels).not.toHaveBeenCalled(); + expect(models).not.toHaveBeenCalled(); + await fireEvent.input(input, { target: { value: 'my local draft' } }); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.open'] })); + expect(catalogModels).toHaveBeenCalled(); + await fireEvent.input(screen.getByLabelText(en['promptGenerator.instructions']), { + target: { value: 'Improve rules' }, + }); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.generate'] })); + expect(config.generatePrompt).toHaveBeenCalledExactlyOnceWith({ + kind: 'behavior', + preset: 'improve', + instructions: 'Improve rules', + currentPrompt: 'my local draft', + model: undefined, + variant: undefined, + }); + await waitFor(() => expect(screen.getByLabelText(en['promptGenerator.preview'])).toBeTruthy()); + expect(input.value).toBe('my local draft'); + expect(config.saveGlobalBehavior).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.apply'] })); + expect(input.value).toBe('Generated behavior preview'); + expect(config.saveGlobalBehavior).not.toHaveBeenCalled(); + const saveBtn = screen.getByRole('button', { name: en['settings.behaviorSave'] }); + expect((saveBtn as HTMLButtonElement).disabled).toBe(false); + await fireEvent.click(saveBtn); + await waitFor(() => + expect(config.saveGlobalBehavior).toHaveBeenCalledExactlyOnceWith({ + content: 'Generated behavior preview', + expectedPath: document.resolvedPath, + expectedContent: document.content, + }), + ); + await screen.findByText(en['settings.behaviorSaved']); +}); + +test('behavior PromptGenerator disabled when inactive prevents opening and leaves draft unchanged', async () => { + const getGlobalBehavior = vi.fn().mockResolvedValue(document); + const saveGlobalBehavior = vi.fn().mockResolvedValue(document); + const { config } = mount( + { getGlobalBehavior, saveGlobalBehavior, generatePrompt: vi.fn().mockResolvedValue('Generated content') }, + false, + ); + const trigger = screen.getByRole('button', { name: en['promptGenerator.open'] }) as HTMLButtonElement; + expect(trigger.disabled).toBe(true); + await fireEvent.click(trigger); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(config.generatePrompt).not.toHaveBeenCalled(); + expect(getGlobalBehavior).not.toHaveBeenCalled(); + expect(saveGlobalBehavior).not.toHaveBeenCalled(); +}); + +test('behavior generator stays disabled during pending document reads and saves', async () => { + let getResolve!: (value: typeof document) => void; + let saveResolve!: (value: typeof document) => void; + const getPromise = new Promise((res) => { + getResolve = res; + }); + const savePromise = new Promise((res) => { + saveResolve = res; + }); + const { config } = mount( + { + getGlobalBehavior: vi.fn().mockImplementation(() => getPromise), + saveGlobalBehavior: vi.fn().mockImplementation(() => savePromise), + generatePrompt: vi.fn().mockResolvedValue('Generated'), + }, + true, + ); + await waitFor(() => expect(config.getGlobalBehavior).toHaveBeenCalledOnce()); + const trigger = screen.getByRole('button', { name: en['promptGenerator.open'] }) as HTMLButtonElement; + expect(trigger.disabled).toBe(true); + await fireEvent.click(trigger); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(config.generatePrompt).not.toHaveBeenCalled(); + getResolve(document); + await waitFor(() => + expect((screen.getByLabelText(en['settings.behaviorContent']) as HTMLTextAreaElement).disabled).toBe(false), + ); + const input = screen.getByLabelText(en['settings.behaviorContent']) as HTMLTextAreaElement; + await fireEvent.input(input, { target: { value: 'draft' } }); + await fireEvent.click(screen.getByRole('button', { name: en['settings.behaviorSave'] })); + expect(config.saveGlobalBehavior).toHaveBeenCalledOnce(); + const savingTrigger = screen.getByRole('button', { name: en['promptGenerator.open'] }) as HTMLButtonElement; + expect(savingTrigger.disabled).toBe(true); + await fireEvent.click(savingTrigger); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(config.generatePrompt).not.toHaveBeenCalled(); + expect(input.value).toBe('draft'); + saveResolve({ ...document, content: 'draft' }); + await screen.findByText(en['settings.behaviorSaved']); + expect((screen.getByRole('button', { name: en['promptGenerator.open'] }) as HTMLButtonElement).disabled).toBe(false); +}); + +test('deactivating behavior discards in-flight generation without changing the retained draft', async () => { + let resolve!: (value: string) => void; + const pendingPromise = new Promise((res) => { + resolve = res; + }); + const { config, rerender } = mount({ generatePrompt: vi.fn().mockImplementation(() => pendingPromise) }, true); + const input = screen.getByLabelText(en['settings.behaviorContent']) as HTMLTextAreaElement; + await waitFor(() => expect(input.disabled).toBe(false)); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.open'] })); + await fireEvent.input(screen.getByLabelText(en['promptGenerator.instructions']), { + target: { value: 'Instructions before unmount' }, + }); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.generate'] })); + expect(config.generatePrompt).toHaveBeenCalledTimes(1); + await rerender({ active: false }); + resolve('Result for unmounted component'); + await pendingPromise; + await tick(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(input.value).toBe(document.content); + expect(config.saveGlobalBehavior).not.toHaveBeenCalled(); + await rerender({ active: true }); + await fireEvent.click(screen.getByRole('button', { name: en['promptGenerator.open'] })); + expect(screen.getByRole('heading', { name: en['promptGenerator.behaviorTitle'] })).toBeTruthy(); + expect(screen.queryByLabelText(en['promptGenerator.preview'])).toBeNull(); +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index d0a908d..5f2afbb 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -9,7 +9,7 @@ export const AGENT_SOURCES = ['inline', 'markdown', 'both'] as const; export const AGENT_STORAGES = ['inline', 'global_markdown'] as const; export const GROUP_TYPES = [GROUP_TYPE_NATIVE, GROUP_TYPE_SLIM, GROUP_TYPE_OMO] as const; export const AGENT_MODES = ['primary', 'subagent', 'all'] as const; -export const PROMPT_GENERATION_KINDS = ['agent', 'command'] as const; +export const PROMPT_GENERATION_KINDS = ['agent', 'command', 'behavior'] as const; export const PROMPT_GENERATION_PRESETS = ['generate', 'improve', 'shorten'] as const; export const COLOR_THEMES = ['primary', 'secondary', 'accent', 'success', 'warning', 'error', 'info'] as const; // V2 permission effects and built-in rule actions (plugins may define more). From 223d515441ac11ae19971275db1409b9646f7144 Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 1 Nov 2026 15:54:07 +0800 Subject: [PATCH 81/97] feat(http): share MCP authentication and isolate startup failures --- src-tauri/src/commands/app.rs | 40 +- src-tauri/src/commands/mcp_proxy.rs | 21 +- src-tauri/src/commands/mcp_proxy/tests.rs | 302 ++++++++++- src-tauri/src/http_server.rs | 240 +++++++-- src-tauri/src/http_server/auth.rs | 189 +++++++ src-tauri/src/http_server/tests_auth.rs | 416 +++++++++++++++ src-tauri/src/lib.rs | 52 +- src-tauri/src/mcp_proxy/auth.rs | 74 --- src-tauri/src/mcp_proxy/config.rs | 75 ++- src-tauri/src/mcp_proxy/http.rs | 105 +--- src-tauri/src/mcp_proxy/mod.rs | 84 ++- src-tauri/src/mcp_proxy/session.rs | 61 ++- src-tauri/src/mcp_proxy/test_peer.rs | 4 + src-tauri/src/mcp_proxy/tests.rs | 480 ++++++++++++++++-- .../__tests__/mcp-proxy-adapter.test.mjs | 55 +- src/config/adapter.ts | 4 +- src/config/store.svelte.ts | 6 +- src/i18n/dictionaries/en.ts | 28 +- src/i18n/dictionaries/zh.ts | 27 +- src/routes/mcp/ProxyPanel.svelte | 40 +- src/routes/mcp/ProxyServerForm.svelte | 13 +- src/routes/mcp/__tests__/mcp-proxy.test.ts | 201 +++++++- src/routes/settings/McpProxySettings.svelte | 83 ++- src/routes/settings/RotateKeyDialog.svelte | 50 +- src/routes/settings/SettingsSearch.svelte | 11 +- .../__tests__/mcp-proxy-settings.test.ts | 274 ++++++++-- .../__tests__/rotate-key-dialog.test.ts | 84 ++- .../__tests__/settings-search.test.ts | 43 +- 28 files changed, 2491 insertions(+), 571 deletions(-) create mode 100644 src-tauri/src/http_server/auth.rs create mode 100644 src-tauri/src/http_server/tests_auth.rs delete mode 100644 src-tauri/src/mcp_proxy/auth.rs diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 77b16d4..a54b2a9 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -14,17 +14,51 @@ type CommandResult = Result; #[tauri::command] pub async fn get_mom_http_settings( - http: State<'_, std::sync::Arc>, + http: State<'_, crate::http_server::HttpServerState>, ) -> CommandResult { + let http = http.inner().as_ref().map_err(Clone::clone)?; Ok(http.settings().await) } #[tauri::command] pub async fn set_mom_http_settings( - http: State<'_, std::sync::Arc>, + http: State<'_, crate::http_server::HttpServerState>, + proxy: State<'_, std::sync::Arc>, settings: crate::http_server::MomHttpSettings, ) -> CommandResult<()> { - http.set_settings(settings).await + let http = http.inner().as_ref().map_err(Clone::clone)?; + update_http_settings( + http.as_ref().clone(), + proxy.inner().as_ref().clone(), + settings, + ) + .await +} + +pub(crate) async fn update_http_settings( + http: crate::http_server::HttpServer, + proxy: crate::mcp_proxy::ProxyService, + settings: crate::http_server::MomHttpSettings, +) -> CommandResult<()> { + http.set_settings_with_cleanup(settings, async move { proxy.revoke_http_requests().await }) + .await +} + +#[tauri::command] +pub async fn rotate_mom_http_key( + http: State<'_, crate::http_server::HttpServerState>, + proxy: State<'_, std::sync::Arc>, +) -> CommandResult { + let http = http.inner().as_ref().map_err(Clone::clone)?; + rotate_http_key(http.as_ref().clone(), proxy.inner().as_ref().clone()).await +} + +pub(crate) async fn rotate_http_key( + http: crate::http_server::HttpServer, + proxy: crate::mcp_proxy::ProxyService, +) -> CommandResult { + http.rotate_key(async move { proxy.revoke_http_requests().await }) + .await } #[tauri::command] diff --git a/src-tauri/src/commands/mcp_proxy.rs b/src-tauri/src/commands/mcp_proxy.rs index a767ef5..654d5df 100644 --- a/src-tauri/src/commands/mcp_proxy.rs +++ b/src-tauri/src/commands/mcp_proxy.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; -use crate::http_server::HttpServer; +use crate::http_server::HttpServerState; use crate::mcp_proxy::config::{ GatewaySettings, ProxyProtocol, ProxyServiceConfig, ServiceTimeouts, }; @@ -161,13 +161,13 @@ pub struct CloseMcpProxyUnitRequest { #[tauri::command] pub async fn get_mcp_proxy_state( proxy: tauri::State<'_, Arc>, - http: tauri::State<'_, Arc>, + http: tauri::State<'_, HttpServerState>, ) -> Result { + let http = http.inner().as_ref().map_err(Clone::clone)?; let config = proxy.config().await; let bound = http.bound_addr().await; let settings = http.settings().await; - let is_running = - bound.is_some() && config.gateway.enabled && config.gateway.token_verifier.is_some(); + let is_running = bound.is_some() && config.gateway.enabled && http.auth_ready(); let actual_state = if proxy.cleanup_failed() { "cleanup_failed".to_string() } else if is_running { @@ -181,8 +181,8 @@ pub async fn get_mcp_proxy_state( security_errors .push("MCP runtime cleanup failed; process outcome may be uncertain".to_string()); } - if config.gateway.token_verifier.is_none() { - security_errors.push("Missing authentication token verifier".to_string()); + if !http.auth_ready() { + security_errors.push("MOM HTTP authentication is not configured".to_string()); } if settings.allow_lan { security_errors.push("LAN access is enabled (0.0.0.0)".to_string()); @@ -245,12 +245,10 @@ pub async fn save_mcp_proxy_settings( settings: McpProxySettingsDto, expected_revision: u64, ) -> Result<(), AppError> { - let current_config = proxy.config().await; let gateway = GatewaySettings { enabled: settings.enabled, idle_timeout_secs: settings.idle_timeout, max_units: settings.max_units, - token_verifier: current_config.gateway.token_verifier, }; proxy @@ -258,13 +256,6 @@ pub async fn save_mcp_proxy_settings( .await } -#[tauri::command] -pub async fn rotate_mcp_proxy_key( - proxy: tauri::State<'_, Arc>, -) -> Result { - proxy.rotate_key().await -} - #[tauri::command] pub async fn get_mcp_proxy_server( proxy: tauri::State<'_, Arc>, diff --git a/src-tauri/src/commands/mcp_proxy/tests.rs b/src-tauri/src/commands/mcp_proxy/tests.rs index 4da9fe6..8aea484 100644 --- a/src-tauri/src/commands/mcp_proxy/tests.rs +++ b/src-tauri/src/commands/mcp_proxy/tests.rs @@ -4,10 +4,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use serde_json::json; +use tauri::Manager; use uuid::Uuid; use super::*; use crate::error::ErrorCode; +use crate::http_server::HttpServer; use crate::mcp_proxy::config::{ProxyProtocol, ProxyServiceConfig}; use crate::mcp_proxy::ProxyService; use crate::paths::ConfigPaths; @@ -196,7 +198,7 @@ fn ipc_crud_and_conflict_rejection() { let app = tauri::test::mock_builder() .manage(service.clone()) - .manage(http.clone()) + .manage(Ok::<_, AppError>(http.clone())) .manage(paths) .invoke_handler(tauri::generate_handler![ get_mcp_proxy_state, @@ -204,7 +206,7 @@ fn ipc_crud_and_conflict_rejection() { crate::commands::set_mom_http_settings, get_mcp_proxy_settings, save_mcp_proxy_settings, - rotate_mcp_proxy_key, + crate::commands::rotate_mom_http_key, get_mcp_proxy_server, create_mcp_proxy_server, update_mcp_proxy_server, @@ -322,14 +324,42 @@ fn ipc_crud_and_conflict_rejection() { assert_eq!(tauri::async_runtime::block_on(http.settings()).port, port); assert_eq!(updated_settings.revision, 2); - // 3. Key rotation - let key_res = invoke("rotate_mcp_proxy_key", json!({})).unwrap(); + // 3. Shared HTTP key rotation does not change proxy configuration. + let config_before_rotate = tauri::async_runtime::block_on(service.config()); + let generation_before_rotate = service.generation(); + let key_res = invoke("rotate_mom_http_key", json!({})).unwrap(); let token: String = key_res.deserialize().unwrap(); - assert!(!token.is_empty()); + assert_eq!(token.len(), 64); + assert!(http.auth_ready()); + assert_eq!( + tauri::async_runtime::block_on(service.config()), + config_before_rotate + ); + assert_eq!(service.generation(), generation_before_rotate + 1); + assert!(invoke("rotate_mcp_proxy_key", json!({})).is_err()); + let persisted: serde_json::Value = + serde_json::from_slice(&fs::read(home.path().join("mom-http.json")).unwrap()).unwrap(); + assert!(crate::http_server::auth::verify_token( + &token, + persisted["auth"]["tokenVerifier"].as_str().unwrap(), + )); + assert!(!persisted.to_string().contains(&token)); + let verifier = persisted["auth"]["tokenVerifier"].as_str().unwrap(); + for command in [ + "get_mom_http_settings", + "get_mcp_proxy_settings", + "get_mcp_proxy_state", + ] { + let wire: serde_json::Value = invoke(command, json!({})).unwrap().deserialize().unwrap(); + let serialized = wire.to_string(); + assert!(!serialized.contains(&token), "{command}"); + assert!(!serialized.contains(verifier), "{command}"); + assert!(!serialized.contains("tokenVerifier"), "{command}"); + } let res = invoke("get_mcp_proxy_settings", json!({})).unwrap(); let settings_after_rotate: McpProxySettingsDto = res.deserialize().unwrap(); - assert_eq!(settings_after_rotate.revision, 3); + assert_eq!(settings_after_rotate.revision, 2); // 4. Server CRUD let server_dto = json!({ @@ -344,7 +374,7 @@ fn ipc_crud_and_conflict_rejection() { "startupTimeout": 30, "executionTimeout": 300 }, - "expectedRevision": 3 + "expectedRevision": 2 }); let res = invoke("create_mcp_proxy_server", server_dto).unwrap(); @@ -366,7 +396,7 @@ fn ipc_crud_and_conflict_rejection() { "startupTimeout": 30, "executionTimeout": 300 }, - "expectedRevision": 4 + "expectedRevision": 3 }), ) .unwrap_err(); @@ -402,7 +432,7 @@ fn ipc_crud_and_conflict_rejection() { .unwrap_err(); assert_eq!(err["code"], "validation_failed"); - // Update server with correct revision (4) + // Update server with correct revision (3) let res = invoke( "update_mcp_proxy_server", json!({ @@ -417,7 +447,7 @@ fn ipc_crud_and_conflict_rejection() { "startupTimeout": 40, "executionTimeout": 400 }, - "expectedRevision": 4 + "expectedRevision": 3 }), ) .unwrap(); @@ -436,12 +466,12 @@ fn ipc_crud_and_conflict_rejection() { .unwrap_err(); assert_eq!(err["code"], "validation_failed"); - // Delete server with correct revision (5) + // Delete server with correct revision (4) invoke( "delete_mcp_proxy_server", json!({ "id": "my-tool", - "expectedRevision": 5 + "expectedRevision": 4 }), ) .unwrap(); @@ -554,19 +584,263 @@ fn ipc_crud_and_conflict_rejection() { tauri::async_runtime::block_on(http.shutdown(std::time::Duration::from_secs(2))).unwrap(); } +fn http_test_app(paths: ConfigPaths) -> tauri::App { + let mut app = tauri::test::mock_builder() + .setup(move |app| { + let proxy = Arc::new(ProxyService::new(&paths)?); + app.manage(proxy.clone()); + app.manage(paths.clone()); + crate::setup_http(app, &paths, &proxy); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + crate::commands::get_mom_http_settings, + crate::commands::set_mom_http_settings, + crate::commands::rotate_mom_http_key, + get_mcp_proxy_state, + get_mcp_proxy_settings, + save_mcp_proxy_settings, + ]) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + #[allow(deprecated)] + app.run_iteration(|_, _| {}); + app +} + +fn invoke_http_test( + webview: &tauri::WebviewWindow, + cmd: &str, + body: serde_json::Value, +) -> Result { + tauri::test::get_ipc_response( + webview, + tauri::webview::InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: tauri::ipc::InvokeBody::Json(body), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.into(), + }, + ) +} + +#[test] +fn http_initialization_failure_preserves_setup_proxy_ipc_and_files() { + for (config, invalid_legacy_auth) in [ + (Some(b"invalid HTTP json".as_slice()), false), + (Some(br#"{"port":23456,"auth":null}"#.as_slice()), false), + ( + Some(br#"{"port":23456,"auth":{"tokenVerifier":"private-invalid"}}"#.as_slice()), + false, + ), + (Some(br#"{"port":23456}"#.as_slice()), true), + (None, false), + ] { + let home = TempHome::new(); + let paths = ConfigPaths::with_resource_parts( + home.path().to_path_buf(), + None, + None, + home.path().join(".config"), + home.path().join("cwd"), + ); + let directory = paths.app_config_dir(); + fs::create_dir_all(&directory).unwrap(); + let http_path = directory.join("mom-http.json"); + if let Some(bytes) = config { + crate::private_file::publish(&http_path, bytes).unwrap(); + } else { + fs::create_dir(&http_path).unwrap(); + fs::write(http_path.join("untouched"), b"private-invalid").unwrap(); + } + let proxy_path = directory.join("mcp-proxy.json"); + crate::mcp_proxy::config::save_config(&proxy_path, &Default::default()).unwrap(); + if invalid_legacy_auth { + let bytes = serde_json::to_vec(&json!({ + "gateway": {"tokenVerifier": "private-invalid"}, + })) + .unwrap(); + #[cfg(windows)] + let bytes = serde_json::to_vec(&json!({ + "version": 1, + "encryptedPayload": crate::protected_data::protect(&bytes).unwrap(), + })) + .unwrap(); + crate::private_file::publish(&proxy_path, &bytes).unwrap(); + } + let proxy_before = fs::read(&proxy_path).unwrap(); + let app = http_test_app(paths); + let http = app.state::(); + let error = http.inner().as_ref().err().unwrap(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + let expected = serde_json::to_value(error).unwrap(); + assert!(!expected.to_string().contains("private-invalid")); + assert!(app.try_state::>().is_none()); + let proxy = app.state::>(); + let config_before = tauri::async_runtime::block_on(proxy.config()); + let generation_before = proxy.generation(); + let request_token = tauri::async_runtime::block_on(proxy.request_token()); + let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + for (cmd, body) in [ + ("get_mom_http_settings", json!({})), + ( + "set_mom_http_settings", + json!({"settings": {"port": 23457, "allowLan": false}}), + ), + ("rotate_mom_http_key", json!({})), + ("get_mcp_proxy_state", json!({})), + ] { + assert_eq!(invoke_http_test(&webview, cmd, body).unwrap_err(), expected); + } + let settings: McpProxySettingsDto = + invoke_http_test(&webview, "get_mcp_proxy_settings", json!({})) + .unwrap() + .deserialize() + .unwrap(); + assert_eq!(settings.revision, config_before.revision); + assert_eq!(settings.max_units, config_before.gateway.max_units); + assert_eq!( + tauri::async_runtime::block_on(proxy.config()), + config_before + ); + assert_eq!(proxy.generation(), generation_before); + assert!(!request_token.is_cancelled()); + assert_eq!(fs::read(&proxy_path).unwrap(), proxy_before); + assert_eq!(fs::read_dir(&directory).unwrap().count(), 2); + if let Some(bytes) = config { + assert_eq!(fs::read(&http_path).unwrap(), bytes); + } else { + assert!(http_path.is_dir()); + assert_eq!(fs::read_dir(&http_path).unwrap().count(), 1); + assert_eq!( + fs::read(http_path.join("untouched")).unwrap(), + b"private-invalid" + ); + } + invoke_http_test( + &webview, + "save_mcp_proxy_settings", + json!({ + "settings": {"enabled": false, "idleTimeout": 301, "maxUnits": 8}, + "expectedRevision": settings.revision, + }), + ) + .unwrap(); + let updated: McpProxySettingsDto = + invoke_http_test(&webview, "get_mcp_proxy_settings", json!({})) + .unwrap() + .deserialize() + .unwrap(); + assert_eq!(updated.revision, settings.revision + 1); + assert_eq!(updated.idle_timeout, 301); + assert_eq!(updated.max_units, 8); + let generation = proxy.generation(); + tauri::async_runtime::block_on(crate::shutdown_http( + app.handle(), + &proxy, + std::time::Duration::from_secs(2), + )); + assert!(request_token.is_cancelled()); + assert_eq!(proxy.generation(), generation + 1); + } +} + +#[test] +fn occupied_http_port_does_not_block_setup_or_http_reconfiguration() { + let home = TempHome::new(); + let paths = ConfigPaths::with_resource_parts( + home.path().to_path_buf(), + None, + None, + home.path().join(".config"), + home.path().join("cwd"), + ); + fs::create_dir_all(paths.app_config_dir()).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let http_path = paths.app_config_dir().join("mom-http.json"); + let bytes = serde_json::to_vec(&json!({ + "port": port, + "allowLan": false, + "auth": {"tokenVerifier": null}, + })) + .unwrap(); + crate::private_file::publish(&http_path, &bytes).unwrap(); + let app = http_test_app(paths); + let http = app.state::(); + let http = http.inner().as_ref().unwrap(); + let error = tauri::async_runtime::block_on(http.start()).unwrap_err(); + assert_eq!(error.code, ErrorCode::ConfigurationFailed); + assert_eq!(fs::read(&http_path).unwrap(), bytes); + let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + let settings: crate::http_server::MomHttpSettings = + invoke_http_test(&webview, "get_mom_http_settings", json!({})) + .unwrap() + .deserialize() + .unwrap(); + assert_eq!(settings.port, port); + let state: McpProxyStateDto = invoke_http_test(&webview, "get_mcp_proxy_state", json!({})) + .unwrap() + .deserialize() + .unwrap(); + assert_eq!(state.actual_state, "stopped"); + assert_eq!(state.bound_addr, None); + let token: String = invoke_http_test(&webview, "rotate_mom_http_key", json!({})) + .unwrap() + .deserialize() + .unwrap(); + assert_eq!(token.len(), 64); + let available = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = available.local_addr().unwrap().port(); + drop(available); + invoke_http_test( + &webview, + "set_mom_http_settings", + json!({"settings": {"port": port, "allowLan": false}}), + ) + .unwrap(); + let state: McpProxyStateDto = invoke_http_test(&webview, "get_mcp_proxy_state", json!({})) + .unwrap() + .deserialize() + .unwrap(); + assert_eq!(state.port, port); + assert_eq!(state.bound_addr, Some(format!("127.0.0.1:{port}"))); + let proxy = app.state::>(); + tauri::async_runtime::block_on(crate::shutdown_http( + app.handle(), + &proxy, + std::time::Duration::from_secs(2), + )); + assert_eq!(tauri::async_runtime::block_on(http.bound_addr()), None); +} + #[tokio::test] async fn exit_barrier_and_shutdown_lifecycle() { let home = TempHome::new(); let config_path = home.path().join("mcp-proxy.json"); let service = Arc::new(ProxyService::new_with_path(config_path).expect("init service")); - // Rotate key to allow gateway to start if enabled - let _ = service.rotate_key().await.unwrap(); + let http = + HttpServer::new_with_path(home.path().join("mom-http.json"), service.router()).unwrap(); + crate::commands::app::rotate_http_key(http.clone(), service.as_ref().clone()) + .await + .unwrap(); // Verify shutdown completes within deadline let res = service.shutdown(std::time::Duration::from_secs(8)).await; assert!(res.is_ok()); + http.shutdown(std::time::Duration::from_secs(2)) + .await + .unwrap(); + // Second shutdown is idempotent and succeeds quickly let res = service.shutdown(std::time::Duration::from_secs(8)).await; assert!(res.is_ok()); diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs index db2101d..53469e2 100644 --- a/src-tauri/src/http_server.rs +++ b/src-tauri/src/http_server.rs @@ -1,9 +1,9 @@ use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use std::time::Duration; -use axum::{Extension, Router}; +use axum::Router; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; use tokio::sync::Mutex; @@ -13,6 +13,11 @@ use tokio_util::sync::CancellationToken; use crate::error::AppError; use crate::paths::ConfigPaths; +pub(crate) mod auth; + +#[cfg(test)] +mod tests_auth; + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", default)] pub struct MomHttpSettings { @@ -64,6 +69,7 @@ mod tests { let path = temp.path().join("mom-http.json"); let router = Router::new().route("/health", axum::routing::get(|| async { "ok" })); let mut http = HttpServer::new_with_path(path.clone(), router).unwrap(); + let key = http.rotate_key(async { Ok(()) }).await.unwrap(); let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); let (release_tx, release_rx) = std::sync::mpsc::channel(); let release_rx = std::sync::Mutex::new(release_rx); @@ -102,6 +108,7 @@ mod tests { assert_eq!( reqwest::Client::new() .get(format!("http://{addr}/health")) + .bearer_auth(&key) .send() .await .unwrap() @@ -132,10 +139,30 @@ impl MomHttpSettings { #[derive(Clone)] pub(crate) struct HttpContext { - pub settings: MomHttpSettings, pub shutdown_token: CancellationToken, } +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct HttpConfig { + #[serde(flatten)] + settings: MomHttpSettings, + #[serde(default)] + auth: auth::AuthConfig, +} + +fn publish_config(path: &std::path::Path, config: &HttpConfig) -> Result<(), AppError> { + let bytes = serde_json::to_vec_pretty(config) + .map_err(|_| AppError::configuration("serialize MOM HTTP configuration"))?; + if let Some(directory) = path.parent() { + std::fs::create_dir_all(directory)?; + } + crate::private_file::publish(path, &bytes).map_err(|error| { + AppError::configuration(format!("save MOM HTTP configuration: {error:?}")) + })?; + Ok(()) +} + struct Runtime { settings: MomHttpSettings, bound_addr: Option, @@ -148,37 +175,68 @@ pub struct HttpServer { config_path: PathBuf, router: Router, runtime: Arc>, + auth: Arc>, shutdown_token: CancellationToken, #[cfg(test)] publish_hook: Option>, } +pub(crate) type HttpServerState = Result, AppError>; + impl HttpServer { pub fn new(paths: &ConfigPaths, router: Router) -> Result { Self::new_with_path(paths.app_config_dir().join("mom-http.json"), router) } pub(crate) fn new_with_path(config_path: PathBuf, router: Router) -> Result { - let settings: MomHttpSettings = match crate::private_file::read(&config_path, 8192) - .map_err(|error| { - AppError::configuration(format!("read MOM HTTP settings: {error:?}")) - })? { - Some(bytes) => serde_json::from_slice(&bytes).map_err(|error| { - AppError::configuration(format!("invalid MOM HTTP settings: {error}")) - })?, - None => MomHttpSettings::default(), + let bytes = crate::private_file::read(&config_path, 8192).map_err(|error| { + AppError::configuration(format!("read MOM HTTP settings: {error:?}")) + })?; + let value: serde_json::Value = match bytes { + Some(bytes) => serde_json::from_slice(&bytes) + .map_err(|_| AppError::configuration("invalid MOM HTTP configuration"))?, + None => serde_json::json!({}), }; - settings.validate()?; + if !value.is_object() || value.get("auth").is_some_and(|auth| !auth.is_object()) { + return Err(AppError::configuration("invalid MOM HTTP configuration")); + } + let mut config: HttpConfig = serde_json::from_value(value.clone()) + .map_err(|_| AppError::configuration("invalid MOM HTTP configuration"))?; + config.settings.validate()?; + if !value + .as_object() + .is_some_and(|object| object.contains_key("auth")) + { + config.auth.token_verifier = crate::mcp_proxy::config::legacy_http_verifier( + &config_path.with_file_name("mcp-proxy.json"), + )?; + publish_config(&config_path, &config)?; + } + if config + .auth + .token_verifier + .as_deref() + .is_some_and(|verifier| !auth::valid_verifier(verifier)) + { + return Err(AppError::configuration( + "invalid MOM HTTP authentication verifier", + )); + } + let shutdown_token = CancellationToken::new(); Ok(Self { config_path, router, runtime: Arc::new(Mutex::new(Runtime { - settings, + settings: config.settings, bound_addr: None, listener_handle: None, - shutdown_token: CancellationToken::new(), + shutdown_token: shutdown_token.child_token(), })), - shutdown_token: CancellationToken::new(), + auth: Arc::new(RwLock::new(auth::AuthState { + config: config.auth, + epoch: shutdown_token.child_token(), + })), + shutdown_token, #[cfg(test)] publish_hook: None, }) @@ -188,6 +246,75 @@ impl HttpServer { self.runtime.lock().await.settings } + pub fn auth_ready(&self) -> bool { + let state = self.auth.read().unwrap_or_else(|error| error.into_inner()); + !self.shutdown_token.is_cancelled() && state.config.token_verifier.is_some() + } + + pub(crate) async fn rotate_key( + &self, + cleanup: impl std::future::Future> + Send + 'static, + ) -> Result { + let server = self.clone(); + tokio::spawn(async move { server.rotate_key_owned(cleanup).await }) + .await + .map_err(|error| { + AppError::configuration(format!("MOM HTTP key rotation task failed: {error}")) + })? + } + + async fn rotate_key_owned( + &self, + cleanup: impl std::future::Future>, + ) -> Result { + let runtime = self.runtime.lock().await; + if self.shutdown_token.is_cancelled() { + return Err(AppError::configuration("MOM HTTP service is shutting down")); + } + let key = auth::generate_key(); + let config = HttpConfig { + settings: runtime.settings, + auth: auth::AuthConfig { + token_verifier: Some(key.verifier), + }, + }; + let path = self.config_path.clone(); + #[cfg(test)] + let publish_hook = self.publish_hook.clone(); + let config = tokio::task::spawn_blocking(move || { + #[cfg(test)] + if let Some(hook) = publish_hook { + hook(); + } + publish_config(&path, &config)?; + Ok::<_, AppError>(config) + }) + .await + .map_err(|error| { + AppError::configuration(format!("save MOM HTTP key task failed: {error}")) + })??; + { + let mut state = self.auth.write().unwrap_or_else(|error| error.into_inner()); + state.epoch.cancel(); + state.config = config.auth; + } + // Keep admission closed until all owners of the previous epoch are gone. + if let Err(error) = cleanup.await { + self.shutdown_token.cancel(); + eprintln!("warning: MOM HTTP key committed; admission remains closed: {error}"); + return Ok(key.raw_token); + } + let mut state = self.auth.write().unwrap_or_else(|error| error.into_inner()); + if !self.shutdown_token.is_cancelled() { + state.epoch = if runtime.listener_handle.is_some() { + runtime.shutdown_token.child_token() + } else { + self.shutdown_token.child_token() + }; + } + Ok(key.raw_token) + } + pub async fn bound_addr(&self) -> Option { let runtime = self.runtime.lock().await; runtime @@ -232,10 +359,22 @@ impl HttpServer { return Err(AppError::configuration("MOM HTTP service is shutting down")); } let shutdown_token = self.shutdown_token.child_token(); - let router = self.router.clone().layer(Extension(HttpContext { - settings: runtime.settings, - shutdown_token: shutdown_token.clone(), - })); + { + let mut state = self.auth.write().unwrap_or_else(|error| error.into_inner()); + state.epoch.cancel(); + state.epoch = shutdown_token.child_token(); + } + let mut router = self.router.clone(); + if router.has_routes() { + router = router.route_layer(axum::middleware::from_fn_with_state( + auth::HttpAuth { + settings: runtime.settings, + listener_token: shutdown_token.clone(), + state: self.auth.clone(), + }, + auth::enforce, + )); + } let stop = shutdown_token.clone(); runtime.listener_handle = Some(tokio::spawn(async move { if let Err(error) = axum::serve(listener, router) @@ -251,10 +390,19 @@ impl HttpServer { } pub async fn set_settings(&self, settings: MomHttpSettings) -> Result<(), AppError> { + self.set_settings_with_cleanup(settings, async { Ok(()) }) + .await + } + + pub(crate) async fn set_settings_with_cleanup( + &self, + settings: MomHttpSettings, + cleanup: impl std::future::Future> + Send + 'static, + ) -> Result<(), AppError> { settings.validate()?; let server = self.clone(); tokio::spawn(async move { - let result = server.set_settings_owned(settings).await; + let result = server.set_settings_owned(settings, cleanup).await; if let Err(error) = &result { eprintln!("warning: MOM HTTP settings update failed: {error}"); } @@ -266,7 +414,11 @@ impl HttpServer { })? } - async fn set_settings_owned(&self, settings: MomHttpSettings) -> Result<(), AppError> { + async fn set_settings_owned( + &self, + settings: MomHttpSettings, + cleanup: impl std::future::Future>, + ) -> Result<(), AppError> { let mut runtime = self.runtime.lock().await; if self.shutdown_token.is_cancelled() { return Err(AppError::configuration("MOM HTTP service is shutting down")); @@ -281,10 +433,30 @@ impl HttpServer { } let was_running = runtime.bound_addr.is_some(); - Self::stop(&mut runtime, Duration::from_secs(8)).await?; + self.auth + .write() + .unwrap_or_else(|error| error.into_inner()) + .epoch + .cancel(); + if let Err(error) = Self::stop(&mut runtime, Duration::from_secs(8)).await { + if let Err(cleanup) = cleanup.await { + self.shutdown_token.cancel(); + eprintln!("warning: MOM HTTP stopped; MCP cleanup failed: {cleanup}"); + } + return Err(error); + } let prepared = async { let listener = Self::bind(settings).await?; let path = self.config_path.clone(); + let config = HttpConfig { + settings, + auth: self + .auth + .read() + .unwrap_or_else(|error| error.into_inner()) + .config + .clone(), + }; #[cfg(test)] let publish_hook = self.publish_hook.clone(); tokio::task::spawn_blocking(move || { @@ -292,16 +464,7 @@ impl HttpServer { if let Some(hook) = publish_hook { hook(); } - let bytes = serde_json::to_vec_pretty(&settings).map_err(|error| { - AppError::configuration(format!("serialize MOM HTTP settings: {error}")) - })?; - if let Some(directory) = path.parent() { - std::fs::create_dir_all(directory)?; - } - crate::private_file::publish(&path, &bytes).map_err(|error| { - AppError::configuration(format!("save MOM HTTP settings: {error:?}")) - })?; - Ok::<_, AppError>(()) + publish_config(&path, &config) }) .await .map_err(|error| { @@ -310,14 +473,23 @@ impl HttpServer { Ok::<_, AppError>(listener) } .await; + let cleanup = cleanup.await; + if let Err(error) = &cleanup { + self.shutdown_token.cancel(); + eprintln!( + "warning: MOM HTTP admission remains closed after MCP cleanup failed: {error}" + ); + } match prepared { Ok(listener) => { runtime.settings = settings; - self.serve(&mut runtime, listener)?; + if cleanup.is_ok() { + self.serve(&mut runtime, listener)?; + } Ok(()) } Err(error) => { - if was_running { + if was_running && cleanup.is_ok() { let restored = Self::bind(runtime.settings).await; match restored.and_then(|listener| self.serve(&mut runtime, listener)) { Ok(_) => {} diff --git a/src-tauri/src/http_server/auth.rs b/src-tauri/src/http_server/auth.rs new file mode 100644 index 0000000..fc799f0 --- /dev/null +++ b/src-tauri/src/http_server/auth.rs @@ -0,0 +1,189 @@ +use std::sync::{Arc, RwLock}; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use futures::StreamExt; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use tokio_util::sync::CancellationToken; + +use super::{HttpContext, MomHttpSettings}; + +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct AuthConfig { + pub token_verifier: Option, +} + +pub(super) struct AuthState { + pub config: AuthConfig, + pub epoch: CancellationToken, +} + +#[derive(Clone)] +pub(super) struct HttpAuth { + pub settings: MomHttpSettings, + pub listener_token: CancellationToken, + pub state: Arc>, +} + +pub(crate) struct GeneratedKey { + pub raw_token: String, + pub verifier: String, +} + +fn to_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut hex = String::with_capacity(bytes.len() * 2); + for byte in bytes { + write!(hex, "{byte:02x}").expect("write to String"); + } + hex +} + +pub(crate) fn valid_verifier(verifier: &str) -> bool { + verifier.len() == 64 && verifier.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +pub(crate) fn generate_key() -> GeneratedKey { + let mut bytes = [0; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let raw_token = to_hex(&bytes); + let verifier = to_hex(&Sha256::digest(raw_token.as_bytes())); + GeneratedKey { + raw_token, + verifier, + } +} + +pub(crate) fn verify_token(token: &str, verifier: &str) -> bool { + if !valid_verifier(verifier) { + return false; + } + let mut expected = [0; 32]; + for (index, byte) in expected.iter_mut().enumerate() { + *byte = u8::from_str_radix(&verifier[index * 2..index * 2 + 2], 16) + .expect("validated hexadecimal verifier"); + } + Sha256::digest(token.as_bytes()).ct_eq(&expected).into() +} + +fn valid_host(headers: &HeaderMap, settings: MomHttpSettings) -> bool { + if headers.get_all("host").iter().count() != 1 { + return false; + } + let Some(authority) = headers + .get("host") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + else { + return false; + }; + if authority.as_str().contains('@') || authority.port_u16().unwrap_or(80) != settings.port { + return false; + } + match authority.host() { + "localhost" | "127.0.0.1" | "[::1]" => true, + host => settings.allow_lan && host.parse::().is_ok(), + } +} + +fn valid_origin(headers: &HeaderMap, port: u16) -> bool { + let mut origins = headers.get_all("origin").iter(); + let Some(origin) = origins.next() else { + return true; + }; + origins.next().is_none() + && origin.to_str().is_ok_and(|origin| { + ["127.0.0.1", "localhost", "[::1]"] + .iter() + .any(|host| origin == format!("http://{host}:{port}")) + }) +} + +pub(super) async fn enforce( + State(auth): State, + mut request: Request, + next: Next, +) -> Response { + if !valid_host(request.headers(), auth.settings) { + return StatusCode::BAD_REQUEST.into_response(); + } + if !valid_origin(request.headers(), auth.settings.port) { + return StatusCode::FORBIDDEN.into_response(); + } + let epoch = { + let state = auth.state.read().unwrap_or_else(|error| error.into_inner()); + if auth.listener_token.is_cancelled() || state.epoch.is_cancelled() { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + let Some(verifier) = &state.config.token_verifier else { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + if request.headers().get_all("authorization").iter().count() != 1 + || !request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|token| verify_token(token, verifier)) + { + return StatusCode::UNAUTHORIZED.into_response(); + } + state.epoch.clone() + }; + request.extensions_mut().insert(HttpContext { + shutdown_token: epoch.clone(), + }); + let response = tokio::select! { + biased; + _ = epoch.cancelled() => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + response = next.run(request) => response, + }; + let (parts, body) = response.into_parts(); + Response::from_parts( + parts, + Body::from_stream(body.into_data_stream().take_until(epoch.cancelled_owned())), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_and_request_boundaries_fail_closed() { + let key = generate_key(); + assert_eq!(key.raw_token.len(), 64); + assert!(verify_token(&key.raw_token, &key.verifier)); + assert!(!verify_token("wrong", &key.verifier)); + assert!(!verify_token(&key.raw_token, "invalid")); + let settings = MomHttpSettings::default(); + let mut headers = HeaderMap::new(); + for host in ["localhost:18765", "127.0.0.1:18765", "[::1]:18765"] { + headers.insert("host", host.parse().unwrap()); + assert!(valid_host(&headers, settings)); + } + for host in [ + "localhost", + "localhost:no", + "[::1]:1", + "evil.test:18765", + "x@localhost:18765", + ] { + headers.insert("host", host.parse().unwrap()); + assert!(!valid_host(&headers, settings)); + } + headers.insert("origin", "null".parse().unwrap()); + assert!(!valid_origin(&headers, settings.port)); + headers.insert("origin", "http://localhost:18765".parse().unwrap()); + assert!(valid_origin(&headers, settings.port)); + headers.append("origin", "http://evil.test".parse().unwrap()); + assert!(!valid_origin(&headers, settings.port)); + } +} diff --git a/src-tauri/src/http_server/tests_auth.rs b/src-tauri/src/http_server/tests_auth.rs new file mode 100644 index 0000000..073c01d --- /dev/null +++ b/src-tauri/src/http_server/tests_auth.rs @@ -0,0 +1,416 @@ +use super::*; +use axum::http::StatusCode; +use serde_json::{json, Value}; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn publish(path: &Path, value: &Value) { + crate::private_file::publish(path, &serde_json::to_vec(value).unwrap()).unwrap(); +} + +fn publish_legacy(path: &Path, value: &Value) { + #[cfg(not(windows))] + publish(path, value); + #[cfg(windows)] + publish( + path, + &json!({ + "version": 1, + "encryptedPayload": crate::protected_data::protect(&serde_json::to_vec(value).unwrap()).unwrap(), + }), + ); +} + +async fn start(http: &HttpServer) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let settings = MomHttpSettings { + port: listener.local_addr().unwrap().port(), + allow_lan: false, + }; + drop(listener); + http.set_settings(settings).await.unwrap(); + http.bound_addr().await.unwrap() +} + +#[tokio::test] +async fn migration_adopts_once_preserves_network_and_never_retains_proxy_credentials() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mom-http.json"); + let legacy_path = temp.path().join("mcp-proxy.json"); + let key = auth::generate_key(); + publish(&path, &json!({"port": 23456, "allowLan": true})); + publish_legacy( + &legacy_path, + &json!({"gateway": {"tokenVerifier": key.verifier}}), + ); + let http = HttpServer::new_with_path(path.clone(), Router::new()).unwrap(); + assert_eq!( + http.settings().await, + MomHttpSettings { + port: 23456, + allow_lan: true + } + ); + let disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(disk["auth"]["tokenVerifier"], key.verifier); + assert!(!disk.to_string().contains(&key.raw_token)); + let proxy = crate::mcp_proxy::config::load_config(&legacy_path).unwrap(); + assert!(!format!("{proxy:?}").contains(&key.verifier)); + crate::mcp_proxy::config::save_config(&legacy_path, &proxy).unwrap(); + assert!( + !String::from_utf8_lossy(&std::fs::read(&legacy_path).unwrap()).contains("tokenVerifier") + ); + let restarted = HttpServer::new_with_path(path.clone(), Router::new()).unwrap(); + assert!(auth::verify_token( + &key.raw_token, + restarted + .auth + .read() + .unwrap() + .config + .token_verifier + .as_deref() + .unwrap() + )); + let new_key = restarted.rotate_key(async { Ok(()) }).await.unwrap(); + publish_legacy( + &legacy_path, + &json!({"gateway": {"tokenVerifier": key.verifier}}), + ); + let restarted = HttpServer::new_with_path(path.clone(), Router::new()).unwrap(); + { + let auth = restarted.auth.read().unwrap(); + let verifier = auth.config.token_verifier.as_deref().unwrap(); + assert!(auth::verify_token(&new_key, verifier)); + assert!(!auth::verify_token(&key.raw_token, verifier)); + } + publish( + &path, + &json!({"port": 23456, "allowLan": true, "auth": {"tokenVerifier": null}}), + ); + let explicit_none = HttpServer::new_with_path(path.clone(), Router::new()).unwrap(); + assert!(!explicit_none.auth_ready()); + assert_eq!(explicit_none.settings().await.port, 23456); + crate::private_file::publish(&legacy_path, b"invalid legacy data").unwrap(); + assert!(HttpServer::new_with_path(path, Router::new()).is_ok()); +} + +#[test] +fn migration_marks_absence_and_rejects_invalid_relevant_auth_without_rewriting() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mom-http.json"); + let legacy_path = temp.path().join("mcp-proxy.json"); + let http = HttpServer::new_with_path(path.clone(), Router::new()).unwrap(); + assert!(!http.auth_ready()); + let disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(disk["auth"], json!({"tokenVerifier": null})); + publish_legacy( + &legacy_path, + &json!({"gateway": {"tokenVerifier": auth::generate_key().verifier}}), + ); + assert!(!HttpServer::new_with_path(path.clone(), Router::new()) + .unwrap() + .auth_ready()); + + for legacy in [ + json!([]), + json!({"gateway": null}), + json!({"gateway": 1}), + json!({"gateway": {"tokenVerifier": "private-invalid"}}), + json!({"gateway": {"tokenVerifier": 7}}), + ] { + publish(&path, &json!({"port": 23456, "allowLan": true})); + let before = std::fs::read(&path).unwrap(); + publish_legacy(&legacy_path, &legacy); + let error = HttpServer::new_with_path(path.clone(), Router::new()) + .err() + .unwrap(); + assert!(!error.to_string().contains("private-invalid")); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + for auth in [ + json!(null), + json!(false), + json!({"tokenVerifier": 7}), + json!({"tokenVerifier": "private-invalid"}), + json!({"rawToken": "private-invalid"}), + ] { + publish( + &path, + &json!({"port": 23456, "allowLan": true, "auth": auth}), + ); + let before = std::fs::read(&path).unwrap(); + assert!(HttpServer::new_with_path(path.clone(), Router::new()).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + } +} + +#[cfg(unix)] +#[test] +fn failed_migration_publication_preserves_original_settings_and_legacy_verifier() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mom-http.json"); + let legacy_path = temp.path().join("mcp-proxy.json"); + publish(&path, &json!({"port": 23456, "allowLan": true})); + publish_legacy( + &legacy_path, + &json!({"gateway": {"tokenVerifier": auth::generate_key().verifier}}), + ); + let before = std::fs::read(&path).unwrap(); + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); + let result = HttpServer::new_with_path(path.clone(), Router::new()); + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!(crate::mcp_proxy::config::legacy_http_verifier(&legacy_path) + .unwrap() + .is_some()); +} + +#[tokio::test] +async fn every_http_method_is_authenticated_before_handlers_and_settings_cannot_replace_key() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mom-http.json"); + let calls = Arc::new(AtomicUsize::new(0)); + let counted = calls.clone(); + let router = Router::new().route( + "/health", + axum::routing::any(move || { + let calls = counted.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + "ok" + } + }), + ); + let http = HttpServer::new_with_path(path.clone(), router).unwrap(); + let addr = start(&http).await; + let client = reqwest::Client::new(); + let url = format!("http://{addr}/health"); + assert_eq!( + client.get(&url).send().await.unwrap().status(), + StatusCode::SERVICE_UNAVAILABLE + ); + let token = http.rotate_key(async { Ok(()) }).await.unwrap(); + for method in [ + reqwest::Method::GET, + reqwest::Method::POST, + reqwest::Method::DELETE, + reqwest::Method::PUT, + reqwest::Method::PATCH, + reqwest::Method::HEAD, + reqwest::Method::OPTIONS, + reqwest::Method::TRACE, + ] { + for key in [None, Some("wrong")] { + let mut request = client.request(method.clone(), &url); + if let Some(key) = key { + request = request.bearer_auth(key); + } + assert_eq!( + request.send().await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + } + assert_eq!( + client + .request(method, &url) + .bearer_auth(&token) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + } + assert_eq!(calls.load(Ordering::SeqCst), 8); + let verifier = http.auth.read().unwrap().config.token_verifier.clone(); + let next = start(&http).await; + assert_eq!( + client + .get(format!("http://{next}/health")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + assert_eq!(http.auth.read().unwrap().config.token_verifier, verifier); + assert_eq!( + serde_json::to_value(http.settings().await) + .unwrap() + .as_object() + .unwrap() + .len(), + 2 + ); + assert!(!String::from_utf8_lossy(&std::fs::read(&path).unwrap()).contains(&token)); + http.shutdown(Duration::from_secs(2)).await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_rotation_commits_before_revocation_and_serializes_settings_and_next_rotation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mom-http.json"); + let mut http = HttpServer::new_with_path(path.clone(), Router::new()).unwrap(); + let old_key = http.rotate_key(async { Ok(()) }).await.unwrap(); + let addr = start(&http).await; + let old_epoch = http.auth.read().unwrap().epoch.clone(); + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let release_rx = std::sync::Mutex::new(release_rx); + let worker = std::thread::current().id(); + http.publish_hook = Some(Arc::new(move || { + assert_ne!(worker, std::thread::current().id()); + entered_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + })); + let publisher = http.clone(); + let (clean_tx, clean_rx) = tokio::sync::oneshot::channel(); + let (resume_tx, resume_rx) = tokio::sync::oneshot::channel(); + let committed_path = path.clone(); + let waiter = tokio::spawn(async move { + publisher + .rotate_key(async move { + let disk: Value = + serde_json::from_slice(&std::fs::read(committed_path).unwrap()).unwrap(); + assert!(!auth::verify_token( + &old_key, + disk["auth"]["tokenVerifier"].as_str().unwrap() + )); + clean_tx.send(()).unwrap(); + resume_rx.await.unwrap(); + Ok(()) + }) + .await + }); + entered_rx.recv().await.unwrap(); + assert!(!old_epoch.is_cancelled()); + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + release_tx.send(()).unwrap(); + clean_rx.await.unwrap(); + assert!(old_epoch.is_cancelled()); + assert!(http.runtime.try_lock().is_err()); + let mut contender = http.clone(); + contender.publish_hook = None; + let next_key_server = contender.clone(); + let next = tokio::spawn(async move { next_key_server.rotate_key(async { Ok(()) }).await }); + let settings = MomHttpSettings { + port: addr.port(), + allow_lan: false, + }; + let setter = tokio::spawn(async move { contender.set_settings(settings).await }); + resume_tx.send(()).unwrap(); + let key = next.await.unwrap().unwrap(); + setter.await.unwrap().unwrap(); + let disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(auth::verify_token( + &key, + disk["auth"]["tokenVerifier"].as_str().unwrap() + )); + assert_eq!(http.settings().await, settings); + assert!(!http.auth.read().unwrap().epoch.is_cancelled()); + http.shutdown(Duration::from_secs(2)).await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn rotation_cleanup_failure_preserves_committed_key_and_keeps_admission_closed() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mom-http.json"); + let http = HttpServer::new_with_path(path.clone(), Router::new()).unwrap(); + let old_key = http.rotate_key(async { Ok(()) }).await.unwrap(); + start(&http).await; + let old_epoch = http.auth.read().unwrap().epoch.clone(); + let key = http + .rotate_key(async { Err(AppError::configuration("mock cleanup failure")) }) + .await + .expect("post-commit cleanup failure must not discard the rotated key"); + let disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let verifier = disk["auth"]["tokenVerifier"].as_str().unwrap(); + assert!(auth::verify_token(&key, verifier)); + assert!(!auth::verify_token(&old_key, verifier)); + assert!(!disk.to_string().contains(&key)); + assert!(old_epoch.is_cancelled()); + assert!(http.auth.read().unwrap().epoch.is_cancelled()); + assert!(!http.auth_ready()); + assert!(http.start().await.is_err()); + http.shutdown(Duration::from_secs(2)).await.unwrap(); + assert!(HttpServer::new_with_path(path, Router::new()) + .unwrap() + .auth_ready()); +} + +#[tokio::test(flavor = "current_thread")] +async fn shutdown_during_rotation_publication_cannot_reopen_auth_or_listener() { + let temp = tempfile::tempdir().unwrap(); + let mut http = + HttpServer::new_with_path(temp.path().join("mom-http.json"), Router::new()).unwrap(); + start(&http).await; + let old_epoch = http.auth.read().unwrap().epoch.clone(); + let listener_token = http.runtime.lock().await.shutdown_token.clone(); + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let release_rx = std::sync::Mutex::new(release_rx); + http.publish_hook = Some(Arc::new(move || { + entered_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + })); + let publisher = http.clone(); + let rotation = tokio::spawn(async move { publisher.rotate_key(async { Ok(()) }).await }); + entered_rx.recv().await.unwrap(); + let stopper = http.clone(); + let shutdown = tokio::spawn(async move { stopper.shutdown(Duration::from_secs(2)).await }); + tokio::task::yield_now().await; + assert!(http.shutdown_token.is_cancelled()); + assert!(old_epoch.is_cancelled()); + assert!(listener_token.is_cancelled()); + assert!(!http.auth_ready()); + release_tx.send(()).unwrap(); + assert_eq!(rotation.await.unwrap().unwrap().len(), 64); + shutdown.await.unwrap().unwrap(); + assert!(!http.auth_ready()); + assert!(http.auth.read().unwrap().epoch.is_cancelled()); + assert!(http.bound_addr().await.is_none()); + assert!(http.rotate_key(async { Ok(()) }).await.is_err()); + assert!(http.start().await.is_err()); +} + +#[tokio::test(flavor = "current_thread")] +async fn shutdown_during_rotation_cleanup_cannot_reopen_admission_without_listener() { + let temp = tempfile::tempdir().unwrap(); + let http = HttpServer::new_with_path(temp.path().join("mom-http.json"), Router::new()).unwrap(); + let old_epoch = http.auth.read().unwrap().epoch.clone(); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let publisher = http.clone(); + let rotation = tokio::spawn(async move { + publisher + .rotate_key(async move { + entered_tx.send(()).unwrap(); + release_rx.await.unwrap(); + Ok(()) + }) + .await + }); + entered_rx.await.unwrap(); + assert!(old_epoch.is_cancelled()); + let stopper = http.clone(); + let shutdown = tokio::spawn(async move { stopper.shutdown(Duration::from_secs(2)).await }); + tokio::task::yield_now().await; + assert!(http.shutdown_token.is_cancelled()); + release_tx.send(()).unwrap(); + let key = rotation.await.unwrap().unwrap(); + assert_eq!(key.len(), 64); + assert!(http.auth.read().unwrap().epoch.is_cancelled()); + shutdown.await.unwrap().unwrap(); + assert!(!http.auth_ready()); + assert!(http.bound_addr().await.is_none()); + let disk: Value = serde_json::from_slice(&std::fs::read(&http.config_path).unwrap()).unwrap(); + assert!(auth::verify_token( + &key, + disk["auth"]["tokenVerifier"].as_str().unwrap() + )); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 21fa8f3..7510a32 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -56,19 +56,10 @@ pub fn run() { mcp_proxy::ProxyService::new_disabled(&paths) }, )); - let http = std::sync::Arc::new(http_server::HttpServer::new( - &paths, - axum::Router::new().merge(proxy.router()), - )?); - app.manage(http.clone()); - app.manage(proxy); + app.manage(proxy.clone()); app.manage(power::PowerService::new(paths.app_config_dir())?); app.manage(paths.clone()); - tauri::async_runtime::spawn(async move { - if let Err(error) = http.start().await { - eprintln!("warning: failed to start MOM HTTP service: {error}"); - } - }); + setup_http(app, &paths, &proxy); *setup_notifications .lock() .unwrap_or_else(|error| error.into_inner()) = Some(app.handle().clone()); @@ -161,7 +152,7 @@ pub fn run() { commands::get_mcp_proxy_state, commands::get_mcp_proxy_settings, commands::save_mcp_proxy_settings, - commands::rotate_mcp_proxy_key, + commands::rotate_mom_http_key, commands::get_mcp_proxy_server, commands::create_mcp_proxy_server, commands::update_mcp_proxy_server, @@ -225,15 +216,44 @@ pub fn run() { }); } -async fn shutdown_http( - app: &tauri::AppHandle, +fn setup_http( + app: &tauri::App, + paths: &paths::ConfigPaths, + proxy: &mcp_proxy::ProxyService, +) { + let http: http_server::HttpServerState = + http_server::HttpServer::new(paths, axum::Router::new().merge(proxy.router())) + .map(std::sync::Arc::new); + app.manage(http.clone()); + match http { + Ok(http) => { + tauri::async_runtime::spawn(async move { + if let Err(error) = http.start().await { + eprintln!("warning: failed to start MOM HTTP service: {error}"); + } + }); + } + Err(error) => { + eprintln!( + "warning: MOM HTTP service initialization failed; disabling HTTP service: {error}" + ); + } + } +} + +async fn shutdown_http( + app: &tauri::AppHandle, proxy: &mcp_proxy::ProxyService, deadline: std::time::Duration, ) { let (http_result, proxy_result) = tokio::join!( async { - if let Some(http) = app.try_state::>() { - http.shutdown(deadline).await + if let Some(http) = app.try_state::() { + if let Ok(http) = http.inner().as_ref() { + http.shutdown(deadline).await + } else { + Ok(()) + } } else { Ok(()) } diff --git a/src-tauri/src/mcp_proxy/auth.rs b/src-tauri/src/mcp_proxy/auth.rs deleted file mode 100644 index 85ef1bc..0000000 --- a/src-tauri/src/mcp_proxy/auth.rs +++ /dev/null @@ -1,74 +0,0 @@ -#![allow(dead_code)] - -use rand::RngCore; -use sha2::{Digest, Sha256}; -use subtle::ConstantTimeEq; - -pub struct GeneratedKey { - pub raw_token: String, - pub verifier: String, -} - -fn to_hex(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - use std::fmt::Write; - let _ = write!(s, "{:02x}", b); - } - s -} - -fn from_hex(hex: &str) -> Option> { - if hex.len() & 1 != 0 || !hex.is_ascii() { - return None; - } - (0..hex.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok()) - .collect() -} - -pub fn hash_token(token: &str) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hasher.finalize().into() -} - -pub fn generate_key() -> GeneratedKey { - let mut bytes = [0u8; 32]; - rand::rngs::OsRng.fill_bytes(&mut bytes); - let raw_token = to_hex(&bytes); - let hash = hash_token(&raw_token); - let verifier = to_hex(&hash); - GeneratedKey { - raw_token, - verifier, - } -} - -pub fn verify_token(incoming_token: &str, expected_verifier_hex: &str) -> bool { - let incoming_hash = hash_token(incoming_token); - let Some(expected_hash) = from_hex(expected_verifier_hex) else { - return false; - }; - if expected_hash.len() != 32 { - return false; - } - incoming_hash.ct_eq(expected_hash.as_slice()).into() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn key_generation_and_verification() { - let key = generate_key(); - assert_eq!(key.raw_token.len(), 64); - assert_eq!(key.verifier.len(), 64); - assert!(verify_token(&key.raw_token, &key.verifier)); - assert!(!verify_token("invalid-token", &key.verifier)); - assert!(!verify_token(&key.raw_token, "invalid-verifier")); - assert!(!verify_token(&key.raw_token, "aéa")); - } -} diff --git a/src-tauri/src/mcp_proxy/config.rs b/src-tauri/src/mcp_proxy/config.rs index 3a342ce..43eb6a2 100644 --- a/src-tauri/src/mcp_proxy/config.rs +++ b/src-tauri/src/mcp_proxy/config.rs @@ -77,8 +77,6 @@ pub struct GatewaySettings { pub idle_timeout_secs: u64, #[serde(default = "default_max_units")] pub max_units: usize, - #[serde(default)] - pub token_verifier: Option, } fn default_idle_timeout_secs() -> u64 { @@ -95,7 +93,6 @@ impl Default for GatewaySettings { enabled: false, idle_timeout_secs: default_idle_timeout_secs(), max_units: default_max_units(), - token_verifier: None, } } } @@ -147,13 +144,6 @@ pub fn validate_config(config: &ProxyConfig) -> Result<(), AppError> { if config.gateway.max_units == 0 || config.gateway.max_units > 128 { return Err(AppError::validation("max units must be between 1 and 128")); } - if let Some(ref verifier) = config.gateway.token_verifier { - if verifier.len() != 64 || !verifier.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(AppError::validation( - "token verifier must be a 64-character hex string", - )); - } - } if config.services.len() > MAX_SERVICES { return Err(AppError::validation(format!( "services count exceeds maximum limit of {}", @@ -226,19 +216,63 @@ pub fn validate_config(config: &ProxyConfig) -> Result<(), AppError> { } pub fn load_config(path: &Path) -> Result { + let Some(bytes) = read_config(path)? else { + return Ok(ProxyConfig::default()); + }; + let config: ProxyConfig = serde_json::from_slice(&bytes.0) + .map_err(|_| AppError::configuration("invalid proxy configuration"))?; + validate_config(&config)?; + Ok(config) +} + +pub(crate) fn legacy_http_verifier(path: &Path) -> Result, AppError> { + let Some(bytes) = read_config(path) + .map_err(|_| AppError::configuration("read legacy proxy configuration"))? + else { + return Ok(None); + }; + #[derive(Deserialize)] + struct LegacyConfig { + #[serde(default)] + gateway: LegacyGateway, + } + #[derive(Default, Deserialize)] + #[serde(rename_all = "camelCase")] + struct LegacyGateway { + token_verifier: Option, + } + let value: serde_json::Value = serde_json::from_slice(&bytes.0) + .map_err(|_| AppError::configuration("invalid legacy proxy configuration"))?; + if !value.is_object() + || value + .get("gateway") + .is_some_and(|gateway| !gateway.is_object()) + { + return Err(AppError::configuration( + "invalid legacy proxy configuration", + )); + } + let value: LegacyConfig = serde_json::from_value(value) + .map_err(|_| AppError::configuration("invalid legacy proxy configuration"))?; + match value.gateway.token_verifier { + None => Ok(None), + Some(verifier) if crate::http_server::auth::valid_verifier(&verifier) => Ok(Some(verifier)), + _ => Err(AppError::configuration( + "invalid legacy HTTP authentication verifier", + )), + } +} + +fn read_config(path: &Path) -> Result, AppError> { #[cfg(not(windows))] { let bytes = crate::private_file::read(path, MAX_CONFIG_PAYLOAD_BYTES).map_err(|e| { AppError::configuration(format!("failed to read proxy configuration: {:?}", e)) })?; let Some(bytes) = bytes else { - return Ok(ProxyConfig::default()); + return Ok(None); }; - let bytes = SecretBytes(bytes); - let config: ProxyConfig = serde_json::from_slice(&bytes.0) - .map_err(|e| AppError::configuration(format!("invalid proxy configuration: {e}")))?; - validate_config(&config)?; - Ok(config) + Ok(Some(SecretBytes(bytes))) } #[cfg(windows)] @@ -247,7 +281,7 @@ pub fn load_config(path: &Path) -> Result { AppError::configuration(format!("failed to read proxy configuration: {:?}", e)) })?; let Some(bytes) = bytes else { - return Ok(ProxyConfig::default()); + return Ok(None); }; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -256,7 +290,7 @@ pub fn load_config(path: &Path) -> Result { encrypted_payload: Vec, } let envelope: WindowsEnvelope = serde_json::from_slice(&bytes) - .map_err(|e| AppError::configuration(format!("invalid envelope: {e}")))?; + .map_err(|_| AppError::configuration("invalid proxy configuration envelope"))?; if envelope.version != 1 { return Err(AppError::configuration("unsupported envelope version")); } @@ -268,10 +302,7 @@ pub fn load_config(path: &Path) -> Result { "decrypted proxy configuration exceeds maximum payload limit", )); } - let config: ProxyConfig = serde_json::from_slice(&decrypted.0) - .map_err(|e| AppError::configuration(format!("invalid proxy configuration: {e}")))?; - validate_config(&config)?; - Ok(config) + Ok(Some(decrypted)) } } diff --git a/src-tauri/src/mcp_proxy/http.rs b/src-tauri/src/mcp_proxy/http.rs index 171ebe5..bdafc1e 100644 --- a/src-tauri/src/mcp_proxy/http.rs +++ b/src-tauri/src/mcp_proxy/http.rs @@ -2,7 +2,7 @@ use std::time::Duration; use axum::body::Body; use axum::extract::{Extension, Request, State}; -use axum::http::{HeaderMap, Method, StatusCode}; +use axum::http::{Method, StatusCode}; use axum::response::{IntoResponse, Response}; use futures::StreamExt; use rmcp::model::*; @@ -26,84 +26,16 @@ pub struct HttpState { pub shutdown_token: CancellationToken, pub proxy_token: CancellationToken, pub events: Option>, + pub admission: std::sync::Arc>, } pub fn create_router(proxy_state: ProxyService) -> axum::Router { axum::Router::new() - .route("/mcp", axum::routing::any(handle_mcp)) + .route("/mcp-proxy", axum::routing::any(handle_mcp)) .layer(axum::extract::DefaultBodyLimit::max(MAX_BODY_BYTES)) .with_state(proxy_state) } -fn validate_host(headers: &HeaderMap, allow_lan: bool, gateway_port: u16) -> bool { - let Some(host_val) = headers.get("host").and_then(|h| h.to_str().ok()) else { - return false; - }; - let host_part = if let Some(stripped) = host_val.strip_prefix('[') { - if let Some(end) = stripped.find(']') { - &stripped[..end] - } else { - return false; - } - } else if let Some((h, port_str)) = host_val.split_once(':') { - if let Ok(p) = port_str.parse::() { - if p != gateway_port { - return false; - } - } - h - } else { - host_val - }; - - if host_part == "127.0.0.1" || host_part == "localhost" || host_part == "::1" { - return true; - } - - if allow_lan { - if host_part == "0.0.0.0" { - return true; - } - if host_part.parse::().is_ok() { - return true; - } - } - - false -} - -fn validate_origin(headers: &HeaderMap, gateway_port: u16) -> Result<(), StatusCode> { - let Some(origin_val) = headers.get("origin").and_then(|h| h.to_str().ok()) else { - return Ok(()); - }; - if origin_val == "null" { - return Err(StatusCode::FORBIDDEN); - } - if origin_val == format!("http://127.0.0.1:{gateway_port}") - || origin_val == format!("http://localhost:{gateway_port}") - || origin_val == format!("http://[::1]:{gateway_port}") - { - return Ok(()); - } - Err(StatusCode::FORBIDDEN) -} - -fn validate_token(headers: &HeaderMap, token_verifier: Option<&str>) -> Result<(), StatusCode> { - let Some(verifier) = token_verifier else { - return Err(StatusCode::SERVICE_UNAVAILABLE); - }; - let Some(auth_val) = headers.get("authorization").and_then(|h| h.to_str().ok()) else { - return Err(StatusCode::UNAUTHORIZED); - }; - let Some(token) = auth_val.strip_prefix("Bearer ") else { - return Err(StatusCode::UNAUTHORIZED); - }; - if !crate::mcp_proxy::auth::verify_token(token.trim(), verifier) { - return Err(StatusCode::UNAUTHORIZED); - } - Ok(()) -} - fn extract_server_id(query_str: &str) -> Result { let mut server_id: Option = None; for pair in query_str.split('&') { @@ -158,6 +90,7 @@ async fn handle_mcp( shutdown_token: http.shutdown_token, proxy_token, events: proxy.events.clone(), + admission: proxy.admission.clone(), }; if state.shutdown_token.is_cancelled() || state.proxy_token.is_cancelled() { return StatusCode::SERVICE_UNAVAILABLE.into_response(); @@ -171,18 +104,6 @@ async fn handle_mcp( return StatusCode::SERVICE_UNAVAILABLE.into_response(); } - if !validate_host(req.headers(), http.settings.allow_lan, http.settings.port) { - return StatusCode::BAD_REQUEST.into_response(); - } - - if let Err(status) = validate_origin(req.headers(), http.settings.port) { - return status.into_response(); - } - - if let Err(status) = validate_token(req.headers(), gateway.token_verifier.as_deref()) { - return status.into_response(); - } - let query_str = req.uri().query().unwrap_or(""); let server_id = match extract_server_id(query_str) { Ok(id) => id, @@ -256,7 +177,14 @@ async fn handle_legacy( let session = match state .legacy_sessions - .create_session(&service, permit, idle_timeout_secs) + .create_session( + &service, + permit, + idle_timeout_secs, + state.admission.clone(), + state.shutdown_token.clone(), + state.proxy_token.clone(), + ) .await { Ok(s) => s, @@ -456,7 +384,14 @@ async fn handle_modern(state: HttpState, service: ProxyServiceConfig, req: Reque return StatusCode::TOO_MANY_REQUESTS.into_response(); } - let bridge = ModernBridge::new(service, state.budget, state.events); + let bridge = ModernBridge::new( + service, + state.budget, + state.events, + state.admission, + state.shutdown_token.clone(), + state.proxy_token.clone(), + ); let low_bridge = LowLevelBridge(bridge); let (transport, receiver) = OneshotTransport::::new(message); diff --git a/src-tauri/src/mcp_proxy/mod.rs b/src-tauri/src/mcp_proxy/mod.rs index 1bb6eb3..cdc3bfa 100644 --- a/src-tauri/src/mcp_proxy/mod.rs +++ b/src-tauri/src/mcp_proxy/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod auth; pub(crate) mod config; pub(crate) mod http; pub(crate) mod process; @@ -29,6 +28,8 @@ pub struct ProxyService { budget: OwnerBudget, legacy_sessions: LegacySessionManager, shutdown_token: Arc>, + stopped: CancellationToken, + admission: Arc>, generation: Arc, events: Option>, #[cfg(test)] @@ -56,12 +57,15 @@ impl ProxyService { fn from_config(config_path: PathBuf, config: ProxyConfig) -> Self { let max_units = config.gateway.max_units; + let stopped = CancellationToken::new(); Self { config_path, config: Arc::new(RwLock::new(config)), budget: OwnerBudget::new(max_units), legacy_sessions: LegacySessionManager::new(), - shutdown_token: Arc::new(Mutex::new(CancellationToken::new())), + shutdown_token: Arc::new(Mutex::new(stopped.child_token())), + stopped, + admission: Arc::new(RwLock::new(())), generation: Arc::new(AtomicU64::new(1)), events: None, #[cfg(test)] @@ -97,55 +101,37 @@ impl ProxyService { self.shutdown_token.lock().await.clone() } - pub async fn rotate_key(&self) -> Result { - let service = self.clone(); - tokio::spawn(async move { - let result = service.rotate_key_owned().await; - if let Err(error) = &result { - eprintln!("warning: MCP proxy key rotation failed: {error}"); - } - result - }) - .await - .map_err(|error| { - AppError::configuration(format!("MCP proxy key rotation task failed: {error}")) - })? - } - - async fn rotate_key_owned(&self) -> Result { - let key = auth::generate_key(); - let mut config_guard = self.config.write().await; - let mut updated = config_guard.clone(); - updated.gateway.token_verifier = Some(key.verifier); - updated.revision = updated.revision.saturating_add(1); - - let path = self.config_path.clone(); - #[cfg(test)] - let publish_hook = self.publish_hook.clone(); - let updated = tokio::task::spawn_blocking(move || { - #[cfg(test)] - if let Some(hook) = publish_hook { - hook(); - } - config::save_config(&path, &updated)?; - Ok::<_, AppError>(updated) - }) - .await - .map_err(|error| { - AppError::configuration(format!("save MCP proxy key task failed: {error}")) - })??; + pub(crate) async fn revoke_http_requests(&self) -> Result<(), AppError> { + let config = self.config.write().await; let mut token = self.shutdown_token.lock().await; token.cancel(); - *token = CancellationToken::new(); - *config_guard = updated; self.generation.fetch_add(1, Ordering::SeqCst); - drop(token); + drop(config); - if let Err(error) = self.legacy_sessions.close_all().await { - eprintln!("warning: MCP proxy key saved; session cleanup failed: {error}"); + // Keep fresh request snapshots blocked until previous owners and queued permits drain. + let result = tokio::time::timeout(Duration::from_secs(8), async { + let admission = self.admission.write().await; + let cleanup = self.legacy_sessions.close_all().await; + drop(admission); + cleanup?; + if self.budget.cleanup_failed() { + return Err(AppError::configuration( + "MCP process cleanup failed; outcome uncertain", + )); + } + self.budget.wait_idle().await; + Ok(()) + }) + .await + .map_err(|_| AppError::configuration("timed out revoking MCP HTTP requests")) + .and_then(|result| result); + if result.is_err() { + self.stopped.cancel(); + self.budget.record_cleanup_failure(); + } else if !self.stopped.is_cancelled() { + *token = self.stopped.child_token(); } - - Ok(key.raw_token) + result } pub async fn save_gateway_settings( @@ -224,7 +210,7 @@ impl ProxyService { if disabling { let mut token = self.shutdown_token.lock().await; token.cancel(); - *token = CancellationToken::new(); + *token = self.stopped.child_token(); } *config_guard = new_config; self.generation.fetch_add(1, Ordering::SeqCst); @@ -248,6 +234,7 @@ impl ProxyService { } pub async fn shutdown(&self, deadline: Duration) -> Result<(), AppError> { + self.stopped.cancel(); let service = self.clone(); tokio::spawn(async move { service.shutdown_owned(deadline).await }) .await @@ -257,10 +244,11 @@ impl ProxyService { } async fn shutdown_owned(&self, deadline: Duration) -> Result<(), AppError> { - self.shutdown_token.lock().await.cancel(); let mut cleanup = None; let wait = tokio::time::timeout(deadline, async { + let admission = self.admission.write().await; cleanup = Some(self.legacy_sessions.close_all().await); + drop(admission); self.budget.wait_idle().await; }) .await diff --git a/src-tauri/src/mcp_proxy/session.rs b/src-tauri/src/mcp_proxy/session.rs index d892d7c..f7676b5 100644 --- a/src-tauri/src/mcp_proxy/session.rs +++ b/src-tauri/src/mcp_proxy/session.rs @@ -192,13 +192,24 @@ impl LegacySessionManager { service: &ProxyServiceConfig, permit: OwnerPermit, idle_timeout_secs: u64, + admission: Arc>, + http_token: CancellationToken, + proxy_token: CancellationToken, ) -> Result, AppError> { let manager = self.clone(); let service = service.clone(); tokio::spawn(async move { + let _admission = admission.read().await; + if http_token.is_cancelled() || proxy_token.is_cancelled() { + return Err(AppError::configuration("MOM HTTP request was revoked")); + } let session = manager .create_session_owned(&service, permit, idle_timeout_secs) .await?; + if http_token.is_cancelled() || proxy_token.is_cancelled() { + manager.close_session(&session.id).await?; + return Err(AppError::configuration("MOM HTTP request was revoked")); + } Ok::<_, AppError>(manager.pending_session(session)) }) .await @@ -508,6 +519,9 @@ pub struct ModernBridge { pub service: ProxyServiceConfig, pub budget: OwnerBudget, pub events: Option>, + admission: Arc>, + http_token: CancellationToken, + proxy_token: CancellationToken, } impl ModernBridge { @@ -515,11 +529,17 @@ impl ModernBridge { service: ProxyServiceConfig, budget: OwnerBudget, events: Option>, + admission: Arc>, + http_token: CancellationToken, + proxy_token: CancellationToken, ) -> Self { Self { service, budget, events, + admission, + http_token, + proxy_token, } } @@ -540,7 +560,11 @@ impl ModernBridge { request: ClientJsonRpcMessage, context: RequestContext, ) -> Result { - if context.ct.is_cancelled() { + let _admission = self.admission.read().await; + if context.ct.is_cancelled() + || self.http_token.is_cancelled() + || self.proxy_token.is_cancelled() + { return Err(ErrorData::internal_error("request cancelled", None)); } let permit = self @@ -603,22 +627,43 @@ impl ModernBridge { let mut process_transport = AsyncRwTransport::new_client(stdout, stdin).with_max_line_length(8 * 1024 * 1024); - let result = match process_transport.send(request).await { - Err(error) => Err(ErrorData::internal_error( - format!("failed to send request to process: {error}"), - None, - )), + let cancelled = async { + tokio::select! { + _ = context.ct.cancelled() => {}, + _ = self.http_token.cancelled() => {}, + _ = self.proxy_token.cancelled() => {}, + } + }; + tokio::pin!(cancelled); + let sent = tokio::select! { + biased; + _ = &mut cancelled => Err(ErrorData::internal_error("request cancelled; outcome uncertain", None)), + sent = tokio::time::timeout( + Duration::from_secs(self.service.timeouts.execution_secs), + process_transport.send(request), + ) => match sent { + Ok(result) => result.map_err(|error| ErrorData::internal_error( + format!("failed to send request to process: {error}"), + None, + )), + Err(_) => Err(ErrorData::internal_error("timed out sending request to process; outcome uncertain", None)), + }, + }; + let result = match sent { + Err(error) => Err(error), Ok(()) => loop { tokio::select! { biased; - _ = context.ct.cancelled() => { + _ = &mut cancelled => { let cancel_msg = ClientJsonRpcMessage::notification( ClientNotification::CancelledNotification(CancelledNotification::new( CancelledNotificationParam::new(Some(context.id.clone()), None), )), ); - let _ = process_transport.send(cancel_msg).await; let seen = tokio::time::timeout(Duration::from_secs(1), async { + if process_transport.send(cancel_msg).await.is_err() { + return false; + } loop { let reply = process_transport.receive().await; let Some(msg) = reply else { break false; }; diff --git a/src-tauri/src/mcp_proxy/test_peer.rs b/src-tauri/src/mcp_proxy/test_peer.rs index 949757c..9f8ab71 100644 --- a/src-tauri/src/mcp_proxy/test_peer.rs +++ b/src-tauri/src/mcp_proxy/test_peer.rs @@ -138,6 +138,10 @@ async fn main() { let mut args = std::env::args().skip(1); let mode = args.next().expect("mode"); let tag = args.next().expect("tag"); + if mode == "unread" { + std::future::pending::<()>().await; + return; + } let peer = FakePeer { mode, tag }; let server = peer .serve((tokio::io::stdin(), tokio::io::stdout())) diff --git a/src-tauri/src/mcp_proxy/tests.rs b/src-tauri/src/mcp_proxy/tests.rs index 633bfdc..046b3b5 100644 --- a/src-tauri/src/mcp_proxy/tests.rs +++ b/src-tauri/src/mcp_proxy/tests.rs @@ -10,8 +10,7 @@ use serde_json::{json, Value}; use tokio::net::TcpListener; use tokio::sync::mpsc; -use crate::http_server::{HttpServer, MomHttpSettings}; -use crate::mcp_proxy::auth; +use crate::http_server::{auth, HttpServer, MomHttpSettings}; use crate::mcp_proxy::config::{ save_config, GatewaySettings, ProxyConfig, ProxyProtocol, ProxyServiceConfig, ServiceTimeouts, }; @@ -55,8 +54,6 @@ impl TestServer { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("mcp-proxy.json"); - let key = auth::generate_key(); - let port = unused_port().await; let peer_bin = test_peer_binary(); @@ -101,7 +98,6 @@ impl TestServer { enabled: true, idle_timeout_secs: 300, max_units, - token_verifier: Some(key.verifier.clone()), }, services, }; @@ -116,6 +112,7 @@ impl TestServer { let legacy_sessions = proxy.legacy_sessions().clone(); let http = HttpServer::new_with_path(temp.path().join("mom-http.json"), proxy.router()).unwrap(); + let raw_token = http.rotate_key(async { Ok(()) }).await.unwrap(); http.set_settings(MomHttpSettings { port, allow_lan: false, @@ -124,8 +121,8 @@ impl TestServer { .unwrap(); Self { - url: format!("http://127.0.0.1:{port}/mcp"), - raw_token: key.raw_token, + url: format!("http://127.0.0.1:{port}/mcp-proxy"), + raw_token, budget, legacy_sessions, events: rx, @@ -194,13 +191,11 @@ async fn mom_http_settings_are_independent_persisted_and_failed_updates_restore_ .send() .await .unwrap() - .text() - .await - .unwrap(), - "ok" + .status(), + reqwest::StatusCode::SERVICE_UNAVAILABLE ); assert_eq!(http.start().await.unwrap(), addr); - let url = format!("http://{addr}/mcp?server=missing"); + let url = format!("http://{addr}/mcp-proxy?server=missing"); assert_eq!( http_client().post(&url).send().await.unwrap().status(), reqwest::StatusCode::SERVICE_UNAVAILABLE @@ -217,7 +212,19 @@ async fn mom_http_settings_are_independent_persisted_and_failed_updates_restore_ http_client().post(&url).send().await.unwrap().status(), reqwest::StatusCode::SERVICE_UNAVAILABLE ); - proxy.rotate_key().await.unwrap(); + let token = http.rotate_key(async { Ok(()) }).await.unwrap(); + assert_eq!( + http_client() + .get(format!("http://{addr}/health")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .text() + .await + .unwrap(), + "ok" + ); assert_eq!( http_client().post(&url).send().await.unwrap().status(), reqwest::StatusCode::UNAUTHORIZED @@ -254,6 +261,7 @@ async fn mom_http_settings_are_independent_persisted_and_failed_updates_restore_ assert!(http.bound_addr().await.unwrap().ip().is_unspecified()); let reloaded = HttpServer::new_with_path(path.clone(), proxy.router()).unwrap(); assert_eq!(reloaded.settings().await, next); + assert!(reloaded.auth_ready()); assert!( !serde_json::to_value(proxy.config().await).unwrap()["gateway"] .as_object() @@ -305,7 +313,13 @@ async fn disabling_proxy_does_not_stop_shared_http_listener() { .unwrap() .unwrap(); assert_eq!( - client.post(&url).send().await.unwrap().status(), + client + .post(&url) + .bearer_auth(&server.raw_token) + .send() + .await + .unwrap() + .status(), reqwest::StatusCode::SERVICE_UNAVAILABLE ); let mut config = server.proxy.config().await; @@ -345,10 +359,15 @@ async fn rotation_cancels_old_http_epoch_and_rejects_old_key() { .unwrap(); let epoch = server.proxy.request_token().await; let bound = server.http.bound_addr().await; - let new_key = server.proxy.rotate_key().await.unwrap(); + let config = server.proxy.config().await; + let new_key = + crate::commands::app::rotate_http_key(server.http.clone(), server.proxy.as_ref().clone()) + .await + .unwrap(); assert!(epoch.is_cancelled()); assert!(!server.proxy.request_token().await.is_cancelled()); assert_eq!(server.http.bound_addr().await, bound); + assert_eq!(server.proxy.config().await, config); assert_eq!(server.legacy_sessions.active_count().await, 0); assert_eq!(server.budget.available_permits(), 2); tokio::time::timeout(DEADLINE, stream.bytes()) @@ -379,6 +398,116 @@ async fn rotation_cancels_old_http_epoch_and_rejects_old_key() { server.close().await; } +#[tokio::test] +async fn unchanged_http_settings_preserve_proxy_epoch_and_live_session() { + let server = TestServer::start(1).await; + let session = server.legacy_session().await; + let settings = server.http.settings().await; + let addr = server.http.bound_addr().await; + let epoch = server.proxy.request_token().await; + let generation = server.proxy.generation(); + crate::commands::app::update_http_settings( + server.http.clone(), + server.proxy.as_ref().clone(), + settings, + ) + .await + .unwrap(); + assert!(!epoch.is_cancelled()); + assert!(!session.owner.is_closed()); + assert_eq!(server.proxy.generation(), generation); + assert_eq!(server.http.bound_addr().await, addr); + assert_eq!(server.legacy_sessions.active_count().await, 1); + assert_eq!(server.budget.available_permits(), 0); + server.close().await; +} + +#[tokio::test(flavor = "current_thread")] +async fn rotation_cancels_legacy_initialize_waiting_for_admission_without_leaking_session() { + let server = TestServer::start(1).await; + let admission = server.proxy.admission.write().await; + let request = http_client() + .post(format!("{}?server=legacy-svc", server.url)) + .bearer_auth(&server.raw_token) + .json(&initialize(LEGACY)) + .timeout(DEADLINE); + let initialize = tokio::spawn(async move { request.send().await }); + tokio::time::timeout(DEADLINE, async { + while server.budget.available_permits() != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("legacy initialize must reach the owned creation await"); + let rotation = tokio::spawn(crate::commands::app::rotate_http_key( + server.http.clone(), + server.proxy.as_ref().clone(), + )); + let response = tokio::time::timeout(DEADLINE, initialize) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); + drop(admission); + let key = tokio::time::timeout(DEADLINE, rotation) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(key.len(), 64); + assert_eq!(server.legacy_sessions.active_count().await, 0); + assert_eq!(server.budget.available_permits(), 1); + assert!(server.proxy.admission.try_write().is_ok()); + assert!(!server.proxy.cleanup_failed()); + server.close().await; +} + +#[tokio::test(flavor = "current_thread")] +async fn revocation_drains_queued_legacy_creation_before_publishing_request_epoch() { + for shutdown in [false, true] { + let server = TestServer::start(1).await; + let service = server.proxy.config().await.services[0].clone(); + let epoch = server.proxy.request_token().await; + let admission = server.proxy.admission.write().await; + let mut creation = Box::pin(server.legacy_sessions.create_session( + &service, + server.budget.try_acquire().unwrap(), + 300, + server.proxy.admission.clone(), + tokio_util::sync::CancellationToken::new(), + epoch.clone(), + )); + assert!(futures::poll!(&mut creation).is_pending()); + tokio::task::yield_now().await; + let mut revocation = Box::pin(server.proxy.revoke_http_requests()); + assert!(futures::poll!(&mut revocation).is_pending()); + assert!(epoch.is_cancelled()); + let mut next_epoch = Box::pin(server.proxy.request_token()); + assert!(futures::poll!(&mut next_epoch).is_pending()); + if shutdown { + let mut stop = Box::pin(server.proxy.shutdown(DEADLINE)); + assert!(futures::poll!(&mut stop).is_pending()); + drop(admission); + let (created, revoked, stopped) = tokio::join!(creation, revocation, stop); + assert!(created.is_err()); + revoked.unwrap(); + stopped.unwrap(); + } else { + drop(admission); + let (created, revoked) = tokio::join!(creation, revocation); + assert!(created.is_err()); + revoked.unwrap(); + } + assert_eq!(next_epoch.await.is_cancelled(), shutdown); + assert_eq!(server.legacy_sessions.active_count().await, 0); + assert_eq!(server.budget.available_permits(), 1); + assert!(server.proxy.admission.try_write().is_ok()); + assert!(!server.proxy.cleanup_failed()); + server.close().await; + } +} + fn http_client() -> reqwest::Client { reqwest::Client::builder() .pool_max_idle_per_host(0) @@ -452,6 +581,64 @@ async fn until_result(response: &mut SseReader) -> Value { // Test 1: Inbound Pipeline Security Gates (Zero Process Spawns) // ----------------------------------------------------------------------------- +#[tokio::test] +async fn external_proxy_path_requires_auth_and_old_mcp_path_is_not_found() { + let mut server = TestServer::start(4).await; + let client = http_client(); + let addr = server.http.bound_addr().await.unwrap(); + + for (path, expected) in [ + ("/mcp-proxy", reqwest::StatusCode::UNAUTHORIZED), + ( + "/mcp-proxy?server=legacy-svc", + reqwest::StatusCode::UNAUTHORIZED, + ), + ("/mcp", reqwest::StatusCode::NOT_FOUND), + ("/mcp?server=legacy-svc", reqwest::StatusCode::NOT_FOUND), + ] { + for method in [ + reqwest::Method::POST, + reqwest::Method::GET, + reqwest::Method::DELETE, + ] { + let response = client + .request(method.clone(), format!("http://{addr}{path}")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), expected, "{method} {path}"); + } + } + + for path in ["/mcp", "/mcp?server=legacy-svc"] { + for method in [ + reqwest::Method::GET, + reqwest::Method::POST, + reqwest::Method::DELETE, + ] { + let response = client + .request(method.clone(), format!("http://{addr}{path}")) + .bearer_auth(&server.raw_token) + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + reqwest::StatusCode::NOT_FOUND, + "{method} {path}" + ); + } + } + assert_eq!(server.budget.available_permits(), 4); + assert!( + tokio::time::timeout(Duration::from_millis(50), server.events.recv()) + .await + .is_err() + ); + + server.close().await; +} + #[tokio::test] async fn unauthenticated_request_rejected_401_zero_spawns() { let mut server = TestServer::start(4).await; @@ -823,6 +1010,96 @@ async fn modern_fresh_process_per_request_and_cancellation_reaps_child() { server.close().await; } +#[tokio::test] +async fn modern_unread_stdin_cancellation_and_timeout_reap_owner_and_release_admission() { + for action in ["disconnect", "rotate", "shutdown", "timeout"] { + let mut server = TestServer::start(1).await; + let mut config = server.proxy.config().await; + let revision = config.revision; + let service = config + .services + .iter_mut() + .find(|service| service.id == "modern-svc") + .unwrap(); + service.command[1] = "unread".into(); + service.timeouts.execution_secs = if action == "timeout" { 1 } else { 30 }; + server.proxy.save_config(config, revision).await.unwrap(); + + let mut wire = call(17, "wait", Some(metadata("unread-client"))); + wire["params"]["arguments"] = json!({"payload": "x".repeat(4 * 1024 * 1024)}); + let response = http_client() + .post(format!("{}?server=modern-svc", server.url)) + .bearer_auth(&server.raw_token) + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", MODERN) + .header("Mcp-Method", "tools/call") + .json(&wire) + .timeout(DEADLINE) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let pid = match server.event().await { + Observation::Started(pid) => pid, + other => panic!("expected Started, got {other:?}"), + }; + assert_eq!(server.budget.available_permits(), 0); + assert!(server.proxy.admission.try_write().is_err()); + + match action { + "disconnect" => drop(response), + "rotate" => { + let key = tokio::time::timeout( + Duration::from_secs(4), + crate::commands::app::rotate_http_key( + server.http.clone(), + server.proxy.as_ref().clone(), + ), + ) + .await + .expect("rotation must not wait for a blocked stdin write") + .unwrap(); + assert_eq!(key.len(), 64); + drop(response); + } + "shutdown" => { + let (http, proxy) = tokio::join!( + server.http.shutdown(Duration::from_secs(4)), + server.proxy.shutdown(Duration::from_secs(4)), + ); + http.unwrap(); + proxy.unwrap(); + drop(response); + } + "timeout" => { + let mut reader: SseReader = Box::pin(sse_stream::SseStream::from_bytes_stream( + response.bytes_stream(), + )); + let result = until_result(&mut reader).await; + assert!(result["error"]["message"] + .as_str() + .unwrap() + .contains("timed out sending request")); + } + _ => unreachable!(), + } + assert!(matches!(server.event().await, Observation::Closed(closed) if closed == pid)); + assert_eq!(server.budget.available_permits(), 1, "{action}"); + assert!(server.proxy.admission.try_write().is_ok(), "{action}"); + assert!(!server.proxy.cleanup_failed(), "{action}"); + #[cfg(unix)] + { + // SAFETY: signal zero only checks the test child's PID after owner cleanup. + assert_eq!(unsafe { libc::kill(pid as libc::pid_t, 0) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); + } + server.close().await; + } +} + #[tokio::test] async fn modern_mrtr_stateless_fresh_processes() { let mut server = TestServer::start(4).await; @@ -932,7 +1209,6 @@ fn config_persistence_validation_and_permissions() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("mcp-proxy.json"); - let key = auth::generate_key(); let config = ProxyConfig { schema_version: 1, revision: 1, @@ -940,7 +1216,6 @@ fn config_persistence_validation_and_permissions() { enabled: true, idle_timeout_secs: 180, max_units: 8, - token_verifier: Some(key.verifier), }, services: vec![ProxyServiceConfig { id: "test-svc".into(), @@ -1018,11 +1293,10 @@ fn config_temporary_plaintext_is_wiped_on_parse_and_publication_failures() { // ----------------------------------------------------------------------------- #[tokio::test] -async fn proxy_service_rotate_key_invalidates_sessions_and_shutdown() { +async fn shared_http_key_rotation_persists_verifier_without_changing_proxy_revision() { let temp = tempfile::tempdir().unwrap(); let config_path = temp.path().join("mcp-proxy.json"); - let initial_key = auth::generate_key(); let config = ProxyConfig { schema_version: 1, revision: 1, @@ -1030,48 +1304,145 @@ async fn proxy_service_rotate_key_invalidates_sessions_and_shutdown() { enabled: true, idle_timeout_secs: 300, max_units: 4, - token_verifier: Some(initial_key.verifier), }, services: vec![], }; save_config(&config_path, &config).unwrap(); let service = ProxyService::new_with_path(config_path.clone()).unwrap(); + let http_path = temp.path().join("mom-http.json"); + let http = HttpServer::new_with_path(http_path.clone(), service.router()).unwrap(); + assert!(!http.auth_ready()); assert_eq!(service.generation(), 1); - // Rotate key - let new_token = service.rotate_key().await.unwrap(); + let new_token = crate::commands::app::rotate_http_key(http.clone(), service.clone()) + .await + .unwrap(); assert_eq!(new_token.len(), 64); assert_eq!(service.generation(), 2); + assert!(http.auth_ready()); let updated_config = service.config().await; - assert_eq!(updated_config.revision, 2); - let verifier = updated_config.gateway.token_verifier.unwrap(); - assert!(auth::verify_token(&new_token, &verifier)); + assert_eq!(updated_config, config); + let persisted: Value = serde_json::from_slice(&std::fs::read(&http_path).unwrap()).unwrap(); + let verifier = persisted["auth"]["tokenVerifier"].as_str().unwrap(); + assert!(auth::verify_token(&new_token, verifier)); + assert!(!persisted.to_string().contains(&new_token)); + assert!( + HttpServer::new_with_path(http_path.clone(), service.router()) + .unwrap() + .auth_ready() + ); + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(std::fs::metadata(&http_path).unwrap().mode() & 0o777, 0o600); + } - // Verify on-disk persistence of rotated verifier let on_disk = crate::mcp_proxy::config::load_config(&config_path).unwrap(); - assert_eq!(on_disk.revision, 2); - assert_eq!(on_disk.gateway.token_verifier.unwrap(), verifier); + assert_eq!(on_disk, config); + let proxy_wire = serde_json::to_value(on_disk).unwrap(); + assert!(!proxy_wire["gateway"] + .as_object() + .unwrap() + .contains_key("tokenVerifier")); // Shutdown completes within bounded deadline service.shutdown(Duration::from_secs(2)).await.unwrap(); + http.shutdown(Duration::from_secs(2)).await.unwrap(); +} + +#[tokio::test] +async fn cancelled_shared_rotation_waiter_still_commits_and_revokes_sessions() { + let server = TestServer::start(1).await; + let session = server.legacy_session().await; + let before = server.proxy.config().await; + let generation = server.proxy.generation(); + let epoch = server.proxy.request_token().await; + let path = server._temp.path().join("mom-http.json"); + let guard = server.proxy.config.write().await; + let http = server.http.clone(); + let proxy = server.proxy.as_ref().clone(); + let waiter = tokio::spawn(crate::commands::app::rotate_http_key(http, proxy)); + tokio::time::timeout(DEADLINE, async { + loop { + let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + if !auth::verify_token( + &server.raw_token, + persisted["auth"]["tokenVerifier"].as_str().unwrap(), + ) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("shared HTTP rotation must persist before proxy revocation"); + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + drop(guard); + tokio::time::timeout(DEADLINE, async { + while !epoch.is_cancelled() || !session.owner.is_closed() { + tokio::task::yield_now().await; + } + }) + .await + .expect("owned key rotation must outlive its waiter"); + assert_eq!(server.proxy.config().await, before); + assert_eq!(server.proxy.generation(), generation + 1); + assert_eq!(server.legacy_sessions.active_count().await, 0); + assert_eq!(server.budget.available_permits(), 1); + assert_eq!( + http_client() + .get(format!("{}?server=legacy-svc", server.url)) + .bearer_auth(&server.raw_token) + .send() + .await + .unwrap() + .status(), + reqwest::StatusCode::UNAUTHORIZED + ); + server.close().await; } #[tokio::test] async fn failed_rotation_preserves_config_and_revision() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("mcp-proxy.json"); - let service = ProxyService::new_with_path(path.clone()).unwrap(); - service.rotate_key().await.unwrap(); - let before = service.config().await; - let generation = service.generation(); + let server = TestServer::start(1).await; + let session = server.legacy_session().await; + let before = server.proxy.config().await; + let generation = server.proxy.generation(); + let epoch = server.proxy.request_token().await; + let path = server._temp.path().join("mom-http.json"); + let persisted = std::fs::read(&path).unwrap(); std::fs::remove_file(&path).unwrap(); std::fs::create_dir(&path).unwrap(); - assert!(service.rotate_key().await.is_err()); - assert_eq!(service.config().await, before); - assert_eq!(service.generation(), generation); + assert!(crate::commands::app::rotate_http_key( + server.http.clone(), + server.proxy.as_ref().clone(), + ) + .await + .is_err()); + assert_eq!(server.proxy.config().await, before); + assert_eq!(server.proxy.generation(), generation); + assert!(!epoch.is_cancelled()); + assert!(!session.owner.is_closed()); + assert_eq!(server.legacy_sessions.active_count().await, 1); + assert_eq!(server.budget.available_permits(), 0); + assert_eq!( + http_client() + .get(format!("{}?server=missing", server.url)) + .bearer_auth(&server.raw_token) + .send() + .await + .unwrap() + .status(), + reqwest::StatusCode::NOT_FOUND + ); + std::fs::remove_dir(&path).unwrap(); + crate::private_file::publish(&path, &persisted).unwrap(); + server.close().await; } #[tokio::test(flavor = "current_thread")] @@ -1079,9 +1450,12 @@ async fn cancelled_publication_waiter_still_commits_disk_and_memory() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("mcp-proxy.json"); let service = ProxyService::new_with_path(path.clone()).unwrap(); + let mut config = service.config().await; + let revision = config.revision; + config.gateway.max_units = 3; let guard = service.config.write().await; let publisher = service.clone(); - let waiter = tokio::spawn(async move { publisher.rotate_key().await }); + let waiter = tokio::spawn(async move { publisher.save_config(config, revision).await }); tokio::task::yield_now().await; waiter.abort(); assert!(waiter.await.unwrap_err().is_cancelled()); @@ -1257,7 +1631,11 @@ async fn cancelled_failed_publication_preserves_memory_budget_and_epoch() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("mcp-proxy.json"); let mut service = ProxyService::new_with_path(path.clone()).unwrap(); - service.rotate_key().await.unwrap(); + let config = service.config().await; + service + .save_config(config.clone(), config.revision) + .await + .unwrap(); let before = service.config().await; let generation = service.generation(); let token = service.request_token().await; @@ -1288,7 +1666,7 @@ async fn cancelled_failed_publication_preserves_memory_budget_and_epoch() { #[tokio::test(flavor = "current_thread")] async fn single_worker_publication_offloads_io_and_survives_cancel() { - for rotation in [false, true] { + for settings_only in [false, true] { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("mcp-proxy.json"); let mut service = ProxyService::new_with_path(path.clone()).unwrap(); @@ -1310,8 +1688,10 @@ async fn single_worker_publication_offloads_io_and_survives_cancel() { let revision = updated.revision; updated.gateway.max_units = 3; let waiter = tokio::spawn(async move { - if rotation { - publisher.rotate_key().await.map(|_| ()) + if settings_only { + publisher + .save_gateway_settings(updated.gateway, revision) + .await } else { publisher.save_config(updated, revision).await } @@ -1426,7 +1806,14 @@ async fn failed_legacy_spawn_removes_sdk_session_and_releases_budget() { timeouts: ServiceTimeouts::default(), }; assert!(sessions - .create_session(&service, budget.try_acquire().unwrap(), 300) + .create_session( + &service, + budget.try_acquire().unwrap(), + 300, + Arc::new(tokio::sync::RwLock::new(())), + tokio_util::sync::CancellationToken::new(), + tokio_util::sync::CancellationToken::new(), + ) .await .is_err()); assert_eq!(sessions.active_count().await, 0); @@ -1440,7 +1827,6 @@ async fn shutdown_closes_live_streams_and_sessions_before_waiting_for_listener() let config = ProxyConfig { gateway: GatewaySettings { enabled: true, - token_verifier: Some(auth::generate_key().verifier), max_units: 5, ..GatewaySettings::default() }, @@ -1458,9 +1844,9 @@ async fn shutdown_closes_live_streams_and_sessions_before_waiting_for_listener() }; save_config(&path, &config).unwrap(); let service = Arc::new(ProxyService::new_with_path(path).unwrap()); - let token = service.rotate_key().await.unwrap(); let http = HttpServer::new_with_path(temp.path().join("mom-http.json"), service.router()).unwrap(); + let token = http.rotate_key(async { Ok(()) }).await.unwrap(); http.set_settings(MomHttpSettings { port: unused_port().await, allow_lan: false, @@ -1468,7 +1854,7 @@ async fn shutdown_closes_live_streams_and_sessions_before_waiting_for_listener() .await .unwrap(); let addr = http.bound_addr().await.unwrap(); - let url = format!("http://{addr}/mcp?server=legacy"); + let url = format!("http://{addr}/mcp-proxy?server=legacy"); let client = http_client(); let mut streams = Vec::new(); for _ in 0..4 { @@ -1501,7 +1887,7 @@ async fn shutdown_closes_live_streams_and_sessions_before_waiting_for_listener() .await .unwrap(); let response = client - .post(format!("http://{addr}/mcp?server=modern")) + .post(format!("http://{addr}/mcp-proxy?server=modern")) .bearer_auth(&token) .header("MCP-Protocol-Version", MODERN) .header("Mcp-Method", "tools/call") diff --git a/src/config/__tests__/mcp-proxy-adapter.test.mjs b/src/config/__tests__/mcp-proxy-adapter.test.mjs index 30ed0f3..dcf84ba 100644 --- a/src/config/__tests__/mcp-proxy-adapter.test.mjs +++ b/src/config/__tests__/mcp-proxy-adapter.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createServer } from 'vite'; -test('MCP proxy adapter maps commands and arguments accurately to Tauri IPC', async () => { +test('MOM HTTP and MCP proxy adapter map separate commands and exact payloads to Tauri IPC', async () => { const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); try { const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); @@ -48,7 +48,7 @@ test('MCP proxy adapter maps commands and arguments accurately to Tauri IPC', as if (command === 'save_mcp_proxy_settings') { return undefined; } - if (command === 'rotate_mcp_proxy_key') { + if (command === 'rotate_mom_http_key') { return 'test-secret-token-12345'; } if (command === 'get_mcp_proxy_server') { @@ -113,10 +113,9 @@ test('MCP proxy adapter maps commands and arguments accurately to Tauri IPC', as await adapter.saveMcpProxySettings(settings, 1); assert.deepEqual(calls.at(-1), ['save_mcp_proxy_settings', { settings, expectedRevision: 1 }]); - // 4. rotateMcpProxyKey - const key = await adapter.rotateMcpProxyKey(); + const key = await adapter.rotateMomHttpKey(); assert.equal(key, 'test-secret-token-12345'); - assert.deepEqual(calls.at(-1), ['rotate_mcp_proxy_key', undefined]); + assert.deepEqual(calls.at(-1), ['rotate_mom_http_key', undefined]); // 5. getMcpProxyServer const srv = await adapter.getMcpProxyServer('srv-1'); @@ -219,7 +218,7 @@ test('ConfigStore MCP proxy facades are lazy pass-throughs and do not trigger ru throw err; } }, - rotateMcpProxyKey: async () => 'key-abc', + rotateMomHttpKey: async () => 'key-abc', getMcpProxyServer: async (id) => ({ id, name: 'Srv', @@ -272,9 +271,51 @@ test('ConfigStore MCP proxy facades are lazy pass-throughs and do not trigger ru await assert.rejects(store.saveMcpProxySettings(settings, 1)); assert.equal(store.draftRecovery, null); - const key = await store.rotateMcpProxyKey(); + const key = await store.rotateMomHttpKey(); assert.equal(key, 'key-abc'); } finally { await vite.close(); } }); + +test('MOM HTTP token rotation stays lazy, untracked and secret-local without Proxy reads or global refresh', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + const calls = []; + let finish; + let failure; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const adapter = createTauriAdapter(async (command, args) => { + calls.push([command, args]); + assert.equal(command, 'rotate_mom_http_key'); + if (failure) throw failure; + return pending; + }); + const store = createConfigStore(adapter, false, false); + assert.deepEqual(calls, []); + const before = JSON.stringify(store); + const rotating = store.rotateMomHttpKey(); + assert.deepEqual(calls, [['rotate_mom_http_key', undefined]]); + assert.equal(store.saving, false); + assert.equal(store.loading, false); + assert.equal(store.error, null); + assert.equal(store.notice, null); + assert.equal(store.draftRecovery, null); + finish('one-time-private-http-token'); + assert.equal(await rotating, 'one-time-private-http-token'); + assert.equal(JSON.stringify(store), before); + failure = { code: 'configuration_failed', message: 'private-rotation-failure', detail: 'private-detail' }; + await assert.rejects(store.rotateMomHttpKey(), (cause) => cause === failure); + assert.equal(JSON.stringify(store), before); + assert.deepEqual(calls, [ + ['rotate_mom_http_key', undefined], + ['rotate_mom_http_key', undefined], + ]); + } finally { + await vite.close(); + } +}); diff --git a/src/config/adapter.ts b/src/config/adapter.ts index 7f230a1..a12f06a 100644 --- a/src/config/adapter.ts +++ b/src/config/adapter.ts @@ -146,9 +146,9 @@ export interface CommandAdapter { getMcpProxyState(): Result; getMomHttpSettings(): Result; setMomHttpSettings(settings: MomHttpSettings): Result; + rotateMomHttpKey(): Result; getMcpProxySettings(): Result; saveMcpProxySettings(settings: McpProxySettingsDto, expectedRevision: number): Result; - rotateMcpProxyKey(): Result; getMcpProxyServer(id: string): Result; createMcpProxyServer(server: McpProxyServerDto, expectedRevision: number): Result; updateMcpProxyServer(server: McpProxyServerDto, expectedRevision: number): Result; @@ -249,10 +249,10 @@ export function createTauriAdapter(invoke: TauriInvoke): CommandAdapter { getMcpProxyState: () => invoke('get_mcp_proxy_state'), getMomHttpSettings: () => invoke('get_mom_http_settings'), setMomHttpSettings: (settings) => invoke('set_mom_http_settings', { settings }), + rotateMomHttpKey: () => invoke('rotate_mom_http_key'), getMcpProxySettings: () => invoke('get_mcp_proxy_settings'), saveMcpProxySettings: (settings, expectedRevision) => invoke('save_mcp_proxy_settings', { settings, expectedRevision }), - rotateMcpProxyKey: () => invoke('rotate_mcp_proxy_key'), getMcpProxyServer: (id) => invoke('get_mcp_proxy_server', { id }), createMcpProxyServer: (server, expectedRevision) => invoke('create_mcp_proxy_server', { server, expectedRevision }), updateMcpProxyServer: (server, expectedRevision) => invoke('update_mcp_proxy_server', { server, expectedRevision }), diff --git a/src/config/store.svelte.ts b/src/config/store.svelte.ts index 4a11a26..fe98a0d 100644 --- a/src/config/store.svelte.ts +++ b/src/config/store.svelte.ts @@ -640,15 +640,15 @@ export function createConfigStore(adapter: CommandAdapter, catalogOnRefresh = tr setMomHttpSettings(settings: MomHttpSettings): Promise { return adapter.setMomHttpSettings(settings); }, + rotateMomHttpKey(): Promise { + return adapter.rotateMomHttpKey(); + }, getMcpProxySettings(): Promise { return adapter.getMcpProxySettings(); }, saveMcpProxySettings(settings: McpProxySettingsDto, expectedRevision: number): Promise { return adapter.saveMcpProxySettings(settings, expectedRevision); }, - rotateMcpProxyKey(): Promise { - return adapter.rotateMcpProxyKey(); - }, getMcpProxyServer(id: string): Promise { return adapter.getMcpProxyServer(id); }, diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 828df14..0ecc93e 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1133,22 +1133,32 @@ export const en = { 'settings.tabGeneral': 'General', 'settings.tabMcpProxy': 'MCP Proxy', 'settings.http.title': 'MOM HTTP Service', - 'settings.http.description': 'MOM HTTP listener, also used by MCP Proxy.', + 'settings.http.description': 'MOM HTTP listener and shared access token, also used by MCP Proxy.', 'settings.http.port': 'Port', 'settings.http.portHint': '1–65535; default 18765.', 'settings.http.allowLan': 'Allow LAN', 'settings.http.allowLanHint': 'Listen on 0.0.0.0 instead of loopback 127.0.0.1.', 'settings.http.allowLanWarning': - 'SECURITY WARNING: LAN access exposes MOM HTTP to your local network. Devices with the secret token can execute MCP Proxy commands and tools. Enable only on trusted networks.', + 'SECURITY WARNING: LAN access exposes MOM HTTP to your local network. Devices with the shared MOM HTTP token can access its APIs, including MCP Proxy commands and tools. Enable only on trusted networks.', 'settings.http.statusUnknown': 'Status unknown', 'settings.http.refresh': 'Refresh HTTP Status', 'settings.http.saveSettings': 'Save HTTP Settings', 'settings.http.saveSuccess': 'MOM HTTP settings saved.', + 'settings.http.rotateKey': 'Rotate Secret Token…', + 'settings.http.rotateKeyTitle': 'Rotate MOM HTTP Secret Token', + 'settings.http.rotateKeyDescription': + 'A new shared MOM HTTP token invalidates the old token and immediately terminates active client connections, including MCP sessions. Update your clients.', + 'settings.http.rotateKeyConfirm': 'Generate New Token', + 'settings.http.rotateKeySuccess': 'New Secret Token Generated', + 'settings.http.rotateKeyWarning': 'Shown once. Copy now; unavailable after closing.', + 'settings.http.copyKey': 'Copy Token', + 'settings.http.copied': 'Copied!', + 'settings.http.close': 'Close', 'settings.proxy.title': 'MCP Proxy Gateway', - 'settings.proxy.description': 'MCP Proxy capacity, timeouts, and token.', + 'settings.proxy.description': 'MCP Proxy capacity and timeouts.', 'settings.proxy.enableGateway': 'Enable MCP Gateway', 'settings.proxy.enableGatewayHint': 'Enable MCP Proxy; the MOM HTTP listener stays separate.', - 'settings.proxy.httpHint': 'Port and LAN access: General settings.', + 'settings.proxy.httpHint': 'MOM HTTP port, LAN access, and shared token: General settings.', 'settings.proxy.idleTimeout': 'Session Idle Timeout (seconds)', 'settings.proxy.idleTimeoutHint': 'Reclaim units and sessions after this idle time.', 'settings.proxy.maxUnits': 'Maximum Concurrent Units', @@ -1158,16 +1168,6 @@ export const en = { 'settings.proxy.saveSuccess': 'MCP Proxy settings saved.', 'settings.proxy.saveFailed': 'Failed to save MCP Proxy settings.', 'settings.proxy.loadFailed': 'Failed to load MCP Proxy settings.', - 'settings.proxy.rotateKey': 'Rotate Secret Token…', - 'settings.proxy.rotateKeyTitle': 'Rotate MCP Proxy Secret Token', - 'settings.proxy.rotateKeyDescription': - 'A new token invalidates the old one and immediately ends active client sessions. Update your clients.', - 'settings.proxy.rotateKeyConfirm': 'Generate New Token', - 'settings.proxy.rotateKeySuccess': 'New Secret Token Generated', - 'settings.proxy.rotateKeyWarning': 'Shown once. Copy now; unavailable after closing.', - 'settings.proxy.copyKey': 'Copy Token', - 'settings.proxy.copied': 'Copied!', - 'settings.proxy.close': 'Close', 'settings.proxy.statusRunning': 'Running', 'settings.proxy.statusStopped': 'Stopped', 'settings.proxy.actualState': 'Actual state: {state}', diff --git a/src/i18n/dictionaries/zh.ts b/src/i18n/dictionaries/zh.ts index 9cc46c1..e199192 100644 --- a/src/i18n/dictionaries/zh.ts +++ b/src/i18n/dictionaries/zh.ts @@ -1112,22 +1112,32 @@ export const zh: Record = { 'settings.tabGeneral': '常规设置', 'settings.tabMcpProxy': 'MCP 代理', 'settings.http.title': 'MOM HTTP 服务', - 'settings.http.description': 'MOM HTTP 监听,供 MCP 代理共用。', + 'settings.http.description': 'MOM HTTP 监听与共享访问令牌,供 MCP 代理等服务共用。', 'settings.http.port': '端口', 'settings.http.portHint': '1–65535;默认 18765。', 'settings.http.allowLan': '允许局域网访问', 'settings.http.allowLanHint': '监听 0.0.0.0,而非本机回环地址 127.0.0.1。', 'settings.http.allowLanWarning': - '安全告警:局域网访问会向本地网络公开 MOM HTTP。持有访问令牌的设备可执行 MCP 代理命令和工具。请仅在可信网络中启用。', + '安全告警:局域网访问会向本地网络公开 MOM HTTP。持有共享 MOM HTTP 令牌的设备可访问其 API,包括 MCP 代理命令和工具。请仅在可信网络中启用。', 'settings.http.statusUnknown': '状态未知', 'settings.http.refresh': '刷新 HTTP 状态', 'settings.http.saveSettings': '保存 HTTP 设置', 'settings.http.saveSuccess': 'MOM HTTP 设置已保存。', + 'settings.http.rotateKey': '轮换访问令牌…', + 'settings.http.rotateKeyTitle': '轮换 MOM HTTP 访问令牌', + 'settings.http.rotateKeyDescription': + '新的共享 MOM HTTP 令牌会使旧令牌失效,并立即终止活跃客户端连接(包括 MCP 会话);请更新客户端令牌。', + 'settings.http.rotateKeyConfirm': '生成新令牌', + 'settings.http.rotateKeySuccess': '已生成新访问令牌', + 'settings.http.rotateKeyWarning': '仅显示一次;请立即复制,关闭后无法再查看。', + 'settings.http.copyKey': '复制令牌', + 'settings.http.copied': '已复制!', + 'settings.http.close': '关闭', 'settings.proxy.title': 'MCP 代理网关', - 'settings.proxy.description': 'MCP 代理容量、超时和令牌。', + 'settings.proxy.description': 'MCP 代理容量和超时。', 'settings.proxy.enableGateway': '启用 MCP 网关', 'settings.proxy.enableGatewayHint': '启用 MCP 代理;MOM HTTP 监听独立运行。', - 'settings.proxy.httpHint': '端口和局域网访问请到常规设置调整。', + 'settings.proxy.httpHint': 'MOM HTTP 端口、局域网访问和共享令牌请到常规设置调整。', 'settings.proxy.idleTimeout': '会话空闲超时(秒)', 'settings.proxy.idleTimeoutHint': '空闲达到此时长后回收执行单元和会话。', 'settings.proxy.maxUnits': '最大并发单元数', @@ -1137,15 +1147,6 @@ export const zh: Record = { 'settings.proxy.saveSuccess': 'MCP 代理设置已保存。', 'settings.proxy.saveFailed': '保存 MCP 代理设置失败。', 'settings.proxy.loadFailed': '加载 MCP 代理设置失败。', - 'settings.proxy.rotateKey': '轮换访问令牌…', - 'settings.proxy.rotateKeyTitle': '轮换 MCP 代理访问令牌', - 'settings.proxy.rotateKeyDescription': '新令牌会使旧令牌失效并立即终止活跃客户端会话;请更新客户端令牌。', - 'settings.proxy.rotateKeyConfirm': '生成新令牌', - 'settings.proxy.rotateKeySuccess': '已生成新访问令牌', - 'settings.proxy.rotateKeyWarning': '仅显示一次;请立即复制,关闭后无法再查看。', - 'settings.proxy.copyKey': '复制令牌', - 'settings.proxy.copied': '已复制!', - 'settings.proxy.close': '关闭', 'settings.proxy.statusRunning': '运行中', 'settings.proxy.statusStopped': '已停止', 'settings.proxy.actualState': '实际状态:{state}', diff --git a/src/routes/mcp/ProxyPanel.svelte b/src/routes/mcp/ProxyPanel.svelte index d7c926c..afd6143 100644 --- a/src/routes/mcp/ProxyPanel.svelte +++ b/src/routes/mcp/ProxyPanel.svelte @@ -46,7 +46,7 @@ let closeUnitError = $state(''); let mounted = false; let request = 0; -let timer: ReturnType | undefined; +let refreshQueued = false; const isRunning = $derived(!loading && !error && proxyState?.actualState === 'running'); const services = $derived(proxyState?.services ?? []); @@ -63,16 +63,13 @@ const filtered = $derived( const currentPage = $derived(Math.min(page, Math.max(1, Math.ceil(filtered.length / pageSize)))); const rows = $derived(filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize)); -function schedule() { - clearTimeout(timer); - if (mounted && !querying && !deletePending && !closeUnitPending) { - timer = setTimeout(() => void readState(), 2000); +async function readState(manual = false, afterMutation = false) { + if (!mounted) return; + if (querying) { + refreshQueued ||= afterMutation; + return; } -} - -async function readState(manual = false) { - if (!mounted || querying) return; - clearTimeout(timer); + refreshQueued = false; const seq = ++request; querying = true; refreshing = manual; @@ -90,7 +87,7 @@ async function readState(manual = false) { querying = false; refreshing = false; loading = false; - schedule(); + if (refreshQueued) await readState(true, true); } } } @@ -112,7 +109,7 @@ async function toggleGateway(next: boolean) { }, current.revision, ); - await readState(true); + await readState(true, true); } catch (cause) { if (mounted) { toast({ @@ -154,7 +151,7 @@ async function toggleEnabled(summary: McpProxyServerSummaryDto) { enabled: !full.enabled, }; await config.updateMcpProxyServer(updated, revision); - await readState(true); + await readState(true, true); } catch (cause) { if (mounted) toast({ variant: 'destructive', description: readError(cause) }); } finally { @@ -170,14 +167,11 @@ async function confirmDelete() { await config.deleteMcpProxyServer(deletingServer.id, proxyState.revision); if (!mounted) return; deletingServer = null; - await readState(true); + await readState(true, true); } catch (cause) { if (mounted) deleteError = readError(cause); } finally { - if (mounted) { - deletePending = false; - schedule(); - } + if (mounted) deletePending = false; } } @@ -193,14 +187,11 @@ async function submitCloseUnit() { if (!mounted) return; closeUnitOpen = false; closeUnitId = ''; - await readState(true); + await readState(true, true); } catch (cause) { if (mounted) closeUnitError = readError(cause); } finally { - if (mounted) { - closeUnitPending = false; - schedule(); - } + if (mounted) closeUnitPending = false; } } @@ -212,7 +203,6 @@ onMount(() => { onDestroy(() => { mounted = false; request += 1; - clearTimeout(timer); }); export function refresh() { @@ -405,7 +395,7 @@ export function refresh() { bind:open={formOpen} server={editingServer} expectedRevision={editingRevision} - onSaved={() => readState(true)} + onSaved={() => readState(true, true)} /> diff --git a/src/routes/mcp/ProxyServerForm.svelte b/src/routes/mcp/ProxyServerForm.svelte index 4ffa7a1..5423498 100644 --- a/src/routes/mcp/ProxyServerForm.svelte +++ b/src/routes/mcp/ProxyServerForm.svelte @@ -8,6 +8,7 @@ import FormActions from '$src/components/FormActions.svelte'; import { Input } from '$src/components/input/index.js'; import Select from '$src/components/Select.svelte'; import { Switch } from '$src/components/switch/index.js'; +import { Textarea } from '$src/components/textarea/index.js'; import { getConfig } from '$src/config/context.js'; import { getI18n } from '$src/i18n/context.js'; import type { McpServer } from '$src/types/mcp.js'; @@ -304,13 +305,13 @@ async function save() { - + /> {i18n.t('mcp.proxy.form.commandHint')} @@ -332,13 +333,13 @@ async function save() { - + /> {i18n.t('mcp.proxy.form.envHint')} diff --git a/src/routes/mcp/__tests__/mcp-proxy.test.ts b/src/routes/mcp/__tests__/mcp-proxy.test.ts index b67436d..2806183 100644 --- a/src/routes/mcp/__tests__/mcp-proxy.test.ts +++ b/src/routes/mcp/__tests__/mcp-proxy.test.ts @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; import { tick } from 'svelte'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { goto } from '$app/navigation'; @@ -125,7 +125,7 @@ afterEach(() => { }); test.each(['edit', 'toggle'] as const)( - '%s keeps its lookup revision across polling and ignores duplicate clicks', + '%s keeps its lookup revision across explicit refresh and ignores duplicate clicks', async (action) => { let resolve!: (value: McpProxyServerDto) => void; const pending = new Promise((yes) => { @@ -139,14 +139,16 @@ test.each(['edit', 'toggle'] as const)( .mockResolvedValue({ ...defaultProxyState, revision: 5 }); const config = makeConfig({ getMcpProxyServer, getMcpProxyState }); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + const view = render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); await vi.advanceTimersByTimeAsync(0); await tick(); const button = screen.getByRole('button', { name: action === 'edit' ? 'Edit' : 'Disabled' }); await fireEvent.click(button); await fireEvent.click(button); expect(getMcpProxyServer).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(60000); + expect(getMcpProxyState).toHaveBeenCalledOnce(); + await view.component.refresh(); await tick(); expect(screen.getByText('Revision: 4')).toBeDefined(); @@ -154,18 +156,189 @@ test.each(['edit', 'toggle'] as const)( await pending; await tick(); if (action === 'edit') { - await vi.advanceTimersByTimeAsync(2000); + const command = screen.getByRole('textbox', { name: 'Command & Arguments' }) as HTMLTextAreaElement; + await fireEvent.input(command, { target: { value: 'node\ncustom-server' } }); + await view.component.refresh(); await tick(); expect(screen.getByText('Revision: 5')).toBeDefined(); + expect(command.value).toBe('node\ncustom-server'); await fireEvent.click(screen.getByRole('button', { name: 'Save Server' })); } expect(config.updateMcpProxyServer).toHaveBeenCalledExactlyOnceWith( - { ...fullServer, enabled: action === 'edit' }, + { + ...fullServer, + enabled: action === 'edit', + ...(action === 'edit' && { command: ['node', 'custom-server'] }), + }, 3, ); + await tick(); + const reads = getMcpProxyState.mock.calls.length; + await vi.advanceTimersByTimeAsync(60000); + expect(getMcpProxyState).toHaveBeenCalledTimes(reads); }, ); +const mutations = [ + ['gateway', 'saveMcpProxySettings'], + ['toggle', 'updateMcpProxyServer'], + ['save', 'updateMcpProxyServer'], + ['delete', 'deleteMcpProxyServer'], + ['close', 'closeMcpProxyUnit'], +] as const; + +async function startMutation(action: (typeof mutations)[number][0]) { + if (action === 'gateway') { + await fireEvent.click(screen.getByRole('checkbox', { name: 'Enable MCP Gateway' })); + } else if (action === 'toggle') { + await fireEvent.click(screen.getByRole('button', { name: 'Disabled' })); + } else if (action === 'save') { + await fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + await fireEvent.click(screen.getByRole('button', { name: 'Save Server' })); + } else { + const name = action === 'delete' ? 'Delete' : 'Close Unit'; + await fireEvent.click(screen.getByRole('button', { name })); + const dialog = within(screen.getByRole('dialog')); + if (action === 'close') { + await fireEvent.input(dialog.getByRole('textbox'), { target: { value: ' unit-abc ' } }); + } + await fireEvent.click(dialog.getByRole('button', { name })); + } +} + +test.each(mutations)('%s queues one successful readback behind an in-flight manual refresh', async (action, method) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + let finishMutation!: () => void; + const pendingMutation = new Promise((resolve) => { + finishMutation = resolve; + }); + let finishRead!: (value: McpProxyStateDto) => void; + const pendingRead = new Promise((resolve) => { + finishRead = resolve; + }); + const mutation = vi.fn().mockReturnValue(pendingMutation); + const getMcpProxyState = vi + .fn() + .mockResolvedValueOnce(defaultProxyState) + .mockReturnValueOnce(pendingRead) + .mockResolvedValue({ ...defaultProxyState, revision: 4, activeSessions: 8 }); + const config = makeConfig({ [method]: mutation, getMcpProxyState }); + const view = render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await vi.advanceTimersByTimeAsync(0); + await tick(); + await startMutation(action); + if (action === 'gateway' || action === 'toggle') { + await startMutation(action); + } else if (action === 'save') { + await fireEvent.click(screen.getByRole('button', { name: 'Saving…' })); + } else { + await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Updating…' })); + } + expect(mutation).toHaveBeenCalledOnce(); + if (action === 'delete') expect(mutation).toHaveBeenCalledWith('fs-server', 3); + if (action === 'close') expect(mutation).toHaveBeenCalledWith({ unitId: 'unit-abc', expectedGeneration: 1 }); + const refreshing = view.component.refresh(); + await view.component.refresh(); + await vi.advanceTimersByTimeAsync(60000); + expect(getMcpProxyState).toHaveBeenCalledTimes(2); + finishMutation(); + await pendingMutation; + await tick(); + expect(getMcpProxyState).toHaveBeenCalledTimes(2); + finishRead(defaultProxyState); + await refreshing; + await tick(); + expect(getMcpProxyState).toHaveBeenCalledTimes(3); + expect(screen.getByText('Revision: 4')).toBeDefined(); + expect(screen.getByText('Active Sessions: 8')).toBeDefined(); + await vi.advanceTimersByTimeAsync(60000); + expect(getMcpProxyState).toHaveBeenCalledTimes(3); +}); + +test.each(mutations)('%s failure retains feedback without triggering another read', async (action, method) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const mutation = vi.fn().mockRejectedValue(new Error('Mutation failed')); + const config = makeConfig({ [method]: mutation }); + render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await vi.advanceTimersByTimeAsync(0); + await tick(); + await startMutation(action); + await tick(); + expect(mutation).toHaveBeenCalledOnce(); + if (action === 'gateway' || action === 'toggle') { + expect(toast).toHaveBeenCalledWith({ variant: 'destructive', description: 'Mutation failed' }); + } else { + expect(screen.getByRole('alert').textContent).toContain('Mutation failed'); + expect(screen.getByRole('dialog')).toBeDefined(); + } + await vi.advanceTimersByTimeAsync(60000); + expect(config.getMcpProxyState).toHaveBeenCalledOnce(); +}); + +test.each(mutations)('%s completion after teardown starts no readback or feedback', async (action, method) => { + let finishMutation!: () => void; + const pending = new Promise((resolve) => { + finishMutation = resolve; + }); + const mutation = vi.fn().mockReturnValue(pending); + const config = makeConfig({ [method]: mutation }); + const view = render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByText('Filesystem Server'); + await startMutation(action); + expect(mutation).toHaveBeenCalledOnce(); + view.unmount(); + finishMutation(); + await pending; + await tick(); + expect(config.getMcpProxyState).toHaveBeenCalledOnce(); + expect(toast).not.toHaveBeenCalled(); +}); + +test('teardown drops queued mutation readback and late state results', async () => { + let finishRead!: (value: McpProxyStateDto) => void; + const pending = new Promise((resolve) => { + finishRead = resolve; + }); + const getMcpProxyState = vi.fn().mockResolvedValueOnce(defaultProxyState).mockReturnValueOnce(pending); + const config = makeConfig({ getMcpProxyState }); + const view = render(ProxyPanel, {}, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByText('Filesystem Server'); + const refreshing = view.component.refresh(); + await startMutation('toggle'); + expect(config.updateMcpProxyServer).toHaveBeenCalledOnce(); + view.unmount(); + finishRead({ ...defaultProxyState, revision: 99 }); + await refreshing; + await tick(); + expect(getMcpProxyState).toHaveBeenCalledTimes(2); + expect(toast).not.toHaveBeenCalled(); +}); + +test('Proxy tab reads on entry and header refresh only, and remounts on reentry', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const config = makeConfig(); + render(McpPage, {}, { wrapper: Harness, wrapperProps: { config } }); + await vi.advanceTimersByTimeAsync(0); + await tick(); + expect(config.getMcpProxyState).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('tab', { name: 'MCP Proxy' })); + await tick(); + expect(config.getMcpProxyState).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(60000); + expect(config.getMcpProxyState).toHaveBeenCalledOnce(); + const configReads = vi.mocked(config.listMcps).mock.calls.length; + await fireEvent.click(screen.getAllByRole('button', { name: 'Refresh' })[0] as HTMLButtonElement); + await tick(); + expect(config.getMcpProxyState).toHaveBeenCalledTimes(2); + expect(config.listMcps).toHaveBeenCalledTimes(configReads); + await fireEvent.click(screen.getByRole('tab', { name: 'Configuration' })); + await vi.advanceTimersByTimeAsync(60000); + expect(config.getMcpProxyState).toHaveBeenCalledTimes(2); + await fireEvent.click(screen.getByRole('tab', { name: 'MCP Proxy' })); + await tick(); + expect(config.getMcpProxyState).toHaveBeenCalledTimes(3); +}); + test('status stays loading until readback and becomes unknown after a failed refresh', async () => { let resolve!: (value: McpProxyStateDto) => void; const pending = new Promise((yes) => { @@ -355,7 +528,7 @@ test('import ignores duplicate submissions and late completion after unmount', a expect(toast).not.toHaveBeenCalled(); }); -test('server form preserves argument boundaries and literal environment values', async () => { +test.each([false, true])('server form dialog=%s preserves textarea borders and literal values', async (dialog) => { const server: McpProxyServerDto = { ...fullServer, command: ['node', '', '""', "''", ' spaced ', '', ''], @@ -373,14 +546,26 @@ test('server form preserves argument boundaries and literal environment values', ProxyServerForm, { open: true, + dialog, server, expectedRevision: 3, onSaved: vi.fn(), }, { wrapper: Harness, wrapperProps: { config } }, ); - await screen.findByRole('dialog'); + await screen.findByRole(dialog ? 'dialog' : 'form'); + const command = screen.getByLabelText('Command & Arguments') as HTMLTextAreaElement; const env = screen.getByLabelText('Environment Variables') as HTMLTextAreaElement; + for (const [field, height] of [ + [command, 'min-h-20'], + [env, 'min-h-16'], + ] as const) { + expect(field.getAttribute('data-slot')).toBe('textarea'); + for (const name of ['border', 'border-[var(--border-default)]', 'font-mono', 'text-xs', height]) { + expect(field.classList.contains(name)).toBe(true); + } + expect(field.classList.contains('min-h-24')).toBe(false); + } await fireEvent.input(env, { target: { value: `${env.value}\n # comment\n MORE = more ` } }); await fireEvent.click(screen.getByRole('button', { name: 'Save Server' })); await waitFor(() => diff --git a/src/routes/settings/McpProxySettings.svelte b/src/routes/settings/McpProxySettings.svelte index cb75715..14242bd 100644 --- a/src/routes/settings/McpProxySettings.svelte +++ b/src/routes/settings/McpProxySettings.svelte @@ -37,29 +37,25 @@ let rotateDialogOpen = $state(false); let mounted = $state(false); let request = 0; -let timer: ReturnType | undefined; +let refreshQueued = false; const isDirty = $derived(savedSettings !== null && JSON.stringify(settings) !== JSON.stringify(savedSettings)); const validPort = $derived(Number.isInteger(settings.port) && settings.port >= 1 && settings.port <= 65535); -function schedule() { - clearTimeout(timer); - if (mounted && active && !querying && !saving) { - timer = setTimeout(() => void readStatus(), 2000); +async function readStatus(manual = false, afterMutation = false) { + if (!mounted || !active) return; + if (querying || saving) { + refreshQueued ||= afterMutation; + return; } -} - -async function readStatus(manual = false) { - if (!mounted || !active || querying || saving) return; - clearTimeout(timer); + refreshQueued = false; const seq = ++request; querying = true; refreshing = manual; try { - const [fetchedSettings, fetchedState] = await Promise.all([ - http ? config.getMomHttpSettings() : config.getMcpProxySettings(), - config.getMcpProxyState(), - ]); + const [fetchedSettings, fetchedState] = http + ? [await config.getMomHttpSettings(), null] + : await Promise.all([config.getMcpProxySettings(), config.getMcpProxyState()]); if (!mounted || seq !== request) return; if (!isDirty) { settings = { ...settings, ...fetchedSettings }; @@ -78,14 +74,13 @@ async function readStatus(manual = false) { querying = false; refreshing = false; loading = false; - if (!saving && !querying) schedule(); + if (refreshQueued) await readStatus(true, true); } } } async function save() { if (!mounted || !active || saving || querying || !known || (http && !validPort)) return; - clearTimeout(timer); const seq = ++request; saving = true; error = ''; @@ -103,7 +98,7 @@ async function save() { description: i18n.t(http ? 'settings.http.saveSuccess' : 'settings.proxy.saveSuccess'), }); saving = false; - await readStatus(true); + await readStatus(true, true); } catch (cause) { if (mounted && seq === request) { error = readError(cause); @@ -111,7 +106,7 @@ async function save() { } finally { if (mounted) { saving = false; - schedule(); + if (refreshQueued) await readStatus(true, true); } } } @@ -124,15 +119,12 @@ $effect(() => { if (!mounted) return; if (active) { untrack(() => void readStatus()); - } else { - clearTimeout(timer); } }); onDestroy(() => { mounted = false; request += 1; - clearTimeout(timer); }); export function refresh() { @@ -200,6 +192,26 @@ export function refresh() { {/if}
    + +
    +
    + + {i18n.t('settings.http.rotateKey')} + +

    + {i18n.t('settings.http.rotateKeyDescription')} +

    +
    + +
    +
    + + readStatus(true, true)} /> {:else}
    {#if error} @@ -296,31 +310,6 @@ export function refresh() {
    - -
    -
    - - {i18n.t('settings.proxy.rotateKey')} - -

    - {i18n.t('settings.proxy.rotateKeyDescription')} -

    -
    - -
    -
    - - readStatus(true)} /> {/if} diff --git a/src/routes/settings/RotateKeyDialog.svelte b/src/routes/settings/RotateKeyDialog.svelte index 6f6991e..35b3b31 100644 --- a/src/routes/settings/RotateKeyDialog.svelte +++ b/src/routes/settings/RotateKeyDialog.svelte @@ -22,27 +22,32 @@ let newKey = $state(''); let copied = $state(false); let mounted = true; let displayGeneration = 0; +let copyTimer: ReturnType | undefined; + +function clearDisplay() { + displayGeneration += 1; + newKey = ''; + error = ''; + copied = false; + clearTimeout(copyTimer); + copyTimer = undefined; +} $effect(() => { if (!open || !active) { - displayGeneration += 1; - newKey = ''; - error = ''; - copied = false; + clearDisplay(); } }); onDestroy(() => { mounted = false; - newKey = ''; + clearDisplay(); }); function close() { if (pending) return; const hadKey = newKey.length > 0; - newKey = ''; - error = ''; - copied = false; + clearDisplay(); open = false; if (hadKey && onRotated) { void onRotated(); @@ -56,7 +61,7 @@ async function rotate() { error = ''; copied = false; try { - const key = await config.rotateMcpProxyKey(); + const key = await config.rotateMomHttpKey(); if (!mounted || !active || !open || generation !== displayGeneration) return; newKey = key; } catch (cause) { @@ -71,12 +76,16 @@ async function rotate() { } async function copy() { - if (!mounted || !active || !newKey) return; + if (!mounted || !active || !open || !newKey) return; + const generation = displayGeneration; try { await navigator.clipboard.writeText(newKey); + if (!mounted || !active || !open || generation !== displayGeneration) return; + clearTimeout(copyTimer); copied = true; - setTimeout(() => { + copyTimer = setTimeout(() => { copied = false; + copyTimer = undefined; }, 2000); } catch { // fallback if clipboard API fails @@ -92,9 +101,9 @@ async function copy() { onInteractOutside={(event) => { if (pending) event.preventDefault(); }} > - {i18n.t('settings.proxy.rotateKeyTitle')} + {i18n.t('settings.http.rotateKeyTitle')} - {i18n.t('settings.proxy.rotateKeyDescription')} + {i18n.t('settings.http.rotateKeyDescription')} @@ -107,24 +116,25 @@ async function copy() { {#if newKey}
    @@ -133,11 +143,11 @@ async function copy() { {#if newKey} - + {:else} {/if} diff --git a/src/routes/settings/SettingsSearch.svelte b/src/routes/settings/SettingsSearch.svelte index 001cd2e..5abcd3c 100644 --- a/src/routes/settings/SettingsSearch.svelte +++ b/src/routes/settings/SettingsSearch.svelte @@ -131,9 +131,14 @@ const entries: (SettingsSearchTarget & { keys: [MessageKey, MessageKey, ...Messa keys: ['settings.proxy.maxUnits', 'settings.proxy.maxUnitsHint', 'settings.proxy.title'], }, { - tab: 'mcp-proxy', - target: 'settings-proxy-token', - keys: ['settings.proxy.rotateKey', 'settings.proxy.rotateKeyDescription', 'settings.proxy.title'], + tab: 'general', + target: 'settings-http-token', + keys: [ + 'settings.http.rotateKey', + 'settings.http.rotateKeyDescription', + 'settings.http.rotateKeyTitle', + 'settings.http.title', + ], }, ]; diff --git a/src/routes/settings/__tests__/mcp-proxy-settings.test.ts b/src/routes/settings/__tests__/mcp-proxy-settings.test.ts index bd8593e..8c64663 100644 --- a/src/routes/settings/__tests__/mcp-proxy-settings.test.ts +++ b/src/routes/settings/__tests__/mcp-proxy-settings.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { goto } from '$app/navigation'; import { page } from '$app/state'; import type { ConfigStore } from '$src/config/store.svelte.js'; +import { toast } from '$src/shell/toast.svelte.js'; import Harness from '$src/test-utils/harness.svelte'; import type { McpProxySettingsDto, McpProxyStateDto, MomHttpSettings } from '$src/types/mcp-proxy.js'; import SettingsPage from '../+page.svelte'; @@ -92,7 +93,7 @@ function makeConfig(overrides: Record = {}) { getMcpProxySettings: vi.fn().mockResolvedValue(defaultSettings), getMcpProxyState: vi.fn().mockResolvedValue(defaultProxyState), saveMcpProxySettings: vi.fn().mockResolvedValue(undefined), - rotateMcpProxyKey: vi.fn().mockResolvedValue('test-rotated-key-xyz'), + rotateMomHttpKey: vi.fn().mockResolvedValue('test-rotated-key-xyz'), ...overrides, } as unknown as ConfigStore; } @@ -100,10 +101,12 @@ function makeConfig(overrides: Record = {}) { beforeEach(() => { page.url = new URL('http://localhost/settings'); vi.mocked(goto).mockClear(); + vi.mocked(toast).mockClear(); }); afterEach(() => { cleanup(); + vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -114,7 +117,7 @@ async function settle() { } test.each([false, true])( - 'HTTP=%s retains in-flight read/save guards across activation and never polls while inactive', + 'HTTP=%s retains in-flight read/save guards across activation without periodic reads', async (http) => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); let finishRead!: (value: McpProxySettingsDto | MomHttpSettings) => void; @@ -168,12 +171,13 @@ test.each([false, true])( await settle(); expect(screen.getByLabelText(http ? 'Port' : 'Session Idle Timeout (seconds)')).toBe(input); expect(getSettings).toHaveBeenCalledTimes(3); - await vi.advanceTimersByTimeAsync(2000); - expect(getSettings).toHaveBeenCalledTimes(4); + await vi.advanceTimersByTimeAsync(60000); + expect(getSettings).toHaveBeenCalledTimes(3); + expect(config.getMcpProxyState).toHaveBeenCalledTimes(http ? 0 : 3); }, ); -test.each([false, true])('HTTP=%s polls without replacing a dirty draft or its expected revision', async (http) => { +test.each([false, true])('HTTP=%s refreshes clean fields but retains dirty drafts and their revision', async (http) => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const baseline = http ? defaultHttpSettings : defaultSettings; const getSettings = vi.fn().mockResolvedValue({ ...baseline }); @@ -182,19 +186,25 @@ test.each([false, true])('HTTP=%s polls without replacing a dirty draft or its e [http ? 'getMomHttpSettings' : 'getMcpProxySettings']: getSettings, getMcpProxyState, }); - render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); + const view = render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); await settle(); const field = http ? 'port' : 'idleTimeout'; const input = screen.getByLabelText(http ? 'Port' : 'Session Idle Timeout (seconds)') as HTMLInputElement; getSettings.mockResolvedValue({ ...baseline, [field]: 23457, ...(!http && { revision: 6 }) }); - await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(60000); + expect(getSettings).toHaveBeenCalledOnce(); + expect(config.getMcpProxyState).toHaveBeenCalledTimes(http ? 0 : 1); + expect(input.value).toBe(String(http ? defaultHttpSettings.port : defaultSettings.idleTimeout)); + await view.component.refresh(); + await settle(); expect(input.value).toBe('23457'); await fireEvent.input(input, { target: { value: '24567' } }); getSettings.mockResolvedValue({ ...baseline, [field]: 23458, ...(!http && { revision: 7 }) }); getMcpProxyState.mockResolvedValue({ ...defaultProxyState, activeSessions: 9 }); - await vi.advanceTimersByTimeAsync(2000); + await view.component.refresh(); + await settle(); expect(input.value).toBe('24567'); await fireEvent.click(screen.getByRole('button', { name: http ? 'Save HTTP Settings' : 'Save Gateway Settings' })); @@ -209,6 +219,10 @@ test.each([false, true])('HTTP=%s polls without replacing a dirty draft or its e ); expect(config.setMomHttpSettings).not.toHaveBeenCalled(); } + expect(getSettings).toHaveBeenCalledTimes(4); + expect(input.value).toBe('23458'); + await vi.advanceTimersByTimeAsync(60000); + expect(getSettings).toHaveBeenCalledTimes(4); }); test.each([ @@ -216,7 +230,7 @@ test.each([ [false, true], [true, false], [true, true], -])('HTTP=%s save failure=%s preserves polling and refreshes successful saves', async (http, fails) => { +])('HTTP=%s save failure=%s reads back successful saves once without periodic reads', async (http, fails) => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); let resolve!: () => void; let reject!: (reason: unknown) => void; @@ -231,7 +245,7 @@ test.each([ [http ? 'getMomHttpSettings' : 'getMcpProxySettings']: getSettings, [http ? 'setMomHttpSettings' : 'saveMcpProxySettings']: saveSettings, }); - const { unmount } = render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); + const view = render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); await settle(); const field = http ? 'port' : 'idleTimeout'; @@ -239,6 +253,9 @@ test.each([ const save = screen.getByRole('button', { name: http ? 'Save HTTP Settings' : 'Save Gateway Settings' }); await fireEvent.input(input, { target: { value: '24567' } }); await fireEvent.click(save); + await fireEvent.click(save); + await view.component.refresh(); + expect(saveSettings).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(6000); expect(getSettings).toHaveBeenCalledTimes(1); @@ -253,12 +270,13 @@ test.each([ expect(getSettings).toHaveBeenCalledTimes(fails ? 1 : 2); expect(input.value).toBe(fails ? '24567' : '24568'); - await vi.advanceTimersByTimeAsync(2000); - expect(getSettings).toHaveBeenCalledTimes(fails ? 2 : 3); + await vi.advanceTimersByTimeAsync(60000); + expect(getSettings).toHaveBeenCalledTimes(fails ? 1 : 2); expect(input.value).toBe(fails ? '24567' : '24568'); - unmount(); + if (fails) expect(screen.getByRole('alert').textContent).toContain('Save failed'); + view.unmount(); await vi.advanceTimersByTimeAsync(4000); - expect(getSettings).toHaveBeenCalledTimes(fails ? 2 : 3); + expect(getSettings).toHaveBeenCalledTimes(fails ? 1 : 2); }); test.each([false, true])('HTTP=%s does not start a save while a status read is in flight', async (http) => { @@ -273,9 +291,11 @@ test.each([false, true])('HTTP=%s does not start a save while a status read is i .mockResolvedValueOnce({ ...baseline }) .mockReturnValueOnce(pending); const config = makeConfig({ [http ? 'getMomHttpSettings' : 'getMcpProxySettings']: getSettings }); - render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); + const view = render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); await settle(); - await vi.advanceTimersByTimeAsync(2000); + const refreshing = view.component.refresh(); + await settle(); + await view.component.refresh(); const save = screen.getByRole('button', { name: http ? 'Save HTTP Settings' : 'Save Gateway Settings', }) as HTMLButtonElement; @@ -286,8 +306,94 @@ test.each([false, true])('HTTP=%s does not start a save while a status read is i await vi.advanceTimersByTimeAsync(6000); expect(getSettings).toHaveBeenCalledTimes(2); resolve({ ...baseline }); + await refreshing; await settle(); expect(save.disabled).toBe(false); + await vi.advanceTimersByTimeAsync(60000); + expect(getSettings).toHaveBeenCalledTimes(2); +}); + +test.each([ + [false, 'read', false], + [false, 'read', true], + [true, 'read', false], + [true, 'read', true], + [false, 'save', false], + [false, 'save', true], + [true, 'save', false], + [true, 'save', true], +] as const)('HTTP=%s %s failure=%s ignores completion after unmount', async (http, operation, fails) => { + let finish!: (value: McpProxySettingsDto | MomHttpSettings | undefined) => void; + let reject!: (reason: unknown) => void; + const pending = new Promise((resolve, no) => { + finish = resolve; + reject = no; + }); + const baseline = http ? defaultHttpSettings : defaultSettings; + const getSettings = vi.fn().mockResolvedValue(baseline); + const saveSettings = vi.fn().mockReturnValue(pending); + if (operation === 'read') getSettings.mockReturnValueOnce(pending); + const config = makeConfig({ + [http ? 'getMomHttpSettings' : 'getMcpProxySettings']: getSettings, + [http ? 'setMomHttpSettings' : 'saveMcpProxySettings']: saveSettings, + }); + const view = render(McpProxySettings, { http }, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + if (operation === 'save') { + await fireEvent.click(screen.getByRole('button', { name: http ? 'Save HTTP Settings' : 'Save Gateway Settings' })); + expect(saveSettings).toHaveBeenCalledOnce(); + } + view.unmount(); + if (fails) reject(new Error('Late failure')); + else finish(operation === 'read' ? baseline : undefined); + await pending.catch(() => {}); + await settle(); + expect(getSettings).toHaveBeenCalledOnce(); + expect(config.getMcpProxyState).toHaveBeenCalledTimes(http ? 0 : 1); + expect(toast).not.toHaveBeenCalled(); +}); + +test('HTTP token close queues listener-only readback and retains dirty port/LAN drafts', async () => { + let finishRead!: (value: MomHttpSettings) => void; + const pendingRead = new Promise((resolve) => { + finishRead = resolve; + }); + const getMomHttpSettings = vi + .fn() + .mockResolvedValueOnce(defaultHttpSettings) + .mockReturnValueOnce(pendingRead) + .mockResolvedValue({ port: 24568, allowLan: false }); + const config = makeConfig({ getMomHttpSettings }); + const view = render(McpProxySettings, { http: true }, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + const port = screen.getByRole('spinbutton', { name: 'Port' }) as HTMLInputElement; + const allowLan = screen.getByRole('checkbox', { name: 'Allow LAN' }) as HTMLInputElement; + expect(allowLan.checked).toBe(false); + await fireEvent.input(port, { target: { value: '24567' } }); + await fireEvent.click(allowLan); + expect(allowLan.checked).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Rotate Secret Token…' })); + const refreshing = view.component.refresh(); + expect(getMomHttpSettings).toHaveBeenCalledTimes(2); + await fireEvent.click(screen.getByRole('button', { name: 'Generate New Token' })); + await screen.findByLabelText('New Secret Token Generated'); + await fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(getMomHttpSettings).toHaveBeenCalledTimes(2); + finishRead(defaultHttpSettings); + await refreshing; + await settle(); + expect(getMomHttpSettings).toHaveBeenCalledTimes(3); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); + expect(port.value).toBe('24567'); + expect(screen.getByRole('checkbox', { name: 'Allow LAN' })).toBe(allowLan); + expect((screen.getByRole('checkbox', { name: 'Allow LAN' }) as HTMLInputElement).checked).toBe(true); + expect(config.rotateMomHttpKey).toHaveBeenCalledOnce(); + expect(config.setMomHttpSettings).not.toHaveBeenCalled(); + expect(config.getMcpProxyState).not.toHaveBeenCalled(); + expect(config.getMcpProxySettings).not.toHaveBeenCalled(); + expect(config.saveMcpProxySettings).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole('button', { name: 'Save HTTP Settings' })); + expect(config.setMomHttpSettings).toHaveBeenCalledExactlyOnceWith({ port: 24567, allowLan: true }); }); test('settings switches to MCP Proxy tab and does not read autostart on proxy tab', async () => { @@ -306,6 +412,8 @@ test('settings switches to MCP Proxy tab and does not read autostart on proxy ta expect(proxy.getByRole('region', { name: 'MCP Proxy Gateway' })).toBeDefined(); expect(proxy.getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' })).toBeDefined(); expect(proxy.getByRole('spinbutton', { name: 'Maximum Concurrent Units' })).toBeDefined(); + expect(proxy.queryByRole('button', { name: 'Rotate Secret Token…', hidden: true })).toBeNull(); + expect(screen.queryByRole('dialog')).toBeNull(); const generalPanel = document.getElementById('settings-tabpanel-general') as HTMLElement; expect(generalPanel.hidden).toBe(true); expect(within(generalPanel).getByRole('spinbutton', { name: 'Port', hidden: true })).toBeDefined(); @@ -313,7 +421,7 @@ test('settings switches to MCP Proxy tab and does not read autostart on proxy ta expect(screen.queryByRole('checkbox', { name: 'Allow LAN' })).toBeNull(); }); -test('General owns HTTP settings without proxy writes and stops polling on tab switch', async () => { +test('General owns HTTP settings and header refresh without periodic reads on either tab', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const config = makeConfig(); const { unmount } = render(SettingsPage, {}, { wrapper: Harness, wrapperProps: { config } }); @@ -322,7 +430,7 @@ test('General owns HTTP settings without proxy writes and stops polling on tab s const general = within(generalPanel); expect((document.getElementById('settings-tabpanel-mcp-proxy') as HTMLElement).hidden).toBe(true); expect(screen.queryByRole('checkbox', { name: 'Enable MCP Gateway' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Rotate Secret Token…' })).toBeNull(); + expect(general.getByRole('button', { name: 'Rotate Secret Token…' })).toBeDefined(); const port = general.getByRole('spinbutton', { name: 'Port' }) as HTMLInputElement; const save = general.getByRole('button', { name: 'Save HTTP Settings' }) as HTMLButtonElement; @@ -350,12 +458,20 @@ test('General owns HTTP settings without proxy writes and stops polling on tab s expect(general.getByRole('spinbutton', { name: 'Port', hidden: true })).toBe(port); const proxy = within(screen.getByRole('tabpanel', { name: 'MCP Proxy' })); expect(proxy.getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' })).toBeDefined(); - await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(60000); expect(config.getMomHttpSettings).toHaveBeenCalledTimes(3); + expect(config.getMcpProxySettings).toHaveBeenCalledTimes(1); + await fireEvent.click(screen.getAllByRole('button', { name: 'Refresh' })[0] as HTMLButtonElement); + await settle(); expect(config.getMcpProxySettings).toHaveBeenCalledTimes(2); + expect(config.getMomHttpSettings).toHaveBeenCalledTimes(3); + await fireEvent.click(screen.getByRole('tab', { name: 'General' })); + await settle(); + expect(config.getMomHttpSettings).toHaveBeenCalledTimes(4); + expect(general.getByRole('spinbutton', { name: 'Port' })).toBe(port); unmount(); await vi.advanceTimersByTimeAsync(4000); - expect(config.getMomHttpSettings).toHaveBeenCalledTimes(3); + expect(config.getMomHttpSettings).toHaveBeenCalledTimes(4); expect(config.getMcpProxySettings).toHaveBeenCalledTimes(2); }); @@ -366,10 +482,11 @@ test('HTTP settings displays error banner after a failed read', async () => { .mockResolvedValueOnce(defaultHttpSettings) .mockRejectedValue(new Error('Read failed')); const config = makeConfig({ getMomHttpSettings }); - render(McpProxySettings, { http: true }, { wrapper: Harness, wrapperProps: { config } }); + const view = render(McpProxySettings, { http: true }, { wrapper: Harness, wrapperProps: { config } }); await settle(); expect(screen.queryByRole('alert')).toBeNull(); - await vi.advanceTimersByTimeAsync(2000); + await view.component.refresh(); + await settle(); expect(screen.getByRole('alert')).toBeDefined(); expect(screen.getByRole('alert').textContent).toContain('Read failed'); }); @@ -401,33 +518,128 @@ test('McpProxySettings saves gateway settings with expected revision', async () }); }); -test('RotateKeyDialog confirms and displays rotated secret token once', async () => { - page.url = new URL('http://localhost/settings?tab=mcp-proxy'); - const rotateMcpProxyKey = vi.fn().mockResolvedValue('secret-key-super-safe'); - const config = makeConfig({ rotateMcpProxyKey }); +test.each(['disabled', 'unavailable'] as const)('General token rotation works with Proxy %s', async (proxy) => { + const rotateMomHttpKey = vi.fn().mockResolvedValue('secret-key-super-safe'); + const config = makeConfig({ + rotateMomHttpKey, + getMcpProxySettings: + proxy === 'disabled' + ? vi.fn().mockResolvedValue({ ...defaultSettings, enabled: false }) + : vi.fn().mockRejectedValue(new Error('Proxy configuration unavailable')), + getMcpProxyState: + proxy === 'disabled' + ? vi.fn().mockResolvedValue({ ...defaultProxyState, actualState: 'stopped' }) + : vi.fn().mockRejectedValue(new Error('Proxy unavailable')), + }); render(SettingsPage, {}, { wrapper: Harness, wrapperProps: { config } }); await waitFor(() => { - expect(config.getMcpProxySettings).toHaveBeenCalled(); + expect(config.getMomHttpSettings).toHaveBeenCalledOnce(); }); const rotateBtn = screen.getByRole('button', { name: /Rotate Secret Token/i }); await fireEvent.click(rotateBtn); - // Dialog opens + expect(screen.getByRole('dialog', { name: 'Rotate MOM HTTP Secret Token' })).toBeDefined(); const confirmBtn = screen.getByRole('button', { name: 'Generate New Token' }); await fireEvent.click(confirmBtn); await waitFor(() => { - expect(rotateMcpProxyKey).toHaveBeenCalled(); + expect(rotateMomHttpKey).toHaveBeenCalledOnce(); }); - // Displays key input const keyInput = (await screen.findByLabelText('New Secret Token Generated')) as HTMLInputElement; expect(keyInput.value).toBe('secret-key-super-safe'); + expect(keyInput.name).toBe('mom-http-new-token'); + expect(keyInput.id).toBe('mom-http-new-token'); - // Close dialog const closeBtn = screen.getByRole('button', { name: 'Close' }); await fireEvent.click(closeBtn); + await settle(); + expect(config.getMomHttpSettings).toHaveBeenCalledTimes(2); + expect(config.getMcpProxySettings).not.toHaveBeenCalled(); + expect(config.getMcpProxyState).not.toHaveBeenCalled(); + expect(config.saveMcpProxySettings).not.toHaveBeenCalled(); + expect(config.setMomHttpSettings).not.toHaveBeenCalled(); + await fireEvent.click(rotateBtn); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); + expect(rotateMomHttpKey).toHaveBeenCalledOnce(); +}); + +test.each(['displayed', 'pending'] as const)( + 'General to Proxy and back discards a %s token without mutation readback', + async (phase) => { + let finishRotation!: (value: string) => void; + const rotation = new Promise((resolve) => { + finishRotation = resolve; + }); + const rotateMomHttpKey = vi + .fn() + .mockReturnValue(phase === 'pending' ? rotation : Promise.resolve('one-time-http-token')); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const config = makeConfig({ rotateMomHttpKey, refresh: vi.fn() }); + render(SettingsPage, {}, { wrapper: Harness, wrapperProps: { config } }); + await settle(); + const generalPanel = screen.getByRole('tabpanel', { name: 'General' }); + const port = within(generalPanel).getByRole('spinbutton', { name: 'Port' }) as HTMLInputElement; + await fireEvent.input(port, { target: { value: '24567' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Rotate Secret Token…' })); + await fireEvent.click(screen.getByRole('button', { name: 'Generate New Token' })); + if (phase === 'displayed') { + expect(((await screen.findByLabelText('New Secret Token Generated')) as HTMLInputElement).value).toBe( + 'one-time-http-token', + ); + await fireEvent.click(screen.getByRole('button', { name: 'Copy Token' })); + expect(writeText).toHaveBeenCalledExactlyOnceWith('one-time-http-token'); + expect(screen.getByRole('button', { name: 'Copied!' })).toBeTruthy(); + } + + await fireEvent.click(document.getElementById('settings-tab-mcp-proxy') as HTMLButtonElement); + await settle(); + expect(generalPanel.hidden).toBe(true); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Copied!' })).toBeNull(); + expect(config.getMomHttpSettings).toHaveBeenCalledOnce(); + expect(config.getMcpProxySettings).toHaveBeenCalledOnce(); + expect(config.getMcpProxyState).toHaveBeenCalledOnce(); + + await fireEvent.click(screen.getByRole('tab', { name: 'General' })); + await settle(); + expect(generalPanel.hidden).toBe(false); + expect(within(generalPanel).getByRole('spinbutton', { name: 'Port' })).toBe(port); + expect(port.value).toBe('24567'); + if (phase === 'pending') { + finishRotation('late-http-token'); + await settle(); + } + await fireEvent.click(document.querySelector('#settings-http-token button') as HTMLButtonElement); + expect((screen.getByRole('button', { name: 'Generate New Token' }) as HTMLButtonElement).disabled).toBe(false); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Copied!' })).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await settle(); + expect(rotateMomHttpKey).toHaveBeenCalledOnce(); + expect(config.getMomHttpSettings).toHaveBeenCalledTimes(2); + expect(config.getMcpProxySettings).toHaveBeenCalledOnce(); + expect(config.getMcpProxyState).toHaveBeenCalledOnce(); + expect(config.setMomHttpSettings).not.toHaveBeenCalled(); + expect(config.saveMcpProxySettings).not.toHaveBeenCalled(); + expect(config.refresh).not.toHaveBeenCalled(); + }, +); + +test('HTTP token action is independent of listener draft loading and read errors', async () => { + const config = makeConfig({ getMomHttpSettings: vi.fn().mockRejectedValue(new Error('Listener read failed')) }); + render(McpProxySettings, { http: true }, { wrapper: Harness, wrapperProps: { config } }); + await screen.findByText('Listener read failed'); + await fireEvent.click(screen.getByRole('button', { name: 'Rotate Secret Token…' })); + await fireEvent.click(screen.getByRole('button', { name: 'Generate New Token' })); + expect(((await screen.findByLabelText('New Secret Token Generated')) as HTMLInputElement).value).toBe( + 'test-rotated-key-xyz', + ); + expect(config.getMcpProxySettings).not.toHaveBeenCalled(); + expect(config.getMcpProxyState).not.toHaveBeenCalled(); }); diff --git a/src/routes/settings/__tests__/rotate-key-dialog.test.ts b/src/routes/settings/__tests__/rotate-key-dialog.test.ts index c6932dc..43bd718 100644 --- a/src/routes/settings/__tests__/rotate-key-dialog.test.ts +++ b/src/routes/settings/__tests__/rotate-key-dialog.test.ts @@ -1,14 +1,18 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { expect, test, vi } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import type { ConfigStore } from '$src/config/store.svelte.js'; import Harness from '$src/test-utils/harness.svelte'; import RotateKeyDialog from '../RotateKeyDialog.svelte'; -function mount(rotateMcpProxyKey = vi.fn().mockResolvedValue('private-rotated-token')) { - const config = { preferences: { locale: 'en' }, rotateMcpProxyKey } as unknown as ConfigStore; +function mount(rotateMomHttpKey = vi.fn().mockResolvedValue('private-rotated-token')) { + const config = { preferences: { locale: 'en' }, rotateMomHttpKey } as unknown as ConfigStore; return render(RotateKeyDialog, { open: true }, { wrapper: Harness, wrapperProps: { config } }); } +afterEach(() => { + vi.unstubAllGlobals(); +}); + test('externally closing the token dialog clears its one-time secret before reopening', async () => { const { rerender } = mount(); await fireEvent.click(await screen.findByRole('button', { name: 'Generate New Token' })); @@ -22,22 +26,84 @@ test('externally closing the token dialog clears its one-time secret before reop expect(await screen.findByRole('button', { name: 'Generate New Token' })).toBeTruthy(); }); -test('leaving and re-entering the proxy tab discards a pending one-time token', async () => { +test.each(['close', 'inactive'] as const)('%s discards late one-time token results before re-entry', async (action) => { let resolve!: (value: string) => void; - const rotateMcpProxyKey = vi.fn().mockReturnValue( + const rotateMomHttpKey = vi.fn().mockReturnValue( new Promise((done) => { resolve = done; }), ); - const { rerender } = mount(rotateMcpProxyKey); - await fireEvent.click(await screen.findByRole('button', { name: 'Generate New Token' })); + const { rerender } = mount(rotateMomHttpKey); + const confirm = (await screen.findByRole('button', { name: 'Generate New Token' })) as HTMLButtonElement; + await fireEvent.click(confirm); + expect(confirm.disabled).toBe(true); + expect(screen.getByRole('dialog').getAttribute('aria-busy')).toBe('true'); + await fireEvent.click(confirm); + expect(rotateMomHttpKey).toHaveBeenCalledOnce(); - await rerender({ open: true, active: false }); + await rerender(action === 'close' ? { open: false } : { open: true, active: false }); await rerender({ open: true, active: true }); + const pendingConfirm = screen.getByRole('button', { name: 'Updating…' }) as HTMLButtonElement; + expect(pendingConfirm.disabled).toBe(true); + await fireEvent.click(pendingConfirm); + expect(rotateMomHttpKey).toHaveBeenCalledOnce(); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); resolve('late-private-token'); await waitFor(() => expect((screen.getByRole('button', { name: 'Generate New Token' }) as HTMLButtonElement).disabled).toBe(false), ); expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); - expect(rotateMcpProxyKey).toHaveBeenCalledOnce(); + expect(rotateMomHttpKey).toHaveBeenCalledOnce(); +}); + +test('leaving General clears the displayed token and copied state before returning', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const { rerender } = mount(); + await fireEvent.click(await screen.findByRole('button', { name: 'Generate New Token' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Copy Token' })); + expect(writeText).toHaveBeenCalledExactlyOnceWith('private-rotated-token'); + expect(screen.getByRole('button', { name: 'Copied!' })).toBeTruthy(); + await rerender({ open: true, active: false }); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); + await rerender({ open: true, active: true }); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Generate New Token' })); + expect(await screen.findByRole('button', { name: 'Copy Token' })).toBeTruthy(); +}); + +test('late clipboard completion cannot restore copied state after close and reopening', async () => { + let finishCopy!: () => void; + const writeText = vi.fn().mockReturnValue( + new Promise((resolve) => { + finishCopy = resolve; + }), + ); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const { rerender } = mount(); + await fireEvent.click(await screen.findByRole('button', { name: 'Generate New Token' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Copy Token' })); + await rerender({ open: false }); + await rerender({ open: true }); + await fireEvent.click(screen.getByRole('button', { name: 'Generate New Token' })); + finishCopy(); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Copied!' })).toBeNull()); + expect(screen.getByRole('button', { name: 'Copy Token' })).toBeTruthy(); +}); + +test('unmount discards a pending HTTP rotation without feedback or readback', async () => { + let finish!: (value: string) => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const rotateMomHttpKey = vi.fn().mockReturnValue(pending); + const onRotated = vi.fn(); + const view = mount(rotateMomHttpKey); + await view.rerender({ open: true, onRotated }); + await fireEvent.click(await screen.findByRole('button', { name: 'Generate New Token' })); + view.unmount(); + finish('late-unmounted-token'); + await pending; + expect(onRotated).not.toHaveBeenCalled(); + expect(screen.queryByLabelText('New Secret Token Generated')).toBeNull(); }); diff --git a/src/routes/settings/__tests__/settings-search.test.ts b/src/routes/settings/__tests__/settings-search.test.ts index 307d9e3..3e84eae 100644 --- a/src/routes/settings/__tests__/settings-search.test.ts +++ b/src/routes/settings/__tests__/settings-search.test.ts @@ -69,7 +69,7 @@ function makeAdapter() { generation: 1, }), saveMcpProxySettings: vi.fn(), - rotateMcpProxyKey: vi.fn(), + rotateMomHttpKey: vi.fn(), getGlobalBehavior: vi.fn().mockResolvedValue({ path: '/global/AGENTS.md', resolvedPath: '/resolved/AGENTS.md', @@ -121,7 +121,7 @@ function expectNoWrites(adapter: ReturnType) { adapter.checkForUpdates, adapter.setMomHttpSettings, adapter.saveMcpProxySettings, - adapter.rotateMcpProxyKey, + adapter.rotateMomHttpKey, adapter.saveGlobalBehavior, ]) expect(call).not.toHaveBeenCalled(); @@ -256,7 +256,7 @@ test('search and same-tab navigation retain URL, password, clear-password and HT expect(config.draftRecovery).toBeNull(); await vi.advanceTimersByTimeAsync(2000); expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); - expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(2); + expect(adapter.getMomHttpSettings).toHaveBeenCalledOnce(); expect(port.value).toBe('23456'); expect(password.value).toBe('draft-only-private-password'); await fireEvent.click(clear); @@ -267,13 +267,15 @@ test('search and same-tab navigation retain URL, password, clear-password and HT expect(password.isConnected).toBe(false); await vi.advanceTimersByTimeAsync(4000); expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); - expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(2); + expect(adapter.getMomHttpSettings).toHaveBeenCalledOnce(); }); -test('proxy drafts and revisions survive search, empty results, locale changes and polling; header refresh remains active-tab only', async () => { +test('proxy drafts and revisions survive search, empty results, locale changes and explicit refresh', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const { adapter, config, unmount } = mountPage('mcp-proxy'); await settle(); + expect(adapter.getMcpProxySettings).toHaveBeenCalledOnce(); + expect(adapter.getMcpProxyState).toHaveBeenCalledOnce(); const timeout = activePanel().getByRole('spinbutton', { name: 'Session Idle Timeout (seconds)' }) as HTMLInputElement; const units = activePanel().getByRole('spinbutton', { name: 'Maximum Concurrent Units' }) as HTMLInputElement; await fireEvent.input(timeout, { target: { value: '7654' } }); @@ -295,8 +297,10 @@ test('proxy drafts and revisions survive search, empty results, locale changes a ); await settle(); expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(2); + expect(adapter.getMcpProxyState).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(2000); - expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(3); + expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(2); + expect(adapter.getMcpProxyState).toHaveBeenCalledTimes(2); expect(timeout.value).toBe('7654'); expect(units.value).toBe('77'); expect(adapter.getMomHttpSettings).not.toHaveBeenCalled(); @@ -313,10 +317,16 @@ test('proxy drafts and revisions survive search, empty results, locale changes a { enabled: true, idleTimeout: 7654, maxUnits: 77, revision: 1 }, 1, ); + expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(3); + expect(adapter.getMcpProxyState).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(4000); + expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(3); + expect(adapter.getMcpProxyState).toHaveBeenCalledTimes(3); unmount(); const reads = adapter.getMcpProxySettings.mock.calls.length; await vi.advanceTimersByTimeAsync(4000); expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(reads); + expect(adapter.getMcpProxyState).toHaveBeenCalledTimes(3); }); test('cross-tab results navigate and focus without activating controls; only explicit navigation changes lifecycles', async () => { @@ -324,12 +334,12 @@ test('cross-tab results navigate and focus without activating controls; only exp const { adapter, unmount } = mountPage(); await settle(); const search = screen.getByRole('searchbox', { name: 'Search settings' }) as HTMLInputElement; - await fireEvent.input(search, { target: { value: 'rotate' } }); + await fireEvent.input(search, { target: { value: 'idleTimeout' } }); expect(adapter.getMcpProxySettings).not.toHaveBeenCalled(); - await fireEvent.click(within(results()).getByRole('button', { name: /Rotate Secret Token/ })); + await fireEvent.click(within(results()).getByRole('button', { name: /Session Idle Timeout/ })); await settle(); expect(screen.getByRole('tab', { name: 'MCP Proxy' }).getAttribute('aria-selected')).toBe('true'); - expect(document.activeElement?.id).toBe('settings-proxy-token'); + expect(document.activeElement?.id).toBe('settings-proxy-timeout'); expect(screen.queryByRole('dialog')).toBeNull(); expect(adapter.getMcpProxySettings).toHaveBeenCalledOnce(); const lidReads = adapter.getLidProtection.mock.calls.length; @@ -337,13 +347,14 @@ test('cross-tab results navigate and focus without activating controls; only exp await vi.advanceTimersByTimeAsync(2000); expect(adapter.getLidProtection).toHaveBeenCalledTimes(lidReads); expect(adapter.getMomHttpSettings).toHaveBeenCalledTimes(httpReads); - expect(adapter.getMcpProxySettings).toHaveBeenCalledTimes(2); - await fireEvent.input(search, { target: { value: '18765' } }); - await fireEvent.click(within(results()).getByRole('button', { name: /Port/ })); + expect(adapter.getMcpProxySettings).toHaveBeenCalledOnce(); + await fireEvent.input(search, { target: { value: 'MOM HTTP token' } }); + await fireEvent.click(within(results()).getByRole('button', { name: /General Rotate Secret Token/ })); await settle(); - expect(document.activeElement?.id).toBe('settings-http-port'); + expect(document.activeElement?.id).toBe('settings-http-token'); expect(screen.getByRole('tab', { name: 'General' }).getAttribute('aria-selected')).toBe('true'); - expect(search.value).toBe('18765'); + expect(search.value).toBe('MOM HTTP token'); + expect(screen.queryByRole('dialog')).toBeNull(); expect(goto).toHaveBeenCalledTimes(2); expect(vi.mocked(goto).mock.calls[0]?.[0]).toBe('http://localhost/settings?tab=mcp-proxy'); expectNoWrites(adapter); @@ -351,7 +362,7 @@ test('cross-tab results navigate and focus without activating controls; only exp unmount(); }); -test('cross-tab search retains both panels and all drafts while inactive polling stays paused', async () => { +test('cross-tab search retains both panels and all drafts without periodic reads', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const confirm = vi.spyOn(window, 'confirm'); const { adapter } = mountPage(); @@ -447,7 +458,7 @@ test('inactive native info completion cannot start CLI installation or Island re expectNoWrites(adapter); }); -test('HTTP in-flight guards survive fast tab round trips and completion while inactive never resumes polling', async () => { +test('HTTP in-flight guards survive fast tab round trips and inactive completion starts no readback', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const read = deferred<{ port: number; allowLan: boolean }>(); const save = deferred(); From e1cc6661c2ded87117c0fcf1be4045a1d8a7efee Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 1 Nov 2026 15:55:11 +0800 Subject: [PATCH 82/97] refactor(settings): simplify lid preference lifecycle without polling --- src/i18n/dictionaries/en.ts | 31 +- src/i18n/dictionaries/zh.ts | 30 +- src/routes/settings/LidProtection.svelte | 186 ++--- src/routes/settings/SettingsSearch.svelte | 10 +- .../settings/__tests__/lid-protection.test.ts | 686 ++++++------------ .../__tests__/settings-search.test.ts | 9 +- .../settings/__tests__/settings.test.ts | 6 +- 7 files changed, 311 insertions(+), 647 deletions(-) diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 0ecc93e..7decd5f 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -985,31 +985,12 @@ export const en = { 'settings.starProject': 'Star on GitHub', 'settings.lid': 'Stay awake with lid closed', 'settings.lidHint': - 'Keeps Mac awake while monitored sessions work. Off by default; macOS admin authorization required.', - 'settings.lidLoading': 'Reading status…', - 'settings.lidDisabled': 'Disabled', - 'settings.lidIdle': 'Watching — no working sessions', - 'settings.lidChecking': 'Checking authorization — keep the lid open', - 'settings.lidProtected': 'Protected — working sessions detected', - 'settings.lidProtectedOne': 'Protected — 1 working session', - 'settings.lidProtectedCount': 'Protected — {count} working sessions', - 'settings.lidUnknown': 'Status unknown — keep the lid open', - 'settings.lidError': 'Protection not confirmed — keep the lid open', - 'settings.lidAuthorizing': 'Enabling… authorization may take a moment. Keep the lid open.', - 'settings.lidRestoring': 'Restoring power settings… keep the lid open.', - 'settings.lidRefresh': 'Refresh status', - 'settings.lidRetry': 'Retry status', - 'settings.lidTurnOff': 'Turn off and restore', - 'settings.lidLoadFailed': 'Could not confirm lid protection.', - 'settings.lidSaveFailed': 'Could not update lid protection; showing the last confirmed setting.', - 'settings.lidWait': 'Keep MOM running; wait for Protected before closing the lid.', - 'settings.lidDetails': 'Authorization and safety', - 'settings.lidAuthorization': 'macOS asks for administrator authorization once per app run for a native helper.', - 'settings.lidScope': 'Only the selected local OpenCode service is monitored.', - 'settings.lidRestore': - 'Original power settings are restored when work ends or you turn this off. This never forces sleep.', - 'settings.lidSafety': 'Closed and awake uses more battery and can get hot. Never use it in a closed bag.', - 'settings.lidRecovery': 'If the helper is force-quit, power settings may need recovery on the next launch.', + 'While MOM runs, it tries to keep your laptop awake during work in the selected local OpenCode service.', + 'settings.lidLoadFailed': 'Could not read this setting.', + 'settings.lidSaveFailed': 'Could not change this setting.', + 'settings.lidAuthorization': + 'Saved preference only, not protection status. macOS requires administrator authorization each app run; after restarting MOM, toggle off then on to authorize.', + 'settings.lidSafety': 'Uses more power and generates heat; never use in a closed bag.', 'configuration.edit': 'Edit', 'configuration.viewAria': 'View {name}', 'configuration.editAria': 'Edit {name}', diff --git a/src/i18n/dictionaries/zh.ts b/src/i18n/dictionaries/zh.ts index e199192..b3d6146 100644 --- a/src/i18n/dictionaries/zh.ts +++ b/src/i18n/dictionaries/zh.ts @@ -967,30 +967,12 @@ export const zh: Record = { '如果 MOM 帮到了你,欢迎点个 Star。达到 100 星后,我会自费购买 Apple Developer 证书,让安装更顺畅。感谢支持 🙏', 'settings.starProject': '在 GitHub 点个 Star', 'settings.lid': '合盖后保持唤醒', - 'settings.lidHint': '监测到会话工作时保持 Mac 唤醒。默认关闭,需 macOS 管理员授权。', - 'settings.lidLoading': '正在读取状态…', - 'settings.lidDisabled': '已关闭', - 'settings.lidIdle': '正在监测 — 没有正在工作的会话', - 'settings.lidChecking': '正在检查管理员授权 — 请保持屏幕打开', - 'settings.lidProtected': '保护已生效 — 检测到正在工作的会话', - 'settings.lidProtectedOne': '保护已生效 — 1 个会话正在工作', - 'settings.lidProtectedCount': '保护已生效 — {count} 个会话正在工作', - 'settings.lidUnknown': '状态未知 — 请保持屏幕打开', - 'settings.lidError': '无法确认保护状态 — 请保持屏幕打开', - 'settings.lidAuthorizing': '正在启用… 授权可能需要一些时间,请保持屏幕打开。', - 'settings.lidRestoring': '正在恢复电源设置… 请保持屏幕打开。', - 'settings.lidRefresh': '刷新状态', - 'settings.lidRetry': '重试读取状态', - 'settings.lidTurnOff': '关闭并恢复', - 'settings.lidLoadFailed': '无法确认合盖保护状态。', - 'settings.lidSaveFailed': '无法更新合盖保护;开关显示上次确认的设置。', - 'settings.lidWait': '请保持 MOM 运行,等到显示保护已生效再合盖。', - 'settings.lidDetails': '授权与安全', - 'settings.lidAuthorization': 'macOS 每次运行应用会为原生辅助程序请求一次管理员授权。', - 'settings.lidScope': '仅监测选定的本地 OpenCode 服务。', - 'settings.lidRestore': '工作结束或关闭此功能时恢复原来的电源设置,不会强制睡眠。', - 'settings.lidSafety': '合盖保持唤醒会增加耗电和发热,切勿放入封闭的包内。', - 'settings.lidRecovery': '辅助程序被强制结束后,下次启动应用可能需要恢复电源设置。', + 'settings.lidHint': 'MOM 运行时,尝试在选定的本地 OpenCode 服务有会话工作时保持笔记本电脑唤醒。', + 'settings.lidLoadFailed': '无法读取此设置。', + 'settings.lidSaveFailed': '无法更改此设置。', + 'settings.lidAuthorization': + '开关仅表示已保存偏好,不代表保护生效。macOS 每次运行 MOM 均需管理员授权;重启后,请关闭再打开开关以授权。', + 'settings.lidSafety': '会增加耗电和发热,切勿放入封闭的包内。', 'configuration.edit': '编辑', 'configuration.viewAria': '查看 {name}', 'configuration.editAria': '编辑 {name}', diff --git a/src/routes/settings/LidProtection.svelte b/src/routes/settings/LidProtection.svelte index 75ebf9e..8f82d4f 100644 --- a/src/routes/settings/LidProtection.svelte +++ b/src/routes/settings/LidProtection.svelte @@ -1,48 +1,21 @@ @@ -149,73 +104,28 @@ $effect(() => { -

    - {i18n.t('settings.lidHint')} -

    +

    {i18n.t('settings.lidHint')}

    { event.preventDefault(); void setEnabled(!state.enabled); }} + onclick={(event) => { event.preventDefault(); void setEnabled(!enabled); }} />
    -
    -

    - {status} -

    - -
    -

    - {i18n.t('settings.lidWait')} +

    + {i18n.t('settings.lidAuthorization')}

    - {#if failure || state.phase === 'error'} -
    - - -
    - {/if} - {#if uncertain && !loading} - +

    + {i18n.t('settings.lidSafety')} +

    + {#if error} + {/if} -
    - - {i18n.t('settings.lidDetails')} - -
    -

    {i18n.t('settings.lidAuthorization')}

    -

    {i18n.t('settings.lidScope')}

    -

    {i18n.t('settings.lidRestore')}

    -

    {i18n.t('settings.lidSafety')}

    -

    {i18n.t('settings.lidRecovery')}

    -
    -
    diff --git a/src/routes/settings/SettingsSearch.svelte b/src/routes/settings/SettingsSearch.svelte index 5abcd3c..817635e 100644 --- a/src/routes/settings/SettingsSearch.svelte +++ b/src/routes/settings/SettingsSearch.svelte @@ -30,15 +30,7 @@ const entries: (SettingsSearchTarget & { keys: [MessageKey, MessageKey, ...Messa { tab: 'general', target: 'settings-lid', - keys: [ - 'settings.lid', - 'settings.lidHint', - 'settings.lidDetails', - 'settings.lidAuthorization', - 'settings.lidScope', - 'settings.lidSafety', - 'settings.lidRestore', - ], + keys: ['settings.lid', 'settings.lidHint', 'settings.lidAuthorization', 'settings.lidSafety'], }, { tab: 'general', target: 'settings-island', keys: ['settings.island', 'settings.islandHint'] }, { diff --git a/src/routes/settings/__tests__/lid-protection.test.ts b/src/routes/settings/__tests__/lid-protection.test.ts index fa14620..b39f791 100644 --- a/src/routes/settings/__tests__/lid-protection.test.ts +++ b/src/routes/settings/__tests__/lid-protection.test.ts @@ -1,14 +1,18 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; import { tick } from 'svelte'; import { afterEach, expect, test, vi } from 'vitest'; import type { CommandAdapter } from '$src/config/adapter.js'; -import { type ConfigStore, createConfigStore } from '$src/config/store.svelte.js'; +import { createConfigStore } from '$src/config/store.svelte.js'; +import { en } from '$src/i18n/dictionaries/en.js'; +import { zh } from '$src/i18n/dictionaries/zh.js'; import Harness from '$src/test-utils/harness.svelte'; import type { LidState } from '$src/types/power.js'; import LidProtection from '../LidProtection.svelte'; const disabled: LidState = { enabled: false, phase: 'disabled' }; -const protectedState: LidState = { enabled: true, phase: 'protected', activeSessions: 2 }; +const enabled: LidState = { enabled: true, phase: 'protected', activeSessions: 2 }; +const privateError = 'Enable lid protection again. private native error'; + function deferred() { let resolve!: (value: LidState) => void; let reject!: (reason: unknown) => void; @@ -18,531 +22,321 @@ function deferred() { }); return { promise, resolve, reject }; } -function mount(overrides: Record = {}) { - const config = { - preferences: { locale: 'en', theme: 'dark' }, - getLidProtection: vi.fn().mockResolvedValue(disabled), - setLidProtection: vi.fn().mockResolvedValue({ enabled: true, phase: 'idle' }), - ...overrides, - } as unknown as ConfigStore; - const view = render(LidProtection, {}, { wrapper: Harness, wrapperProps: { config } }); - const toggle = screen.getByRole('checkbox', { name: 'Stay awake with lid closed' }) as HTMLInputElement; - return { config, toggle, ...view }; -} -function mountTarget(overrides: Record = {}) { + +function mount(overrides: Record = {}, active = true) { const adapter = { + savePreferences: vi.fn().mockResolvedValue(undefined), getLidProtection: vi.fn().mockResolvedValue(disabled), - setLidProtection: vi.fn().mockResolvedValue(disabled), + setLidProtection: vi.fn().mockResolvedValue({ enabled: true, phase: 'idle' }), setOpenCodeServiceTarget: vi.fn().mockResolvedValue({ connected: false }), ...overrides, }; const config = createConfigStore(adapter as unknown as CommandAdapter, false, false); - const view = render(LidProtection, {}, { wrapper: Harness, wrapperProps: { config } }); + const view = render(LidProtection, { active }, { wrapper: Harness, wrapperProps: { config } }); const toggle = screen.getByRole('checkbox') as HTMLInputElement; return { ...view, config, adapter, toggle }; } + async function settle() { - await tick(); - await Promise.resolve(); - await tick(); + for (let step = 0; step < 4; step++) { + await Promise.resolve(); + await tick(); + } } + afterEach(() => { cleanup(); vi.useRealTimers(); }); -test('loads without enabling, waits through authorization, and uses native phase rather than enabled', async () => { +test('loads the preference once, keeps the native switch pending, and waits for authoritative toggle results', async () => { const read = deferred(); const save = deferred(); - const getLidProtection = vi.fn().mockReturnValueOnce(read.promise).mockResolvedValue(protectedState); - const setLidProtection = vi.fn().mockReturnValue(save.promise); - const { toggle } = mount({ getLidProtection, setLidProtection }); + const { adapter, toggle } = mount({ + getLidProtection: vi.fn().mockReturnValue(read.promise), + setLidProtection: vi.fn().mockReturnValue(save.promise), + }); + expect(toggle.tagName).toBe('INPUT'); + expect(toggle.type).toBe('checkbox'); + expect(toggle.name).toBe('lid-closed'); expect(toggle.disabled).toBe(true); expect(toggle.checked).toBe(false); - expect(screen.getByRole('status').textContent).toBe('Reading status…'); - await settle(); - expect(getLidProtection).toHaveBeenCalledOnce(); - expect(setLidProtection).not.toHaveBeenCalled(); + expect(toggle.getAttribute('aria-busy')).toBe('true'); + await fireEvent.click(toggle); + expect(adapter.setLidProtection).not.toHaveBeenCalled(); read.resolve(disabled); - await waitFor(() => expect(toggle.disabled).toBe(false)); + await settle(); + expect(adapter.getLidProtection).toHaveBeenCalledExactlyOnceWith(); + expect(toggle.disabled).toBe(false); for (const id of toggle.getAttribute('aria-describedby')?.split(' ') ?? []) { expect(document.getElementById(id)).toBeTruthy(); } toggle.focus(); - expect(document.activeElement).toBe(toggle); await fireEvent.click(toggle); - expect(setLidProtection).toHaveBeenCalledExactlyOnceWith(true); + await fireEvent.click(toggle); + expect(adapter.setLidProtection).toHaveBeenCalledExactlyOnceWith(true); expect(toggle.checked).toBe(false); expect(toggle.disabled).toBe(true); - expect(screen.getByRole('status').textContent).toContain('authorization may take a moment'); - expect((screen.getByRole('button', { name: 'Refresh status' }) as HTMLButtonElement).disabled).toBe(true); - save.resolve({ enabled: true, phase: 'idle' }); - await waitFor(() => expect(toggle.checked).toBe(true)); - expect(toggle.disabled).toBe(false); - expect(screen.getByRole('status').textContent).toBe('Watching — no working sessions'); - await fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); - await waitFor(() => expect(screen.getByRole('status').textContent).toBe('Protected — 2 working sessions')); - expect(setLidProtection).toHaveBeenCalledOnce(); -}); - -test.each([ - ['disabled', 'Disabled'], - ['idle', 'Watching — no working sessions'], - ['checking', 'Checking authorization — keep the lid open'], - ['protected', 'Protected — working sessions detected'], - ['unknown', 'Status unknown — keep the lid open'], - ['error', 'Protection not confirmed — keep the lid open'], -] as const)('renders native %s without claiming protection from the enabled flag', async (phase, text) => { - const { toggle } = mount({ getLidProtection: vi.fn().mockResolvedValue({ enabled: true, phase }) }); - await waitFor(() => expect(screen.getByRole('status').textContent).toBe(text)); - expect(toggle.checked).toBe(true); - expect(toggle.disabled).toBe(false); - if (phase === 'unknown' || phase === 'error') { - expect(screen.getByRole('button', { name: 'Turn off and restore' })).toBeTruthy(); - } -}); - -test('failed initial query allows OFF even while retry is pending; its late result cannot overwrite OFF', async () => { - const retry = deferred(); - const getLidProtection = vi - .fn() - .mockRejectedValueOnce(new Error('Status unavailable')) - .mockReturnValueOnce(retry.promise); - const setLidProtection = vi.fn().mockResolvedValue(disabled); - const { toggle } = mount({ getLidProtection, setLidProtection }); - expect((await screen.findByRole('alert')).textContent).toContain('Status unavailable'); - expect(toggle.disabled).toBe(true); - await fireEvent.click(screen.getByRole('button', { name: 'Retry status' })); - await fireEvent.click(screen.getByRole('button', { name: 'Turn off and restore' })); - await waitFor(() => expect(toggle.disabled).toBe(false)); - expect(setLidProtection).toHaveBeenCalledExactlyOnceWith(false); - retry.resolve(protectedState); + save.resolve({ enabled: true, phase: 'checking' }); await settle(); - expect(toggle.checked).toBe(false); - expect(screen.getByRole('status').textContent).toBe('Disabled'); - expect(screen.queryByRole('alert')).toBeNull(); -}); - -test('policy failure preserves confirmed switch state, never claims Protected, and permits OFF retry', async () => { - const setLidProtection = vi - .fn() - .mockRejectedValueOnce(new Error('restore denied')) - .mockResolvedValueOnce(disabled); - const { toggle } = mount({ getLidProtection: vi.fn().mockResolvedValue(protectedState), setLidProtection }); - await waitFor(() => expect(toggle.checked).toBe(true)); - await fireEvent.click(toggle); - const alert = await screen.findByRole('alert'); - expect(alert.textContent).toContain('restore denied'); - expect(alert.querySelector('b')).toBeNull(); expect(toggle.checked).toBe(true); expect(toggle.disabled).toBe(false); - expect(toggle.getAttribute('aria-describedby')).toContain('lid-error'); - expect(screen.getByRole('status').textContent).toBe('Protection not confirmed — keep the lid open'); - await fireEvent.click(screen.getByRole('button', { name: 'Turn off and restore' })); - await waitFor(() => expect(toggle.checked).toBe(false)); - expect(setLidProtection).toHaveBeenNthCalledWith(2, false); - expect(screen.getByRole('status').textContent).toBe('Disabled'); -}); - -test('accepted command is not a fake success when native readback reports an error or a different enabled value', async () => { - const { toggle, config } = mount({ - setLidProtection: vi.fn().mockResolvedValue({ enabled: false, phase: 'error', error: 'Authorization cancelled' }), - }); - await waitFor(() => expect(toggle.disabled).toBe(false)); - await fireEvent.click(toggle); - expect((await screen.findByRole('alert')).textContent).toContain('Authorization cancelled'); - expect(toggle.checked).toBe(false); - expect(config.setLidProtection).toHaveBeenCalledExactlyOnceWith(true); - expect(screen.getByRole('status').textContent).toBe('Protection not confirmed — keep the lid open'); -}); - -test('a fresh poll clears a query failure, but a failed setting needs explicit readback or OFF retry', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const getLidProtection = vi - .fn() - .mockResolvedValueOnce(protectedState) - .mockRejectedValueOnce(new Error('Query failed')) - .mockResolvedValue(protectedState); - const setLidProtection = vi.fn().mockRejectedValue(new Error('Setting failed')); - const { toggle } = mount({ getLidProtection, setLidProtection }); - await settle(); - await vi.advanceTimersByTimeAsync(2000); - expect(screen.getByRole('alert').textContent).toContain('Query failed'); - expect(toggle.disabled).toBe(false); - expect(screen.getByRole('status').textContent).toBe('Protection not confirmed — keep the lid open'); - await vi.advanceTimersByTimeAsync(2000); - expect(screen.queryByRole('alert')).toBeNull(); - expect(screen.getByRole('status').textContent).toBe('Protected — 2 working sessions'); - await fireEvent.click(toggle); - await settle(); - await vi.advanceTimersByTimeAsync(2000); - expect(screen.getByRole('alert').textContent).toContain('Setting failed'); - expect(screen.getByRole('status').textContent).toBe('Protection not confirmed — keep the lid open'); - await fireEvent.click(screen.getByRole('button', { name: 'Retry status' })); - await settle(); + expect(toggle.getAttribute('aria-busy')).toBe('false'); + expect(document.activeElement).toBe(toggle); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); expect(screen.queryByRole('alert')).toBeNull(); - expect(screen.getByRole('status').textContent).toBe('Protected — 2 working sessions'); -}); - -test('polls status only, without overlap or unchanged live-region mutations, and pauses while saving', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const poll = deferred(); - const save = deferred(); - const getLidProtection = vi - .fn() - .mockResolvedValueOnce(protectedState) - .mockReturnValueOnce(poll.promise) - .mockResolvedValue(protectedState); - const setLidProtection = vi.fn().mockReturnValue(save.promise); - const { toggle, unmount } = mount({ getLidProtection, setLidProtection }); - await settle(); - const status = screen.getByRole('status'); - const changed = vi.fn(); - const observer = new MutationObserver(changed); - observer.observe(status, { childList: true, characterData: true, subtree: true }); - await vi.advanceTimersByTimeAsync(2000); - expect(getLidProtection).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(6000); - expect(getLidProtection).toHaveBeenCalledTimes(2); - expect(status.textContent).toBe('Protected — 2 working sessions'); - poll.resolve({ ...protectedState }); - await settle(); - expect(changed).not.toHaveBeenCalled(); - expect(observer.takeRecords()).toHaveLength(0); - observer.disconnect(); - await fireEvent.click(toggle); - expect(status.textContent).toContain('Restoring power settings'); - await vi.advanceTimersByTimeAsync(6000); - expect(getLidProtection).toHaveBeenCalledTimes(2); - save.resolve(disabled); - await settle(); - expect(toggle.checked).toBe(false); - expect(status.textContent).toBe('Disabled'); - unmount(); - await vi.advanceTimersByTimeAsync(4000); - expect(getLidProtection).toHaveBeenCalledTimes(2); - expect(setLidProtection).toHaveBeenCalledExactlyOnceWith(false); }); -test.each(['resolve', 'reject'] as const)( - 'late poll %s cannot override a newer save; polling resumes after reconciliation', - async (completion) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const poll = deferred(); - const getLidProtection = vi - .fn() - .mockResolvedValueOnce(protectedState) - .mockReturnValueOnce(poll.promise) - .mockResolvedValue(disabled); - const setLidProtection = vi.fn().mockResolvedValue(disabled); - const { toggle } = mount({ getLidProtection, setLidProtection }); - await settle(); - await vi.advanceTimersByTimeAsync(2000); - await fireEvent.click(toggle); +test.each(['disabled', 'idle', 'checking', 'protected', 'unknown', 'error'] as const)( + 'native %s readback displays only the enabled preference, never phase/session warnings or backend errors', + async (phase) => { + const { container, toggle } = mount({ + getLidProtection: vi.fn().mockResolvedValue({ ...enabled, phase, error: privateError }), + }); await settle(); - expect(toggle.checked).toBe(false); - await vi.advanceTimersByTimeAsync(4000); - expect(getLidProtection).toHaveBeenCalledTimes(2); - if (completion === 'resolve') poll.resolve(protectedState); - else poll.reject(new Error('stale read failure')); - await settle(); - expect(screen.getByRole('status').textContent).toBe('Disabled'); + expect(toggle.checked).toBe(true); + expect(toggle.disabled).toBe(false); + expect(screen.queryByRole('status')).toBeNull(); expect(screen.queryByRole('alert')).toBeNull(); - await vi.advanceTimersByTimeAsync(2000); - expect(getLidProtection).toHaveBeenCalledTimes(3); - expect(setLidProtection).toHaveBeenCalledExactlyOnceWith(false); + expect(screen.queryByRole('button')).toBeNull(); + expect(container.querySelector('details')).toBeNull(); + expect( + screen.queryByText(/protected|protection not confirmed|status unknown|keep the lid open|working sessions/i), + ).toBeNull(); + expect(container.textContent).not.toContain(privateError); + expect(screen.getByText(en['settings.lidHint'])).toBeTruthy(); + expect(container.textContent).toContain(en['settings.lidAuthorization']); + expect(container.textContent).toContain(en['settings.lidSafety']); }, ); -test('a stale read finishing during authorization cannot restore the old protected status or restart polling', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const poll = deferred(); - const save = deferred(); - const getLidProtection = vi - .fn() - .mockResolvedValueOnce(protectedState) - .mockReturnValueOnce(poll.promise) - .mockResolvedValue(disabled); - const setLidProtection = vi.fn().mockReturnValue(save.promise); - const { toggle } = mount({ getLidProtection, setLidProtection }); - await settle(); - await vi.advanceTimersByTimeAsync(2000); - await fireEvent.click(toggle); - poll.resolve(protectedState); - await settle(); - expect(toggle.disabled).toBe(true); - expect(screen.getByRole('status').textContent).toContain('Restoring power settings'); - await vi.advanceTimersByTimeAsync(4000); - expect(getLidProtection).toHaveBeenCalledTimes(2); - save.resolve(disabled); - await settle(); - expect(toggle.checked).toBe(false); - await vi.advanceTimersByTimeAsync(2000); - expect(getLidProtection).toHaveBeenCalledTimes(3); -}); - -test.each(['read', 'save'] as const)( - 'unmount invalidates a pending %s and does not schedule another status read', - async (operation) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const pending = deferred(); - const getLidProtection = vi - .fn() - .mockReturnValue(operation === 'read' ? pending.promise : Promise.resolve(disabled)); - const setLidProtection = vi.fn().mockReturnValue(pending.promise); - const { toggle, unmount } = mount({ getLidProtection, setLidProtection }); +test.each(['en', 'zh'] as const)( + 'failed explicit toggles keep the preference and show only a concise %s error', + async (locale) => { + const messages = locale === 'zh' ? zh : en; + const { adapter, config, container, toggle } = mount({ + getLidProtection: vi.fn().mockResolvedValue(enabled), + setLidProtection: vi + .fn() + .mockRejectedValueOnce({ code: 'configuration_failed', message: privateError }) + .mockResolvedValueOnce(disabled), + }); await settle(); - if (operation === 'save') await fireEvent.click(toggle); - unmount(); - pending.reject(new Error('completed after unmount')); + await config.setLocale(locale); await settle(); - await vi.advanceTimersByTimeAsync(4000); - expect(getLidProtection).toHaveBeenCalledOnce(); - expect(setLidProtection).toHaveBeenCalledTimes(operation === 'save' ? 1 : 0); - expect(screen.queryByRole('status')).toBeNull(); - }, -); - -test.each([disabled, protectedState])( - 'successful target saves reread without changing enabled=$enabled; failed saves do nothing', - async (value) => { - const view = mountTarget({ getLidProtection: vi.fn().mockResolvedValue(value) }); - await waitFor(() => expect(view.toggle.disabled).toBe(false)); - expect(view.toggle.checked).toBe(value.enabled); - const details = view.container.querySelector('details') as HTMLDetailsElement; - details.open = true; - await view.config.setOpenCodeServiceTarget('http://127.0.0.1:5096'); - await waitFor(() => expect(view.adapter.getLidProtection).toHaveBeenCalledTimes(2)); - expect(screen.getByRole('checkbox')).toBe(view.toggle); - expect(view.toggle.checked).toBe(value.enabled); - expect(details.open).toBe(true); - view.adapter.setOpenCodeServiceTarget.mockRejectedValueOnce(new Error('Save failed')); - await expect(view.config.setOpenCodeServiceTarget(null)).rejects.toThrow('Save failed'); + expect(container.textContent).toContain(messages['settings.lidAuthorization']); + expect(container.textContent).toContain(messages['settings.lidSafety']); + await fireEvent.click(toggle); + await settle(); + expect(screen.getByRole('alert').textContent?.trim()).toBe(messages['settings.lidSaveFailed']); + expect(container.textContent).not.toContain(privateError); + expect(toggle.checked).toBe(true); + expect(toggle.disabled).toBe(false); + expect(toggle.getAttribute('aria-describedby')).toContain('lid-error'); + await fireEvent.click(toggle); await settle(); - expect(view.config.serviceTargetVersion).toBe(1); - expect(view.adapter.getLidProtection).toHaveBeenCalledTimes(2); - expect(view.adapter.setLidProtection).not.toHaveBeenCalled(); + expect(toggle.checked).toBe(false); + expect(screen.queryByRole('alert')).toBeNull(); + expect(adapter.setLidProtection).toHaveBeenNthCalledWith(1, false); + expect(adapter.setLidProtection).toHaveBeenNthCalledWith(2, false); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); }, ); -test.each(['resolve', 'reject'] as const)( - 'target changes during a pending read ignore its old %s and reread only the latest target', - async (completion) => { - const pending = deferred(); - const latest = deferred(); - const getLidProtection = vi.fn().mockReturnValueOnce(pending.promise).mockReturnValueOnce(latest.promise); - const view = mountTarget({ getLidProtection }); - await settle(); - await view.config.setOpenCodeServiceTarget('http://127.0.0.1:5096'); - await settle(); - await view.config.setOpenCodeServiceTarget(null); - await settle(); - expect(getLidProtection).toHaveBeenCalledOnce(); - if (completion === 'resolve') pending.resolve(protectedState); - else pending.reject(new Error('Old target error')); +test.each([ + { enabled: false, phase: 'error', error: privateError }, + { enabled: true, phase: 'error', error: privateError }, + { enabled: true, phase: 'idle', error: privateError }, + { enabled: false, phase: 'idle' }, +] satisfies LidState[])( + 'failed toggle readback owns enabled=$enabled without exposing its backend payload', + async (result) => { + const { container, toggle } = mount({ setLidProtection: vi.fn().mockResolvedValue(result) }); await settle(); - expect(getLidProtection).toHaveBeenCalledTimes(2); - expect(view.toggle.checked).toBe(false); - expect(screen.queryByRole('alert')).toBeNull(); - latest.resolve(disabled); + await fireEvent.click(toggle); await settle(); - expect(screen.getByRole('status').textContent).toBe('Disabled'); - expect(view.adapter.setLidProtection).not.toHaveBeenCalled(); + expect(toggle.checked).toBe(result.enabled); + expect(toggle.disabled).toBe(false); + expect(screen.getByRole('alert').textContent?.trim()).toBe(en['settings.lidSaveFailed']); + expect(container.textContent).not.toContain(privateError); + expect(screen.queryByRole('status')).toBeNull(); }, ); -test('target change during authorization defers the latest read without retrying the preference write', async () => { - const save = deferred(); - const latest = deferred(); - const getLidProtection = vi.fn().mockResolvedValueOnce(disabled).mockReturnValueOnce(latest.promise); - const setLidProtection = vi.fn().mockReturnValueOnce(save.promise); - const view = mountTarget({ getLidProtection, setLidProtection }); - await settle(); - await fireEvent.click(view.toggle); - await view.config.setOpenCodeServiceTarget(null); - await settle(); - expect(getLidProtection).toHaveBeenCalledOnce(); - save.resolve(protectedState); +test('initial read failure is concise, keeps an unknown preference disabled, and retries only on active entry', async () => { + const { adapter, container, rerender, toggle } = mount({ + getLidProtection: vi.fn().mockRejectedValueOnce(new Error(privateError)).mockResolvedValueOnce(enabled), + }); await settle(); - expect(getLidProtection).toHaveBeenCalledTimes(2); - expect(view.toggle.checked).toBe(false); - expect(screen.getByRole('status').textContent).not.toContain('Protected'); - latest.resolve({ enabled: true, phase: 'idle' }); + expect(screen.getByRole('alert').textContent?.trim()).toBe(en['settings.lidLoadFailed']); + expect(container.textContent).not.toContain(privateError); + expect(toggle.checked).toBe(false); + expect(toggle.disabled).toBe(true); + await fireEvent.click(toggle); + expect(adapter.setLidProtection).not.toHaveBeenCalled(); + await rerender({ active: false }); + await rerender({ active: true }); await settle(); - expect(view.toggle.checked).toBe(true); - expect(screen.getByRole('status').textContent).toBe('Watching — no working sessions'); - expect(setLidProtection).toHaveBeenCalledExactlyOnceWith(true); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(toggle.checked).toBe(true); + expect(toggle.disabled).toBe(false); + expect(screen.queryByRole('alert')).toBeNull(); }); -test.each([false, true])( - 'target change immediately hides old protected counts and errors while latest read is pending (errors=%s)', - async (errors) => { - const latest = deferred(); - const getLidProtection = vi - .fn() - .mockResolvedValueOnce({ ...protectedState, ...(errors ? { error: 'Old native error' } : {}) }) - .mockReturnValueOnce(latest.promise); - const setLidProtection = vi.fn().mockRejectedValueOnce(new Error('Old save error')); - const view = mountTarget({ getLidProtection, setLidProtection }); - await settle(); - expect(view.toggle.checked).toBe(true); - if (errors) { - await fireEvent.click(view.toggle); - await screen.findByRole('alert'); - expect(screen.getByRole('alert').textContent).toContain('Old save error'); - } else expect(screen.getByRole('status').textContent).toBe('Protected — 2 working sessions'); - await view.config.setOpenCodeServiceTarget(null); - await settle(); - expect(getLidProtection).toHaveBeenCalledTimes(2); - expect(view.toggle.checked).toBe(true); - expect(screen.getByRole('status').textContent).toBe('Reading status…'); - expect(screen.getByRole('status').className).not.toContain('accent-primary'); - expect(screen.queryByRole('alert')).toBeNull(); - latest.resolve({ enabled: true, phase: 'idle' }); - await settle(); - expect(screen.getByRole('status').textContent).toBe('Watching — no working sessions'); - expect(screen.queryByRole('alert')).toBeNull(); - expect(setLidProtection).toHaveBeenCalledTimes(errors ? 1 : 0); - }, -); - -test('an idle target reload consumes its queue once and returns to a single 2s polling timer', async () => { +test('never polls after load, toggles or target readback, even as fake timers advance', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const latest = deferred(); - const getLidProtection = vi - .fn() - .mockResolvedValueOnce(protectedState) - .mockReturnValueOnce(latest.promise) - .mockResolvedValue(disabled); - const view = mountTarget({ getLidProtection }); + const { adapter, config, toggle, unmount } = mount(); await settle(); - await view.config.setOpenCodeServiceTarget(null); + await vi.advanceTimersByTimeAsync(10000); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); + await fireEvent.click(toggle); + await settle(); + await vi.advanceTimersByTimeAsync(10000); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); + await config.setOpenCodeServiceTarget(null); await settle(); - expect(getLidProtection).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(10000); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); - await vi.advanceTimersByTimeAsync(8000); - expect(getLidProtection).toHaveBeenCalledTimes(2); - latest.resolve(disabled); + unmount(); + await vi.advanceTimersByTimeAsync(10000); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); +}); + +test('inactive mount and target changes start no work; each active entry reads without toggling', async () => { + const { adapter, config, rerender, toggle } = mount({}, false); + await config.setOpenCodeServiceTarget(null); await settle(); - expect(vi.getTimerCount()).toBe(1); - await vi.advanceTimersByTimeAsync(1999); - expect(getLidProtection).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(1); - expect(getLidProtection).toHaveBeenCalledTimes(3); - expect(vi.getTimerCount()).toBe(1); - expect(view.adapter.setLidProtection).not.toHaveBeenCalled(); + await fireEvent.click(toggle); + expect(adapter.getLidProtection).not.toHaveBeenCalled(); + expect(adapter.setLidProtection).not.toHaveBeenCalled(); + await rerender({ active: true }); + await settle(); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); + await rerender({ active: false }); + await fireEvent.click(toggle); + await rerender({ active: true }); + await settle(); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(adapter.setLidProtection).not.toHaveBeenCalled(); }); -test.each(['read', 'save', 'read-first', 'save-first'] as const)( - 'target reload stays queued through %s, drains once, then resumes the ordinary 2s poll', - async (operation) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const read = deferred(); - const save = deferred(); - const latest = deferred(); - const getLidProtection = vi.fn().mockResolvedValueOnce(protectedState); - if (operation !== 'save') getLidProtection.mockReturnValueOnce(read.promise); - getLidProtection.mockReturnValueOnce(latest.promise).mockResolvedValue(disabled); - const view = mountTarget({ getLidProtection, setLidProtection: vi.fn().mockReturnValue(save.promise) }); +test.each([disabled, enabled])( + 'only successful target saves reread the preference without remounting (enabled=$enabled)', + async (value) => { + const { adapter, config, toggle } = mount({ getLidProtection: vi.fn().mockResolvedValue(value) }); await settle(); - if (operation !== 'save') await vi.advanceTimersByTimeAsync(2000); - if (operation !== 'read') await fireEvent.click(view.toggle); - const beforeReload = getLidProtection.mock.calls.length; - await view.config.setOpenCodeServiceTarget('http://127.0.0.1:5096'); + await config.setOpenCodeServiceTarget('http://127.0.0.1:5096'); await settle(); - await view.config.setOpenCodeServiceTarget(null); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(screen.getByRole('checkbox')).toBe(toggle); + expect(toggle.checked).toBe(value.enabled); + adapter.setOpenCodeServiceTarget.mockRejectedValueOnce(new Error('Save failed')); + await expect(config.setOpenCodeServiceTarget(null)).rejects.toThrow('Save failed'); await settle(); - expect(getLidProtection).toHaveBeenCalledTimes(beforeReload); - expect(screen.getByRole('status').textContent).not.toContain('Protected'); - await vi.advanceTimersByTimeAsync(8000); - expect(getLidProtection).toHaveBeenCalledTimes(beforeReload); - expect(vi.getTimerCount()).toBe(0); + expect(config.serviceTargetVersion).toBe(1); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(adapter.setLidProtection).not.toHaveBeenCalled(); + }, +); - if (operation === 'read-first' || operation === 'save-first') { - if (operation === 'read-first') read.reject(new Error('Old target read failed')); - else save.reject(new Error('Old target preference write failed')); +test.each(['read', 'save'] as const)( + 'target changes during a pending %s defer one latest read and reject stale success or failure', + async (operation) => { + for (const completion of ['resolve', 'reject'] as const) { + const pending = deferred(); + const latest = deferred(); + const getLidProtection = vi.fn(); + if (operation === 'save') getLidProtection.mockResolvedValueOnce(disabled); + getLidProtection.mockReturnValueOnce(operation === 'read' ? pending.promise : latest.promise); + if (operation === 'read') getLidProtection.mockReturnValueOnce(latest.promise); + const view = mount({ getLidProtection, setLidProtection: vi.fn().mockReturnValue(pending.promise) }); + await settle(); + if (operation === 'save') await fireEvent.click(view.toggle); + await view.config.setOpenCodeServiceTarget('http://127.0.0.1:5096'); await settle(); - await vi.advanceTimersByTimeAsync(4000); - expect(getLidProtection).toHaveBeenCalledTimes(beforeReload); - expect(vi.getTimerCount()).toBe(0); + await view.config.setOpenCodeServiceTarget(null); + await settle(); + expect(getLidProtection).toHaveBeenCalledOnce(); + expect(view.toggle.disabled).toBe(true); + if (completion === 'resolve') pending.resolve(enabled); + else pending.reject(new Error(privateError)); + await settle(); + expect(getLidProtection).toHaveBeenCalledTimes(2); + expect(view.toggle.checked).toBe(false); + expect(view.toggle.disabled).toBe(true); + expect(screen.queryByRole('alert')).toBeNull(); + latest.resolve({ enabled: true, phase: 'unknown', error: privateError }); + await settle(); + expect(view.toggle.checked).toBe(true); + expect(view.toggle.disabled).toBe(false); + expect(screen.queryByRole('alert')).toBeNull(); + expect(view.adapter.setLidProtection).toHaveBeenCalledTimes(operation === 'save' ? 1 : 0); + view.unmount(); } - read.resolve(protectedState); - save.resolve(protectedState); - await settle(); - expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 1); - expect(screen.queryByRole('alert')).toBeNull(); - await vi.advanceTimersByTimeAsync(8000); - expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 1); - expect(vi.getTimerCount()).toBe(0); - - latest.resolve(disabled); - await settle(); - expect(screen.getByRole('status').textContent).toBe('Disabled'); - expect(vi.getTimerCount()).toBe(1); - await vi.advanceTimersByTimeAsync(1999); - expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 1); - await vi.advanceTimersByTimeAsync(1); - expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 2); - expect(vi.getTimerCount()).toBe(1); - expect(view.adapter.setLidProtection).toHaveBeenCalledTimes(operation === 'read' ? 0 : 1); - view.unmount(); - expect(vi.getTimerCount()).toBe(0); - await vi.advanceTimersByTimeAsync(4000); - expect(getLidProtection).toHaveBeenCalledTimes(beforeReload + 2); }, ); -test.each(['read', 'save'] as const)('unmount drops a queued target reload behind a pending %s', async (operation) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const pending = deferred(); - const getLidProtection = vi - .fn() - .mockReturnValueOnce(operation === 'read' ? pending.promise : Promise.resolve(disabled)); - const view = mountTarget({ getLidProtection, setLidProtection: vi.fn().mockReturnValue(pending.promise) }); - await settle(); - if (operation === 'save') await fireEvent.click(view.toggle); - await view.config.setOpenCodeServiceTarget(null); - await settle(); - view.unmount(); - pending.reject(new Error('Old target completed after unmount')); - await settle(); - await vi.advanceTimersByTimeAsync(8000); - expect(getLidProtection).toHaveBeenCalledOnce(); - expect(vi.getTimerCount()).toBe(0); - expect(screen.queryByRole('status')).toBeNull(); -}); - test.each(['read', 'save'] as const)( - 'inactive completion of a pending %s keeps target reload queued until re-entry', + 'inactive completion of a pending %s keeps the latest target read queued until re-entry', async (operation) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const pending = deferred(); - const getLidProtection = vi.fn().mockResolvedValueOnce(protectedState); - if (operation === 'read') getLidProtection.mockReturnValueOnce(pending.promise); - getLidProtection.mockResolvedValue(disabled); - const view = mountTarget({ getLidProtection, setLidProtection: vi.fn().mockReturnValue(pending.promise) }); + const getLidProtection = vi.fn(); + if (operation === 'save') getLidProtection.mockResolvedValueOnce(disabled); + else getLidProtection.mockReturnValueOnce(pending.promise); + getLidProtection.mockResolvedValue(enabled); + const view = mount({ getLidProtection, setLidProtection: vi.fn().mockReturnValue(pending.promise) }); await settle(); - if (operation === 'read') await vi.advanceTimersByTimeAsync(2000); - else await fireEvent.click(view.toggle); + if (operation === 'save') await fireEvent.click(view.toggle); await view.rerender({ active: false }); await view.config.setOpenCodeServiceTarget(null); await settle(); - const reads = getLidProtection.mock.calls.length; - pending.resolve(protectedState); + pending.resolve(enabled); await settle(); - await vi.advanceTimersByTimeAsync(6000); - expect(getLidProtection).toHaveBeenCalledTimes(reads); - expect(vi.getTimerCount()).toBe(0); - expect(screen.getByRole('status').textContent).not.toContain('Protected'); + expect(getLidProtection).toHaveBeenCalledOnce(); await fireEvent.click(view.toggle); expect(view.adapter.setLidProtection).toHaveBeenCalledTimes(operation === 'save' ? 1 : 0); await view.rerender({ active: true }); await settle(); - expect(getLidProtection).toHaveBeenCalledTimes(reads + 1); + expect(getLidProtection).toHaveBeenCalledTimes(2); expect(screen.getByRole('checkbox')).toBe(view.toggle); - expect(screen.getByRole('status').textContent).toBe('Disabled'); - expect(vi.getTimerCount()).toBe(1); - await vi.advanceTimersByTimeAsync(2000); - expect(getLidProtection).toHaveBeenCalledTimes(reads + 2); + expect(view.toggle.checked).toBe(true); + expect(view.toggle.disabled).toBe(false); }, ); + +test('fast active round trips retain the pending write guard and defer readback without another write', async () => { + const save = deferred(); + const { adapter, rerender, toggle } = mount({ setLidProtection: vi.fn().mockReturnValue(save.promise) }); + await settle(); + await fireEvent.click(toggle); + await rerender({ active: false }); + await rerender({ active: true }); + await fireEvent.click(toggle); + expect(adapter.setLidProtection).toHaveBeenCalledExactlyOnceWith(true); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); + expect(toggle.disabled).toBe(true); + save.resolve(enabled); + await settle(); + expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(toggle.disabled).toBe(false); +}); + +test.each(['read', 'save'] as const)('unmount drops a queued target reload behind a pending %s', async (operation) => { + const pending = deferred(); + const { adapter, config, toggle, unmount } = mount({ + getLidProtection: vi.fn().mockReturnValueOnce(operation === 'read' ? pending.promise : Promise.resolve(disabled)), + setLidProtection: vi.fn().mockReturnValue(pending.promise), + }); + await settle(); + if (operation === 'save') await fireEvent.click(toggle); + await config.setOpenCodeServiceTarget(null); + await settle(); + unmount(); + pending.reject(new Error(privateError)); + await settle(); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); + expect(adapter.setLidProtection).toHaveBeenCalledTimes(operation === 'save' ? 1 : 0); + expect(screen.queryByRole('checkbox')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); +}); diff --git a/src/routes/settings/__tests__/settings-search.test.ts b/src/routes/settings/__tests__/settings-search.test.ts index 3e84eae..658e019 100644 --- a/src/routes/settings/__tests__/settings-search.test.ts +++ b/src/routes/settings/__tests__/settings-search.test.ts @@ -160,11 +160,16 @@ test('matches bilingual labels, descriptions and technical names locally, and fo ['局域网', en['settings.http.allowLan']], ['默认插件', en['settings.defaultPlugin']], ['登录', en['settings.autostart']], + ['closed bag', en['settings.lid']], + ['管理员', en['settings.lid']], ['password', en['settings.servicePassword']], + ['MOM HTTP token', en['settings.http.rotateKey']], + ['令牌', en['settings.http.rotateKey']], ] as const) { await fireEvent.input(search, { target: { value: query } }); expect(within(results()).getByText(label)).toBeTruthy(); } + await fireEvent.input(search, { target: { value: 'password' } }); await config.setLocale('zh'); await tick(); expect(search).toBe(screen.getByRole('searchbox', { name: '搜索设置' })); @@ -255,7 +260,7 @@ test('search and same-tab navigation retain URL, password, clear-password and HT expectNoWrites(adapter); expect(config.draftRecovery).toBeNull(); await vi.advanceTimersByTimeAsync(2000); - expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); expect(adapter.getMomHttpSettings).toHaveBeenCalledOnce(); expect(port.value).toBe('23456'); expect(password.value).toBe('draft-only-private-password'); @@ -266,7 +271,7 @@ test('search and same-tab navigation retain URL, password, clear-password and HT unmount(); expect(password.isConnected).toBe(false); await vi.advanceTimersByTimeAsync(4000); - expect(adapter.getLidProtection).toHaveBeenCalledTimes(2); + expect(adapter.getLidProtection).toHaveBeenCalledOnce(); expect(adapter.getMomHttpSettings).toHaveBeenCalledOnce(); }); diff --git a/src/routes/settings/__tests__/settings.test.ts b/src/routes/settings/__tests__/settings.test.ts index 30a2a8b..64dfdbd 100644 --- a/src/routes/settings/__tests__/settings.test.ts +++ b/src/routes/settings/__tests__/settings.test.ts @@ -125,7 +125,7 @@ test('uses OS readback instead of requested value and refresh only reads status' expect(setAutostart).toHaveBeenCalledOnce(); }); -test('unavailable autostart does not block lid interaction or its independent refresh', async () => { +test('unavailable autostart does not block the independent lid preference', async () => { const getAutostart = vi.fn().mockRejectedValue(new Error('Login unavailable')); const config = mount({ getAutostart }); const lid = screen.getByRole('checkbox', { name: 'Stay awake with lid closed' }) as HTMLInputElement; @@ -133,8 +133,8 @@ test('unavailable autostart does not block lid interaction or its independent re await fireEvent.click(lid); await waitFor(() => expect(lid.checked).toBe(true)); expect(config.setLidProtection).toHaveBeenCalledExactlyOnceWith(true); - await fireEvent.click(screen.getByRole('button', { name: 'Refresh status' })); - await waitFor(() => expect(config.getLidProtection).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('button', { name: 'Refresh status' })).toBeNull(); + expect(config.getLidProtection).toHaveBeenCalledOnce(); expect(getAutostart).toHaveBeenCalledOnce(); expect(config.setAutostart).not.toHaveBeenCalled(); }); From 3bc0de743117d439db56d0a424522133980b8211 Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 1 Nov 2026 15:55:52 +0800 Subject: [PATCH 83/97] test(config): include child output in persistence failure diagnostics --- src-tauri/src/models/tests.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs index cf6349b..602e29e 100644 --- a/src-tauri/src/models/tests.rs +++ b/src-tauri/src/models/tests.rs @@ -275,16 +275,22 @@ fn save_config_partial_write_failure_preserves_original_bytes() { let Some(directory) = std::env::var_os(CHILD_DIRECTORY) else { let directory = ConfigDirectory::new(); fs::write(directory.path().join("config.json"), "{}").unwrap(); - let status = std::process::Command::new(std::env::current_exe().unwrap()) + let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ "--exact", "models::tests::save_config_partial_write_failure_preserves_original_bytes", "--nocapture", ]) .env(CHILD_DIRECTORY, directory.path()) - .status() + .output() .unwrap(); - assert!(status.success()); + assert!( + output.status.success(), + "child {}: {}{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); return; }; let directory = std::path::PathBuf::from(directory); From 47e3f2c04f86ab59f9f54dc4216a1012bf424bfa Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 1 Nov 2026 19:16:39 +0800 Subject: [PATCH 84/97] fix(plugins): satisfy strict clippy without changing validation --- src-tauri/src/opencode/plugins.rs | 22 ++++++++++------------ src-tauri/src/opencode/plugins/tests.rs | 1 + 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/opencode/plugins.rs b/src-tauri/src/opencode/plugins.rs index f382705..54da86d 100644 --- a/src-tauri/src/opencode/plugins.rs +++ b/src-tauri/src/opencode/plugins.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::{Component, Path, PathBuf}; +use std::path::{Component, Path}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -488,16 +488,14 @@ fn project_runtime(envelope: RuntimeEnvelope) -> Result version, outdated, updating, - } => { - if !bounded_text(target, 4096) - || version - .as_ref() - .is_some_and(|version| !bounded_text(version, 4096)) - || *outdated == Some(false) - || *updating == Some(false) - { - return Err(invalid_runtime()); - } + } if !bounded_text(target, 4096) + || version + .as_ref() + .is_some_and(|version| !bounded_text(version, 4096)) + || *outdated == Some(false) + || *updating == Some(false) => + { + return Err(invalid_runtime()); } _ => {} } @@ -604,7 +602,7 @@ fn validate_requested_directory( directory: Option<&Path>, runtime: &PluginRuntime, ) -> Result<(), AppError> { - if directory.is_some_and(|directory| PathBuf::from(&runtime.directory) != directory) { + if directory.is_some_and(|directory| Path::new(&runtime.directory) != directory) { return Err(AppError::configuration( "Plugin runtime location differs from the requested location", )); diff --git a/src-tauri/src/opencode/plugins/tests.rs b/src-tauri/src/opencode/plugins/tests.rs index 201165d..4513067 100644 --- a/src-tauri/src/opencode/plugins/tests.rs +++ b/src-tauri/src/opencode/plugins/tests.rs @@ -1,6 +1,7 @@ use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; From 0dbd89cf7b10224f8361d97b7f196092f07b1a94 Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 1 Nov 2026 19:17:12 +0800 Subject: [PATCH 85/97] feat(logging): persist bounded local diagnostics and trace operations --- src-tauri/src/AGENTS.md | 2 + src-tauri/src/commands/agent.rs | 6 +- src-tauri/src/commands/app.rs | 34 +- src-tauri/src/commands/group.rs | 15 +- src-tauri/src/commands/island.rs | 66 +- src-tauri/src/commands/logging.rs | 45 ++ src-tauri/src/commands/mcp_proxy.rs | 116 ++-- src-tauri/src/commands/mod.rs | 161 ++++- src-tauri/src/commands/opencode.rs | 12 +- src-tauri/src/commands/resources.rs | 35 +- src-tauri/src/groups.rs | 121 +++- src-tauri/src/http_server.rs | 121 ++-- src-tauri/src/island/AGENTS.md | 1 + src-tauri/src/island/mod.rs | 19 + src-tauri/src/island/tests.rs | 23 + src-tauri/src/lib.rs | 22 + src-tauri/src/logging/AGENTS.md | 33 + src-tauri/src/logging/mod.rs | 569 +++++++++++++++++ src-tauri/src/logging/storage.rs | 409 ++++++++++++ src-tauri/src/logging/tests.rs | 927 ++++++++++++++++++++++++++++ src-tauri/src/logging/types.rs | 346 +++++++++++ src-tauri/src/mcp_proxy/mod.rs | 53 +- src-tauri/src/models.rs | 2 + src-tauri/src/models/tests.rs | 24 + src-tauri/src/paths.rs | 4 + src-tauri/src/power/AGENTS.md | 1 + src-tauri/src/power/mod.rs | 20 + src-tauri/src/power/tests.rs | 20 + 28 files changed, 3047 insertions(+), 160 deletions(-) create mode 100644 src-tauri/src/commands/logging.rs create mode 100644 src-tauri/src/logging/AGENTS.md create mode 100644 src-tauri/src/logging/mod.rs create mode 100644 src-tauri/src/logging/storage.rs create mode 100644 src-tauri/src/logging/tests.rs create mode 100644 src-tauri/src/logging/types.rs diff --git a/src-tauri/src/AGENTS.md b/src-tauri/src/AGENTS.md index 4226fb3..7fc5c94 100644 --- a/src-tauri/src/AGENTS.md +++ b/src-tauri/src/AGENTS.md @@ -9,6 +9,7 @@ Rust crate root for configuration persistence, resource management, group projec ```text src/ ├── lib.rs # Tauri initialization and shutdown +├── logging/ # Opt-in local diagnostic recording, retention and bounded queries ├── commands/ # Domain IPC boundary ├── models.rs # App config types and persistence ├── paths.rs # Captured paths and resource scopes @@ -55,6 +56,7 @@ src/ | --- | --- | | Configuration and resource safety | [Configuration contracts](docs/config-contracts.md) | | Desktop ownership and validation | [Runtime lifecycle](docs/runtime-lifecycle.md) | +| Local diagnostic logs | [Logging contracts](logging/AGENTS.md) | ## Sync diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index f14be04..b30bb12 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -244,9 +244,9 @@ pub async fn create_agent( agent: AgentDefinitionDto, project_id: Option, ) -> CommandResult { - reject_project_write(project_id)?; let paths = state.inner().clone(); super::spawn_blocking_task("Create agent", move || { + reject_project_write(project_id)?; let storage = agent.storage.map(Into::into).ok_or_else(|| { AppError::validation("agent creation requires an explicit storage target") })?; @@ -272,9 +272,9 @@ pub async fn update_agent( agent: AgentDefinitionDto, project_id: Option, ) -> CommandResult { - reject_project_write(project_id)?; let paths = state.inner().clone(); super::spawn_blocking_task("Update agent", move || { + reject_project_write(project_id)?; let storage = agent.storage.map(Into::into).ok_or_else(|| { AppError::validation("agent update requires an explicit storage target") })?; @@ -292,9 +292,9 @@ pub async fn delete_agent( storage: Option, project_id: Option, ) -> CommandResult<()> { - reject_project_write(project_id)?; let paths = state.inner().clone(); super::spawn_blocking_task("Delete agent", move || { + reject_project_write(project_id)?; let storage: AgentStorage = match storage { Some(storage) => storage.into(), None => { diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index a54b2a9..adc9488 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use crate::agents; use crate::commands::agent::AgentDefinitionDto; use crate::error::AppError; +use crate::logging::{record, LogStatus, LogType}; use crate::models; use crate::paths::ConfigPaths; use crate::providers; @@ -26,7 +27,10 @@ pub async fn set_mom_http_settings( proxy: State<'_, std::sync::Arc>, settings: crate::http_server::MomHttpSettings, ) -> CommandResult<()> { - let http = http.inner().as_ref().map_err(Clone::clone)?; + let http = http.inner().as_ref().map_err(|error| { + record(LogType::Http, LogStatus::Error, "http_settings_unavailable"); + error.clone() + })?; update_http_settings( http.as_ref().clone(), proxy.inner().as_ref().clone(), @@ -49,7 +53,14 @@ pub async fn rotate_mom_http_key( http: State<'_, crate::http_server::HttpServerState>, proxy: State<'_, std::sync::Arc>, ) -> CommandResult { - let http = http.inner().as_ref().map_err(Clone::clone)?; + let http = http.inner().as_ref().map_err(|error| { + record( + LogType::Http, + LogStatus::Error, + "http_key_rotate_unavailable", + ); + error.clone() + })?; rotate_http_key(http.as_ref().clone(), proxy.inner().as_ref().clone()).await } @@ -159,11 +170,22 @@ pub async fn save_preferences( Ok(()) }) .await?; - if let Err(error) = app.emit_to("tray", "app:preferences-changed", ()) { - eprintln!("failed to notify tray window of preference change: {error}"); + if app.emit_to("tray", "app:preferences-changed", ()).is_err() { + record( + LogType::Config, + LogStatus::Error, + "preferences_committed_tray_notify_failed", + ); } - if let Err(error) = app.emit_to("island", "app:preferences-changed", ()) { - eprintln!("failed to notify island window of preference change: {error}"); + if app + .emit_to("island", "app:preferences-changed", ()) + .is_err() + { + record( + LogType::Config, + LogStatus::Error, + "preferences_committed_island_notify_failed", + ); } Ok(()) } diff --git a/src-tauri/src/commands/group.rs b/src-tauri/src/commands/group.rs index bdac694..dc9471a 100644 --- a/src-tauri/src/commands/group.rs +++ b/src-tauri/src/commands/group.rs @@ -3,6 +3,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::groups; +use crate::logging::{record, LogStatus, LogType}; use crate::models::ModelGroup; use crate::paths::ConfigPaths; @@ -50,13 +51,17 @@ pub async fn switch_group( ) -> CommandResult<()> { let paths = state.inner().clone(); super::spawn_blocking_task("Switch group", move || groups::switch_group(&paths, id)).await?; - if window.label() == "tray" { - if let Err(error) = window + if window.label() == "tray" + && window .app_handle() .emit_to("main", "tray:config-changed", ()) - { - eprintln!("failed to notify main window of group switch: {error}"); - } + .is_err() + { + record( + LogType::Group, + LogStatus::Error, + "group_switch_committed_notify_failed", + ); } Ok(()) } diff --git a/src-tauri/src/commands/island.rs b/src-tauri/src/commands/island.rs index a13ab9e..f3c0270 100644 --- a/src-tauri/src/commands/island.rs +++ b/src-tauri/src/commands/island.rs @@ -3,6 +3,7 @@ use tauri::{AppHandle, Manager, State}; use crate::error::AppError; use crate::island::{IslandService, IslandState, IslandView}; +use crate::logging::{operation, LogType}; use crate::opencode::island::IslandMessage; use crate::paths::ConfigPaths; @@ -36,22 +37,30 @@ pub(crate) async fn refresh_island( app: AppHandle, service: State<'_, IslandService>, ) -> Result { - service.refresh(&app).await?; - Ok(service.state()) + let operation = operation(LogType::Island, "island_refresh"); + let result = service.refresh(&app).await.map(|()| service.state()); + operation.finish(&result); + result } #[tauri::command] pub(crate) async fn set_island_view(app: AppHandle, view: IslandView) -> Result<(), AppError> { - let receiver = super::spawn_blocking_task("Set Mini Island view", move || { - let generation = app.state::().enabled_generation()?; - crate::island::on_main(&app, move |app| { - app.state::().set_view(app, view, generation) + let operation = operation(LogType::Island, "island_view_set"); + let result = async { + let receiver = super::spawn_blocking_task("Set Mini Island view", move || { + let generation = app.state::().enabled_generation()?; + crate::island::on_main(&app, move |app| { + app.state::().set_view(app, view, generation) + }) }) - }) - .await?; - receiver - .await - .map_err(|_| AppError::configuration("Mini Island native transition closed"))? + .await?; + receiver + .await + .map_err(|_| AppError::configuration("Mini Island native transition closed"))? + } + .await; + operation.finish(&result); + result } #[tauri::command] @@ -73,13 +82,16 @@ pub(crate) async fn island_send( message_id: String, expected_connection_generation: u64, ) -> Result<(), AppError> { - service + let operation = operation(LogType::Island, "island_send"); + let result = service .request( expected_connection_generation, |api| async move { api.send(&session_id, &text, &message_id).await }, true, ) - .await + .await; + operation.finish(&result); + result } #[tauri::command] @@ -90,7 +102,8 @@ pub(crate) async fn island_reply_permission( decision: String, expected_connection_generation: u64, ) -> Result<(), AppError> { - service + let operation = operation(LogType::Island, "island_permission_reply"); + let result = service .request( expected_connection_generation, |api| async move { @@ -99,7 +112,9 @@ pub(crate) async fn island_reply_permission( }, true, ) - .await + .await; + operation.finish(&result); + result } #[tauri::command] @@ -110,13 +125,16 @@ pub(crate) async fn island_reply_question( answer: Map, expected_connection_generation: u64, ) -> Result<(), AppError> { - service + let operation = operation(LogType::Island, "island_question_reply"); + let result = service .request( expected_connection_generation, |api| async move { api.reply_question(&session_id, &form_id, answer).await }, true, ) - .await + .await; + operation.finish(&result); + result } #[tauri::command] @@ -126,13 +144,16 @@ pub(crate) async fn island_cancel_question( form_id: String, expected_connection_generation: u64, ) -> Result<(), AppError> { - service + let operation = operation(LogType::Island, "island_question_cancel"); + let result = service .request( expected_connection_generation, |api| async move { api.cancel_question(&session_id, &form_id).await }, true, ) - .await + .await; + operation.finish(&result); + result } #[tauri::command] @@ -141,11 +162,14 @@ pub(crate) async fn island_interrupt( session_id: String, expected_connection_generation: u64, ) -> Result<(), AppError> { - service + let operation = operation(LogType::Island, "island_interrupt"); + let result = service .request( expected_connection_generation, |api| async move { api.interrupt(&session_id).await }, true, ) - .await + .await; + operation.finish(&result); + result } diff --git a/src-tauri/src/commands/logging.rs b/src-tauri/src/commands/logging.rs new file mode 100644 index 0000000..51d6895 --- /dev/null +++ b/src-tauri/src/commands/logging.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use tauri::State; + +use crate::error::AppError; +use crate::logging::{LogPage, LogQuery, LoggingService, LoggingState}; + +#[tauri::command] +pub(crate) fn get_logging_state(service: State<'_, Arc>) -> LoggingState { + service.state() +} + +#[tauri::command] +pub(crate) async fn set_logging_enabled( + service: State<'_, Arc>, + enabled: bool, +) -> Result { + let service = service.inner().clone(); + super::spawn_blocking_task("Logging settings", move || service.set_enabled(enabled)).await +} + +#[tauri::command] +pub(crate) async fn query_logs( + service: State<'_, Arc>, + query: LogQuery, +) -> Result { + let service = service.inner().clone(); + super::spawn_blocking_task("Query logs", move || service.query(query)).await +} + +#[tauri::command] +pub(crate) async fn open_logs_directory( + service: State<'_, Arc>, +) -> Result<(), AppError> { + let service = service.inner().clone(); + super::spawn_blocking_task("Open logs", move || service.open_directory()).await +} + +#[tauri::command] +pub(crate) async fn clear_logs( + service: State<'_, Arc>, +) -> Result { + let service = service.inner().clone(); + super::spawn_blocking_task("Clear logs", move || service.clear()).await +} diff --git a/src-tauri/src/commands/mcp_proxy.rs b/src-tauri/src/commands/mcp_proxy.rs index 654d5df..1306772 100644 --- a/src-tauri/src/commands/mcp_proxy.rs +++ b/src-tauri/src/commands/mcp_proxy.rs @@ -7,6 +7,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::http_server::HttpServerState; +use crate::logging::{operation, LogType}; use crate::mcp_proxy::config::{ GatewaySettings, ProxyProtocol, ProxyServiceConfig, ServiceTimeouts, }; @@ -245,15 +246,18 @@ pub async fn save_mcp_proxy_settings( settings: McpProxySettingsDto, expected_revision: u64, ) -> Result<(), AppError> { + let operation = operation(LogType::McpProxy, "mcp_proxy_settings_save"); let gateway = GatewaySettings { enabled: settings.enabled, idle_timeout_secs: settings.idle_timeout, max_units: settings.max_units, }; - proxy + let result = proxy .save_gateway_settings(gateway, expected_revision) - .await + .await; + operation.finish(&result); + result } #[tauri::command] @@ -276,19 +280,25 @@ pub async fn create_mcp_proxy_server( server: McpProxyServerDto, expected_revision: u64, ) -> Result { - let mut config = proxy.config().await; - if config.services.iter().any(|s| s.id == server.id) { - return Err(AppError::validation(format!( - "proxy server '{}' already exists", - server.id - ))); - } + let operation = operation(LogType::McpProxy, "mcp_proxy_server_create"); + let result = async { + let mut config = proxy.config().await; + if config.services.iter().any(|s| s.id == server.id) { + return Err(AppError::validation(format!( + "proxy server '{}' already exists", + server.id + ))); + } - let service = ProxyServiceConfig::try_from(server)?; - config.services.push(service.clone()); - proxy.save_config(config, expected_revision).await?; + let service = ProxyServiceConfig::try_from(server)?; + config.services.push(service.clone()); + proxy.save_config(config, expected_revision).await?; - Ok(McpProxyServerDto::from(&service)) + Ok(McpProxyServerDto::from(&service)) + } + .await; + operation.finish(&result); + result } #[tauri::command] @@ -297,18 +307,26 @@ pub async fn update_mcp_proxy_server( server: McpProxyServerDto, expected_revision: u64, ) -> Result { - let mut config = proxy.config().await; - let index = config - .services - .iter() - .position(|s| s.id == server.id) - .ok_or_else(|| AppError::not_found(format!("proxy server '{}' not found", server.id)))?; - - let service = ProxyServiceConfig::try_from(server)?; - config.services[index] = service.clone(); - proxy.save_config(config, expected_revision).await?; - - Ok(McpProxyServerDto::from(&service)) + let operation = operation(LogType::McpProxy, "mcp_proxy_server_update"); + let result = async { + let mut config = proxy.config().await; + let index = config + .services + .iter() + .position(|s| s.id == server.id) + .ok_or_else(|| { + AppError::not_found(format!("proxy server '{}' not found", server.id)) + })?; + + let service = ProxyServiceConfig::try_from(server)?; + config.services[index] = service.clone(); + proxy.save_config(config, expected_revision).await?; + + Ok(McpProxyServerDto::from(&service)) + } + .await; + operation.finish(&result); + result } #[tauri::command] @@ -317,17 +335,23 @@ pub async fn delete_mcp_proxy_server( id: String, expected_revision: u64, ) -> Result<(), AppError> { - let mut config = proxy.config().await; - let index = config - .services - .iter() - .position(|s| s.id == id) - .ok_or_else(|| AppError::not_found(format!("proxy server '{id}' not found")))?; - - config.services.remove(index); - proxy.save_config(config, expected_revision).await?; - - Ok(()) + let operation = operation(LogType::McpProxy, "mcp_proxy_server_delete"); + let result = async { + let mut config = proxy.config().await; + let index = config + .services + .iter() + .position(|s| s.id == id) + .ok_or_else(|| AppError::not_found(format!("proxy server '{id}' not found")))?; + + config.services.remove(index); + proxy.save_config(config, expected_revision).await?; + + Ok(()) + } + .await; + operation.finish(&result); + result } fn extract_command( @@ -460,14 +484,20 @@ pub async fn close_mcp_proxy_unit( proxy: tauri::State<'_, Arc>, request: CloseMcpProxyUnitRequest, ) -> Result<(), AppError> { - if request.expected_generation != proxy.generation() { - return Err(AppError::validation("proxy runtime generation conflict")); + let operation = operation(LogType::McpProxy, "mcp_proxy_unit_close"); + let result = async { + if request.expected_generation != proxy.generation() { + return Err(AppError::validation("proxy runtime generation conflict")); + } + proxy + .legacy_sessions() + .close_session(&request.unit_id) + .await?; + Ok(()) } - proxy - .legacy_sessions() - .close_session(&request.unit_id) - .await?; - Ok(()) + .await; + operation.finish(&result); + result } #[cfg(test)] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 346035f..8fbb407 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod behavior; pub mod custom_commands; pub mod group; pub(crate) mod island; +pub mod logging; pub mod mcp_proxy; pub mod model; pub mod opencode; @@ -15,6 +16,7 @@ pub mod resources; pub mod updates; use crate::error::AppError; +use crate::logging::{operation, LogType}; pub use crate::tray::*; pub use agent::*; pub use app::*; @@ -23,6 +25,7 @@ pub use behavior::*; pub use custom_commands::*; pub use group::*; pub(crate) use island::*; +pub(crate) use logging::*; pub use mcp_proxy::*; pub use model::*; pub use opencode::*; @@ -32,23 +35,137 @@ pub use provider::*; pub use resources::*; pub use updates::*; -pub(crate) async fn spawn_blocking_task( - operation: &'static str, - task: F, -) -> Result +fn critical_operation(label: &str) -> Option<(LogType, &'static str)> { + Some(match label { + "Save group" => (LogType::Group, "group_save"), + "Copy group" => (LogType::Group, "group_copy"), + "Delete group" => (LogType::Group, "group_delete"), + "Create provider" => (LogType::Resource, "provider_create"), + "Update provider" => (LogType::Resource, "provider_update"), + "Delete provider" => (LogType::Resource, "provider_delete"), + "Create model" => (LogType::Resource, "model_create"), + "Update model" => (LogType::Resource, "model_update"), + "Delete model" => (LogType::Resource, "model_delete"), + "Create agent" => (LogType::Resource, "agent_create"), + "Update agent" => (LogType::Resource, "agent_update"), + "Delete agent" => (LogType::Resource, "agent_delete"), + "Create MCP" => (LogType::Resource, "mcp_create"), + "Update MCP" => (LogType::Resource, "mcp_update"), + "Delete MCP" => (LogType::Resource, "mcp_delete"), + "Create skill" => (LogType::Resource, "skill_create"), + "Update skill" => (LogType::Resource, "skill_update"), + "Create slash command" => (LogType::Resource, "slash_command_create"), + "Update slash command" => (LogType::Resource, "slash_command_update"), + "Delete slash command" => (LogType::Resource, "slash_command_delete"), + "Save Global instructions" => (LogType::Config, "global_instructions_save"), + "Add project" => (LogType::Config, "project_add"), + "Remove project" => (LogType::Config, "project_remove"), + "Select project" => (LogType::Config, "project_select"), + "Save preferences" => (LogType::Config, "preferences_save"), + "Update plugin package" => (LogType::Resource, "plugin_package_update"), + "Plugin declaration removal" => (LogType::Resource, "plugin_declaration_remove"), + "OpenCode service target" => (LogType::Opencode, "opencode_target_set"), + "OpenCode update check" => (LogType::Opencode, "opencode_update_check"), + "OpenCode upgrade" => (LogType::Opencode, "opencode_upgrade"), + "OpenCode reload" => (LogType::Opencode, "opencode_reload"), + "Check for updates" => (LogType::App, "app_update_check"), + "Set autostart" => (LogType::App, "autostart_set"), + "Set lid protection" => (LogType::Power, "lid_protection_set"), + "Set Mini Island enabled" => (LogType::Island, "island_enabled_set"), + "MCP proxy import" => (LogType::McpProxy, "mcp_proxy_import_prepare"), + _ => return None, + }) +} + +fn read_failure_operation(label: &str) -> Option<(LogType, &'static str)> { + Some(match label { + "Load app state" => (LogType::Config, "app_state_load"), + "Provider discovery" => (LogType::Resource, "provider_list"), + "Model discovery" => (LogType::Resource, "model_list"), + "Agent discovery" => (LogType::Resource, "agent_list"), + "Read agent" => (LogType::Resource, "agent_read"), + "MCP discovery" => (LogType::Resource, "mcp_list"), + "Read MCP" => (LogType::Resource, "mcp_read"), + "Skill discovery" => (LogType::Resource, "skill_list"), + "Read skill" => (LogType::Resource, "skill_read"), + "Slash-command discovery" => (LogType::Resource, "slash_command_list"), + "Read slash command" => (LogType::Resource, "slash_command_read"), + "Read Global instructions" => (LogType::Config, "global_instructions_read"), + "Plugin runtime" => (LogType::Resource, "plugin_runtime_list"), + "Plugin removal preview" => (LogType::Resource, "plugin_removal_preview"), + "OpenCode model catalog" => (LogType::Opencode, "opencode_model_catalog"), + "OpenCode installation" => (LogType::Opencode, "opencode_installation_read"), + "Open project page" => (LogType::App, "project_page_open"), + _ => return None, + }) +} + +pub(crate) async fn spawn_blocking_task(label: &'static str, task: F) -> Result where T: Send + 'static, F: FnOnce() -> Result + Send + 'static, { - tauri::async_runtime::spawn_blocking(task) - .await - .map_err(|_| AppError::configuration(format!("{operation} worker unavailable")))? + let critical = critical_operation(label); + let tracked = critical + .or_else(|| read_failure_operation(label)) + .or_else(|| { + (label == "Switch group").then_some((LogType::Group, "group_switch_worker_failed")) + }) + .map(|(kind, event)| std::sync::Arc::new(operation(kind, event))); + let worker_operation = tracked.clone(); + tauri::async_runtime::spawn_blocking(move || { + let result = task(); + if let Some(operation) = worker_operation + .filter(|_| critical.is_some() || (label != "Switch group" && result.is_err())) + { + operation.finish(&result); + } + result + }) + .await + .map_err(|_| { + let error = AppError::configuration(format!("{label} worker unavailable")); + if let Some(operation) = tracked { + operation.finish::<()>(&Err(error.clone())); + } + error + })? } #[cfg(test)] mod tests { use super::*; + #[test] + fn logging_allowlist_excludes_reads_polls_and_domain_owned_switch() { + assert!(critical_operation("Save group").is_some()); + assert!(critical_operation("Switch group").is_none()); + assert!(critical_operation("Load app state").is_none()); + assert!(read_failure_operation("Load app state").is_some()); + for label in [ + "OpenCode service status", + "OpenCode active sessions", + "OpenCode token usage", + "Read lid protection", + "Read Mini Island state", + "Read autostart", + "Set Mini Island view", + "unknown-user-supplied-label", + ] { + assert!(critical_operation(label).is_none()); + assert!(read_failure_operation(label).is_none()); + } + for label in [ + "Logging preference", + "Query logs", + "Open logs", + "Clear logs", + ] { + assert!(critical_operation(label).is_none()); + assert!(read_failure_operation(label).is_none()); + } + } + #[test] fn blocking_task_runs_off_the_caller_thread_and_preserves_results() { let caller = std::thread::current().id(); @@ -61,7 +178,7 @@ mod tests { let expected = AppError::validation("invalid input"); let error = expected.clone(); assert_eq!( - spawn_blocking_task::<(), _>("Failure check", move || Err(error)).await, + spawn_blocking_task::<(), _>("Save group", move || Err(error)).await, Err(expected) ); }); @@ -70,12 +187,36 @@ mod tests { #[test] fn blocking_task_maps_worker_panics_to_configuration_errors() { let result = - tauri::async_runtime::block_on(spawn_blocking_task::<(), _>("Panic check", || { + tauri::async_runtime::block_on(spawn_blocking_task::<(), _>("Save group", || { panic!("worker failed") })); assert_eq!( result, - Err(AppError::configuration("Panic check worker unavailable")) + Err(AppError::configuration("Save group worker unavailable")) ); } + + #[test] + fn cancelling_a_waiter_does_not_cancel_its_blocking_mutation() { + let (entered, started) = std::sync::mpsc::channel(); + let (release, proceed) = std::sync::mpsc::channel(); + let (completed, result) = std::sync::mpsc::channel(); + tauri::async_runtime::block_on(async { + let waiter = tokio::spawn(spawn_blocking_task("Save group", move || { + entered.send(()).unwrap(); + proceed.recv().unwrap(); + completed.send(()).unwrap(); + Ok(()) + })); + started + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + release.send(()).unwrap(); + result + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + }); + } } diff --git a/src-tauri/src/commands/opencode.rs b/src-tauri/src/commands/opencode.rs index 78fbd30..9c7b45e 100644 --- a/src-tauri/src/commands/opencode.rs +++ b/src-tauri/src/commands/opencode.rs @@ -2,6 +2,7 @@ use tauri::{AppHandle, Manager, State}; use super::spawn_blocking_task; use crate::error::AppError; +use crate::logging::{operation, record, LogStatus, LogType}; use crate::opencode::{ self, installation, ModelCatalogEntry, OpenCodeInstallation, OpenCodeServiceStatus, OpenCodeUpdateCheck, TokenUsageRecord, @@ -14,7 +15,10 @@ type CommandResult = Result; pub async fn opencode_generate_prompt( request: opencode::generate::PromptGenerationRequest, ) -> CommandResult { - opencode::generate::generate_prompt(request).await + let operation = operation(LogType::Opencode, "opencode_prompt_generate"); + let result = opencode::generate::generate_prompt(request).await; + operation.finish(&result); + result } #[tauri::command] @@ -59,7 +63,11 @@ pub async fn opencode_set_service_target( |generation| { power.finish_target_refresh(); if island.finish_target_refresh(&app, generation).is_err() { - eprintln!("OpenCode target saved; Mini Island refresh unavailable"); + record( + LogType::Opencode, + LogStatus::Error, + "opencode_target_committed_island_unavailable", + ); } }, ) diff --git a/src-tauri/src/commands/resources.rs b/src-tauri/src/commands/resources.rs index e781613..d0092de 100644 --- a/src-tauri/src/commands/resources.rs +++ b/src-tauri/src/commands/resources.rs @@ -37,9 +37,12 @@ pub async fn create_mcp( draft: McpDraft, project_id: Option, ) -> Result { - reject_project_write(project_id)?; let paths = state.inner().clone(); - super::spawn_blocking_task("Create MCP", move || mcp::create(&paths, draft)).await + super::spawn_blocking_task("Create MCP", move || { + reject_project_write(project_id)?; + mcp::create(&paths, draft) + }) + .await } #[tauri::command] @@ -48,9 +51,12 @@ pub async fn update_mcp( draft: McpUpdate, project_id: Option, ) -> Result { - reject_project_write(project_id)?; let paths = state.inner().clone(); - super::spawn_blocking_task("Update MCP", move || mcp::update(&paths, draft)).await + super::spawn_blocking_task("Update MCP", move || { + reject_project_write(project_id)?; + mcp::update(&paths, draft) + }) + .await } #[tauri::command] @@ -59,9 +65,12 @@ pub async fn delete_mcp( name: String, project_id: Option, ) -> Result<(), AppError> { - reject_project_write(project_id)?; let paths = state.inner().clone(); - super::spawn_blocking_task("Delete MCP", move || mcp::delete(&paths, &name)).await + super::spawn_blocking_task("Delete MCP", move || { + reject_project_write(project_id)?; + mcp::delete(&paths, &name) + }) + .await } #[tauri::command] @@ -95,9 +104,12 @@ pub async fn create_skill( draft: SkillDraft, project_id: Option, ) -> Result { - reject_project_write(project_id)?; let paths = state.inner().clone(); - super::spawn_blocking_task("Create skill", move || skills::create(&paths, draft)).await + super::spawn_blocking_task("Create skill", move || { + reject_project_write(project_id)?; + skills::create(&paths, draft) + }) + .await } #[tauri::command] @@ -106,7 +118,10 @@ pub async fn update_skill( draft: SkillUpdate, project_id: Option, ) -> Result { - reject_project_write(project_id)?; let paths = state.inner().clone(); - super::spawn_blocking_task("Update skill", move || skills::update(&paths, draft)).await + super::spawn_blocking_task("Update skill", move || { + reject_project_write(project_id)?; + skills::update(&paths, draft) + }) + .await } diff --git a/src-tauri/src/groups.rs b/src-tauri/src/groups.rs index 36490fe..0c10f0e 100644 --- a/src-tauri/src/groups.rs +++ b/src-tauri/src/groups.rs @@ -3,6 +3,7 @@ use uuid::Uuid; use crate::document::{JsoncDoc, OMO_SCHEMA, OPENCODE_SCHEMA, SLIM_SCHEMA}; use crate::error::AppError; +use crate::logging::{operation, LogType}; use crate::models::{self, AppConfig, GroupType, ModelGroup}; use crate::paths::ConfigPaths; use crate::projection; @@ -131,9 +132,21 @@ pub fn delete_group(paths: &ConfigPaths, id: Uuid) -> Result Result<(), AppError> { + let operation = operation(LogType::Group, "group_switch"); + let result = switch_group_inner(paths, group_id, |event| operation.checkpoint(event)); + operation.finish(&result); + result +} + +fn switch_group_inner( + paths: &ConfigPaths, + group_id: Uuid, + mut checkpoint: impl FnMut(&'static str), +) -> Result<(), AppError> { let _write = models::config_write_lock(); let mut config = load(paths)?; let group = find_enabled_group(&config, group_id)?.clone(); + checkpoint("group_switch_validated"); if config.state.selected_group_id == Some(group_id) { return Ok(()); } @@ -143,12 +156,14 @@ pub fn switch_group(paths: &ConfigPaths, group_id: Uuid) -> Result<(), AppError> let source = JsoncDoc::read(&slim_file, SLIM_SCHEMA)?; let document = projection::project_slim_active(&group, &source)?; document.save(&slim_file)?; + checkpoint("group_switch_projection_complete"); } if group.group_type == GroupType::Omo { let omo_file = paths.omo_file(); let source = JsoncDoc::read(&omo_file, OMO_SCHEMA)?; let document = projection::project_omo(&group, &source)?; document.save(&omo_file)?; + checkpoint("group_switch_projection_complete"); } if group.group_type == GroupType::Native && projection::has_effective_opencode_overrides(&group) { @@ -156,8 +171,9 @@ pub fn switch_group(paths: &ConfigPaths, group_id: Uuid) -> Result<(), AppError> let source = JsoncDoc::read(&opencode_file, OPENCODE_SCHEMA)?; let (document, warnings) = projection::project_opencode(&group, &source)?; document.save(&opencode_file)?; - for warning in warnings { - eprintln!("warning: {warning}"); + checkpoint("group_switch_projection_complete"); + if !warnings.is_empty() { + eprintln!("warning: group projection skipped unavailable agent mappings"); } } @@ -348,8 +364,109 @@ fn unique_copy_group_name(source: &str, groups: &[ModelGroup]) -> Result> + Send + 'static, ) -> Result { let server = self.clone(); - tokio::spawn(async move { server.rotate_key_owned(cleanup).await }) - .await - .map_err(|error| { - AppError::configuration(format!("MOM HTTP key rotation task failed: {error}")) - })? + let operation = Arc::new(operation(LogType::Http, "http_key_rotate")); + let worker_operation = operation.clone(); + tokio::spawn(async move { + let result = server.rotate_key_owned(cleanup).await; + worker_operation.finish(&result); + result + }) + .await + .map_err(|error| { + let error = + AppError::configuration(format!("MOM HTTP key rotation task failed: {error}")); + operation.finish::<()>(&Err(error.clone())); + error + })? } async fn rotate_key_owned( @@ -299,9 +309,13 @@ impl HttpServer { state.config = config.auth; } // Keep admission closed until all owners of the previous epoch are gone. - if let Err(error) = cleanup.await { + if cleanup.await.is_err() { self.shutdown_token.cancel(); - eprintln!("warning: MOM HTTP key committed; admission remains closed: {error}"); + record( + LogType::Http, + LogStatus::Error, + "http_key_committed_cleanup_failed", + ); return Ok(key.raw_token); } let mut state = self.auth.write().unwrap_or_else(|error| error.into_inner()); @@ -325,19 +339,25 @@ impl HttpServer { } pub async fn start(&self) -> Result { - let mut runtime = self.runtime.lock().await; - if self.shutdown_token.is_cancelled() { - return Err(AppError::configuration("MOM HTTP service is shutting down")); - } - if runtime - .listener_handle - .as_ref() - .is_some_and(|handle| !handle.is_finished()) - { - return Ok(runtime.bound_addr.expect("running HTTP listener address")); + let operation = operation(LogType::Http, "http_start"); + let result = async { + let mut runtime = self.runtime.lock().await; + if self.shutdown_token.is_cancelled() { + return Err(AppError::configuration("MOM HTTP service is shutting down")); + } + if runtime + .listener_handle + .as_ref() + .is_some_and(|handle| !handle.is_finished()) + { + return Ok(runtime.bound_addr.expect("running HTTP listener address")); + } + let listener = Self::bind(runtime.settings).await?; + self.serve(&mut runtime, listener) } - let listener = Self::bind(runtime.settings).await?; - self.serve(&mut runtime, listener) + .await; + operation.finish(&result); + result } async fn bind(settings: MomHttpSettings) -> Result { @@ -377,11 +397,12 @@ impl HttpServer { } let stop = shutdown_token.clone(); runtime.listener_handle = Some(tokio::spawn(async move { - if let Err(error) = axum::serve(listener, router) + if axum::serve(listener, router) .with_graceful_shutdown(stop.cancelled_owned()) .await + .is_err() { - eprintln!("warning: MOM HTTP listener failed: {error}"); + record(LogType::Http, LogStatus::Error, "http_listener_failed"); } })); runtime.shutdown_token = shutdown_token; @@ -399,18 +420,23 @@ impl HttpServer { settings: MomHttpSettings, cleanup: impl std::future::Future> + Send + 'static, ) -> Result<(), AppError> { - settings.validate()?; let server = self.clone(); + let operation = Arc::new(operation(LogType::Http, "http_settings_save")); + let worker_operation = operation.clone(); tokio::spawn(async move { - let result = server.set_settings_owned(settings, cleanup).await; - if let Err(error) = &result { - eprintln!("warning: MOM HTTP settings update failed: {error}"); + let result = async { + settings.validate()?; + server.set_settings_owned(settings, cleanup).await } + .await; + worker_operation.finish(&result); result }) .await .map_err(|error| { - AppError::configuration(format!("MOM HTTP settings task failed: {error}")) + let error = AppError::configuration(format!("MOM HTTP settings task failed: {error}")); + operation.finish::<()>(&Err(error.clone())); + error })? } @@ -439,9 +465,13 @@ impl HttpServer { .epoch .cancel(); if let Err(error) = Self::stop(&mut runtime, Duration::from_secs(8)).await { - if let Err(cleanup) = cleanup.await { + if cleanup.await.is_err() { self.shutdown_token.cancel(); - eprintln!("warning: MOM HTTP stopped; MCP cleanup failed: {cleanup}"); + record( + LogType::Http, + LogStatus::Error, + "http_stopped_cleanup_failed", + ); } return Err(error); } @@ -470,14 +500,17 @@ impl HttpServer { .map_err(|error| { AppError::configuration(format!("save MOM HTTP settings task failed: {error}")) })??; + record(LogType::Http, LogStatus::Success, "http_settings_committed"); Ok::<_, AppError>(listener) } .await; let cleanup = cleanup.await; - if let Err(error) = &cleanup { + if cleanup.is_err() { self.shutdown_token.cancel(); - eprintln!( - "warning: MOM HTTP admission remains closed after MCP cleanup failed: {error}" + record( + LogType::Http, + LogStatus::Error, + "http_admission_closed_cleanup_failed", ); } match prepared { @@ -520,7 +553,7 @@ impl HttpServer { handle.abort(); if let Err(error) = handle.await { if !error.is_cancelled() { - eprintln!("warning: MOM HTTP listener task failed: {error}"); + record(LogType::Http, LogStatus::Error, "http_listener_task_failed"); } } return Err(AppError::configuration( @@ -533,15 +566,21 @@ impl HttpServer { } pub async fn shutdown(&self, deadline: Duration) -> Result<(), AppError> { - self.shutdown_token.cancel(); - let until = tokio::time::Instant::now() + deadline; - let mut runtime = tokio::time::timeout_at(until, self.runtime.lock()) + let operation = operation(LogType::Http, "http_shutdown"); + let result = async { + self.shutdown_token.cancel(); + let until = tokio::time::Instant::now() + deadline; + let mut runtime = tokio::time::timeout_at(until, self.runtime.lock()) + .await + .map_err(|_| AppError::configuration("timed out waiting for MOM HTTP shutdown"))?; + Self::stop( + &mut runtime, + until.saturating_duration_since(tokio::time::Instant::now()), + ) .await - .map_err(|_| AppError::configuration("timed out waiting for MOM HTTP shutdown"))?; - Self::stop( - &mut runtime, - until.saturating_duration_since(tokio::time::Instant::now()), - ) - .await + } + .await; + operation.finish(&result); + result } } diff --git a/src-tauri/src/island/AGENTS.md b/src-tauri/src/island/AGENTS.md index 2bb6dbd..61f4fc6 100644 --- a/src-tauri/src/island/AGENTS.md +++ b/src-tauri/src/island/AGENTS.md @@ -34,6 +34,7 @@ island/ - **Snapshot flow**: Subscribe before snapshots, coalesce only SSE invalidations, and limit message notifications to the expanded selected session; reconnect and collapse must clear the appropriate local lifetimes. - **Native authority**: Change views only explicitly, focus only current explicit expansion, and commit geometry after verified native readback; keep AppKit on its main thread without global monitors or timers. - **Acceptance**: Keep diagnostics free of session data and credentials; use synthetic lifecycle tests, then rebuild and restart for manual native hover, notch, focus, and display acceptance. +- **Diagnostics**: Log connection/error/recovery transitions only, never each snapshot or repeated failed poll. Keep session IDs, messages, questions, permissions and raw errors out of diagnostic records. ## Details diff --git a/src-tauri/src/island/mod.rs b/src-tauri/src/island/mod.rs index 3340738..2e2183f 100644 --- a/src-tauri/src/island/mod.rs +++ b/src-tauri/src/island/mod.rs @@ -7,6 +7,7 @@ use tauri::{AppHandle, Manager}; use tokio::sync::watch; use crate::error::AppError; +use crate::logging::{record, LogStatus, LogType}; use crate::models::{self, AppPreferences}; use crate::opencode::island::{self as api, IslandApi, IslandChange, IslandSnapshot}; use crate::paths::ConfigPaths; @@ -67,6 +68,17 @@ struct Cache { } impl Cache { + fn connection_event(&self, connection: &'static str) -> Option<(LogStatus, &'static str)> { + if self.connection == Some(connection) { + return None; + } + match connection { + "connected" => Some((LogStatus::Success, "island_connected")), + "error" => Some((LogStatus::Error, "island_connection_failed")), + _ => None, + } + } + fn current(&self, generation: u64) -> bool { self.enabled && self.generation == generation } @@ -126,6 +138,9 @@ impl Cache { } self.published = revision; self.snapshot = Arc::new(snapshot); + if let Some((status, event)) = self.connection_event("connected") { + record(LogType::Island, status, event); + } self.connection = Some("connected"); self.error = None; true @@ -137,6 +152,9 @@ impl Cache { } self.invalidate_api(); self.snapshot = Arc::default(); + if let Some((status, event)) = self.connection_event("error") { + record(LogType::Island, status, event); + } self.connection = Some("error"); self.error = Some("OpenCode connection unavailable; retrying".into()); true @@ -147,6 +165,7 @@ impl Cache { return false; } self.stop(); + record(LogType::Island, LogStatus::Error, "island_window_failed"); self.connection = Some("error"); self.error = Some("Mini Island window could not be created".into()); true diff --git a/src-tauri/src/island/tests.rs b/src-tauri/src/island/tests.rs index 570ce26..346e25e 100644 --- a/src-tauri/src/island/tests.rs +++ b/src-tauri/src/island/tests.rs @@ -4,6 +4,29 @@ use std::net::{SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::thread; +#[test] +fn connection_logging_selects_only_transitions_and_fixed_safe_events() { + let mut cache = Cache::default(); + assert_eq!(cache.connection_event("connecting"), None); + assert_eq!( + cache.connection_event("connected"), + Some((LogStatus::Success, "island_connected")) + ); + cache.connection = Some("connected"); + assert_eq!(cache.connection_event("connected"), None); + assert_eq!( + cache.connection_event("error"), + Some((LogStatus::Error, "island_connection_failed")) + ); + cache.connection = Some("error"); + cache.error = Some("private path, session, prompt and raw error".into()); + assert_eq!(cache.connection_event("error"), None); + assert_eq!( + cache.connection_event("connected"), + Some((LogStatus::Success, "island_connected")) + ); +} + struct FakeApi { address: SocketAddr, requests: Arc, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7510a32..79bcfb1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ pub mod groups; pub(crate) mod http_server; mod island; pub mod jsonc; +pub(crate) mod logging; pub mod mcp; pub(crate) mod mcp_proxy; pub mod models; @@ -49,6 +50,9 @@ pub fn run() { })) .setup(move |app| { let paths = paths::ConfigPaths::from_environment()?; + let logging = logging::LoggingService::new(&paths); + logging.install(); + app.manage(logging); opencode::target::initialize(&paths)?; let proxy = std::sync::Arc::new(mcp_proxy::ProxyService::new(&paths).unwrap_or_else( |error| { @@ -70,10 +74,20 @@ pub fn run() { ); #[cfg(any(target_os = "macos", target_os = "windows"))] tray::setup(app)?; + logging::record( + logging::LogType::App, + logging::LogStatus::Success, + "app.started", + ); Ok(()) }) .invoke_handler(tauri::generate_handler![ commands::load_app_state, + commands::get_logging_state, + commands::set_logging_enabled, + commands::query_logs, + commands::open_logs_directory, + commands::clear_logs, commands::get_island_state, commands::set_island_enabled, commands::refresh_island, @@ -211,6 +225,14 @@ pub fn run() { if let Some(service) = app.try_state::() { service.shutdown(); } + logging::record( + logging::LogType::App, + logging::LogStatus::Success, + "app.stopped", + ); + if let Some(service) = app.try_state::>() { + service.shutdown(std::time::Duration::from_secs(2)); + } } _ => {} }); diff --git a/src-tauri/src/logging/AGENTS.md b/src-tauri/src/logging/AGENTS.md new file mode 100644 index 0000000..2e9d4f8 --- /dev/null +++ b/src-tauri/src/logging/AGENTS.md @@ -0,0 +1,33 @@ +# Local Logging + +## Overview + +Opt-in, local-only JSONL diagnostics. Logging defaults off; the persisted switch is `state.loggingEnabled`. Instrumentation never changes business results and accepts only static event identifiers and typed, safe context. + +## Directory Structure + +```text +logging/ +├── mod.rs # Nonblocking instrumentation, writer queue and acknowledged controls +├── types.rs # Wire records, safe fields and validated query filters +├── storage.rs # Private bounded files, rotation and snapshot cursors +└── tests.rs # Persistence, lifecycle, privacy and filesystem regressions +``` + +## Conventions + +- Use `operation(kind, event)`, `checkpoint(event)` and `finish(&result)`; use `record(kind, status, event)` for standalone lifecycle events. Event names must match the kind-specific catalogue in `types.rs`; add new fixed identifiers there with instrumentation. Only declared group-switch checkpoints accept context, and their step must equal the event. Messages are fixed internally; only `ErrorCode` crosses the error boundary. +- Tests can create a `LoggingService` with temporary `ConfigPaths` and use its instance `operation`/`record` methods, `set_enabled`, and `query` to inspect records without installing or replacing the global logger. +- Persisted `enabled` is user intent, not recording success. After a committed setting change, writer failures return safe state (`enabled: true`, `recording: false`, fixed `error`); config save failures return `AppError` without changing recording. Re-enabling retries a failed writer even if the preference was already true. +- Enable persists configuration before starting or rotating files; a failed config save must never evict retained logs. Logging lifecycle identifiers are allowed, but setting/clear failures are exposed through safe state rather than recursively recorded. +- `app.started` marks completion of synchronous desktop setup, after target, proxy, power, Island and tray initialization. It does not claim that asynchronously started services are ready. +- Power phase and Island connection/window lifecycle events describe state changes at their existing publication boundaries only; never record polling iterations or attach raw state payloads. Deduplication belongs to those existing state-change seams, not the logger. +- Never capture console output, panic payloads, third-party logs, raw errors, credentials, paths, prompts, configuration contents or messages. No runtime logger arguments or arbitrary JSON context. +- One bounded queue owns writes and serialized acknowledged controls. A successful disable barrier drains previously accepted records before returning; a failed drain returns non-recording state with a safe error. Operation generations prevent pre-enable and previous-enable work from leaking into a new recording period. Shutdown has a deadline and never joins an unbounded I/O thread. +- Open the directory from a blocking worker and reap the opener with `status()`. Release the controls lock before waiting so a slow desktop opener cannot block logging settings, queries or shutdown. +- Keep at most five managed files of at most 5 MiB, bounded lines and pages of 1–200 records (default 100). Queries walk newest accepted records first and use bounded in-memory snapshot cursors; rotation, clear and file replacement invalidate cursors. +- Reject unsafe managed entries, symlinks and Windows reparse points. Unix directories/files require owner-only `0700`/`0600`; Windows uses inherited permissions, without claiming private ACL enforcement. This is not protection against a hostile same-account filesystem race. + +## Sync + +Keep this guide and linked docs in sync with changes to this directory. diff --git a/src-tauri/src/logging/mod.rs b/src-tauri/src/logging/mod.rs new file mode 100644 index 0000000..5e7b07c --- /dev/null +++ b/src-tauri/src/logging/mod.rs @@ -0,0 +1,569 @@ +mod storage; +mod types; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex, MutexGuard, Weak}; +use std::time::{Duration, Instant}; + +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use uuid::Uuid; + +use crate::error::{AppError, ErrorCode}; +use crate::models; +use crate::paths::ConfigPaths; +use storage::Storage; +pub(crate) use types::{ + LogContext, LogPage, LogQuery, LogRecord, LogStatus, LogType, LoggingState, +}; + +const QUEUE_CAPACITY: usize = 512; +const CONTROL_TIMEOUT: Duration = Duration::from_secs(5); +static INSTALLED: Mutex> = Mutex::new(Weak::new()); + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|error| error.into_inner()) +} + +struct Gate { + generation: u64, + active: bool, + sequence: u64, +} + +struct Shared { + gate: Mutex, + state: Mutex, + recording: AtomicBool, + dropped: AtomicU64, +} + +impl Shared { + fn fail(&self, message: &'static str) { + let mut gate = lock(&self.gate); + gate.active = false; + gate.generation = gate.generation.wrapping_add(1); + self.recording.store(false, Ordering::Release); + let mut state = lock(&self.state); + state.recording = false; + state.error = Some(message.to_owned()); + } + + fn dropped(&self) { + if self.recording.load(Ordering::Acquire) { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } +} + +enum Control { + Set { enabled: bool, generation: u64 }, + Clear { enabled: bool, generation: u64 }, + Query(LogQuery), + Directory, + Shutdown, +} + +enum Reply { + State, + Page(LogPage), + Directory(PathBuf), +} + +enum Message { + Record { + generation: u64, + record: Box, + }, + Control(Control, mpsc::Sender>), +} + +pub(crate) struct LoggingService { + config_file: PathBuf, + session_id: Uuid, + shared: Arc, + sender: mpsc::SyncSender, + controls: Mutex<()>, +} + +impl LoggingService { + pub(crate) fn new(paths: &ConfigPaths) -> Arc { + let (enabled, error) = match models::load_config(&paths.config_file()) { + Ok(config) => (config.state.logging_enabled, None), + Err(_) => (false, Some("Logging preference unavailable".to_owned())), + }; + let shared = Arc::new(Shared { + gate: Mutex::new(Gate { + generation: 0, + active: false, + sequence: 0, + }), + state: Mutex::new(LoggingState { + enabled, + recording: false, + directory: paths.logs_dir().to_string_lossy().into_owned(), + error, + dropped_records: 0, + }), + recording: AtomicBool::new(false), + dropped: AtomicU64::new(0), + }); + let (sender, receiver) = mpsc::sync_channel(QUEUE_CAPACITY); + let service = Arc::new(Self { + config_file: paths.config_file(), + session_id: Uuid::new_v4(), + shared: shared.clone(), + sender, + controls: Mutex::new(()), + }); + let directory = paths.logs_dir(); + match std::thread::Builder::new() + .name("mom-local-logs".to_owned()) + .spawn(move || writer(shared, receiver, Storage::new(directory))) + { + Ok(handle) => drop(handle), + Err(_) => service.shared.fail("Logging worker unavailable"), + } + if enabled { + let generation = service.pause(); + let _ = service.request( + Control::Set { + enabled, + generation, + }, + CONTROL_TIMEOUT, + ); + } + service + } + + pub(crate) fn install(self: &Arc) { + *lock(&INSTALLED) = Arc::downgrade(self); + } + + pub(crate) fn operation(self: &Arc, kind: LogType, event: &'static str) -> Operation { + Operation::new(self, kind, event) + } + + pub(crate) fn record(self: &Arc, kind: LogType, status: LogStatus, event: &'static str) { + self.operation(kind, event).emit( + event, + status, + match status { + LogStatus::Success => "Event completed", + LogStatus::Error => "Event failed", + }, + None, + false, + ); + } + + pub(crate) fn state(&self) -> LoggingState { + let mut state = lock(&self.shared.state).clone(); + state.recording = self.shared.recording.load(Ordering::Acquire); + state.dropped_records = self.shared.dropped.load(Ordering::Relaxed); + state + } + + fn pause(&self) -> u64 { + let mut gate = lock(&self.shared.gate); + gate.active = false; + gate.generation = gate.generation.wrapping_add(1); + self.shared.recording.store(false, Ordering::Release); + lock(&self.shared.state).recording = false; + gate.generation + } + + fn request(&self, control: Control, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let (reply, receiver) = mpsc::channel(); + let mut message = Message::Control(control, reply); + loop { + match self.sender.try_send(message) { + Ok(()) => break, + Err(mpsc::TrySendError::Full(returned)) if Instant::now() < deadline => { + message = returned; + std::thread::sleep(Duration::from_millis(1)); + } + Err(_) => { + self.shared.fail("Logging worker unavailable"); + return Err(AppError::configuration("Logging worker unavailable")); + } + } + } + match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(result) => result, + Err(_) => { + self.shared.fail("Logging worker timed out"); + Err(AppError::configuration("Logging worker timed out")) + } + } + } + + pub(crate) fn set_enabled(&self, enabled: bool) -> Result { + let _control = lock(&self.controls); + { + let _write = models::config_write_lock(); + let result = (|| { + let mut config = models::load_config(&self.config_file)?; + config.state.logging_enabled = enabled; + models::save_config(&self.config_file, &config) + })(); + if result.is_err() { + lock(&self.shared.state).error = + Some("Logging preference could not be saved".to_owned()); + return Err(AppError::configuration( + "Logging preference could not be saved", + )); + } + } + lock(&self.shared.state).enabled = enabled; + if !enabled || !self.shared.recording.load(Ordering::Acquire) { + let generation = self.pause(); + let _ = self.request( + Control::Set { + enabled, + generation, + }, + CONTROL_TIMEOUT, + ); + } else { + let gate = lock(&self.shared.gate); + if gate.active { + lock(&self.shared.state).error = None; + } + } + Ok(self.state()) + } + + pub(crate) fn clear(&self) -> Result { + let _control = lock(&self.controls); + let enabled = self.state().enabled; + let generation = self.pause(); + self.request( + Control::Clear { + enabled, + generation, + }, + CONTROL_TIMEOUT, + )?; + self.shared.dropped.store(0, Ordering::Relaxed); + Ok(self.state()) + } + + pub(crate) fn query(&self, query: LogQuery) -> Result { + let _control = lock(&self.controls); + match self.request(Control::Query(query), CONTROL_TIMEOUT)? { + Reply::Page(page) => Ok(page), + _ => Err(AppError::configuration("Logging worker unavailable")), + } + } + + pub(crate) fn open_directory(&self) -> Result<(), AppError> { + let control = lock(&self.controls); + let Reply::Directory(directory) = self.request(Control::Directory, CONTROL_TIMEOUT)? else { + return Err(AppError::configuration("Logging worker unavailable")); + }; + drop(control); + #[cfg(target_os = "macos")] + let mut command = std::process::Command::new("open"); + #[cfg(target_os = "windows")] + let mut command = std::process::Command::new("explorer.exe"); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let mut command = std::process::Command::new("xdg-open"); + let status = command + .arg(directory) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|_| AppError::configuration("Could not open local log directory"))?; + if !status.success() { + return Err(AppError::configuration( + "Could not open local log directory", + )); + } + Ok(()) + } + + pub(crate) fn shutdown(&self, timeout: Duration) { + // Exit never waits for a blocked filesystem thread or joins it without a deadline. + let Ok(_control) = self.controls.try_lock() else { + self.shared.fail("Logging shutdown incomplete"); + return; + }; + self.pause(); + let _ = self.request(Control::Shutdown, timeout); + } + + fn emit(&self, generation: u64, mut record: LogRecord) { + let Ok(mut gate) = self.shared.gate.try_lock() else { + self.shared.dropped(); + return; + }; + if !gate.active || gate.generation != generation { + return; + } + if !record.is_safe() { + self.shared.dropped(); + return; + } + gate.sequence = gate.sequence.saturating_add(1); + record.sequence = gate.sequence; + match self.sender.try_send(Message::Record { + generation, + record: Box::new(record), + }) { + Ok(()) => {} + Err(mpsc::TrySendError::Full(_)) => self.shared.dropped(), + Err(mpsc::TrySendError::Disconnected(_)) => { + self.shared.dropped(); + gate.active = false; + gate.generation = gate.generation.wrapping_add(1); + self.shared.recording.store(false, Ordering::Release); + drop(gate); + if let Ok(mut state) = self.shared.state.try_lock() { + state.recording = false; + state.error = Some("Logging worker unavailable".to_owned()); + } + } + } + } +} + +impl Drop for LoggingService { + fn drop(&mut self) { + self.pause(); + let (sender, _) = mpsc::channel(); + let _ = self + .sender + .try_send(Message::Control(Control::Shutdown, sender)); + } +} + +fn activate(shared: &Shared, generation: u64, enabled: bool) -> bool { + let mut gate = lock(&shared.gate); + if gate.generation != generation { + return false; + } + gate.active = enabled; + shared.recording.store(enabled, Ordering::Release); + let mut state = lock(&shared.state); + state.recording = enabled; + state.error = None; + true +} + +fn writer(shared: Arc, receiver: mpsc::Receiver, mut storage: Storage) { + let mut generation = None; + while let Ok(message) = receiver.recv() { + match message { + Message::Record { + generation: record_generation, + record, + } => { + if generation != Some(record_generation) { + shared.dropped.fetch_add(1, Ordering::Relaxed); + continue; + } + if storage.write(&record).is_err() { + shared.dropped.fetch_add(1, Ordering::Relaxed); + shared.fail("Local log write failed"); + generation = None; + let _ = storage.stop(); + } + } + Message::Control(control, reply) => { + let shutdown = matches!(control, Control::Shutdown); + let lifecycle = matches!( + control, + Control::Set { .. } | Control::Clear { .. } | Control::Shutdown + ); + let result = match control { + Control::Set { + enabled, + generation: next, + } => { + generation = None; + let result = if enabled { + storage.start() + } else { + storage.stop() + }; + result.map(|()| { + if activate(&shared, next, enabled) && enabled { + generation = Some(next); + } + Reply::State + }) + } + Control::Clear { + enabled, + generation: next, + } => { + generation = None; + storage.clear(enabled).map(|()| { + if activate(&shared, next, enabled) && enabled { + generation = Some(next); + } + Reply::State + }) + } + Control::Query(query) => storage.query(query).map(Reply::Page), + Control::Directory => storage.directory().map(Reply::Directory), + Control::Shutdown => storage.stop().map(|()| Reply::State), + }; + if result + .as_ref() + .is_err_and(|error| lifecycle || error.code == ErrorCode::ConfigurationFailed) + { + generation = None; + shared.fail("Local log storage unavailable"); + let _ = storage.stop(); + } + let _ = reply.send(result); + if shutdown { + return; + } + } + } + } + if storage.stop().is_err() { + shared.fail("Local log flush failed"); + } +} + +pub(crate) struct Operation { + inner: Option, + finished: AtomicBool, +} + +struct OperationInner { + logger: Weak, + generation: u64, + kind: LogType, + event: &'static str, + id: Uuid, + started: Instant, +} + +impl Operation { + fn new(logger: &Arc, kind: LogType, event: &'static str) -> Self { + let generation = match logger.shared.gate.try_lock() { + Ok(gate) => gate.active.then_some(gate.generation), + Err(_) => { + logger.shared.dropped(); + None + } + }; + Self { + inner: generation + .filter(|_| types::safe_event(kind, event)) + .map(|generation| OperationInner { + logger: Arc::downgrade(logger), + generation, + kind, + event, + id: Uuid::new_v4(), + started: Instant::now(), + }), + finished: AtomicBool::new(false), + } + } + + pub(crate) fn checkpoint(&self, event: &'static str) { + if !self.finished.load(Ordering::Relaxed) { + self.emit(event, LogStatus::Success, "Checkpoint reached", None, false); + } + } + + pub(crate) fn finish(&self, result: &Result) { + if self.finished.swap(true, Ordering::Relaxed) { + return; + } + if let Some(inner) = &self.inner { + match result { + Ok(_) => self.emit( + inner.event, + LogStatus::Success, + "Operation completed", + None, + true, + ), + Err(error) => self.emit( + inner.event, + LogStatus::Error, + "Operation failed", + Some(error.code), + true, + ), + } + } + } + + fn emit( + &self, + event: &'static str, + status: LogStatus, + message: &'static str, + error_code: Option, + finished: bool, + ) { + let Some(inner) = &self.inner else { return }; + let Some(logger) = inner.logger.upgrade() else { + return; + }; + let Ok(timestamp) = OffsetDateTime::now_utc().format(&Rfc3339) else { + return; + }; + logger.emit( + inner.generation, + LogRecord { + schema_version: 1, + timestamp, + session_id: logger.session_id, + sequence: 0, + operation_id: inner.id, + kind: inner.kind, + status, + event: event.to_owned(), + message: message.to_owned(), + duration_ms: finished + .then(|| inner.started.elapsed().as_millis().min(u64::MAX as u128) as u64), + error_code, + context: (!finished && message == "Checkpoint reached").then(|| LogContext { + step: Some(event.to_owned()), + count: None, + target_written: None, + }), + }, + ); + } +} + +pub(crate) fn operation(kind: LogType, event: &'static str) -> Operation { + let logger = INSTALLED + .try_lock() + .ok() + .and_then(|logger| logger.upgrade()); + match logger { + Some(logger) => logger.operation(kind, event), + None => Operation { + inner: None, + finished: AtomicBool::new(false), + }, + } +} + +pub(crate) fn record(kind: LogType, status: LogStatus, event: &'static str) { + if let Some(logger) = INSTALLED + .try_lock() + .ok() + .and_then(|logger| logger.upgrade()) + { + logger.record(kind, status, event); + } +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/logging/storage.rs b/src-tauri/src/logging/storage.rs new file mode 100644 index 0000000..84a26b5 --- /dev/null +++ b/src-tauri/src/logging/storage.rs @@ -0,0 +1,409 @@ +use std::collections::VecDeque; +use std::fs::{self, File, Metadata}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use time::OffsetDateTime; +use uuid::Uuid; + +use super::types::{Filter, LogPage, LogQuery, LogRecord}; +use crate::error::AppError; + +pub(super) const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; +const MAX_FILES: usize = 5; +const MAX_LINE_BYTES: usize = 8192; +const MAX_CURSORS: usize = 32; + +fn unavailable() -> AppError { + AppError::configuration("Local log storage unavailable") +} + +fn create_private(path: &Path) -> Result { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path).map_err(|_| unavailable()) +} + +fn stale_cursor() -> AppError { + AppError::validation("Log cursor expired; reload logs") +} + +fn safe_metadata(metadata: &Metadata, directory: bool) -> bool { + if metadata.file_type().is_symlink() + || (directory && !metadata.is_dir()) + || (!directory && (!metadata.is_file() || metadata.len() > MAX_FILE_BYTES)) + { + return false; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return false; + } + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // SAFETY: geteuid reads the effective UID and takes no pointers. + if metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != if directory { 0o700 } else { 0o600 } + { + return false; + } + } + true +} + +fn same_file(left: &Metadata, right: &Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() + } + #[cfg(not(unix))] + { + left.created().ok() == right.created().ok() + } +} + +pub(super) fn ensure_directory(path: &Path) -> Result<(), AppError> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if !safe_metadata(&metadata, true) { + return Err(unavailable()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir_all(path.parent().ok_or_else(unavailable)?) + .map_err(|_| unavailable())?; + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(unavailable()), + } + if !safe_metadata( + &fs::symlink_metadata(path).map_err(|_| unavailable())?, + true, + ) { + return Err(unavailable()); + } + } + Err(_) => return Err(unavailable()), + } + Ok(()) +} + +#[derive(Clone)] +struct Entry { + name: String, + metadata: Metadata, +} + +fn stamp(name: &str) -> Option { + let value = name.strip_prefix("mom-")?.strip_suffix(".jsonl")?; + let (timestamp, id) = value.split_once('-')?; + if timestamp.len() != 39 || !timestamp.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + Uuid::parse_str(id).ok()?; + timestamp.parse().ok() +} + +#[derive(Clone)] +struct Snapshot { + epoch: Uuid, + files: Vec, + file: usize, + offset: usize, + filter: Filter, +} + +pub(super) struct Storage { + directory: PathBuf, + directory_metadata: Option, + active: Option<(Entry, File, u64)>, + epoch: Uuid, + cursors: VecDeque<(Uuid, Snapshot)>, + pub(super) max_bytes: u64, +} + +impl Storage { + pub fn new(directory: PathBuf) -> Self { + Self { + directory, + directory_metadata: None, + active: None, + epoch: Uuid::new_v4(), + cursors: VecDeque::new(), + max_bytes: MAX_FILE_BYTES, + } + } + + pub fn directory(&mut self) -> Result { + ensure_directory(&self.directory)?; + self.entries()?; + Ok(self.directory.clone()) + } + + fn entries(&mut self) -> Result, AppError> { + let metadata = match fs::symlink_metadata(&self.directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if self.directory_metadata.is_some() { + return Err(unavailable()); + } + return Ok(Vec::new()); + } + Err(_) => return Err(unavailable()), + }; + if !safe_metadata(&metadata, true) + || self + .directory_metadata + .as_ref() + .is_some_and(|previous| !same_file(previous, &metadata)) + { + return Err(unavailable()); + } + self.directory_metadata = Some(metadata); + let mut entries = Vec::new(); + for (index, entry) in fs::read_dir(&self.directory) + .map_err(|_| unavailable())? + .enumerate() + { + if index >= 512 { + return Err(unavailable()); + } + let entry = entry.map_err(|_| unavailable())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with("mom-") || !name.ends_with(".jsonl") { + continue; + } + if stamp(name).is_none() { + return Err(unavailable()); + } + let metadata = fs::symlink_metadata(entry.path()).map_err(|_| unavailable())?; + if !safe_metadata(&metadata, false) { + return Err(unavailable()); + } + entries.push(Entry { + name: name.to_owned(), + metadata, + }); + } + entries.sort_unstable_by(|left, right| right.name.cmp(&left.name)); + Ok(entries) + } + + pub fn start(&mut self) -> Result<(), AppError> { + self.stop()?; + ensure_directory(&self.directory)?; + self.rotate() + } + + fn rotate(&mut self) -> Result<(), AppError> { + self.stop()?; + let entries = self.entries()?; + self.epoch = Uuid::new_v4(); + self.cursors.clear(); + for entry in entries.iter().skip(MAX_FILES - 1) { + fs::remove_file(self.directory.join(&entry.name)).map_err(|_| unavailable())?; + } + let timestamp = OffsetDateTime::now_utc().unix_timestamp_nanos().max( + entries + .first() + .and_then(|entry| stamp(&entry.name)) + .unwrap_or(0) + .saturating_add(1), + ); + let name = format!("mom-{timestamp:039}-{}.jsonl", Uuid::new_v4()); + let file = create_private(&self.directory.join(&name))?; + let metadata = file.metadata().map_err(|_| unavailable())?; + if !safe_metadata(&metadata, false) { + return Err(unavailable()); + } + self.active = Some((Entry { name, metadata }, file, 0)); + Ok(()) + } + + pub fn write(&mut self, record: &LogRecord) -> Result<(), AppError> { + if !record.is_safe() { + return Err(AppError::validation("Invalid diagnostic record")); + } + let mut bytes = serde_json::to_vec(record).map_err(|_| unavailable())?; + bytes.push(b'\n'); + if bytes.len() > MAX_LINE_BYTES || bytes.len() as u64 > self.max_bytes { + return Err(AppError::validation("Diagnostic record too large")); + } + let Some((_, _, size)) = &self.active else { + return Err(unavailable()); + }; + if size.saturating_add(bytes.len() as u64) > self.max_bytes { + self.rotate()?; + } + self.entries()?; + let (entry, file, size) = self.active.as_mut().ok_or_else(unavailable)?; + let path_metadata = + fs::symlink_metadata(self.directory.join(&entry.name)).map_err(|_| unavailable())?; + if !safe_metadata(&path_metadata, false) + || !same_file(&entry.metadata, &path_metadata) + || path_metadata.len() != *size + { + return Err(unavailable()); + } + file.write_all(&bytes).map_err(|_| unavailable())?; + *size += bytes.len() as u64; + Ok(()) + } + + pub fn stop(&mut self) -> Result<(), AppError> { + if let Some((_, mut file, _)) = self.active.take() { + file.flush() + .and_then(|()| file.sync_all()) + .map_err(|_| unavailable())?; + } + Ok(()) + } + + pub fn clear(&mut self, resume: bool) -> Result<(), AppError> { + self.stop()?; + let entries = self.entries()?; + self.epoch = Uuid::new_v4(); + self.cursors.clear(); + for entry in entries { + fs::remove_file(self.directory.join(entry.name)).map_err(|_| unavailable())?; + } + if resume { + self.start()?; + } + Ok(()) + } + + pub fn query(&mut self, query: LogQuery) -> Result { + let limit = query.limit.unwrap_or(100); + if limit == 0 || limit > 200 { + return Err(AppError::validation("Log limit must be between 1 and 200")); + } + let filter = Filter::new(&query)?; + let entries = self.entries()?; + if entries.len() > MAX_FILES { + return Err(unavailable()); + } + let mut snapshot = if let Some(cursor) = query.cursor { + if cursor.len() > 36 { + return Err(stale_cursor()); + } + let id = Uuid::parse_str(&cursor).map_err(|_| stale_cursor())?; + let snapshot = self + .cursors + .iter() + .find(|(cursor, _)| *cursor == id) + .map(|(_, snapshot)| snapshot.clone()) + .ok_or_else(stale_cursor)?; + if snapshot.epoch != self.epoch + || snapshot.filter != filter + || snapshot.files.len() != entries.len() + || !snapshot + .files + .iter() + .zip(&entries) + .all(|(previous, current)| { + previous.name == current.name + && previous.metadata.len() <= current.metadata.len() + && same_file(&previous.metadata, ¤t.metadata) + && (previous.metadata.len() < current.metadata.len() + || previous.metadata.modified().ok() + == current.metadata.modified().ok()) + }) + { + return Err(stale_cursor()); + } + snapshot + } else { + Snapshot { + epoch: self.epoch, + offset: entries + .first() + .map_or(0, |entry| entry.metadata.len() as usize), + files: entries, + file: 0, + filter, + } + }; + let mut records = Vec::new(); + let mut invalid_lines = 0; + while snapshot.file < snapshot.files.len() && records.len() < limit { + let entry = &snapshot.files[snapshot.file]; + let path = self.directory.join(&entry.name); + let metadata = fs::symlink_metadata(&path).map_err(|_| unavailable())?; + if !safe_metadata(&metadata, false) || !same_file(&entry.metadata, &metadata) { + return Err(stale_cursor()); + } + let bytes = crate::private_file::read(&path, MAX_FILE_BYTES) + .map_err(|_| unavailable())? + .ok_or_else(unavailable)?; + if bytes.len() < entry.metadata.len() as usize { + return Err(stale_cursor()); + } + while snapshot.offset > 0 && records.len() < limit { + let end = snapshot.offset; + let terminated = bytes[end - 1] == b'\n'; + let content_end = if terminated { end - 1 } else { end }; + let start = bytes[..content_end] + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |position| position + 1); + snapshot.offset = start; + let line = &bytes[start..content_end]; + if !terminated || line.len() > MAX_LINE_BYTES { + invalid_lines += 1; + continue; + } + match serde_json::from_slice::(line) { + Ok(record) if record.is_safe() => { + if snapshot.filter.matches(&record) { + records.push(record); + } + } + _ => invalid_lines += 1, + } + } + if snapshot.offset == 0 { + snapshot.file += 1; + snapshot.offset = snapshot + .files + .get(snapshot.file) + .map_or(0, |entry| entry.metadata.len() as usize); + } + } + let next_cursor = if snapshot.file < snapshot.files.len() { + let id = Uuid::new_v4(); + if self.cursors.len() >= MAX_CURSORS { + self.cursors.pop_front(); + } + self.cursors.push_back((id, snapshot)); + Some(id.to_string()) + } else { + None + }; + Ok(LogPage { + records, + next_cursor, + invalid_lines, + }) + } +} diff --git a/src-tauri/src/logging/tests.rs b/src-tauri/src/logging/tests.rs new file mode 100644 index 0000000..a8f73de --- /dev/null +++ b/src-tauri/src/logging/tests.rs @@ -0,0 +1,927 @@ +use std::fs; +use std::sync::mpsc; + +use super::*; + +fn setup() -> (tempfile::TempDir, ConfigPaths, Arc) { + let directory = tempfile::tempdir().unwrap(); + let paths = ConfigPaths::for_home(directory.path()); + let service = LoggingService::new(&paths); + (directory, paths, service) +} + +fn op(service: &Arc, event: &'static str) -> Operation { + service.operation(LogType::Config, event) +} + +fn record(sequence: u64) -> LogRecord { + LogRecord { + schema_version: 1, + timestamp: "2026-11-01T00:00:00Z".to_owned(), + session_id: Uuid::nil(), + sequence, + operation_id: Uuid::nil(), + kind: LogType::Config, + status: LogStatus::Success, + event: "preferences_save".to_owned(), + message: "Operation completed".to_owned(), + duration_ms: Some(1), + error_code: None, + context: None, + } +} + +fn query(service: &LoggingService) -> LogPage { + service.query(LogQuery::default()).unwrap() +} + +#[test] +fn defaults_off_and_enable_persists_without_changing_other_settings() { + let (_directory, paths, service) = setup(); + assert!(!service.state().enabled); + assert!(!service.state().recording); + op(&service, "project_remove").finish(&Ok::<_, AppError>(())); + assert!(query(&service).records.is_empty()); + assert!(!paths.logs_dir().exists()); + let mut config = models::AppConfig::default(); + config.state.island_enabled = true; + config.state.preferences.locale = models::LocalePreference::Zh; + models::save_config(&paths.config_file(), &config).unwrap(); + assert!(service.set_enabled(true).unwrap().recording); + config.state.logging_enabled = true; + assert_eq!(models::load_config(&paths.config_file()).unwrap(), config); + service.shutdown(Duration::from_secs(1)); + let restarted = LoggingService::new(&paths); + assert!(restarted.state().enabled); + assert!(restarted.state().recording); + restarted.shutdown(Duration::from_secs(1)); +} + +#[test] +fn disable_is_a_barrier_and_old_operations_cannot_cross_enable_generations() { + let (_directory, paths, service) = setup(); + let before = op(&service, "project_add"); + service.set_enabled(true).unwrap(); + before.finish(&Ok::<_, AppError>(())); + let old = Operation::new(&service, LogType::Group, "group_copy"); + for _ in 0..20 { + op(&service, "preferences_save").finish(&Ok::<_, AppError>(())); + } + let disabled = service.set_enabled(false).unwrap(); + assert!(!disabled.enabled); + assert!(!disabled.recording); + assert!( + !models::load_config(&paths.config_file()) + .unwrap() + .state + .logging_enabled + ); + assert_eq!(query(&service).records.len(), 20); + op(&service, "project_remove").finish(&Ok::<_, AppError>(())); + service.set_enabled(true).unwrap(); + old.checkpoint("group_switch_projection_complete"); + old.finish(&Ok::<_, AppError>(())); + let new = Operation::new(&service, LogType::Group, "group_switch"); + new.checkpoint("group_switch_validated"); + new.finish(&Ok::<_, AppError>(())); + new.finish(&Ok::<_, AppError>(())); + let page = query(&service); + assert_eq!(page.records.len(), 22); + assert!(page.records.iter().all(|record| matches!( + record.event.as_str(), + "preferences_save" | "group_switch" | "group_switch_validated" + ))); + assert_eq!(page.records[0].event, "group_switch"); + assert_eq!(page.records[1].event, "group_switch_validated"); + assert_eq!(page.records[0].operation_id, page.records[1].operation_id); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn failures_keep_only_error_code_and_fixed_safe_messages() { + let (_directory, paths, service) = setup(); + service.set_enabled(true).unwrap(); + let error = AppError::references( + "Bearer secret-api-key https://secret/path config contents prompt", + "private session chat message", + ); + op(&service, "preferences_save").finish::<()>(&Err(error)); + let page = query(&service); + assert_eq!(page.records.len(), 1); + assert_eq!(page.records[0].status, LogStatus::Error); + assert_eq!(page.records[0].message, "Operation failed"); + assert_eq!( + page.records[0].error_code, + Some(ErrorCode::ReferencesBlocked) + ); + let path = fs::read_dir(paths.logs_dir()) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let bytes = fs::read_to_string(path).unwrap(); + for forbidden in [ + "Bearer", "secret", "private", "prompt", "https://", "chat", "contents", "detail", + ] { + assert!(!bytes.contains(forbidden), "{forbidden}"); + } + assert!(page.records[0].timestamp.ends_with('Z')); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn config_save_failure_does_not_toggle_runtime_or_overwrite_config() { + let (_directory, paths, service) = setup(); + service.set_enabled(true).unwrap(); + fs::write(paths.config_file(), "{malformed").unwrap(); + assert!(service.set_enabled(false).is_err()); + assert!(service.state().enabled); + assert!(service.state().recording); + assert_eq!( + service.state().error.as_deref(), + Some("Logging preference could not be saved") + ); + assert_eq!( + fs::read_to_string(paths.config_file()).unwrap(), + "{malformed" + ); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn bounded_nonblocking_queue_counts_drops_only_while_recording() { + let (_directory, paths, real_service) = setup(); + real_service.shutdown(Duration::from_secs(1)); + let (sender, _receiver) = mpsc::sync_channel(1); + let shared = Arc::new(Shared { + gate: Mutex::new(Gate { + generation: 1, + active: true, + sequence: 0, + }), + state: Mutex::new(LoggingState { + enabled: true, + recording: true, + directory: String::new(), + error: None, + dropped_records: 0, + }), + recording: AtomicBool::new(true), + dropped: AtomicU64::new(0), + }); + let service = Arc::new(LoggingService { + config_file: paths.config_file(), + session_id: Uuid::new_v4(), + shared, + sender, + controls: Mutex::new(()), + }); + op(&service, "preferences_save").finish(&Ok::<_, AppError>(())); + op(&service, "project_add").finish(&Ok::<_, AppError>(())); + assert_eq!(service.state().dropped_records, 1); + service.pause(); + op(&service, "project_remove").finish(&Ok::<_, AppError>(())); + assert_eq!(service.state().dropped_records, 1); +} + +#[test] +fn storage_failure_stops_recording_without_changing_business_result() { + let (_directory, paths, service) = setup(); + service.set_enabled(true).unwrap(); + let path = fs::read_dir(paths.logs_dir()) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + let result: Result<_, AppError> = std::hint::black_box(Ok(42)); + op(&service, "preferences_save").finish(&result); + assert_eq!(result.unwrap(), 42); + assert!(service.query(LogQuery::default()).is_err()); + let state = service.state(); + assert!(state.enabled); + assert!(!state.recording); + assert!(state.error.is_some()); + assert_eq!(state.dropped_records, 1); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn shutdown_is_bounded_even_if_the_worker_does_not_acknowledge() { + let (_directory, paths, real_service) = setup(); + real_service.shutdown(Duration::from_secs(1)); + let (sender, _receiver) = mpsc::sync_channel(1); + let service = LoggingService { + config_file: paths.config_file(), + session_id: Uuid::new_v4(), + shared: real_service.shared.clone(), + sender, + controls: Mutex::new(()), + }; + let start = Instant::now(); + service.shutdown(Duration::from_millis(20)); + assert!(start.elapsed() < Duration::from_secs(1)); + assert!(!service.state().recording); + assert!(service.state().error.is_some()); +} + +#[test] +fn pages_are_latest_first_snapshot_safe_and_filterable() { + let directory = tempfile::tempdir().unwrap(); + let mut storage = Storage::new(directory.path().join("logs")); + storage.start().unwrap(); + let operation_id = Uuid::new_v4(); + for sequence in 1..=5 { + let mut value = record(sequence); + value.operation_id = operation_id; + if sequence % 2 == 0 { + value.status = LogStatus::Error; + value.message = "Operation failed".to_owned(); + value.error_code = Some(ErrorCode::ValidationFailed); + } + storage.write(&value).unwrap(); + } + let first = storage + .query(LogQuery { + limit: Some(2), + ..Default::default() + }) + .unwrap(); + assert_eq!( + first + .records + .iter() + .map(|record| record.sequence) + .collect::>(), + [5, 4] + ); + storage.write(&record(6)).unwrap(); + let second = storage + .query(LogQuery { + limit: Some(2), + cursor: first.next_cursor.clone(), + ..Default::default() + }) + .unwrap(); + assert_eq!( + second + .records + .iter() + .map(|record| record.sequence) + .collect::>(), + [3, 2] + ); + let third = storage + .query(LogQuery { + limit: Some(2), + cursor: second.next_cursor, + ..Default::default() + }) + .unwrap(); + assert_eq!(third.records[0].sequence, 1); + assert!(third.next_cursor.is_none()); + let filtered = storage + .query(LogQuery { + kind: Some(LogType::Config), + status: Some(LogStatus::Error), + from: Some("2026-11-01T00:00:00Z".to_owned()), + to: Some("2026-11-01T01:00:00+01:00".to_owned()), + operation_id: Some(operation_id), + ..Default::default() + }) + .unwrap(); + assert_eq!( + filtered + .records + .iter() + .map(|record| record.sequence) + .collect::>(), + [4, 2] + ); + assert!(storage + .query(LogQuery { + kind: Some(LogType::Power), + ..Default::default() + }) + .unwrap() + .records + .is_empty()); + for invalid in [ + LogQuery { + limit: Some(0), + ..Default::default() + }, + LogQuery { + limit: Some(201), + ..Default::default() + }, + LogQuery { + from: Some("invalid".to_owned()), + ..Default::default() + }, + LogQuery { + from: Some("2026-11-02T00:00:00Z".to_owned()), + to: Some("2026-11-01T00:00:00Z".to_owned()), + ..Default::default() + }, + LogQuery { + cursor: Some("x".repeat(4096)), + ..Default::default() + }, + ] { + assert!(storage.query(invalid).is_err()); + } + assert!(storage + .query(LogQuery { + kind: Some(LogType::Power), + cursor: first.next_cursor, + ..Default::default() + }) + .is_err()); +} + +#[test] +fn rotation_and_clear_bound_storage_and_invalidate_cursors() { + let directory = tempfile::tempdir().unwrap(); + let logs = directory.path().join("logs"); + let mut storage = Storage::new(logs.clone()); + storage.max_bytes = 1500; + storage.start().unwrap(); + storage.write(&record(1)).unwrap(); + storage.write(&record(2)).unwrap(); + let cursor = storage + .query(LogQuery { + limit: Some(1), + ..Default::default() + }) + .unwrap() + .next_cursor + .unwrap(); + for sequence in 3..100 { + storage.write(&record(sequence)).unwrap(); + } + assert!(storage + .query(LogQuery { + cursor: Some(cursor), + ..Default::default() + }) + .is_err()); + assert!(fs::read_dir(&logs).unwrap().count() <= 5); + for entry in fs::read_dir(&logs).unwrap() { + assert!(entry.unwrap().metadata().unwrap().len() <= 1500); + } + let first = storage + .query(LogQuery { + limit: Some(1), + ..Default::default() + }) + .unwrap(); + assert_eq!(first.records[0].sequence, 99); + fs::write(logs.join("unrelated.txt"), "keep").unwrap(); + storage.clear(false).unwrap(); + assert_eq!( + fs::read_to_string(logs.join("unrelated.txt")).unwrap(), + "keep" + ); + assert!(storage + .query(LogQuery { + cursor: first.next_cursor, + ..Default::default() + }) + .is_err()); + assert!(storage + .query(LogQuery::default()) + .unwrap() + .records + .is_empty()); +} + +#[test] +fn malformed_and_oversized_lines_and_incomplete_tail_are_skipped() { + let directory = tempfile::tempdir().unwrap(); + let logs = directory.path().join("logs"); + let mut storage = Storage::new(logs.clone()); + storage.start().unwrap(); + storage.write(&record(1)).unwrap(); + storage.stop().unwrap(); + let path = fs::read_dir(&logs).unwrap().next().unwrap().unwrap().path(); + let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); + use std::io::Write; + file.write_all(b"invalid\n").unwrap(); + file.write_all(&vec![b'x'; 9000]).unwrap(); + file.write_all(b"\n{\"unfinished\":").unwrap(); + drop(file); + let page = storage.query(LogQuery::default()).unwrap(); + assert_eq!(page.records.len(), 1); + assert_eq!(page.invalid_lines, 3); + let mut unsafe_record = record(2); + unsafe_record.message = "Bearer secret".to_owned(); + assert!(!unsafe_record.is_safe()); +} + +#[cfg(unix)] +#[test] +fn private_modes_and_symlink_nonregular_oversized_managed_files_are_enforced() { + use std::os::unix::fs::{symlink, OpenOptionsExt, PermissionsExt}; + let directory = tempfile::tempdir().unwrap(); + let logs = directory.path().join("logs"); + let mut storage = Storage::new(logs.clone()); + storage.start().unwrap(); + storage.write(&record(1)).unwrap(); + storage.stop().unwrap(); + assert_eq!( + fs::metadata(&logs).unwrap().permissions().mode() & 0o777, + 0o700 + ); + let path = fs::read_dir(&logs).unwrap().next().unwrap().unwrap().path(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(storage.query(LogQuery::default()).is_err()); + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + assert!(storage.clear(false).is_err()); + fs::remove_dir(&path).unwrap(); + let target = directory.path().join("secret"); + fs::write(&target, "untouched").unwrap(); + symlink(&target, &path).unwrap(); + assert!(storage.query(LogQuery::default()).is_err()); + assert!(storage.clear(false).is_err()); + assert_eq!(fs::read_to_string(&target).unwrap(), "untouched"); + fs::remove_file(&path).unwrap(); + let file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .unwrap(); + file.set_len(storage::MAX_FILE_BYTES + 1).unwrap(); + drop(file); + assert!(storage.query(LogQuery::default()).is_err()); + fs::remove_file(path).unwrap(); + fs::remove_dir(&logs).unwrap(); + let target_directory = directory.path().join("elsewhere"); + fs::create_dir(&target_directory).unwrap(); + symlink(&target_directory, &logs).unwrap(); + assert!(storage.start().is_err()); +} + +#[test] +fn unsafe_startup_is_nonfatal_and_reports_safe_failure_state() { + let directory = tempfile::tempdir().unwrap(); + let paths = ConfigPaths::for_home(directory.path()); + let mut config = models::AppConfig::default(); + config.state.logging_enabled = true; + models::save_config(&paths.config_file(), &config).unwrap(); + fs::write(paths.logs_dir(), "not a directory; secret").unwrap(); + let service = LoggingService::new(&paths); + assert!(service.state().enabled); + assert!(!service.state().recording); + assert_eq!( + service.state().error.as_deref(), + Some("Local log storage unavailable") + ); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn enable_storage_failure_preserves_enabled_intent_and_returns_safe_runtime_state() { + let (_directory, paths, service) = setup(); + fs::create_dir_all(paths.app_config_dir()).unwrap(); + fs::write(paths.logs_dir(), "not a directory").unwrap(); + let enabled = service.set_enabled(true).unwrap(); + assert!(enabled.enabled); + assert!(!enabled.recording); + assert_eq!( + enabled.error.as_deref(), + Some("Local log storage unavailable") + ); + assert!( + models::load_config(&paths.config_file()) + .unwrap() + .state + .logging_enabled + ); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn failed_enable_persistence_never_starts_the_writer() { + let (_directory, paths, service) = setup(); + fs::create_dir_all(paths.app_config_dir()).unwrap(); + fs::write(paths.config_file(), "{malformed").unwrap(); + assert!(service.set_enabled(true).is_err()); + assert!(!service.state().enabled); + assert!(!service.state().recording); + assert!(!paths.logs_dir().exists()); + op(&service, "preferences_save").finish(&Ok::<_, AppError>(())); + assert!(query(&service).records.is_empty()); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn failed_enable_persistence_preserves_every_retained_file() { + let (_directory, paths, service) = setup(); + for _ in 0..5 { + service.set_enabled(true).unwrap(); + op(&service, "preferences_save").finish(&Ok::<_, AppError>(())); + service.set_enabled(false).unwrap(); + } + let snapshots = || { + fs::read_dir(paths.logs_dir()) + .unwrap() + .map(|entry| { + let path = entry.unwrap().path(); + let bytes = fs::read(&path).unwrap(); + (path, bytes) + }) + .collect::>() + }; + let before = snapshots(); + assert_eq!(before.len(), 5); + fs::write(paths.config_file(), "{malformed").unwrap(); + assert!(service.set_enabled(true).is_err()); + assert!(!service.state().enabled); + assert!(!service.state().recording); + assert_eq!(snapshots(), before); + assert_eq!( + fs::read_to_string(paths.config_file()).unwrap(), + "{malformed" + ); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn replacing_a_snapshot_file_invalidates_the_cursor() { + let directory = tempfile::tempdir().unwrap(); + let logs = directory.path().join("logs"); + let mut storage = Storage::new(logs.clone()); + storage.start().unwrap(); + storage.write(&record(1)).unwrap(); + storage.write(&record(2)).unwrap(); + storage.stop().unwrap(); + let first = storage + .query(LogQuery { + limit: Some(1), + ..Default::default() + }) + .unwrap(); + let path = fs::read_dir(&logs).unwrap().next().unwrap().unwrap().path(); + let bytes = fs::read(&path).unwrap(); + let old = logs.join("unrelated.backup"); + fs::rename(&path, &old).unwrap(); + fs::write(&path, bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + assert!(storage + .query(LogQuery { + cursor: first.next_cursor, + ..Default::default() + }) + .is_err()); +} + +#[test] +fn clear_acknowledges_old_records_and_starts_a_fresh_generation() { + let (_directory, _paths, service) = setup(); + service.set_enabled(true).unwrap(); + let old = op(&service, "global_instructions_save"); + op(&service, "preferences_save").finish(&Ok::<_, AppError>(())); + let cleared = service.clear().unwrap(); + assert!(cleared.enabled); + assert!(cleared.recording); + assert_eq!(cleared.dropped_records, 0); + old.finish(&Ok::<_, AppError>(())); + op(&service, "project_select").finish(&Ok::<_, AppError>(())); + let page = query(&service); + assert_eq!(page.records.len(), 1); + assert_eq!(page.records[0].event, "project_select"); + service.set_enabled(false).unwrap(); + let cleared = service.clear().unwrap(); + assert!(!cleared.enabled); + assert!(!cleared.recording); + assert!(query(&service).records.is_empty()); + service.shutdown(Duration::from_secs(1)); +} + +#[cfg(unix)] +#[test] +fn partial_write_failure_disables_recording_without_exposing_io_details() { + const CHILD: &str = "MOM_LOG_WRITE_FAULT_CHILD"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "logging::tests::partial_write_failure_disables_recording_without_exposing_io_details", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let (_directory, _paths, service) = setup(); + service.set_enabled(true).unwrap(); + let limit = libc::rlimit { + rlim_cur: 64, + rlim_max: 64, + }; + assert_ne!( + // SAFETY: only this disposable child installs SIG_IGN and a file-size limit. + unsafe { libc::signal(libc::SIGXFSZ, libc::SIG_IGN) }, + libc::SIG_ERR + ); + // SAFETY: limit is a live initialized rlimit; no parent process limit is changed. + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_FSIZE, &limit) }, 0); + op(&service, "preferences_save").finish(&Ok::<_, AppError>(())); + let page = query(&service); + assert!(page.records.is_empty()); + assert_eq!(page.invalid_lines, 1); + let state = service.state(); + assert!(!state.recording); + assert_eq!(state.error.as_deref(), Some("Local log write failed")); + assert_eq!(state.dropped_records, 1); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn enabling_recovers_an_enabled_but_not_recording_logger() { + let directory = tempfile::tempdir().unwrap(); + let paths = ConfigPaths::for_home(directory.path()); + let mut config = models::AppConfig::default(); + config.state.logging_enabled = true; + models::save_config(&paths.config_file(), &config).unwrap(); + fs::write(paths.logs_dir(), "unsafe").unwrap(); + let service = LoggingService::new(&paths); + assert!(service.state().enabled); + assert!(!service.state().recording); + fs::remove_file(paths.logs_dir()).unwrap(); + let retried = service.set_enabled(true).unwrap(); + assert!(retried.enabled); + assert!(retried.recording); + assert!(retried.error.is_none()); + op(&service, "preferences_save").finish(&Ok::<_, AppError>(())); + assert_eq!(query(&service).records.len(), 1); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn query_never_returns_untrusted_unknown_or_sensitive_string_fields() { + use serde_json::json; + use std::io::Write; + + let directory = tempfile::tempdir().unwrap(); + let logs = directory.path().join("logs"); + let mut storage = Storage::new(logs.clone()); + storage.start().unwrap(); + storage.write(&record(1)).unwrap(); + storage.stop().unwrap(); + let path = fs::read_dir(logs).unwrap().next().unwrap().unwrap().path(); + let base = serde_json::to_value(record(2)).unwrap(); + let mut bad_lines = Vec::new(); + for (key, field) in [ + ("event", json!("secret_api_key")), + ("event", json!("group_switch")), + ("message", json!("Bearer secret")), + ("type", json!("secret_provider")), + ("status", json!("secret")), + ("errorCode", json!("secret_error")), + ("timestamp", json!("private_prompt")), + ("sessionId", json!("private_session")), + ("operationId", json!("private_operation")), + ("schemaVersion", json!(2)), + ("context", json!({"step": "secret_api_key"})), + ("context", json!({"step": "preferences_save"})), + ("context", json!({"content": "private_config"})), + ("content", json!("private_prompt")), + ] { + let mut value = base.clone(); + value[key] = field; + bad_lines.push(value); + } + let mut wrong_step = base; + wrong_step["type"] = json!("group"); + wrong_step["event"] = json!("group_switch_validated"); + wrong_step["message"] = json!("Checkpoint reached"); + wrong_step["context"] = json!({"step": "secret_api_key", "count": 1}); + bad_lines.push(wrong_step.clone()); + wrong_step["context"] = json!({"step": "group_switch_validated", "prompt": "private"}); + bad_lines.push(wrong_step); + let mut file = fs::OpenOptions::new().append(true).open(path).unwrap(); + for value in &bad_lines { + serde_json::to_writer(&mut file, value).unwrap(); + file.write_all(b"\n").unwrap(); + } + drop(file); + let page = storage.query(LogQuery::default()).unwrap(); + assert_eq!(page.records.len(), 1); + assert_eq!(page.records[0].sequence, 1); + assert_eq!(page.invalid_lines, bad_lines.len() as u64); + let encoded = serde_json::to_string(&page).unwrap(); + assert!(!encoded.contains("private")); + assert!(!encoded.contains("secret")); +} + +#[test] +fn instance_api_captures_flows_lifecycle_and_filters_without_global_install() { + let (_directory, _paths, service) = setup(); + service.set_enabled(true).unwrap(); + for result in [ + Ok(()), + Err(AppError::references( + "private configuration", + "secret contents", + )), + ] { + let operation = service.operation(LogType::Group, "group_switch"); + operation.checkpoint("group_switch_validated"); + operation.checkpoint("group_switch_projection_complete"); + operation.finish(&result); + } + service.record(LogType::Http, LogStatus::Error, "http_listener_failed"); + let page = query(&service); + assert_eq!(page.records.len(), 7); + assert_eq!(page.records[0].kind, LogType::Http); + assert_eq!(page.records[0].status, LogStatus::Error); + assert_ne!(page.records[1].operation_id, page.records[4].operation_id); + for (index, status, code) in [ + (1, LogStatus::Error, Some(ErrorCode::ReferencesBlocked)), + (4, LogStatus::Success, None), + ] { + let id = page.records[index].operation_id; + let flow = service + .query(LogQuery { + operation_id: Some(id), + ..Default::default() + }) + .unwrap(); + assert_eq!(flow.records.len(), 3); + let finished = &flow.records[0]; + assert_eq!(finished.event, "group_switch"); + assert_eq!(finished.status, status); + assert_eq!(finished.error_code, code); + assert!(finished.duration_ms.is_some()); + assert!(finished.context.is_none()); + for (checkpoint, event) in flow.records[1..] + .iter() + .zip(["group_switch_projection_complete", "group_switch_validated"]) + { + assert_eq!(checkpoint.status, LogStatus::Success); + assert_eq!(checkpoint.event, event); + assert_eq!( + checkpoint.context.as_ref().unwrap().step.as_deref(), + Some(event) + ); + assert_eq!(checkpoint.operation_id, finished.operation_id); + assert_eq!(checkpoint.session_id, finished.session_id); + assert!(checkpoint.sequence < finished.sequence); + assert!(checkpoint.duration_ms.is_none()); + assert!(checkpoint.error_code.is_none()); + } + } + let encoded = serde_json::to_string(&page).unwrap(); + assert!(!encoded.contains("private")); + assert!(!encoded.contains("secret")); + service.record(LogType::Config, LogStatus::Success, "secret_api_key"); + assert_eq!(query(&service).records.len(), 7); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn queued_records_drain_but_inflight_old_work_never_enters_a_new_generation() { + let (_directory, _paths, service) = setup(); + service.set_enabled(true).unwrap(); + let (started, ready) = mpsc::channel(); + let (release, wait) = mpsc::channel(); + let worker_service = service.clone(); + let worker = std::thread::spawn(move || { + let success = worker_service.operation(LogType::Group, "group_switch"); + let failure = worker_service.operation(LogType::Group, "group_switch"); + success.checkpoint("group_switch_validated"); + failure.checkpoint("group_switch_validated"); + started.send(()).unwrap(); + wait.recv().unwrap(); + success.checkpoint("group_switch_projection_complete"); + success.finish(&Ok::<_, AppError>(())); + failure.checkpoint("group_switch_projection_complete"); + failure.finish::<()>(&Err(AppError::configuration("private failure"))); + }); + ready.recv().unwrap(); + for _ in 0..20 { + op(&service, "preferences_save").finish(&Ok::<_, AppError>(())); + } + service.set_enabled(false).unwrap(); + let drained = query(&service); + assert_eq!(drained.records.len(), 22); + assert_eq!(drained.records[20].event, "group_switch_validated"); + service.set_enabled(true).unwrap(); + release.send(()).unwrap(); + worker.join().unwrap(); + op(&service, "project_select").finish(&Ok::<_, AppError>(())); + let page = query(&service); + assert_eq!(page.records.len(), 23); + assert_eq!(page.records[0].event, "project_select"); + assert_eq!( + page.records + .iter() + .filter(|record| record.kind == LogType::Group) + .count(), + 2 + ); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn every_declared_type_roundtrips_through_the_instance_writer_and_query() { + let (_directory, _paths, service) = setup(); + service.set_enabled(true).unwrap(); + let events = [ + (LogType::App, "app.started"), + (LogType::Config, "preferences_save"), + (LogType::Group, "group_save"), + (LogType::Resource, "provider_create"), + (LogType::Opencode, "opencode_reload"), + (LogType::Http, "http_key_rotate_unavailable"), + (LogType::McpProxy, "mcp_proxy_settings_save"), + (LogType::Power, "lid_protection_set"), + (LogType::Island, "island_enabled_set"), + (LogType::Logging, "logging_cleared"), + ]; + for (kind, event) in events { + service.record(kind, LogStatus::Success, event); + } + let page = query(&service); + assert_eq!(page.records.len(), events.len()); + for (value, (kind, event)) in page.records.iter().zip(events.into_iter().rev()) { + assert_eq!(value.kind, kind); + assert_eq!(value.event, event); + } + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn invalid_queries_do_not_stop_healthy_recording() { + let (_directory, _paths, service) = setup(); + service.set_enabled(true).unwrap(); + for query in [ + LogQuery { + cursor: Some("invalid".to_owned()), + ..Default::default() + }, + LogQuery { + from: Some("invalid".to_owned()), + ..Default::default() + }, + LogQuery { + limit: Some(201), + ..Default::default() + }, + ] { + assert_eq!( + service.query(query).unwrap_err().code, + ErrorCode::ValidationFailed + ); + assert!(service.state().recording); + assert!(service.state().error.is_none()); + } + service.record( + LogType::Http, + LogStatus::Error, + "http_key_rotate_unavailable", + ); + let page = query(&service); + assert_eq!(page.records.len(), 1); + assert_eq!(page.records[0].event, "http_key_rotate_unavailable"); + service.shutdown(Duration::from_secs(1)); +} + +#[test] +fn committed_disable_returns_authoritative_state_when_the_barrier_fails() { + let (_directory, paths, service) = setup(); + service.set_enabled(true).unwrap(); + service.shutdown(Duration::from_secs(1)); + let state = service.set_enabled(false).unwrap(); + assert!(!state.enabled); + assert!(!state.recording); + assert!(state.error.is_some()); + assert!( + !models::load_config(&paths.config_file()) + .unwrap() + .state + .logging_enabled + ); +} diff --git a/src-tauri/src/logging/types.rs b/src-tauri/src/logging/types.rs new file mode 100644 index 0000000..dfbd7f4 --- /dev/null +++ b/src-tauri/src/logging/types.rs @@ -0,0 +1,346 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use uuid::Uuid; + +use crate::error::{AppError, ErrorCode}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LogType { + App, + Config, + Group, + Resource, + Opencode, + Http, + McpProxy, + Power, + Island, + Logging, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LogStatus { + Success, + Error, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LoggingState { + pub enabled: bool, + pub recording: bool, + pub directory: String, + pub error: Option, + pub dropped_records: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LogContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub step: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_written: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LogRecord { + pub schema_version: u32, + pub timestamp: String, + pub session_id: Uuid, + pub sequence: u64, + pub operation_id: Uuid, + #[serde(rename = "type")] + pub kind: LogType, + pub status: LogStatus, + pub event: String, + pub message: String, + pub duration_ms: Option, + #[serde(default, deserialize_with = "deserialize_error_code")] + pub error_code: Option, + pub context: Option, +} + +impl LogRecord { + pub(super) fn is_safe(&self) -> bool { + self.schema_version == 1 + && safe_event(self.kind, &self.event) + && self.timestamp.len() <= 40 + && OffsetDateTime::parse(&self.timestamp, &Rfc3339) + .is_ok_and(|time| time.offset().is_utc()) + && match self.status { + LogStatus::Success => { + matches!( + self.message.as_str(), + "Operation completed" | "Checkpoint reached" | "Event completed" + ) && self.error_code.is_none() + } + LogStatus::Error => { + matches!(self.message.as_str(), "Operation failed" | "Event failed") + } + } + && match (&self.context, self.message.as_str()) { + (Some(context), "Checkpoint reached") => { + checkpoint_event(self.kind, &self.event) + && context.step.as_deref() == Some(self.event.as_str()) + } + (None, "Checkpoint reached") => false, + (None, _) => true, + (Some(_), _) => false, + } + } +} + +pub(super) fn safe_event(kind: LogType, event: &str) -> bool { + match kind { + LogType::App => matches!( + event, + "app.started" + | "app.stopped" + | "app_update_check" + | "autostart_set" + | "project_page_open" + ), + LogType::Config => matches!( + event, + "app_state_load" + | "global_instructions_save" + | "global_instructions_read" + | "project_add" + | "project_remove" + | "project_select" + | "preferences_save" + | "preferences_committed_tray_notify_failed" + | "preferences_committed_island_notify_failed" + ), + LogType::Group => matches!( + event, + "group_save" + | "group_copy" + | "group_delete" + | "group_switch" + | "group_switch_projection_warnings" + | "group_switch_worker_failed" + | "group_switch_committed_notify_failed" + | "group_switch_validated" + | "group_switch_projection_complete" + ), + LogType::Resource => matches!( + event, + "provider_create" + | "provider_update" + | "provider_delete" + | "provider_list" + | "model_create" + | "model_update" + | "model_delete" + | "model_list" + | "agent_create" + | "agent_update" + | "agent_delete" + | "agent_list" + | "agent_read" + | "mcp_create" + | "mcp_update" + | "mcp_delete" + | "mcp_list" + | "mcp_read" + | "skill_create" + | "skill_update" + | "skill_list" + | "skill_read" + | "slash_command_create" + | "slash_command_update" + | "slash_command_delete" + | "slash_command_list" + | "slash_command_read" + | "plugin_package_update" + | "plugin_declaration_remove" + | "plugin_runtime_list" + | "plugin_removal_preview" + ), + LogType::Opencode => matches!( + event, + "opencode_target_set" + | "opencode_update_check" + | "opencode_upgrade" + | "opencode_reload" + | "opencode_model_catalog" + | "opencode_installation_read" + | "opencode_prompt_generate" + | "opencode_target_committed_island_unavailable" + ), + LogType::Http => matches!( + event, + "http_key_rotate" + | "http_key_rotate_worker_failed" + | "http_key_committed_cleanup_failed" + | "http_start" + | "http_listener_failed" + | "http_settings_save" + | "http_settings_worker_failed" + | "http_stopped_cleanup_failed" + | "http_settings_committed" + | "http_admission_closed_cleanup_failed" + | "http_listener_task_failed" + | "http_shutdown" + | "http_settings_unavailable" + | "http_key_rotate_unavailable" + ), + LogType::McpProxy => matches!( + event, + "mcp_proxy_import_prepare" + | "mcp_proxy_settings_save" + | "mcp_proxy_server_create" + | "mcp_proxy_server_update" + | "mcp_proxy_server_delete" + | "mcp_proxy_unit_close" + | "mcp_proxy_config_save_failed" + | "mcp_proxy_config_worker_failed" + | "mcp_proxy_config_committed" + | "mcp_proxy_config_committed_cleanup_failed" + | "mcp_proxy_shutdown" + | "mcp_proxy_shutdown_worker_failed" + ), + LogType::Power => matches!( + event, + "lid_protection_set" + | "power_checking" + | "power_protecting" + | "power_idle" + | "power_unknown" + | "power_error" + | "power_disabled" + ), + LogType::Island => matches!( + event, + "island_enabled_set" + | "island_refresh" + | "island_view_set" + | "island_send" + | "island_permission_reply" + | "island_question_reply" + | "island_question_cancel" + | "island_interrupt" + | "island_connected" + | "island_connection_failed" + | "island_window_failed" + ), + LogType::Logging => matches!( + event, + "logging_enabled" | "logging_disabled" | "logging_cleared" + ), + } +} + +pub(super) fn checkpoint_event(kind: LogType, event: &str) -> bool { + kind == LogType::Group + && matches!( + event, + "group_switch_validated" | "group_switch_projection_complete" + ) +} + +fn deserialize_error_code<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(rename_all = "snake_case")] + enum Code { + NotFound, + ReferencesBlocked, + ValidationFailed, + ConfigurationFailed, + Busy, + } + Ok( + Option::::deserialize(deserializer)?.map(|code| match code { + Code::NotFound => ErrorCode::NotFound, + Code::ReferencesBlocked => ErrorCode::ReferencesBlocked, + Code::ValidationFailed => ErrorCode::ValidationFailed, + Code::ConfigurationFailed => ErrorCode::ConfigurationFailed, + Code::Busy => ErrorCode::Busy, + }), + ) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LogQuery { + #[serde(rename = "type")] + pub kind: Option, + pub status: Option, + pub from: Option, + pub to: Option, + pub operation_id: Option, + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LogPage { + pub records: Vec, + pub next_cursor: Option, + pub invalid_lines: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Filter { + kind: Option, + status: Option, + from: Option, + to: Option, + operation_id: Option, +} + +impl Filter { + pub fn new(query: &LogQuery) -> Result { + fn timestamp(value: &Option) -> Result, AppError> { + value + .as_ref() + .map(|value| { + if value.len() > 64 { + return Err(AppError::validation("Invalid log timestamp")); + } + OffsetDateTime::parse(value, &Rfc3339) + .map(|time| time.unix_timestamp_nanos()) + .map_err(|_| AppError::validation("Invalid log timestamp")) + }) + .transpose() + } + let filter = Self { + kind: query.kind, + status: query.status, + from: timestamp(&query.from)?, + to: timestamp(&query.to)?, + operation_id: query.operation_id, + }; + if filter + .from + .zip(filter.to) + .is_some_and(|(from, to)| from > to) + { + return Err(AppError::validation("Invalid log time range")); + } + Ok(filter) + } + + pub fn matches(&self, record: &LogRecord) -> bool { + let Ok(time) = OffsetDateTime::parse(&record.timestamp, &Rfc3339) else { + return false; + }; + let time = time.unix_timestamp_nanos(); + self.kind.is_none_or(|kind| kind == record.kind) + && self.status.is_none_or(|status| status == record.status) + && self.operation_id.is_none_or(|id| id == record.operation_id) + && self.from.is_none_or(|from| time >= from) + && self.to.is_none_or(|to| time <= to) + } +} diff --git a/src-tauri/src/mcp_proxy/mod.rs b/src-tauri/src/mcp_proxy/mod.rs index cdc3bfa..db6b1cf 100644 --- a/src-tauri/src/mcp_proxy/mod.rs +++ b/src-tauri/src/mcp_proxy/mod.rs @@ -15,6 +15,7 @@ use tokio::sync::{Mutex, RwLock}; use tokio_util::sync::CancellationToken; use crate::error::AppError; +use crate::logging::{operation, record, LogStatus, LogType}; use crate::mcp_proxy::config::{GatewaySettings, ProxyConfig}; use crate::mcp_proxy::process::OwnerBudget; use crate::mcp_proxy::session::{LegacySessionManager, Observation}; @@ -151,13 +152,9 @@ impl ProxyService { ) -> Result<(), AppError> { let service = self.clone(); tokio::spawn(async move { - let result = service + service .save_config_owned(new_config, expected_revision) - .await; - if let Err(error) = &result { - eprintln!("warning: MCP proxy configuration update failed: {error}"); - } - result + .await }) .await .map_err(|error| { @@ -214,17 +211,31 @@ impl ProxyService { } *config_guard = new_config; self.generation.fetch_add(1, Ordering::SeqCst); + record( + LogType::McpProxy, + LogStatus::Success, + "mcp_proxy_config_committed", + ); if disabling { - if let Err(error) = self.legacy_sessions.close_all().await { - eprintln!( - "warning: MCP proxy configuration saved; session cleanup failed: {error}" + if self.legacy_sessions.close_all().await.is_err() { + record( + LogType::McpProxy, + LogStatus::Error, + "mcp_proxy_config_committed_cleanup_failed", ); } } else { for id in changed_services { - if let Err(error) = self.legacy_sessions.close_service_sessions(&id).await { - eprintln!( - "warning: MCP proxy configuration saved; session cleanup failed: {error}" + if self + .legacy_sessions + .close_service_sessions(&id) + .await + .is_err() + { + record( + LogType::McpProxy, + LogStatus::Error, + "mcp_proxy_config_committed_cleanup_failed", ); } } @@ -236,11 +247,19 @@ impl ProxyService { pub async fn shutdown(&self, deadline: Duration) -> Result<(), AppError> { self.stopped.cancel(); let service = self.clone(); - tokio::spawn(async move { service.shutdown_owned(deadline).await }) - .await - .map_err(|error| { - AppError::configuration(format!("MCP proxy shutdown task failed: {error}")) - })? + let operation = Arc::new(operation(LogType::McpProxy, "mcp_proxy_shutdown")); + let worker_operation = operation.clone(); + tokio::spawn(async move { + let result = service.shutdown_owned(deadline).await; + worker_operation.finish(&result); + result + }) + .await + .map_err(|error| { + let error = AppError::configuration(format!("MCP proxy shutdown task failed: {error}")); + operation.finish::<()>(&Err(error.clone())); + error + })? } async fn shutdown_owned(&self, deadline: Duration) -> Result<(), AppError> { diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 15666dd..a546bd1 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -187,6 +187,8 @@ pub struct AppSelectionState { pub preferences: AppPreferences, #[serde(default)] pub island_enabled: bool, + #[serde(default)] + pub logging_enabled: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs index 602e29e..dcafdad 100644 --- a/src-tauri/src/models/tests.rs +++ b/src-tauri/src/models/tests.rs @@ -16,6 +16,7 @@ fn legacy_config_without_preferences_yields_defaults() { DefaultPlugin::Native ); assert!(!config.state.island_enabled); + assert!(!config.state.logging_enabled); assert!(config.projects.is_empty()); assert_eq!(config.state.selected_project_id, None); } @@ -52,6 +53,7 @@ fn default_config_serializes_preferences() { assert_eq!(value["projects"], json!([])); assert_eq!(value["state"]["selectedProjectId"], Value::Null); assert_eq!(value["state"]["islandEnabled"], false); + assert_eq!(value["state"]["loggingEnabled"], false); } #[test] @@ -153,6 +155,28 @@ fn island_flag_is_independent_of_preferences() { struct ConfigDirectory(tempfile::TempDir); +#[test] +fn logging_flag_is_independent_of_preferences() { + let mut config: AppConfig = serde_json::from_value(json!({ + "state": { "loggingEnabled": true, "islandEnabled": true } + })) + .unwrap(); + config.state.preferences = AppPreferences { + theme: ThemePreference::Light, + locale: LocalePreference::Zh, + ..Default::default() + }; + let encoded = serde_json::to_value(&config).unwrap(); + assert_eq!(encoded["state"]["loggingEnabled"], true); + assert!(encoded["state"]["preferences"] + .get("loggingEnabled") + .is_none()); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + config + ); +} + impl ConfigDirectory { fn new() -> Self { Self(tempfile::tempdir().unwrap()) diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index 1441e0b..9569da9 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -177,6 +177,10 @@ impl ConfigPaths { self.app_config_dir().join("config.json") } + pub(crate) fn logs_dir(&self) -> PathBuf { + self.app_config_dir().join("logs") + } + pub fn opencode_file(&self) -> PathBuf { self.opencode.clone() } diff --git a/src-tauri/src/power/AGENTS.md b/src-tauri/src/power/AGENTS.md index bb225f9..b6215d2 100644 --- a/src-tauri/src/power/AGENTS.md +++ b/src-tauri/src/power/AGENTS.md @@ -31,6 +31,7 @@ power/ - **Controller boundary**: Keep native effects behind PowerController and serialize effects against target commits; never hold shared state during controller calls or locks during interactive authorization and HTTP queries. - **Generations**: Reject stale observations before native effects; target changes preserve enablement and authorization, while explicit control changes invalidate prior work and stop late effects before publication. - **Observed state**: Publish protection or idle only after acknowledged effects; query failures mean Unknown, never idle, and failed restoration blocks new protection until cleanup succeeds. +- **Diagnostics**: Log only published phase transitions with fixed event identifiers; repeated observations, session counts and raw native/service errors never become log payloads. - **Opt-out**: Enabling requires persisted preference success, but failed writes must never prevent in-memory disablement and restoration; retain the existing strict parser and durable publication path. - **Policy ownership**: Journal before mutation, verify apply and restore readback, preserve external policy changes, and retain unresolved recovery state rather than claiming cleanup succeeded. - **Helper security**: Reuse the shared protocol and preserve socket, peer, token, sequence, and acknowledgment checks; authorization is explicit and restoration failures must remain visible to callers. diff --git a/src-tauri/src/power/mod.rs b/src-tauri/src/power/mod.rs index c557769..1715d60 100644 --- a/src-tauri/src/power/mod.rs +++ b/src-tauri/src/power/mod.rs @@ -17,6 +17,7 @@ use std::time::{Duration, Instant}; use serde::Serialize; use crate::error::AppError; +use crate::logging::{record, LogStatus, LogType}; pub trait PowerController: Send { fn prepare(&mut self) -> Result<(), AppError>; @@ -36,6 +37,22 @@ pub enum LidPhase { Error, } +impl LidPhase { + fn transition_event(self, previous: Self) -> Option<(LogStatus, &'static str)> { + if self == previous { + return None; + } + Some(match self { + Self::Checking => (LogStatus::Success, "power_checking"), + Self::Protected => (LogStatus::Success, "power_protecting"), + Self::Idle => (LogStatus::Success, "power_idle"), + Self::Unknown => (LogStatus::Error, "power_unknown"), + Self::Error => (LogStatus::Error, "power_error"), + Self::Disabled => (LogStatus::Success, "power_disabled"), + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct LidState { @@ -339,6 +356,9 @@ impl Engine { fn publish(&self, phase: LidPhase, count: Option, error: Option) { if let Ok(mut shared) = self.shared.lock() { if shared.desired.generation == self.generation { + if let Some((status, event)) = phase.transition_event(shared.state.phase) { + record(LogType::Power, status, event); + } shared.generation = self.generation; shared.state = LidState { enabled: shared.desired.enabled, diff --git a/src-tauri/src/power/tests.rs b/src-tauri/src/power/tests.rs index 8689c9c..0b90836 100644 --- a/src-tauri/src/power/tests.rs +++ b/src-tauri/src/power/tests.rs @@ -1,5 +1,25 @@ use super::*; +#[test] +fn phase_logging_selects_only_transitions_and_fixed_safe_events() { + for (phase, status, event) in [ + (LidPhase::Checking, LogStatus::Success, "power_checking"), + (LidPhase::Protected, LogStatus::Success, "power_protecting"), + (LidPhase::Idle, LogStatus::Success, "power_idle"), + (LidPhase::Unknown, LogStatus::Error, "power_unknown"), + (LidPhase::Error, LogStatus::Error, "power_error"), + (LidPhase::Disabled, LogStatus::Success, "power_disabled"), + ] { + assert_eq!(phase.transition_event(phase), None); + let previous = if phase == LidPhase::Error { + LidPhase::Unknown + } else { + LidPhase::Error + }; + assert_eq!(phase.transition_event(previous), Some((status, event))); + } +} + #[derive(Default)] struct Mock { calls: Vec, From 6c0ba0ec442c294b8a8b27c404c33a1f0fd01494 Mon Sep 17 00:00:00 2001 From: seho Date: Sun, 1 Nov 2026 19:17:36 +0800 Subject: [PATCH 86/97] feat(logs): add diagnostic viewer and independent recording control --- src/config/__tests__/logging.test.mjs | 168 +++ src/config/adapter.ts | 11 + src/config/store.svelte.ts | 16 + src/i18n/dictionaries/en.ts | 93 ++ src/i18n/dictionaries/zh.ts | 88 ++ src/routes/AGENTS.md | 1 + src/routes/logs/+page.svelte | 444 ++++++++ src/routes/logs/AGENTS.md | 28 + src/routes/logs/LogDetails.svelte | 190 ++++ src/routes/logs/__tests__/logging-ui.test.ts | 957 ++++++++++++++++++ src/routes/logs/log-summary.ts | 15 + src/routes/settings/+page.svelte | 34 +- src/routes/settings/LoggingSettings.svelte | 152 +++ src/routes/settings/SettingsSearch.svelte | 5 + .../__tests__/mcp-proxy-settings.test.ts | 4 + .../__tests__/settings-search.test.ts | 5 + .../settings/__tests__/settings.test.ts | 11 + src/shell/navigation.ts | 2 + src/types/AGENTS.md | 1 + src/types/logging.ts | 58 ++ 20 files changed, 2275 insertions(+), 8 deletions(-) create mode 100644 src/config/__tests__/logging.test.mjs create mode 100644 src/routes/logs/+page.svelte create mode 100644 src/routes/logs/AGENTS.md create mode 100644 src/routes/logs/LogDetails.svelte create mode 100644 src/routes/logs/__tests__/logging-ui.test.ts create mode 100644 src/routes/logs/log-summary.ts create mode 100644 src/routes/settings/LoggingSettings.svelte create mode 100644 src/types/logging.ts diff --git a/src/config/__tests__/logging.test.mjs b/src/config/__tests__/logging.test.mjs new file mode 100644 index 0000000..72bed58 --- /dev/null +++ b/src/config/__tests__/logging.test.mjs @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createServer } from 'vite'; + +test('logging commands preserve exact IPC names, query fields, results, and one-shot failures', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { LOG_TYPES } = await vite.ssrLoadModule('/src/types/logging.ts'); + assert.deepEqual(LOG_TYPES, [ + 'app', + 'config', + 'group', + 'resource', + 'opencode', + 'http', + 'mcp_proxy', + 'power', + 'island', + 'logging', + ]); + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const state = { enabled: true, recording: false, directory: '/logs', error: null, droppedRecords: 3 }; + const page = { records: [], nextCursor: 'next-page', invalidLines: 2 }; + const query = { + type: 'mcp_proxy', + status: 'error', + from: '2026-11-01T00:00:00Z', + to: '2026-11-02T00:00:00Z', + operationId: 'operation-1', + cursor: 'previous-page', + limit: 20, + }; + const calls = []; + let failure; + const adapter = createTauriAdapter(async (...args) => { + calls.push(args); + if (failure) throw failure; + if (args[0] === 'query_logs') return page; + return args[0] === 'open_logs_directory' ? undefined : state; + }); + const cases = [ + ['getLoggingState', [], ['get_logging_state'], state], + ['setLoggingEnabled', [true], ['set_logging_enabled', { enabled: true }], state], + ['setLoggingEnabled', [false], ['set_logging_enabled', { enabled: false }], state], + ['queryLogs', [query], ['query_logs', { query }], page], + ['queryLogs', [{}], ['query_logs', { query: {} }], page], + ['openLogsDirectory', [], ['open_logs_directory'], undefined], + ['clearLogs', [], ['clear_logs'], state], + ]; + assert.deepEqual(calls, []); + for (const [method, args, expected, result] of cases) { + assert.equal(await adapter[method](...args), result); + assert.deepEqual(calls.at(-1), expected); + if (args[0] === query) assert.equal(calls.at(-1)[1].query, query); + } + assert.equal(calls.length, cases.length); + failure = { code: 'configuration_failed', message: 'private failure', detail: 'private detail' }; + for (const [method, args, expected] of cases) { + const before = calls.length; + await assert.rejects(adapter[method](...args), (cause) => cause === failure); + assert.equal(calls.length, before + 1); + assert.deepEqual(calls.at(-1), expected); + } + } finally { + await vite.close(); + } +}); + +test('logging facades stay lazy and never retain payloads, results, or private failures in shared state', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + const calls = []; + const state = { enabled: false, recording: false, directory: '/private/logs', error: null, droppedRecords: 0 }; + const page = { + records: [ + { + schemaVersion: 1, + timestamp: '2026-11-01T00:00:00Z', + sessionId: 'private-session', + sequence: 1, + operationId: 'private-operation', + type: 'config', + status: 'success', + event: 'config_saved', + message: 'Configuration saved', + durationMs: null, + errorCode: null, + context: { step: 'write', count: 1, targetWritten: true }, + }, + ], + nextCursor: null, + invalidLines: 0, + }; + let failure; + let finish; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const adapter = createTauriAdapter(async (...args) => { + calls.push(args); + if (failure) throw failure; + await pending; + if (args[0] === 'query_logs') return page; + return args[0] === 'open_logs_directory' ? undefined : state; + }); + const store = createConfigStore(adapter, false, false); + const before = JSON.stringify(store); + assert.deepEqual(calls, []); + const query = { operationId: 'private-operation', cursor: 'private-cursor', limit: 10 }; + const cases = [ + ['getLoggingState', [], ['get_logging_state'], state], + ['setLoggingEnabled', [false], ['set_logging_enabled', { enabled: false }], state], + ['queryLogs', [query], ['query_logs', { query }], page], + ['openLogsDirectory', [], ['open_logs_directory'], undefined], + ['clearLogs', [], ['clear_logs'], state], + ]; + const requests = cases.map(([method, args, expected]) => { + const request = store[method](...args); + assert.deepEqual(calls.at(-1), expected); + assert.equal(JSON.stringify(store), before); + return request; + }); + finish(undefined); + const results = await Promise.all(requests); + for (const [index, result] of results.entries()) assert.equal(result, cases[index][3]); + assert.equal(JSON.stringify(store), before); + failure = { code: 'conflict', message: 'private failure', detail: { path: '/private/logs' } }; + for (const [method, args, expected] of cases) { + const count = calls.length; + await assert.rejects(store[method](...args), (cause) => cause === failure); + assert.equal(calls.length, count + 1); + assert.deepEqual(calls.at(-1), expected); + assert.equal(JSON.stringify(store), before); + } + } finally { + await vite.close(); + } +}); + +test('global initialization and refresh never prefetch logging or add logging preferences', async () => { + const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); + try { + const { createTauriAdapter } = await vite.ssrLoadModule('/src/config/adapter.ts'); + const { createConfigStore } = await vite.ssrLoadModule('/src/config/store.svelte.ts'); + const calls = []; + const preferences = { theme: 'dark', locale: 'en', defaultPlugin: 'native' }; + const adapter = createTauriAdapter(async (...args) => { + calls.push(args); + if (args[0] === 'load_app_state') return { providers: [], agents: [], groups: [], preferences }; + assert.equal(args[0], 'opencode_list_models'); + return []; + }); + const store = createConfigStore(adapter); + await store.refresh(); + assert.deepEqual(calls, [ + ['opencode_list_models', { provider: undefined }], + ['load_app_state'], + ['opencode_list_models', { provider: undefined }], + ['load_app_state'], + ]); + assert.deepEqual(store.preferences, preferences); + assert.equal(store.loading, false); + } finally { + await vite.close(); + } +}); diff --git a/src/config/adapter.ts b/src/config/adapter.ts index a12f06a..5d2b4cc 100644 --- a/src/config/adapter.ts +++ b/src/config/adapter.ts @@ -27,6 +27,7 @@ import type { IslandState, IslandView, } from '$src/types/island.js'; +import type { LoggingState, LogPage, LogQuery } from '$src/types/logging.js'; import type { McpDraft, McpList, McpServer, McpUpdate } from '$src/types/mcp.js'; import type { CloseMcpProxyUnitRequest, @@ -123,6 +124,11 @@ export interface CommandAdapter { setAutostart(enabled: boolean): Result; getLidProtection(): Result; setLidProtection(enabled: boolean): Result; + getLoggingState(): Result; + setLoggingEnabled(enabled: boolean): Result; + queryLogs(query: LogQuery): Result; + openLogsDirectory(): Result; + clearLogs(): Result; getIslandState(): Result; setIslandEnabled(enabled: boolean): Result; refreshIsland(): Result; @@ -230,6 +236,11 @@ export function createTauriAdapter(invoke: TauriInvoke): CommandAdapter { setAutostart: (enabled) => invoke('set_autostart', { enabled }), getLidProtection: () => invoke('get_lid_protection'), setLidProtection: (enabled) => invoke('set_lid_protection', { enabled }), + getLoggingState: () => invoke('get_logging_state'), + setLoggingEnabled: (enabled) => invoke('set_logging_enabled', { enabled }), + queryLogs: (query) => invoke('query_logs', { query }), + openLogsDirectory: () => invoke('open_logs_directory'), + clearLogs: () => invoke('clear_logs'), getIslandState: () => invoke('get_island_state', {}), setIslandEnabled: (enabled) => invoke('set_island_enabled', { enabled }), refreshIsland: () => invoke('refresh_island', {}), diff --git a/src/config/store.svelte.ts b/src/config/store.svelte.ts index fe98a0d..0cea149 100644 --- a/src/config/store.svelte.ts +++ b/src/config/store.svelte.ts @@ -29,6 +29,7 @@ import type { IslandState, IslandView, } from '$src/types/island.js'; +import type { LoggingState, LogPage, LogQuery } from '$src/types/logging.js'; import type { McpDraft, McpList, McpServer, McpUpdate } from '$src/types/mcp.js'; import type { CloseMcpProxyUnitRequest, @@ -586,6 +587,21 @@ export function createConfigStore(adapter: CommandAdapter, catalogOnRefresh = tr setLidProtection(enabled: boolean): Promise { return adapter.setLidProtection(enabled); }, + getLoggingState(): Promise { + return adapter.getLoggingState(); + }, + setLoggingEnabled(enabled: boolean): Promise { + return adapter.setLoggingEnabled(enabled); + }, + queryLogs(query: LogQuery): Promise { + return adapter.queryLogs(query); + }, + openLogsDirectory(): Promise { + return adapter.openLogsDirectory(); + }, + clearLogs(): Promise { + return adapter.clearLogs(); + }, getIslandState(): Promise { return adapter.getIslandState(); }, diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 7decd5f..bbc3d05 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1,4 +1,97 @@ export const en = { + 'nav.logs': 'Logs', + 'settings.logging': 'Local diagnostic logging', + 'settings.loggingHint': + 'Off by default. The switch shows the saved preference; enabled does not guarantee recording. Turning this off keeps existing history.', + 'settings.loggingRefresh': 'Refresh logging status', + 'settings.loggingRetryRecording': 'Retry recording', + 'settings.loggingPrivacy': 'No credentials, configuration contents, prompts or conversation messages are recorded.', + 'settings.loggingView': 'View logs', + 'settings.loggingLoadFailed': 'Could not read logging status. The last confirmed setting is kept.', + 'settings.loggingSaveFailed': + 'Could not confirm the change. The last confirmed setting is kept; refresh before retrying.', + 'logs.metaTitle': 'Logs · opencode-mom', + 'logs.eyebrow': 'APPLICATION / DIAGNOSTICS', + 'logs.title': 'Logs', + 'logs.description': 'Local operation history · newest first · refresh manually.', + 'logs.settings': 'Logging settings', + 'logs.recording': 'Recording', + 'logs.recordingOff': 'Recording off', + 'logs.recordingFailed': 'Logging needs attention', + 'logs.recordingFailedHint': + 'Logging reported a recording or preference-save problem. Refresh status in Settings; retry recording if it is enabled but stopped.', + 'logs.disabledHistory': 'Recording is off. Saved history is still available; enable recording in Settings.', + 'logs.stateFailed': 'Could not read logging status. Any status shown is the last confirmed result.', + 'logs.droppedRecords': '{count} records were dropped in this app session. History may be incomplete.', + 'logs.invalidLines': 'At least {count} unreadable log lines were skipped. Results may be incomplete.', + 'logs.directory': 'Log directory', + 'logs.openDirectory': 'Open directory', + 'logs.openFailed': 'Could not open the log directory. Try again.', + 'logs.clear': 'Clear history', + 'logs.clearTitle': 'Clear local log history?', + 'logs.clearDescription': + 'This permanently deletes only local diagnostic history. It does not change logging settings or application configuration.', + 'logs.clearConfirm': 'Clear log history', + 'logs.clearing': 'Clearing…', + 'logs.clearFailed': 'History could not be cleared. The previous page is kept; refresh before retrying.', + 'logs.cleared': 'History cleared. Recording settings are unchanged; refresh to see any new records.', + 'logs.type': 'Event type', + 'logs.allTypes': 'All types', + 'logs.status': 'Result', + 'logs.allStatuses': 'All results', + 'logs.success': 'Success', + 'logs.error': 'Error', + 'logs.period': 'Time range', + 'logs.recent24h': 'Last 24 hours', + 'logs.recent7d': 'Last 7 days', + 'logs.allTime': 'All time', + 'logs.localTime': 'Local time · {zone}', + 'logs.tableLabel': 'Local diagnostic log records', + 'logs.time': 'Time', + 'logs.event': 'Event / summary', + 'logs.loading': 'Loading log records…', + 'logs.loadFailed': + 'Could not load log records. Refresh to restart; saved cursors can expire when history rotates or is cleared.', + 'logs.empty': 'No saved records match these filters.', + 'logs.unavailable': 'Log records are unavailable. Refresh to retry.', + 'logs.retained': 'Showing the previous page until a new query succeeds. It may not match the current filters.', + 'logs.recordCount': '{count} records shown', + 'logs.loadMore': 'Older records', + 'logs.details': 'Record details', + 'logs.traceTitle': 'Operation trace', + 'logs.traceHint': + 'Up to {count} latest retained events, oldest first. Rotation or dropped records may leave gaps. A successful event does not confirm the whole operation.', + 'logs.traceEmpty': 'No retained events were found for this operation. Its history may have been cleared or rotated.', + 'logs.traceFailed': 'Could not load this operation. Try again.', + 'logs.traceMore': 'Only the latest {count} retained events are shown. Earlier events are not loaded.', + 'logs.timestamp': 'Timestamp (ISO)', + 'logs.operationId': 'Operation ID', + 'logs.sessionId': 'Session ID', + 'logs.sequence': 'Sequence', + 'logs.duration': 'Duration', + 'logs.durationMs': '{count} ms', + 'logs.errorCode': 'Error code', + 'logs.message': 'Summary', + 'logs.summary.operationCompleted': 'Operation completed', + 'logs.summary.operationFailed': 'Operation failed', + 'logs.summary.checkpointReached': 'Checkpoint reached', + 'logs.summary.eventCompleted': 'Event completed', + 'logs.summary.eventFailed': 'Event failed', + 'logs.step': 'Step', + 'logs.count': 'Count', + 'logs.targetWritten': 'Target written', + 'logs.yes': 'Yes', + 'logs.no': 'No', + 'logs.type.app': 'App', + 'logs.type.config': 'Configuration', + 'logs.type.group': 'Groups', + 'logs.type.resource': 'Resources', + 'logs.type.opencode': 'OpenCode', + 'logs.type.http': 'HTTP', + 'logs.type.mcp_proxy': 'MCP Proxy', + 'logs.type.power': 'Power', + 'logs.type.island': 'Island', + 'logs.type.logging': 'Logging', 'nav.plugins': 'Plugins', 'nav.commands': 'Commands', 'scope.projectWritable': 'Project files · writes only to the selected root', diff --git a/src/i18n/dictionaries/zh.ts b/src/i18n/dictionaries/zh.ts index b3d6146..47e5023 100644 --- a/src/i18n/dictionaries/zh.ts +++ b/src/i18n/dictionaries/zh.ts @@ -1,6 +1,94 @@ import type { MessageKey } from './en.js'; export const zh: Record = { + 'nav.logs': '日志', + 'settings.logging': '本地诊断日志', + 'settings.loggingHint': '默认关闭。开关表示已保存的记录偏好,开启不代表正在正常记录。关闭后仍保留已有记录。', + 'settings.loggingRefresh': '刷新日志状态', + 'settings.loggingRetryRecording': '重试记录', + 'settings.loggingPrivacy': '不记录凭据、配置内容、提示词或对话消息。', + 'settings.loggingView': '查看日志', + 'settings.loggingLoadFailed': '无法读取日志状态,保留上次确认的设置。', + 'settings.loggingSaveFailed': '无法确认更改,保留上次确认的设置。请刷新后再重试。', + 'logs.metaTitle': '日志 · opencode-mom', + 'logs.eyebrow': '应用 / 诊断', + 'logs.title': '日志', + 'logs.description': '本地操作记录 · 最新在前 · 手动刷新。', + 'logs.settings': '日志设置', + 'logs.recording': '正在记录', + 'logs.recordingOff': '已关闭记录', + 'logs.recordingFailed': '日志需要检查', + 'logs.recordingFailedHint': '日志报告了记录或偏好保存问题。请在设置中刷新状态;若已开启但停止记录,可重试记录。', + 'logs.disabledHistory': '已关闭记录,仍可查看已有日志;可在设置中开启记录。', + 'logs.stateFailed': '无法读取日志状态,当前显示的状态为上次确认的结果。', + 'logs.droppedRecords': '本次应用运行中有 {count} 条记录被丢弃,历史记录可能不完整。', + 'logs.invalidLines': '已跳过至少 {count} 行无法读取的日志,结果可能不完整。', + 'logs.directory': '日志目录', + 'logs.openDirectory': '打开目录', + 'logs.openFailed': '无法打开日志目录,请重试。', + 'logs.clear': '清空历史', + 'logs.clearTitle': '清空本地日志历史?', + 'logs.clearDescription': '此操作仅永久删除本地诊断日志,不会更改日志设置或应用配置。', + 'logs.clearConfirm': '清空日志历史', + 'logs.clearing': '正在清空…', + 'logs.clearFailed': '无法清空历史,保留先前显示的页面。请刷新后再重试。', + 'logs.cleared': '历史已清空,记录设置未改变;请刷新查看可能产生的新记录。', + 'logs.type': '事件类型', + 'logs.allTypes': '全部类型', + 'logs.status': '结果', + 'logs.allStatuses': '全部结果', + 'logs.success': '成功', + 'logs.error': '错误', + 'logs.period': '时间范围', + 'logs.recent24h': '最近 24 小时', + 'logs.recent7d': '最近 7 天', + 'logs.allTime': '全部时间', + 'logs.localTime': '本地时间 · {zone}', + 'logs.tableLabel': '本地诊断日志记录', + 'logs.time': '时间', + 'logs.event': '事件 / 摘要', + 'logs.loading': '正在加载日志记录…', + 'logs.loadFailed': '无法加载日志记录,请刷新重新查询;历史轮转或清空后,已有分页游标可能失效。', + 'logs.empty': '没有符合当前筛选条件的已保存记录。', + 'logs.unavailable': '日志记录不可用,请刷新重试。', + 'logs.retained': '新查询成功前保留先前页面,其中记录可能不符合当前筛选条件。', + 'logs.recordCount': '已显示 {count} 条记录', + 'logs.loadMore': '较早记录', + 'logs.details': '记录详情', + 'logs.traceTitle': '操作过程', + 'logs.traceHint': + '按时间顺序显示最近保留的最多 {count} 条事件。日志轮转或记录丢弃可能造成缺失;单个事件成功不代表整个操作成功。', + 'logs.traceEmpty': '未找到此操作保留的事件,相关历史可能已清空或轮转。', + 'logs.traceFailed': '无法加载此操作,请重试。', + 'logs.traceMore': '仅显示最近保留的 {count} 条事件,未加载更早的事件。', + 'logs.timestamp': '时间戳(ISO)', + 'logs.operationId': '操作 ID', + 'logs.sessionId': '应用会话 ID', + 'logs.sequence': '序号', + 'logs.duration': '耗时', + 'logs.durationMs': '{count} 毫秒', + 'logs.errorCode': '错误码', + 'logs.message': '摘要', + 'logs.summary.operationCompleted': '操作已完成', + 'logs.summary.operationFailed': '操作失败', + 'logs.summary.checkpointReached': '已到达检查点', + 'logs.summary.eventCompleted': '事件已完成', + 'logs.summary.eventFailed': '事件失败', + 'logs.step': '步骤', + 'logs.count': '数量', + 'logs.targetWritten': '目标已写入', + 'logs.yes': '是', + 'logs.no': '否', + 'logs.type.app': '应用', + 'logs.type.config': '配置', + 'logs.type.group': '分组', + 'logs.type.resource': '资源', + 'logs.type.opencode': 'OpenCode', + 'logs.type.http': 'HTTP', + 'logs.type.mcp_proxy': 'MCP Proxy', + 'logs.type.power': '电源', + 'logs.type.island': '小岛', + 'logs.type.logging': '日志', 'nav.plugins': '插件', 'nav.commands': '命令', 'scope.projectWritable': '项目文件 · 仅写入所选根目录', diff --git a/src/routes/AGENTS.md b/src/routes/AGENTS.md index 754a665..ca55763 100644 --- a/src/routes/AGENTS.md +++ b/src/routes/AGENTS.md @@ -19,6 +19,7 @@ src/routes/ # SvelteKit page module ├── mcp/ # /mcp: Global management, project-root scopes, and MCP Proxy ├── skills/ # /skills: Global skills and project .opencode skill(s) ├── settings/ # /settings: system settings, OpenCode CLI, and app updates +├── logs/ # /logs: independent local diagnostic history and operation checkpoints ├── island/ # /island: native mini island window └── tray/ # /tray: special tray-window mode ``` diff --git a/src/routes/logs/+page.svelte b/src/routes/logs/+page.svelte new file mode 100644 index 0000000..9a92efd --- /dev/null +++ b/src/routes/logs/+page.svelte @@ -0,0 +1,444 @@ + + +{i18n.t('logs.metaTitle')} +
    + + + +

    {i18n.t('logs.description')}

    + +
    +
    +
    + + + {i18n.t(stateLoading ? 'settings.loading' : clearing ? 'logs.clearing' : stateError || !logging ? 'common.unknownStatus' : logging.recording ? 'logs.recording' : 'logs.recordingOff')} + {#if needsAttention && !stateLoading && !stateError && !clearing} + · {i18n.t('logs.recordingFailed')} + {/if} + + {i18n.t('logs.settings')} +
    +
    + + +
    +
    + {#if logging?.directory} +

    + {i18n.t('logs.directory')}: {logging.directory} +

    + {/if} + {#if logging && !logging.enabled && !logging.recording && !stateLoading && !stateError} +

    {i18n.t('logs.disabledHistory')}

    + {/if} + {#if stateError || needsAttention} + + {/if} + {#if logging && logging.droppedRecords > 0} +

    + {i18n.t('logs.droppedRecords', { count: logging.droppedRecords })} +

    + {/if} + {#if openError} + + {/if} +
    + +
    +
    + + changeFilter('status', value)} + /> +
    +
    + +