use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use serde::Deserialize;
use sha2::{Digest, Sha256};
#[must_use]
pub fn hex_digest(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut hex = String::with_capacity(64);
for byte in digest.as_slice() {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[allow(
dead_code,
reason = "xtask include!s this file but does not normalise; the helper is used in the library, build.rs, and the integration test"
)]
#[must_use]
pub fn normalize_line_endings(bytes: &[u8]) -> Vec<u8> {
if !bytes.windows(2).any(|w| w == b"\r\n") {
return bytes.to_vec();
}
let mut iter = bytes.iter().copied().peekable();
let mut out = Vec::with_capacity(bytes.len());
while let Some(byte) = iter.next() {
if byte == b'\r' && iter.peek() == Some(&b'\n') {
let _ = iter.next();
out.push(b'\n');
} else {
out.push(byte);
}
}
out
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RevisionSpec {
#[serde(default)]
pub tag: String,
pub commit: String,
pub schema_prefix: String,
#[serde(default)]
pub changelog: Option<String>,
#[serde(default)]
pub changelog_is_placeholder: bool,
#[serde(default)]
pub floating: bool,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
impl RevisionSpec {
pub const SCHEMA_FILES: [&'static str; 2] = ["schema.json", "schema.ts"];
pub fn upstream_path(&self, file: &str) -> Result<String, ManifestError> {
if Self::SCHEMA_FILES.contains(&file) {
return Ok(format!("{}/{file}", self.schema_prefix));
}
match (&self.changelog, file) {
(Some(changelog), "CHANGELOG.md") => Ok(changelog.clone()),
_ => Err(ManifestError::UnaccountedFile {
revision: self.tag.clone(),
file: file.to_owned(),
}),
}
}
pub fn floating_branch(&self) -> Result<&str, ManifestError> {
if !self.is_floating() {
return Ok("");
}
self.branch
.as_deref()
.ok_or_else(|| ManifestError::MalformedCommit {
revision: self.tag.clone(),
commit: "<no branch recorded>".to_owned(),
})
}
}
impl RevisionSpec {
#[must_use]
pub const fn is_floating(&self) -> bool {
self.floating || self.tag.is_empty()
}
#[must_use]
pub const fn changelog_is_authored(&self) -> bool {
self.changelog.is_none()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestMeta {
pub schema_version: u32,
pub repository: String,
pub raw_base: String,
#[serde(default)]
pub excluded_tags: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RfcDocument {
pub title: String,
pub url: String,
pub file: String,
pub sha256: String,
#[serde(default)]
pub cited_by: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct A2aRevisionSpec {
pub tag: String,
pub commit: String,
pub package: String,
pub spec_prefix: String,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AcpRevisionSpec {
pub tag: String,
pub commit: String,
pub spec_prefix: String,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub meta: ManifestMeta,
#[serde(default)]
pub revisions: BTreeMap<String, RevisionSpec>,
#[serde(default)]
pub rfc_documents: BTreeMap<String, RfcDocument>,
#[serde(default)]
pub a2a_revisions: BTreeMap<String, A2aRevisionSpec>,
#[serde(default)]
pub acp_revisions: BTreeMap<String, AcpRevisionSpec>,
}
#[derive(Debug)]
pub enum ManifestError {
Parse(String),
UnsupportedSchemaVersion {
found: u32,
supported: u32,
},
MalformedCommit {
revision: String,
commit: String,
},
MalformedDigest {
revision: String,
file: String,
digest: String,
},
NoRevisions,
Unreadable {
revision: String,
file: String,
reason: String,
},
DigestMismatch {
revision: String,
file: String,
expected: String,
actual: String,
},
UnaccountedFile {
revision: String,
file: String,
},
MissingFile {
revision: String,
file: String,
},
}
impl fmt::Display for ManifestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parse(msg) => write!(f, "could not parse MANIFEST.toml: {msg}"),
Self::UnsupportedSchemaVersion { found, supported } => write!(
f,
"manifest schema_version {found} is newer than the supported {supported}"
),
Self::MalformedCommit { revision, commit } => write!(
f,
"revision `{revision}` has commit `{commit}`, which is not a 40-character \
lowercase hex SHA"
),
Self::MalformedDigest {
revision,
file,
digest,
} => write!(
f,
"revision `{revision}` records digest `{digest}` for `{file}`, which is not \
64-character lowercase hex"
),
Self::NoRevisions => {
write!(f, "manifest declares no revisions")
}
Self::Unreadable {
revision,
file,
reason,
} => write!(
f,
"revision `{revision}`: could not read `{file}`: {reason}"
),
Self::DigestMismatch {
revision,
file,
expected,
actual,
} => write!(
f,
"revision `{revision}`: `{file}` digest mismatch\n expected {expected}\n \
actual {actual}\n the vendored file has changed without the manifest being \
refreshed"
),
Self::UnaccountedFile { revision, file } => write!(
f,
"revision `{revision}`: `{file}` is on disk but is neither digest-locked nor \
declared as an authored changelog"
),
Self::MissingFile { revision, file } => write!(
f,
"revision `{revision}`: `{file}` is recorded in the manifest but missing on disk"
),
}
}
}
impl std::error::Error for ManifestError {}
pub const SUPPORTED_SCHEMA_VERSION: u32 = 1;
pub const CITED_DOCUMENT_DIR: &str = "cited";
fn is_lower_hex(value: &str, expected_len: usize) -> bool {
value.len() == expected_len
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
impl Manifest {
pub fn parse(source: &str) -> Result<Self, ManifestError> {
let manifest: Self =
toml::from_str(source).map_err(|err| ManifestError::Parse(err.to_string()))?;
if manifest.meta.schema_version > SUPPORTED_SCHEMA_VERSION {
return Err(ManifestError::UnsupportedSchemaVersion {
found: manifest.meta.schema_version,
supported: SUPPORTED_SCHEMA_VERSION,
});
}
if manifest.revisions.is_empty() {
return Err(ManifestError::NoRevisions);
}
for (name, revision) in &manifest.revisions {
if !is_lower_hex(&revision.commit, 40) {
return Err(ManifestError::MalformedCommit {
revision: name.clone(),
commit: revision.commit.clone(),
});
}
for (file, digest) in &revision.files {
if !is_lower_hex(digest, 64) {
return Err(ManifestError::MalformedDigest {
revision: name.clone(),
file: file.clone(),
digest: digest.clone(),
});
}
}
}
Ok(manifest)
}
pub fn verify<F, G>(&self, mut digest_of: F, files_on_disk: G) -> Result<(), ManifestError>
where
F: FnMut(&str, &str) -> Result<String, String>,
G: Fn(&str) -> Vec<String>,
{
for (name, revision) in &self.revisions {
for (file, expected) in &revision.files {
let actual = digest_of(name, file).map_err(|reason| ManifestError::Unreadable {
revision: name.clone(),
file: file.clone(),
reason,
})?;
if &actual != expected {
return Err(ManifestError::DigestMismatch {
revision: name.clone(),
file: file.clone(),
expected: expected.clone(),
actual,
});
}
}
let on_disk = files_on_disk(name);
let mut accounted: Vec<&str> = revision.files.keys().map(String::as_str).collect();
if revision.changelog_is_authored() {
accounted.push("CHANGELOG.md");
}
for file in &on_disk {
if !accounted.contains(&file.as_str()) {
return Err(ManifestError::UnaccountedFile {
revision: name.clone(),
file: file.clone(),
});
}
}
for file in &accounted {
if !on_disk.iter().any(|present| present == file) {
return Err(ManifestError::MissingFile {
revision: name.clone(),
file: (*file).to_owned(),
});
}
}
}
Ok(())
}
#[must_use]
pub fn revision_names(&self) -> Vec<&str> {
self.revisions.keys().map(String::as_str).collect()
}
pub fn verify_rfc_documents<F, G>(
&self,
mut digest_of: F,
files_on_disk: G,
) -> Result<(), ManifestError>
where
F: FnMut(&str) -> Result<String, String>,
G: Fn() -> Vec<String>,
{
for (id, document) in &self.rfc_documents {
let actual = digest_of(&document.file).map_err(|reason| ManifestError::Unreadable {
revision: id.clone(),
file: document.file.clone(),
reason,
})?;
if actual != document.sha256 {
return Err(ManifestError::DigestMismatch {
revision: id.clone(),
file: document.file.clone(),
expected: document.sha256.clone(),
actual,
});
}
}
let on_disk = files_on_disk();
let accounted: Vec<&str> = self
.rfc_documents
.values()
.map(|document| document.file.as_str())
.collect();
for file in &on_disk {
if !accounted.contains(&file.as_str()) {
return Err(ManifestError::UnaccountedFile {
revision: CITED_DOCUMENT_DIR.to_owned(),
file: file.clone(),
});
}
}
for file in &accounted {
if !on_disk.iter().any(|present| present == file) {
return Err(ManifestError::MissingFile {
revision: CITED_DOCUMENT_DIR.to_owned(),
file: (*file).to_owned(),
});
}
}
Ok(())
}
#[must_use]
pub fn rfc_document_names(&self) -> Vec<&str> {
self.rfc_documents.keys().map(String::as_str).collect()
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
reason = "tests assert on fallible calls; propagating errors would replace a clear failure message with noise"
)]
use super::{Manifest, ManifestError, RevisionSpec, SUPPORTED_SCHEMA_VERSION, hex_digest};
use assert_matches::assert_matches;
fn sample_manifest(files: &str) -> String {
format!(
"[meta]\nschema_version = {SUPPORTED_SCHEMA_VERSION}\n\
repository = \"owner/name\"\nraw_base = \"https://example.invalid\"\n\
\n[revisions.2025-06-18]\ntag = \"2025-06-18\"\n\
commit = \"0000000000000000000000000000000000000000\"\n\
schema_prefix = \"schema/2025-06-18\"\n\
changelog = \"docs/specification/2025-06-18/changelog.mdx\"\n\
{files}"
)
}
fn vendored_pair(
schema_body: &str,
changelog_body: &str,
) -> (String, Vec<(&'static str, String)>) {
let source = sample_manifest(&format!(
"\n[revisions.2025-06-18.files]\n\
\"schema.json\" = \"{}\"\n\"CHANGELOG.md\" = \"{}\"\n",
hex_digest(schema_body.as_bytes()),
hex_digest(changelog_body.as_bytes()),
));
let content = vec![
("2025-06-18/schema.json", schema_body.to_owned()),
("2025-06-18/CHANGELOG.md", changelog_body.to_owned()),
];
(source, content)
}
const ZERO: &str = "0000000000000000000000000000000000000000000000000000000000000000";
fn citing_manifest(body: &str) -> String {
format!(
"{}\n[rfc_documents.rfc7636]\ntitle = \"PKCE\"\n\
url = \"https://example.invalid/rfc7636.txt\"\nfile = \"rfc7636.txt\"\n\
sha256 = \"{}\"\ncited_by = [\"2025-03-26\"]\n",
sample_manifest("").trim_end(),
hex_digest(body.as_bytes()),
)
}
fn on_disk(extra: &[&str]) -> Vec<String> {
let mut names: Vec<String> = std::iter::once("rfc7636.txt".to_owned())
.chain(extra.iter().map(|name| (*name).to_owned()))
.collect();
names.sort();
names
}
#[test]
fn a_cited_document_verifies_when_the_digest_and_the_tree_agree() {
let manifest = Manifest::parse(&citing_manifest("the body")).expect("parses");
assert_matches!(
manifest.verify_rfc_documents(|_| Ok(hex_digest(b"the body")), || on_disk(&[]),),
Ok(())
);
}
#[test]
fn a_cited_document_whose_text_changed_is_refused() {
let manifest = Manifest::parse(&citing_manifest("the body")).expect("parses");
let outcome =
manifest.verify_rfc_documents(|_| Ok(hex_digest(b"a different body")), || on_disk(&[]));
assert_matches!(outcome, Err(ManifestError::DigestMismatch { .. }));
}
#[test]
fn an_unrecorded_document_in_the_tree_is_refused() {
let manifest = Manifest::parse(&citing_manifest("the body")).expect("parses");
let outcome = manifest.verify_rfc_documents(
|_| Ok(hex_digest(b"the body")),
|| on_disk(&["rfc9999.txt"]),
);
assert_matches!(outcome, Err(ManifestError::UnaccountedFile { .. }));
}
#[test]
fn a_recorded_document_that_is_absent_is_refused() {
let manifest = Manifest::parse(&citing_manifest("the body")).expect("parses");
let outcome = manifest.verify_rfc_documents(|_| Ok(hex_digest(b"the body")), Vec::new);
assert_matches!(outcome, Err(ManifestError::MissingFile { .. }));
}
#[test]
fn a_manifest_with_no_cited_documents_still_parses() {
let manifest = Manifest::parse(&sample_manifest("")).expect("parses");
assert!(manifest.rfc_document_names().is_empty());
assert_matches!(
manifest.verify_rfc_documents(|_| Ok(ZERO.to_owned()), Vec::new),
Ok(())
);
}
#[test]
fn hex_digest_matches_the_published_sha256_of_the_empty_string() {
assert_eq!(
hex_digest(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn hex_digest_is_lowercase_hex_of_the_right_width() {
let digest = hex_digest(b"protocol-droid");
assert_eq!(digest.len(), 64);
assert!(
digest
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
);
}
#[test]
fn a_valid_manifest_parses() {
let source = sample_manifest(&format!(
"\n[revisions.2025-06-18.files]\n\"schema.json\" = \"{ZERO}\"\n"
));
let manifest = Manifest::parse(&source).expect("manifest must parse");
assert_eq!(manifest.revision_names(), vec!["2025-06-18"]);
assert_eq!(manifest.revisions.len(), 1);
}
#[test]
fn a_newer_schema_version_is_refused() {
let source = sample_manifest("").replace(
&format!("schema_version = {SUPPORTED_SCHEMA_VERSION}"),
"schema_version = 99",
);
let error = Manifest::parse(&source).expect_err("future version must be refused");
assert!(matches!(
error,
ManifestError::UnsupportedSchemaVersion { found: 99, .. }
));
}
#[test]
fn a_manifest_with_no_revisions_is_refused() {
let source = format!(
"[meta]\nschema_version = {SUPPORTED_SCHEMA_VERSION}\n\
repository = \"owner/name\"\nraw_base = \"https://example.invalid\"\n"
);
let error = Manifest::parse(&source).expect_err("empty manifest must be refused");
assert!(matches!(error, ManifestError::NoRevisions));
}
#[test]
fn a_commit_that_is_not_a_full_sha_is_refused() {
let source =
sample_manifest("").replace("0000000000000000000000000000000000000000", "main");
let error = Manifest::parse(&source).expect_err("branch name must be refused");
assert!(matches!(error, ManifestError::MalformedCommit { .. }));
}
#[test]
fn an_uppercase_commit_is_refused() {
let source = sample_manifest("").replace(
"0000000000000000000000000000000000000000",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
);
let error = Manifest::parse(&source).expect_err("uppercase SHA must be refused");
assert!(matches!(error, ManifestError::MalformedCommit { .. }));
}
#[test]
fn a_digest_that_is_not_64_hex_characters_is_refused() {
let source =
sample_manifest("\n[revisions.2025-06-18.files]\n\"schema.json\" = \"abc123\"\n");
let error = Manifest::parse(&source).expect_err("short digest must be refused");
assert!(matches!(error, ManifestError::MalformedDigest { .. }));
}
#[test]
fn malformed_toml_is_refused() {
let error = Manifest::parse("this is not toml").expect_err("garbage must be refused");
assert!(matches!(error, ManifestError::Parse(_)));
}
#[test]
fn an_unknown_key_is_refused() {
let source = sample_manifest("").replace("tag = \"2025-06-18\"", "tg = \"2025-06-18\"");
let error = Manifest::parse(&source).expect_err("unknown key must be refused");
assert!(matches!(error, ManifestError::Parse(_)));
}
fn verify_against<N: AsRef<str>, B: AsRef<str>>(
source: &str,
content: &[(N, B)],
) -> Result<(), ManifestError> {
let manifest = Manifest::parse(source).expect("manifest must parse");
manifest.verify(
|revision, file| {
let key = format!("{revision}/{file}");
content
.iter()
.find(|(name, _)| name.as_ref() == key)
.map(|(_, bytes)| hex_digest(bytes.as_ref().as_bytes()))
.ok_or_else(|| format!("no such file {key}"))
},
|revision| {
let prefix = format!("{revision}/");
content
.iter()
.filter(|(name, _)| name.as_ref().starts_with(&prefix))
.filter_map(|(name, _)| name.as_ref().strip_prefix(&prefix).map(str::to_owned))
.collect()
},
)
}
#[test]
fn matching_digests_verify() {
let (source, content) = vendored_pair("{}", "# changelog");
let result = verify_against(&source, &content);
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn a_changed_file_is_a_digest_mismatch() {
let (source, mut content) = vendored_pair("{}", "# changelog");
let (_, schema_body) = content
.get_mut(0)
.expect("vendored_pair always yields schema.json first");
*schema_body = r#"{"tampered":true}"#.to_owned();
let error = verify_against(&source, &content).expect_err("tampering must be detected");
assert!(
matches!(error, ManifestError::DigestMismatch { .. }),
"unexpected error: {error}"
);
let rendered = error.to_string();
assert!(
rendered.contains("2025-06-18") && rendered.contains("schema.json"),
"error should name what failed, got: {rendered}"
);
assert!(
rendered.contains("expected") && rendered.contains("actual"),
"error should show both digests, got: {rendered}"
);
}
#[test]
fn an_unreadable_file_is_reported_as_unreadable() {
let (source, _content) = vendored_pair("{}", "# changelog");
let nothing: &[(&str, &str)] = &[];
let error = verify_against(&source, nothing).expect_err("a missing file must be reported");
assert!(
matches!(error, ManifestError::Unreadable { .. }),
"unexpected error: {error}"
);
}
#[test]
fn a_file_on_disk_that_nothing_accounts_for_is_refused() {
let (source, mut content) = vendored_pair("{}", "# changelog");
content.push(("2025-06-18/schema-extra.json", "{}".to_owned()));
let error = verify_against(&source, &content).expect_err("an unrecorded file must fail");
assert!(
matches!(error, ManifestError::UnaccountedFile { .. }),
"unexpected error: {error}"
);
}
#[test]
fn a_recorded_file_missing_from_disk_is_refused() {
let (source, content) = vendored_pair("{}", "# changelog");
let manifest = Manifest::parse(&source).expect("manifest must parse");
let error = manifest
.verify(
|revision, file| {
let key = format!("{revision}/{file}");
content
.iter()
.find(|(name, _)| *name == key)
.map(|(_, bytes)| hex_digest(bytes.as_bytes()))
.ok_or_else(|| format!("no such file {key}"))
},
|_| Vec::new(),
)
.expect_err("a missing recorded file must be refused");
assert!(
matches!(error, ManifestError::MissingFile { .. }),
"unexpected error: {error}"
);
}
#[test]
fn an_authored_changelog_is_accepted_without_a_digest() {
let source = format!(
"[meta]\nschema_version = {SUPPORTED_SCHEMA_VERSION}\n\
repository = \"owner/name\"\nraw_base = \"https://example.invalid\"\n\
\n[revisions.2024-11-05]\ntag = \"2024-11-05\"\n\
commit = \"0000000000000000000000000000000000000000\"\n\
schema_prefix = \"schema\"\n\
\n[revisions.2024-11-05.files]\n\"schema.json\" = \"{}\"\n",
hex_digest(b"{}")
);
let result = verify_against(
&source,
&[
("2024-11-05/schema.json", "{}"),
("2024-11-05/CHANGELOG.md", "# hand written, edited freely"),
],
);
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn a_vendored_changelog_must_be_digest_locked() {
let source = format!(
"[meta]\nschema_version = {SUPPORTED_SCHEMA_VERSION}\n\
repository = \"owner/name\"\nraw_base = \"https://example.invalid\"\n\
\n[revisions.2025-06-18]\ntag = \"2025-06-18\"\n\
commit = \"0000000000000000000000000000000000000000\"\n\
schema_prefix = \"schema/2025-06-18\"\n\
changelog = \"docs/specification/2025-06-18/changelog.mdx\"\n\
\n[revisions.2025-06-18.files]\n\"schema.json\" = \"{}\"\n",
hex_digest(b"{}")
);
let error = verify_against(
&source,
&[
("2025-06-18/schema.json", "{}"),
("2025-06-18/CHANGELOG.md", "# vendored"),
],
)
.expect_err("a vendored changelog must be digest-locked");
assert!(matches!(error, ManifestError::UnaccountedFile { .. }));
}
#[test]
fn floating_state_follows_the_absence_of_a_tag() {
let draft = RevisionSpec {
tag: String::new(),
commit: "0".repeat(40),
changelog: Some("docs/specification/draft/changelog.mdx".to_owned()),
changelog_is_placeholder: true,
floating: true,
branch: None,
schema_prefix: "schema/draft".to_owned(),
files: std::collections::BTreeMap::new(),
};
assert!(draft.is_floating());
assert!(!draft.changelog_is_authored());
let tagged = RevisionSpec {
tag: "2026-07-28".to_owned(),
floating: false,
branch: None,
..draft
};
assert!(!tagged.is_floating());
}
#[test]
fn an_authored_changelog_is_marked_by_an_absent_key() {
let authored = RevisionSpec {
tag: "2024-11-05".to_owned(),
commit: "0".repeat(40),
changelog: None,
changelog_is_placeholder: false,
floating: false,
branch: None,
schema_prefix: "schema".to_owned(),
files: std::collections::BTreeMap::new(),
};
assert!(authored.changelog_is_authored());
}
#[test]
fn upstream_paths_follow_the_recorded_schema_prefix() {
let first = RevisionSpec {
tag: "2024-11-05".to_owned(),
commit: "0".repeat(40),
changelog: None,
changelog_is_placeholder: false,
floating: false,
branch: None,
schema_prefix: "schema".to_owned(),
files: std::collections::BTreeMap::new(),
};
assert_eq!(
first.upstream_path("schema.json").expect("path"),
"schema/schema.json"
);
assert_eq!(
first.upstream_path("schema.ts").expect("path"),
"schema/schema.ts"
);
let later = RevisionSpec {
tag: "2025-06-18".to_owned(),
schema_prefix: "schema/2025-06-18".to_owned(),
changelog: Some("docs/specification/2025-06-18/changelog.mdx".to_owned()),
..first
};
assert_eq!(
later.upstream_path("schema.json").expect("path"),
"schema/2025-06-18/schema.json"
);
assert_eq!(
later.upstream_path("CHANGELOG.md").expect("path"),
"docs/specification/2025-06-18/changelog.mdx"
);
}
#[test]
fn a_file_with_no_upstream_path_is_reported() {
let spec = RevisionSpec {
tag: "2024-11-05".to_owned(),
commit: "0".repeat(40),
changelog: None,
changelog_is_placeholder: false,
floating: false,
branch: None,
schema_prefix: "schema".to_owned(),
files: std::collections::BTreeMap::new(),
};
assert!(spec.upstream_path("CHANGELOG.md").is_err());
assert!(spec.upstream_path("schema-extra.json").is_err());
}
#[test]
fn the_shipped_manifest_derives_a_path_for_every_vendored_file() {
let source = include_str!("../../spec/MANIFEST.toml");
let manifest = Manifest::parse(source).expect("the shipped manifest must parse");
for (name, spec) in &manifest.revisions {
for file in spec.files.keys() {
assert!(
spec.upstream_path(file).is_ok(),
"{name}/{file} has no derivable upstream path"
);
}
}
}
#[test]
fn excluded_tags_are_reported_as_metadata() {
let source = format!(
"[meta]\nschema_version = {SUPPORTED_SCHEMA_VERSION}\n\
repository = \"owner/name\"\nraw_base = \"https://example.invalid\"\n\
\n[meta.excluded_tags]\n\"2024-10-07\" = \"Superseded.\"\n\
\n[revisions.2025-06-18]\ntag = \"2025-06-18\"\n\
commit = \"0000000000000000000000000000000000000000\"\n\
schema_prefix = \"schema/2025-06-18\"\n\
changelog = \"docs/specification/2025-06-18/changelog.mdx\"\n"
);
let manifest = Manifest::parse(&source).expect("manifest must parse");
assert_eq!(
manifest
.meta
.excluded_tags
.get("2024-10-07")
.map(String::as_str),
Some("Superseded.")
);
assert_eq!(manifest.meta.repository, "owner/name");
assert_eq!(manifest.meta.raw_base, "https://example.invalid");
}
#[test]
fn lf_content_round_trips_through_normalization() {
let input = b"line one\nline two\nline three\n";
let output = super::normalize_line_endings(input);
assert_eq!(output.as_slice(), input.as_slice());
}
#[test]
fn crlf_content_collapses_to_lf() {
let input = b"line one\r\nline two\r\nline three\r\n";
let expected = b"line one\nline two\nline three\n";
let output = super::normalize_line_endings(input);
assert_eq!(output.as_slice(), expected.as_slice());
}
#[test]
fn a_lone_carriage_return_is_left_alone() {
let input = b"a\r\nb\nc\rd\r\n";
let expected = b"a\nb\nc\rd\n";
let output = super::normalize_line_endings(input);
assert_eq!(output.as_slice(), expected.as_slice());
}
#[test]
fn empty_input_yields_empty_output() {
let output = super::normalize_line_endings(b"");
assert_eq!(output.as_slice(), b"" as &[u8]);
}
}