diff --git a/CHANGELOG.md b/CHANGELOG.md index 75cf673..f454e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ 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` +- Add optional `developer` and `build_date` display-only fields to `Manifest`, for a + plugin's author/publisher name and build timestamp ## [0.1.1] - 2026-07-20 diff --git a/openspec/changes/add-plugin-build-metadata/.openspec.yaml b/openspec/changes/archive/2026-07-21-add-plugin-build-metadata/.openspec.yaml similarity index 100% rename from openspec/changes/add-plugin-build-metadata/.openspec.yaml rename to openspec/changes/archive/2026-07-21-add-plugin-build-metadata/.openspec.yaml diff --git a/openspec/changes/add-plugin-build-metadata/design.md b/openspec/changes/archive/2026-07-21-add-plugin-build-metadata/design.md similarity index 100% rename from openspec/changes/add-plugin-build-metadata/design.md rename to openspec/changes/archive/2026-07-21-add-plugin-build-metadata/design.md diff --git a/openspec/changes/add-plugin-build-metadata/proposal.md b/openspec/changes/archive/2026-07-21-add-plugin-build-metadata/proposal.md similarity index 100% rename from openspec/changes/add-plugin-build-metadata/proposal.md rename to openspec/changes/archive/2026-07-21-add-plugin-build-metadata/proposal.md diff --git a/openspec/changes/add-plugin-build-metadata/specs/plugin-manifest-format/spec.md b/openspec/changes/archive/2026-07-21-add-plugin-build-metadata/specs/plugin-manifest-format/spec.md similarity index 100% rename from openspec/changes/add-plugin-build-metadata/specs/plugin-manifest-format/spec.md rename to openspec/changes/archive/2026-07-21-add-plugin-build-metadata/specs/plugin-manifest-format/spec.md diff --git a/openspec/changes/add-plugin-build-metadata/tasks.md b/openspec/changes/archive/2026-07-21-add-plugin-build-metadata/tasks.md similarity index 53% rename from openspec/changes/add-plugin-build-metadata/tasks.md rename to openspec/changes/archive/2026-07-21-add-plugin-build-metadata/tasks.md index 754d9e3..078e910 100644 --- a/openspec/changes/add-plugin-build-metadata/tasks.md +++ b/openspec/changes/archive/2026-07-21-add-plugin-build-metadata/tasks.md @@ -1,33 +1,33 @@ ## 1. Manifest Schema -- [ ] 1.1 Add `developer: Option` and `build_date: Option` to `Manifest` in +- [x] 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 +- [x] 1.2 Add the corresponding optional fields to `RawManifest` +- [x] 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 +- [x] 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 +- [x] 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 +- [x] 3.1 Unit test: manifest omitting both fields parses successfully with both `None` +- [x] 3.2 Unit test: manifest declaring both fields parses successfully and exposes them unchanged -- [ ] 3.3 Unit test: empty `developer` field is rejected with +- [x] 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 +- [x] 3.4 Unit test: empty `build_date` field is rejected with `ManifestField::BuildDate` +- [x] 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 +- [x] 4.1 Update `CHANGELOG.md`'s `[Unreleased]` section describing the additive manifest change +- [x] 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/specs/plugin-manifest-format/spec.md b/openspec/specs/plugin-manifest-format/spec.md index c8a8143..cca3a68 100644 --- a/openspec/specs/plugin-manifest-format/spec.md +++ b/openspec/specs/plugin-manifest-format/spec.md @@ -28,3 +28,28 @@ enable/disable state) — those belong to the plugin host runtime. will later reject for policy reasons - **THEN** this crate parses the manifest successfully; the runtime enforcement decision happens outside this crate + +### 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/src/manifest.rs b/src/manifest.rs index f0e75a4..998cb9c 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -61,6 +61,14 @@ pub struct Manifest { pub interface_version: Version, /// Network hosts this plugin requires access to. pub network_hosts: Vec, + /// Display name/identifier for the plugin's author or publisher. Display-only; + /// does not affect schema/interface compatibility checks. + pub developer: Option, + /// Timestamp for when the plugin was built, conventionally RFC 3339 (matching + /// `Fixture.kickoff`'s convention). Not parsed or validated as a timestamp by + /// this crate. Display-only; does not affect schema/interface compatibility + /// checks. + pub build_date: Option, } impl Manifest { @@ -91,6 +99,10 @@ pub enum ManifestField { NetworkHosts, /// The `[names]` table. LocalizedNames, + /// The `developer` field. + Developer, + /// The `build_date` field. + BuildDate, } impl core::fmt::Display for ManifestField { @@ -103,6 +115,8 @@ impl core::fmt::Display for ManifestField { Self::InterfaceVersion => "interface_version", Self::NetworkHosts => "network_hosts", Self::LocalizedNames => "names", + Self::Developer => "developer", + Self::BuildDate => "build_date", }; f.write_str(name) } @@ -136,6 +150,8 @@ struct RawManifest { schema_version: Option, interface_version: Option, network_hosts: Option>, + developer: Option, + build_date: Option, } impl Manifest { @@ -192,6 +208,9 @@ impl Manifest { }); } + let developer = reject_if_empty(raw.developer, ManifestField::Developer)?; + let build_date = reject_if_empty(raw.build_date, ManifestField::BuildDate)?; + Ok(Self { id, name, @@ -200,6 +219,8 @@ impl Manifest { schema_version, interface_version, network_hosts, + developer, + build_date, }) } } @@ -211,6 +232,21 @@ fn required(value: Option, field: ManifestField) -> Result, + field: ManifestField, +) -> Result, ManifestError> { + match value { + Some(v) if v.trim().is_empty() => Err(ManifestError::InvalidField { + field, + reason: format!("{field} must not be empty"), + }), + other => Ok(other), + } +} + fn parse_version(value: Option, field: ManifestField) -> Result { let raw = required(value, field)?; raw.parse().map_err(|_| ManifestError::InvalidField { @@ -409,6 +445,72 @@ mod tests { assert!(crate::INTERFACE_VERSION.accepts(manifest.interface_version)); } + #[test] + fn parses_a_manifest_omitting_developer_and_build_date() { + let manifest = Manifest::parse(valid_toml()).unwrap(); + assert_eq!(manifest.developer, None); + assert_eq!(manifest.build_date, None); + } + + #[test] + fn parses_a_manifest_declaring_developer_and_build_date() { + let toml = r#" + id = "bundesliga" + name = "Bundesliga" + version = "0.1.0" + schema_version = "1.0" + interface_version = "1.0" + network_hosts = ["api.openligadb.de"] + developer = "Jane Plugin Author" + build_date = "2026-07-21T00:00:00Z" + "#; + let manifest = Manifest::parse(toml).unwrap(); + assert_eq!(manifest.developer.as_deref(), Some("Jane Plugin Author")); + assert_eq!(manifest.build_date.as_deref(), Some("2026-07-21T00:00:00Z")); + } + + #[test] + fn rejects_empty_developer_field() { + let toml = r#" + id = "bundesliga" + name = "Bundesliga" + version = "0.1.0" + schema_version = "1.0" + interface_version = "1.0" + network_hosts = ["api.openligadb.de"] + developer = " " + "#; + let err = Manifest::parse(toml).unwrap_err(); + assert!(matches!( + err, + ManifestError::InvalidField { + field: ManifestField::Developer, + .. + } + )); + } + + #[test] + fn rejects_empty_build_date_field() { + let toml = r#" + id = "bundesliga" + name = "Bundesliga" + version = "0.1.0" + schema_version = "1.0" + interface_version = "1.0" + network_hosts = ["api.openligadb.de"] + build_date = " " + "#; + let err = Manifest::parse(toml).unwrap_err(); + assert!(matches!( + err, + ManifestError::InvalidField { + field: ManifestField::BuildDate, + .. + } + )); + } + #[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