Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
## 1. Manifest Schema

- [ ] 1.1 Add `developer: Option<String>` and `build_date: Option<String>` to `Manifest` in
- [x] 1.1 Add `developer: Option<String>` and `build_date: Option<String>` 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<String>`, defaulting to
- [x] 2.1 In `Manifest::parse`, thread both new fields through as `Option<String>`, 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
25 changes: 25 additions & 0 deletions openspec/specs/plugin-manifest-format/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
102 changes: 102 additions & 0 deletions src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ pub struct Manifest {
pub interface_version: Version,
/// Network hosts this plugin requires access to.
pub network_hosts: Vec<String>,
/// Display name/identifier for the plugin's author or publisher. Display-only;
/// does not affect schema/interface compatibility checks.
pub developer: Option<String>,
/// 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<String>,
}

impl Manifest {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -136,6 +150,8 @@ struct RawManifest {
schema_version: Option<String>,
interface_version: Option<String>,
network_hosts: Option<Vec<String>>,
developer: Option<String>,
build_date: Option<String>,
}

impl Manifest {
Expand Down Expand Up @@ -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,
Expand All @@ -200,6 +219,8 @@ impl Manifest {
schema_version,
interface_version,
network_hosts,
developer,
build_date,
})
}
}
Expand All @@ -211,6 +232,21 @@ fn required<T>(value: Option<T>, field: ManifestField) -> Result<T, ManifestErro
})
}

/// Rejects a present-but-empty/whitespace-only optional field, leaving an absent field as
/// `None`. Mirrors the presence check applied to each `network_hosts` entry.
fn reject_if_empty(
value: Option<String>,
field: ManifestField,
) -> Result<Option<String>, 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<String>, field: ManifestField) -> Result<Version, ManifestError> {
let raw = required(value, field)?;
raw.parse().map_err(|_| ManifestError::InvalidField {
Expand Down Expand Up @@ -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
Expand Down