diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 79385aa..856a7a7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,6 +5,9 @@ on: push: branches: [develop] +permissions: + contents: read + jobs: ci: uses: pilgrimagesoftware/github-actions/.github/workflows/rust-ci.yaml@master diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index a791a26..f6f2910 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -3,6 +3,10 @@ name: Prepare Release on: workflow_dispatch: +permissions: + contents: write + pull-requests: write + jobs: prepare: uses: pilgrimagesoftware/github-actions/.github/workflows/rust-prepare-release.yaml@master diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 56d4c96..3e2fa40 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -11,6 +11,10 @@ on: required: true type: string +permissions: + contents: write + id-token: write + jobs: release: uses: pilgrimagesoftware/github-actions/.github/workflows/rust-release.yaml@master diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml index bcaa197..32266eb 100644 --- a/.github/workflows/tag-release.yaml +++ b/.github/workflows/tag-release.yaml @@ -6,6 +6,9 @@ on: branches: - master +permissions: + contents: write + jobs: tag-release: uses: pilgrimagesoftware/github-actions/.github/workflows/rust-tag-release.yaml@master diff --git a/CHANGELOG.md b/CHANGELOG.md index be3694f..75cf673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,39 @@ -## [0.1.1] - 2026-07-20 +## [0.2.0] - 2026-07-21 + + +### Added + +- Add name and localized [names] display-name fields + + +### Documentation + +- Drop spurious "Fixed: Conflicts" changelog entry + +- Document squash-merge subject requirement in RELEASING.md + +- Fix wrong export! form and stale 0.2 version references ### Fixed - Conflicts +- Add explicit permissions to CI/release caller workflows + + ## [Unreleased] -## [0.1.0] - 2026-07-20 +### Added +- Add a required `name` field to `Manifest` for a plugin's human-readable display name, + distinct from `id` (hosts must not derive a display name from `id`) +- Add an optional `[names]` table for locale-keyed display names, and + `Manifest::localized_name` to look one up with fallback to `name` -## [Unreleased] +## [0.1.1] - 2026-07-20 ### Added diff --git a/Cargo.toml b/Cargo.toml index 36041a7..5f398cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fulltime-plugin-api" -version = "0.1.1" +version = "0.2.0" edition = "2021" rust-version = "1.85" description = "Canonical league-data schema, data-provider WIT interface, and plugin manifest format shared by the FullTime plugin host and data-provider plugins." diff --git a/README.md b/README.md index 2333fa4..6e2151d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Neither the host nor any plugin owns this contract - it is versioned and publish Rust bindings are generated from this file via `wit-bindgen`, not hand-written, so the WIT source is the single source of truth. - **Plugin manifest format** (`Manifest`): the static TOML file every plugin ships declaring its ID, release version, targeted schema/interface versions, and required network hosts. This crate validates structure and field format only - network reachability and capability enforcement belong to the host runtime (`Apps/rust`). +- **`host` interface and `Guest`/`export!` bindings**: `world plugin` imports `host.fetch` - a plugin has no direct network access and must call this crate's `host_fetch` wrapper for every upstream request. This crate also re-exports the generated `Guest` trait and `export!` macro so a downstream plugin implements and exports the world using this crate's own canonical types, rather than regenerating an incompatible copy from a vendored WIT file. ## Versioning @@ -29,7 +30,7 @@ See [`Version::accepts`]. ```toml [dependencies] -fulltime-plugin-api = "0.2" +fulltime-plugin-api = "0.1" ``` ```rust diff --git a/RELEASING.md b/RELEASING.md index 9d1342e..90ae916 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,6 +3,10 @@ Releases are driven by [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) on `develop` and [`git-cliff`](https://git-cliff.org), via three workflows in `.github/workflows/` (thin wrappers around the `rust-*` reusable workflows in [`pilgrimagesoftware/github-actions`](https://github.com/pilgrimagesoftware/github-actions)). There's no manual version bumping or changelog editing. +Because of this, **squash-merging a feature PR into `develop` must preserve the original commit's Conventional Commits type and any `!`/`BREAKING CHANGE:` marker.** +GitHub's default squash message is the PR title, which usually drops both — `git-cliff` then can't classify the squashed commit, silently produces an empty/no-op changelog section, and under-bumps the version. +Set the squash subject explicitly, e.g. `gh pr merge --squash --subject "feat!: "`, or merge with a merge commit instead when the PR is a single already-well-formed commit. + ## 1. Prepare the release Trigger **Prepare Release** manually (Actions tab → Prepare Release → Run workflow). It: diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index ee8ceb6..631ea50 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -121,7 +121,10 @@ types from a vendored WIT file: this crate's own `Team`/`Fixture`/`Standings`/`Competition`/`ProviderError` types directly. - [`export!`] — the macro that exports your `Guest` implementation as the component's - `data-provider` interface. + `data-provider` interface. Called from a downstream crate, it needs the `with_types_in` + form — the single-arg form only resolves inside this crate itself, since `export!` is + `wit-bindgen`-generated and expects to find its supporting types in the crate that + declares them. ```rust,ignore struct MyPlugin; @@ -133,7 +136,7 @@ impl fulltime_plugin_api::Guest for MyPlugin { // fetch_fixtures, fetch_results, fetch_standings, fetch_metadata ... } -fulltime_plugin_api::export!(MyPlugin); +fulltime_plugin_api::export!(MyPlugin with_types_in fulltime_plugin_api); ``` ## Getting started @@ -141,11 +144,11 @@ fulltime_plugin_api::export!(MyPlugin); 1. Add this crate as a dependency: ```toml [dependencies] - fulltime-plugin-api = "0.2" + fulltime-plugin-api = "0.1" ``` 2. Implement [`Guest`] against your upstream data source, mapping its response shape into the canonical schema types, and calling [`host_fetch`] for every upstream request. -3. Call [`export!`] with your implementation. +3. Call [`export!`] with the `with_types_in` form against your implementation. 4. Write your `manifest.toml` declaring the network hosts you call and `interface_version = "2.0"`. 5. Build to a WASM component target and load it against the host runtime in `Apps/rust`. diff --git a/openspec/changes/add-plugin-build-metadata/.openspec.yaml b/openspec/changes/add-plugin-build-metadata/.openspec.yaml new file mode 100644 index 0000000..c0a8162 --- /dev/null +++ b/openspec/changes/add-plugin-build-metadata/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-21 diff --git a/openspec/changes/add-plugin-build-metadata/design.md b/openspec/changes/add-plugin-build-metadata/design.md new file mode 100644 index 0000000..9fb9d0d --- /dev/null +++ b/openspec/changes/add-plugin-build-metadata/design.md @@ -0,0 +1,83 @@ +## Context + +The plugin manifest (`src/manifest.rs`) currently carries `id`, `version`, `schema_version`, +`interface_version`, and `network_hosts` — enough for the host to load and version-check a +plugin, but nothing to show a human which developer/publisher built it or when. `Apps/rust`'s +Plugins management screen (`openspec/changes/plugin-host-runtime`, already implemented there) +lists `id` and `version` only, for exactly this reason. + +Two prior manifest fields established a validation precedent worth following here: +`schema_version`/`interface_version` are parsed into a typed [`Version`] because the host +actively compares them for compatibility. `network_hosts` entries get only a "must not be empty" +check because they're used as-is (string equality against a request's host). This change's two +new fields are purely informational — nothing compares or parses them — so they follow the +`network_hosts` precedent, not the `Version` one. + +## Goals / Non-Goals + +**Goals:** +- Let a plugin manifest optionally declare a developer/publisher display name and a build + timestamp. +- Keep every existing manifest (in particular `Plugins/Bundesliga`'s) parsing unchanged with no + edits required — an additive, minor-version change per this crate's own versioning policy. + +**Non-Goals:** +- Validating `build_date` as a well-formed timestamp. This crate never validates `Fixture.kickoff` + (also documented as RFC 3339) either; adding parsing here would be inconsistent and would pull + in a date/time dependency (`time` or `chrono`) this crate has never needed, bloating every + plugin's compiled `wasm32` component for a display-only field. +- Any host-side or UI-side consumption of these fields. Surfacing them in `Apps/rust`'s Plugins + screen is a separate, follow-up change in that repo. +- Making either field required. That would be a breaking, major-version change forcing every + existing plugin (starting with `Plugins/Bundesliga`) to update its manifest before it could be + loaded by a host built against the new version. + +## Decisions + +**Both fields are `Option`, not a new typed wrapper.** `developer` is a free-form display +string (no format to validate beyond non-empty). `build_date` is documented as RFC 3339 but stored +and returned as the raw string, exactly like `Fixture.kickoff` — this crate parses neither. +Alternative considered: a `Version`-style typed date wrapper with parse validation, rejected per +the Non-Goals above (inconsistent with `kickoff`, needless dependency, no consumer that needs a +parsed value yet). + +**Both fields are optional, not required.** Alternative considered: required fields, rejected +because it forces a major version bump and breaks every existing manifest, for two fields whose +absence is a completely reasonable state (a plugin author who hasn't set up a build-date stamping +step yet, or doesn't want to disclose a developer name). + +**Validation mirrors `network_hosts`, not `schema_version`.** When present, each field must be a +non-empty string after trimming (same rule `network_hosts` entries already use) — not a schema +compatibility concern, so no `ManifestField` variant needs special version-parsing logic, just the +same "field is present but empty" rejection path `network_hosts` already has. + +## Risks / Trade-offs + +- [A future need to actually parse `build_date` (e.g. to sort plugins by recency) would require + revisiting the no-validation decision] → Acceptable now: no consumer needs a parsed value yet, + and adding validation later is itself another additive, non-breaking change (tightening an + `Option` to reject previously-accepted malformed strings would be the only breaking + edge case, and is deferred to if/when it's actually needed). +- [`developer` has no format constraint at all, so two plugins could declare visually-identical or + confusingly-similar developer names] → Out of scope: this crate validates manifest structure, + not developer identity or trust — matching its existing stated non-goal for `network_hosts` + ("this crate validates manifest format only"). + +## Migration Plan + +1. Add both fields to `RawManifest` and `Manifest`, both `Option`, with the non-empty + check applied only when present. +2. Bump `Cargo.toml`'s version per this being an additive/minor change (handled by the normal + `git-cliff`-driven release process in `RELEASING.md`, not a manual step here). +3. No manifest anywhere needs to change for this to ship — `Plugins/Bundesliga`'s current + `manifest.toml` keeps parsing exactly as it does today, with both new fields resolving to + `None`. + +Rollback: revert the two-field addition; no data migration exists since nothing is persisted by +this crate itself. + +## Open Questions + +- Should `Apps/rust`'s Plugins screen surface these fields once available? Deferred to a + follow-up change in that repo, coordinated after this one ships and a new `fulltime-plugin-api` + version is cut. diff --git a/openspec/changes/add-plugin-build-metadata/proposal.md b/openspec/changes/add-plugin-build-metadata/proposal.md new file mode 100644 index 0000000..4bf9484 --- /dev/null +++ b/openspec/changes/add-plugin-build-metadata/proposal.md @@ -0,0 +1,45 @@ +## Why + +The plugin manifest currently has no field for who built a plugin or when. `fulltime-core`'s +Plugins management screen (`openspec/changes/plugin-host-runtime` in `Apps/rust`) lists each +plugin's `id` and `version` only, because that's all the manifest carries — there's nowhere to +show a developer/publisher name or a build timestamp to help a user tell plugins apart or judge +how current one is. + +## What Changes + +- Add two optional manifest fields: `developer` (a display name/identifier for the plugin's + author or publisher) and `build_date` (an RFC 3339 timestamp for when the plugin was built). + Optional, not required, so existing manifests (e.g. `Plugins/Bundesliga`'s) keep parsing + without changes — an additive, minor-version manifest schema change under this crate's own + versioning policy (see `RELEASING.md`/`src/version.rs`'s doc comments). +- `Manifest::parse` accepts and exposes both fields when present, and treats their absence as + `None` rather than a parse error. Neither field affects host/plugin compatibility checks — both + are display-only metadata, unlike `schema_version`/`interface_version`. +- `build_date` is documented as RFC 3339 (matching the existing `Fixture.kickoff` convention in + `wit/data-provider.wit`) but is not parsed/validated by this crate — same treatment as + `kickoff`, which this crate also never validates. A non-empty check only, matching + `network_hosts` entries. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `plugin-manifest-format`: the manifest schema gains two optional fields, `developer` and + `build_date`, each exposed on the parsed `Manifest` and validated at parse time when present. + +## Impact + +- **`src/manifest.rs`**: `Manifest` struct gains `developer: Option` and + `build_date: Option` (or a parsed timestamp type — see `design.md`), `RawManifest` + gains the corresponding optional fields, and `Manifest::parse` validates `build_date`'s format + when present. +- **Downstream plugins** (`Plugins/Bundesliga`, future plugins): unaffected unless they choose to + add the new fields to their own `manifest.toml`. +- **`Apps/rust`'s plugin management UI** (`openspec/changes/plugin-host-runtime`, a separate, + already-implemented change in that repo): a follow-up change there would surface these fields + in the Plugins screen once this manifest change ships — out of scope here. diff --git a/openspec/changes/add-plugin-build-metadata/specs/plugin-manifest-format/spec.md b/openspec/changes/add-plugin-build-metadata/specs/plugin-manifest-format/spec.md new file mode 100644 index 0000000..cd1293a --- /dev/null +++ b/openspec/changes/add-plugin-build-metadata/specs/plugin-manifest-format/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Plugin Build Metadata +The manifest schema SHALL support two optional display-only fields: `developer` (a +display name/identifier for the plugin's author or publisher) and `build_date` (a +timestamp, conventionally RFC 3339, for when the plugin was built). Neither field SHALL be +required, and neither SHALL affect schema/interface compatibility checks. + +#### Scenario: Manifest omits both fields +- **WHEN** a manifest has no `developer` or `build_date` field +- **THEN** parsing succeeds and the parsed manifest exposes both as absent, not as an error + +#### Scenario: Manifest declares a developer name +- **WHEN** a manifest includes a non-empty `developer` field +- **THEN** the parsed manifest exposes that value unchanged + +#### Scenario: Manifest declares a build date +- **WHEN** a manifest includes a non-empty `build_date` field +- **THEN** the parsed manifest exposes that value unchanged, without being parsed or validated + as a timestamp + +#### Scenario: Empty developer or build_date field is rejected +- **WHEN** a manifest includes a `developer` or `build_date` field present but empty (or + whitespace-only) +- **THEN** parsing fails with a structured error identifying the invalid field, the same way an + empty `network_hosts` entry is rejected diff --git a/openspec/changes/add-plugin-build-metadata/tasks.md b/openspec/changes/add-plugin-build-metadata/tasks.md new file mode 100644 index 0000000..754d9e3 --- /dev/null +++ b/openspec/changes/add-plugin-build-metadata/tasks.md @@ -0,0 +1,33 @@ +## 1. Manifest Schema + +- [ ] 1.1 Add `developer: Option` and `build_date: Option` to `Manifest` in + `src/manifest.rs`, each with a doc comment noting `build_date` is conventionally RFC 3339 but + unvalidated (matching `Fixture.kickoff`'s treatment) +- [ ] 1.2 Add the corresponding optional fields to `RawManifest` +- [ ] 1.3 Add `ManifestField::Developer` and `ManifestField::BuildDate` variants, including their + `Display` impl arm + +## 2. Parsing and Validation + +- [ ] 2.1 In `Manifest::parse`, thread both new fields through as `Option`, defaulting to + `None` when absent +- [ ] 2.2 Reject a present-but-empty/whitespace-only `developer` or `build_date` with + `ManifestError::InvalidField`, reusing (or extracting into a shared helper alongside) + `network_hosts`'s existing empty-entry check + +## 3. Tests + +- [ ] 3.1 Unit test: manifest omitting both fields parses successfully with both `None` +- [ ] 3.2 Unit test: manifest declaring both fields parses successfully and exposes them + unchanged +- [ ] 3.3 Unit test: empty `developer` field is rejected with + `ManifestField::Developer` +- [ ] 3.4 Unit test: empty `build_date` field is rejected with `ManifestField::BuildDate` +- [ ] 3.5 Update the crate-level doc example in `src/lib.rs` and/or `README.md` if either shows a + full manifest, so they stay accurate (additive fields, no required change, but worth checking) + +## 4. Release + +- [ ] 4.1 Update `CHANGELOG.md`'s `[Unreleased]` section describing the additive manifest change +- [ ] 4.2 Confirm `RELEASING.md`'s process results in a minor version bump (additive manifest + field), not a patch or major diff --git a/openspec/changes/add-host-fetch-capability/.openspec.yaml b/openspec/changes/archive/2026-07-20-add-host-fetch-capability/.openspec.yaml similarity index 100% rename from openspec/changes/add-host-fetch-capability/.openspec.yaml rename to openspec/changes/archive/2026-07-20-add-host-fetch-capability/.openspec.yaml diff --git a/openspec/changes/add-host-fetch-capability/design.md b/openspec/changes/archive/2026-07-20-add-host-fetch-capability/design.md similarity index 100% rename from openspec/changes/add-host-fetch-capability/design.md rename to openspec/changes/archive/2026-07-20-add-host-fetch-capability/design.md diff --git a/openspec/changes/add-host-fetch-capability/proposal.md b/openspec/changes/archive/2026-07-20-add-host-fetch-capability/proposal.md similarity index 100% rename from openspec/changes/add-host-fetch-capability/proposal.md rename to openspec/changes/archive/2026-07-20-add-host-fetch-capability/proposal.md diff --git a/openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md b/openspec/changes/archive/2026-07-20-add-host-fetch-capability/specs/data-provider-plugin-api/spec.md similarity index 100% rename from openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md rename to openspec/changes/archive/2026-07-20-add-host-fetch-capability/specs/data-provider-plugin-api/spec.md diff --git a/openspec/changes/add-host-fetch-capability/specs/host-fetch-capability/spec.md b/openspec/changes/archive/2026-07-20-add-host-fetch-capability/specs/host-fetch-capability/spec.md similarity index 100% rename from openspec/changes/add-host-fetch-capability/specs/host-fetch-capability/spec.md rename to openspec/changes/archive/2026-07-20-add-host-fetch-capability/specs/host-fetch-capability/spec.md diff --git a/openspec/changes/add-host-fetch-capability/tasks.md b/openspec/changes/archive/2026-07-20-add-host-fetch-capability/tasks.md similarity index 100% rename from openspec/changes/add-host-fetch-capability/tasks.md rename to openspec/changes/archive/2026-07-20-add-host-fetch-capability/tasks.md diff --git a/openspec/specs/data-provider-plugin-api/spec.md b/openspec/specs/data-provider-plugin-api/spec.md index 7ba6ed7..7f2dabd 100644 --- a/openspec/specs/data-provider-plugin-api/spec.md +++ b/openspec/specs/data-provider-plugin-api/spec.md @@ -41,11 +41,35 @@ failures surface as unhandled traps. - **THEN** the plugin returns the `schema-mapping-failure` error variant rather than partial or malformed schema data +### Requirement: Downstream Implementation Bindings +This crate SHALL expose the generated `Guest` trait and `export!` macro for the +`data-provider` interface, so a downstream plugin can implement and export the world using +this crate's own canonical types instead of regenerating an incompatible copy from a +vendored WIT file. + +#### Scenario: Plugin implements the Guest trait +- **WHEN** a plugin crate depends on this crate as an ordinary Rust library +- **THEN** it can implement this crate's re-exported `Guest` trait for `data-provider` + using this crate's own `Team`/`Fixture`/`Standings`/`Competition`/`ProviderError` types, + with no separate WIT-derived type set of its own + +#### Scenario: Plugin exports its implementation +- **WHEN** a plugin has implemented the `Guest` trait +- **THEN** it calls this crate's re-exported `export!` macro to export the implementation + as the component's `data-provider` interface, without needing its own + `wit_bindgen::generate!` invocation + ### Requirement: Interface Versioning The data-provider interface SHALL carry an explicit version identifier, independent of the schema version, so the host can detect and reject plugins built against an incompatible interface version before invoking them. +`INTERFACE_VERSION`'s major component covers both axes of compatibility: the shape of the +`data-provider` exports a plugin implements, and the set of imports (currently, `host.fetch`) +a plugin requires from the host. A change to either axis that a plugin built against an +older major version cannot satisfy is a major bump; before `host.fetch` existed, only the +export shape was covered. + #### Scenario: Plugin built against a newer interface than the host supports - **WHEN** the host loads a plugin declaring an interface version newer (major) than any version the host implements @@ -55,4 +79,12 @@ interface version before invoking them. - **WHEN** the host loads a plugin declaring an interface minor version lower than the host's supported version, with the same major version - **THEN** the host loads the plugin, since the host's interface is a superset of the - functions the plugin was built against + functions the plugin was built against, and the plugin requires no imports the host + cannot supply + +#### Scenario: Plugin built before the host-fetch import existed +- **WHEN** the host loads a plugin declaring `interface_version` `1.x` (built before + `host.fetch` was added to the `plugin` world) +- **THEN** the host refuses to load the plugin as a major-version mismatch against its own + `2.x` support, rather than attempting instantiation and failing at the component-linking + stage with a less informative error diff --git a/openspec/specs/host-fetch-capability/spec.md b/openspec/specs/host-fetch-capability/spec.md new file mode 100644 index 0000000..963b9e8 --- /dev/null +++ b/openspec/specs/host-fetch-capability/spec.md @@ -0,0 +1,40 @@ +### Requirement: Host Fetch WIT Import +The `plugin` world SHALL import a `host` interface defining a `fetch` function, so a +plugin component cannot instantiate against a host that does not supply network access. + +#### Scenario: Host implements fetch +- **WHEN** a host loads a plugin component built against the `plugin` world +- **THEN** instantiation requires the host to supply an implementation of `host.fetch` + +#### Scenario: Plugin makes an HTTP GET request +- **WHEN** a plugin needs data from an upstream HTTP API +- **THEN** it calls `host.fetch` with the target URL and receives either the response body + or a `network-failure` error, and issues no direct network connection of its own + +### Requirement: Fetch Errors Reuse the Existing Error Shape +`host.fetch` SHALL report failures using the `errors` interface's existing +`network-failure` record rather than a separate error type. + +#### Scenario: Upstream request fails +- **WHEN** `host.fetch` cannot complete the request (network error, non-2xx status, or a + host-enforced network-host restriction from the plugin's manifest) +- **THEN** it returns `network-failure` with a message describing the failure, and the + plugin handles it identically to a `network-failure` from any other source + +### Requirement: Rust Wrapper Around the Generated Import +This crate SHALL expose a safe Rust function wrapping the generated `host.fetch` import, +so a plugin calls ordinary Rust rather than raw `wit_bindgen`-generated bindings. + +#### Scenario: Plugin calls the wrapper +- **WHEN** a plugin compiled as a `wasm32` component calls this crate's `host_fetch` + function +- **THEN** the call resolves to the generated `host.fetch` import and returns + `Result, NetworkFailure>` using this crate's own re-exported `NetworkFailure` + type + +#### Scenario: Wrapper called outside a real component instantiation +- **WHEN** `host_fetch` is referenced from code compiled for a non-`wasm32` target, or + from a `wasm32` build not instantiated by a host implementing `host.fetch` +- **THEN** the call fails to link or resolve, since the wrapper has no behavior of its own + independent of the generated import — callers are documented to gate use of it behind + `#[cfg(target_arch = "wasm32")]` and keep a separate, injectable seam for native tests diff --git a/src/manifest.rs b/src/manifest.rs index e8dfaf5..f0e75a4 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -3,9 +3,11 @@ //! //! This module validates manifest structure and field presence/format only. It performs //! no host-side enforcement (network reachability, capability granting, enable/disable -//! state) — that belongs to the plugin host runtime. See +//! state) - that belongs to the plugin host runtime. See //! `openspec/changes/define-league-data-contract/specs/plugin-manifest-format/spec.md`. +use std::collections::BTreeMap; + use serde::Deserialize; use crate::version::Version; @@ -19,20 +21,38 @@ use crate::version::Version; /// /// let toml = r#" /// id = "bundesliga" +/// name = "Bundesliga" /// version = "0.1.0" /// schema_version = "1.0" /// interface_version = "1.0" /// network_hosts = ["api.openligadb.de"] +/// +/// [names] +/// de = "Bundesliga" +/// fr = "Bundesliga" /// "#; /// /// let manifest = Manifest::parse(toml).unwrap(); /// assert_eq!(manifest.id, "bundesliga"); +/// assert_eq!(manifest.name, "Bundesliga"); /// assert_eq!(manifest.network_hosts, ["api.openligadb.de"]); +/// assert_eq!(manifest.localized_name("de"), "Bundesliga"); +/// assert_eq!(manifest.localized_name("es"), "Bundesliga"); // falls back to `name` /// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct Manifest { /// Plugin identifier, unique among plugins the host loads. pub id: String, + /// Human-readable display name (e.g. `"Bundesliga"`), distinct from + /// `id`. A plugin manifest is the only place this is declared - hosts + /// must not derive a display name from `id` (e.g. by title-casing it). + /// Used as the fallback when no entry in `localized_names` matches the + /// host's current locale. + pub name: String, + /// Locale-keyed display names (e.g. `"de"` -> `"Bundesliga"`), from the + /// manifest's `[names]` table. Prefer [`Manifest::localized_name`] over + /// reading this directly, since it applies the fallback to `name`. + pub localized_names: BTreeMap, /// Plugin's own release version (not a contract version). pub version: String, /// Canonical schema version this plugin's output targets. @@ -43,11 +63,24 @@ pub struct Manifest { pub network_hosts: Vec, } +impl Manifest { + /// Returns the display name for `locale`, falling back to [`name`](Self::name) + /// if the manifest declares no entry for that locale in `[names]`. + #[must_use] + pub fn localized_name(&self, locale: &str) -> &str { + self.localized_names + .get(locale) + .map_or(self.name.as_str(), String::as_str) + } +} + /// A manifest field that failed presence or format validation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ManifestField { /// The `id` field. Id, + /// The `name` field. + Name, /// The `version` field. Version, /// The `schema_version` field. @@ -56,16 +89,20 @@ pub enum ManifestField { InterfaceVersion, /// The `network_hosts` field. NetworkHosts, + /// The `[names]` table. + LocalizedNames, } impl core::fmt::Display for ManifestField { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let name = match self { Self::Id => "id", + Self::Name => "name", Self::Version => "version", Self::SchemaVersion => "schema_version", Self::InterfaceVersion => "interface_version", Self::NetworkHosts => "network_hosts", + Self::LocalizedNames => "names", }; f.write_str(name) } @@ -92,6 +129,9 @@ pub enum ManifestError { #[derive(Debug, Deserialize)] struct RawManifest { id: Option, + name: Option, + #[serde(default)] + names: BTreeMap, version: Option, schema_version: Option, interface_version: Option, @@ -120,12 +160,31 @@ impl Manifest { let raw: RawManifest = toml::from_str(source)?; let id = required(raw.id, ManifestField::Id)?; + let name = required(raw.name, ManifestField::Name)?; let version = required(raw.version, ManifestField::Version)?; let schema_version = parse_version(raw.schema_version, ManifestField::SchemaVersion)?; let interface_version = parse_version(raw.interface_version, ManifestField::InterfaceVersion)?; let network_hosts = required(raw.network_hosts, ManifestField::NetworkHosts)?; + if name.trim().is_empty() { + return Err(ManifestError::InvalidField { + field: ManifestField::Name, + reason: "name must not be empty".to_owned(), + }); + } + + if raw + .names + .values() + .any(|localized_name| localized_name.trim().is_empty()) + { + return Err(ManifestError::InvalidField { + field: ManifestField::LocalizedNames, + reason: "[names] entries must not be empty".to_owned(), + }); + } + if network_hosts.iter().any(|host| host.trim().is_empty()) { return Err(ManifestError::InvalidField { field: ManifestField::NetworkHosts, @@ -135,6 +194,8 @@ impl Manifest { Ok(Self { id, + name, + localized_names: raw.names, version, schema_version, interface_version, @@ -165,10 +226,15 @@ mod tests { fn valid_toml() -> &'static str { r#" id = "bundesliga" + name = "Bundesliga" version = "0.1.0" schema_version = "1.0" interface_version = "1.0" network_hosts = ["api.openligadb.de"] + + [names] + de = "Bundesliga" + fr = "Bundesliga" "# } @@ -176,8 +242,63 @@ mod tests { fn parses_a_well_formed_manifest() { let manifest = Manifest::parse(valid_toml()).unwrap(); assert_eq!(manifest.id, "bundesliga"); + assert_eq!(manifest.name, "Bundesliga"); assert_eq!(manifest.schema_version, Version::new(1, 0)); assert_eq!(manifest.network_hosts, vec!["api.openligadb.de".to_owned()]); + assert_eq!( + manifest.localized_names.get("de"), + Some(&"Bundesliga".to_owned()) + ); + } + + #[test] + fn localized_name_returns_locale_specific_value() { + let manifest = Manifest::parse(valid_toml()).unwrap(); + assert_eq!(manifest.localized_name("fr"), "Bundesliga"); + } + + #[test] + fn localized_name_falls_back_to_name_when_locale_is_missing() { + let manifest = Manifest::parse(valid_toml()).unwrap(); + assert_eq!(manifest.localized_name("es"), manifest.name); + } + + #[test] + fn parses_a_manifest_with_no_names_table() { + let toml = r#" + id = "bundesliga" + name = "Bundesliga" + version = "0.1.0" + schema_version = "1.0" + interface_version = "1.0" + network_hosts = ["api.openligadb.de"] + "#; + let manifest = Manifest::parse(toml).unwrap(); + assert!(manifest.localized_names.is_empty()); + assert_eq!(manifest.localized_name("de"), "Bundesliga"); + } + + #[test] + fn rejects_empty_localized_name_value() { + let toml = r#" + id = "bundesliga" + name = "Bundesliga" + version = "0.1.0" + schema_version = "1.0" + interface_version = "1.0" + network_hosts = ["api.openligadb.de"] + + [names] + de = " " + "#; + let err = Manifest::parse(toml).unwrap_err(); + assert!(matches!( + err, + ManifestError::InvalidField { + field: ManifestField::LocalizedNames, + .. + } + )); } #[test] @@ -186,7 +307,27 @@ mod tests { assert!(matches!( err, ManifestError::InvalidField { - field: ManifestField::Version, + field: ManifestField::Name, + .. + } + )); + } + + #[test] + fn rejects_empty_name() { + let toml = r#" + id = "bundesliga" + name = " " + version = "0.1.0" + schema_version = "1.0" + interface_version = "1.0" + network_hosts = ["api.openligadb.de"] + "#; + let err = Manifest::parse(toml).unwrap_err(); + assert!(matches!( + err, + ManifestError::InvalidField { + field: ManifestField::Name, .. } )); @@ -196,6 +337,7 @@ mod tests { fn rejects_malformed_version_string() { let toml = r#" id = "bundesliga" + name = "Bundesliga" version = "0.1.0" schema_version = "not-a-version" interface_version = "1.0" @@ -215,6 +357,7 @@ mod tests { fn rejects_empty_network_host_entry() { let toml = r#" id = "bundesliga" + name = "Bundesliga" version = "0.1.0" schema_version = "1.0" interface_version = "1.0" @@ -242,6 +385,7 @@ mod tests { // this crate performs format validation only. let toml = r#" id = "x" + name = "X" version = "0.1.0" schema_version = "1.0" interface_version = "1.0" @@ -254,6 +398,7 @@ mod tests { fn interface_version_2_0_is_accepted_by_the_current_interface_version() { let toml = r#" id = "bundesliga" + name = "Bundesliga" version = "0.1.0" schema_version = "1.0" interface_version = "2.0" @@ -267,7 +412,7 @@ mod tests { #[test] fn interface_version_1_0_is_rejected_after_the_host_fetch_major_bump() { // A plugin built before `host.fetch` existed declares interface_version 1.0; the - // host's INTERFACE_VERSION is now 2.0 (major bump), so it must not accept it — see + // host's INTERFACE_VERSION is now 2.0 (major bump), so it must not accept it - see // openspec/changes/add-host-fetch-capability/specs/data-provider-plugin-api/spec.md // ("Plugin built before the host-fetch import existed"). let manifest = Manifest::parse(valid_toml()).unwrap(); diff --git a/tests/fixtures/manifest.toml b/tests/fixtures/manifest.toml index b78876a..43d3bf4 100644 --- a/tests/fixtures/manifest.toml +++ b/tests/fixtures/manifest.toml @@ -1,5 +1,11 @@ id = "bundesliga" +name = "Bundesliga" version = "0.1.0" schema_version = "1.0" interface_version = "1.0" network_hosts = ["api.openligadb.de"] + +[names] +de = "Bundesliga" +fr = "Bundesliga" +es = "Bundesliga"