use std::{
fmt, fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, anyhow, bail};
use semver::Version;
pub const DEPRECATION_DIR: &str = "deprecation.d";
pub const DEPRECATIONS_JSON: &str = "website/data/deprecations.json";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct DeprecationVersion(pub Version);
impl fmt::Display for DeprecationVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl DeprecationVersion {
pub fn matches_release(&self, release: &Version) -> bool {
self.0.major == release.major && self.0.minor == release.minor
}
}
impl<'de> serde::Deserialize<'de> for DeprecationVersion {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(d)?;
let s = s.trim();
let normalized = if s.chars().filter(|&c| c == '.').count() == 1 {
std::borrow::Cow::Owned(format!("{s}.0"))
} else {
std::borrow::Cow::Borrowed(s)
};
let v = Version::parse(&normalized)
.map_err(|e| serde::de::Error::custom(format!("invalid version '{s}': {e}")))?;
if !v.pre.is_empty() || !v.build.is_empty() {
return Err(serde::de::Error::custom(format!(
"invalid version '{s}': prerelease and build metadata are not allowed; use plain X.Y or X.Y.Z"
)));
}
Ok(DeprecationVersion(v))
}
}
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Frontmatter {
what: String,
deprecated_since: DeprecationVersion,
}
#[derive(Debug, Clone)]
pub struct DeprecationEntry {
pub filename: String,
pub what: String,
pub deprecated_since: DeprecationVersion,
pub description: String,
}
pub struct DeprecationPartition {
pub announcing: Vec<DeprecationEntry>,
pub planned: Vec<DeprecationEntry>,
pub future: Vec<DeprecationEntry>,
}
pub fn partition_by_release(
entries: Vec<DeprecationEntry>,
release: &Version,
) -> DeprecationPartition {
let mut announcing = Vec::new();
let mut planned = Vec::new();
let mut future = Vec::new();
for e in entries {
if e.deprecated_since.matches_release(release) {
announcing.push(e);
} else if e.deprecated_since.0 < *release {
planned.push(e);
} else {
future.push(e);
}
}
DeprecationPartition {
announcing,
planned,
future,
}
}
pub fn read_deprecation_fragments(dir: &Path) -> Result<Vec<DeprecationEntry>> {
let Ok(meta) = fs::symlink_metadata(dir) else {
return Ok(Vec::new());
};
if !meta.file_type().is_dir() {
return Ok(Vec::new());
}
let mut paths: Vec<PathBuf> = fs::read_dir(dir)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| is_deprecation_fragment(p))
.collect();
paths.sort();
let entries: Vec<DeprecationEntry> = paths
.into_iter()
.map(|p| parse_deprecation_fragment(&p))
.collect::<Result<_>>()?;
let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
for e in &entries {
if let Some(prev) = seen.insert(e.what.as_str(), e.filename.as_str()) {
bail!(
"Duplicate deprecation fragments for `what`: '{}' (in {} and {})",
e.what,
prev,
e.filename
);
}
}
Ok(entries)
}
fn is_deprecation_fragment(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if name == "README.md" {
return false;
}
if !std::path::Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
return false;
}
fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file())
}
fn parse_deprecation_fragment(path: &Path) -> Result<DeprecationEntry> {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let raw =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let (frontmatter_str, body) = split_frontmatter(&raw, path)?;
let fm: Frontmatter = serde_yaml::from_str(frontmatter_str)
.with_context(|| format!("Failed to parse YAML frontmatter in {}", path.display()))?;
if fm.what.trim().is_empty() {
bail!(
"Deprecation fragment {}: `what` field must not be empty",
path.display()
);
}
Ok(DeprecationEntry {
filename,
what: fm.what.trim().to_string(),
deprecated_since: fm.deprecated_since,
description: body.trim().to_string(),
})
}
fn split_frontmatter<'a>(content: &'a str, path: &Path) -> Result<(&'a str, &'a str)> {
let content = content.trim_start();
let after_open = content
.strip_prefix("---")
.ok_or_else(|| {
anyhow!(
"Deprecation fragment {} must begin with YAML frontmatter (---)",
path.display()
)
})?
.trim_start_matches([' ', '\t'])
.trim_start_matches('\n');
let (frontmatter, rest) = after_open.split_once("\n---").ok_or_else(|| {
anyhow!(
"Deprecation fragment {} has unclosed frontmatter",
path.display()
)
})?;
let rest = rest.trim_start_matches(['\r', '\n']);
let body = rest.trim_start_matches(['\r', '\n']);
Ok((frontmatter, body))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EnactedEntry {
pub what: String,
pub deprecated_since: String,
pub removed_in: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct DeprecationsJson {
deprecations_pending: Vec<PendingJsonEntry>,
deprecations_enacted: Vec<EnactedEntry>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct PendingJsonEntry {
what: String,
deprecated_since: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
description: String,
}
pub fn read_enacted(repo_root: &Path) -> Result<Vec<EnactedEntry>> {
Ok(read_json(repo_root)?.deprecations_enacted)
}
fn read_json(repo_root: &Path) -> Result<DeprecationsJson> {
let path = repo_root.join(DEPRECATIONS_JSON);
if !path.exists() {
return Ok(DeprecationsJson {
deprecations_pending: Vec::new(),
deprecations_enacted: Vec::new(),
});
}
let raw =
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("Failed to parse {}", path.display()))
}
fn write_json(repo_root: &Path, data: &DeprecationsJson) -> Result<()> {
let path = repo_root.join(DEPRECATIONS_JSON);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let out = serde_json::to_string_pretty(data)? + "\n";
fs::write(&path, out).with_context(|| format!("Failed to write {}", path.display()))
}
pub fn append_enacted(repo_root: &Path, entry: EnactedEntry) -> Result<()> {
let dir = repo_root.join(DEPRECATION_DIR);
let pending = read_deprecation_fragments(&dir)?;
let mut data = read_json(repo_root)?;
if let Some(existing) = data
.deprecations_enacted
.iter()
.find(|e| e.what == entry.what)
{
if existing.removed_in != entry.removed_in {
bail!(
"Conflicting enacted entry for '{}': already recorded as removed in {}, refusing to record as removed in {}",
entry.what,
existing.removed_in,
entry.removed_in
);
}
if existing.deprecated_since != entry.deprecated_since
|| existing.description != entry.description
{
bail!(
"Mismatched enacted entry for '{}': the existing record in {} differs from the fragment data \
(deprecated_since or description). \
Either revert the fragment edit or update the enacted JSON by hand.",
entry.what,
existing.removed_in,
);
}
} else {
data.deprecations_enacted.push(entry);
}
data.deprecations_pending = pending_excluding_enacted(&pending, &data.deprecations_enacted);
write_json(repo_root, &data)
}
fn pending_excluding_enacted(
pending: &[DeprecationEntry],
enacted: &[EnactedEntry],
) -> Vec<PendingJsonEntry> {
let enacted_what: std::collections::HashSet<&str> =
enacted.iter().map(|e| e.what.as_str()).collect();
pending
.iter()
.filter(|e| !enacted_what.contains(e.what.as_str()))
.map(|e| PendingJsonEntry {
what: e.what.clone(),
deprecated_since: e.deprecated_since.to_string(),
description: e.description.clone(),
})
.collect()
}
pub fn validate_enacted(repo_root: &Path) -> Result<usize> {
let enacted = read_enacted(repo_root)?;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for e in &enacted {
let dep = parse_strict_release(&e.deprecated_since).with_context(|| {
format!(
"Enacted entry '{}' has invalid deprecated_since '{}'",
e.what, e.deprecated_since
)
})?;
let rem = parse_strict_release(&e.removed_in).with_context(|| {
format!(
"Enacted entry '{}' has invalid removed_in '{}'",
e.what, e.removed_in
)
})?;
if !later_minor(&rem, &dep) {
bail!(
"Enacted entry '{}' has removed_in ({}) that is not in a later minor release than deprecated_since ({}); the deprecation policy requires at least one minor release between announcement and removal.",
e.what,
e.removed_in,
e.deprecated_since
);
}
if !seen.insert(e.what.clone()) {
bail!(
"Duplicate enacted entry for '{}'; the same feature cannot be recorded as removed more than once.",
e.what
);
}
}
Ok(enacted.len())
}
pub fn later_minor(a: &Version, b: &Version) -> bool {
(a.major, a.minor) > (b.major, b.minor)
}
pub fn parse_strict_release(s: &str) -> Result<Version> {
let v = Version::parse(s)?;
if !v.pre.is_empty() || !v.build.is_empty() {
bail!("version '{s}' has prerelease or build metadata; only plain X.Y.Z is allowed");
}
Ok(v)
}
pub fn rendered_json(repo_root: &Path) -> Result<String> {
let dir = repo_root.join(DEPRECATION_DIR);
let pending = read_deprecation_fragments(&dir)?;
let mut data = read_json(repo_root)?;
data.deprecations_pending = pending_excluding_enacted(&pending, &data.deprecations_enacted);
Ok(serde_json::to_string_pretty(&data)? + "\n")
}
pub fn sync_deprecations_cue(repo_root: &Path) -> Result<()> {
let dir = repo_root.join(DEPRECATION_DIR);
let pending = read_deprecation_fragments(&dir)?;
let mut data = read_json(repo_root)?;
data.deprecations_pending = pending_excluding_enacted(&pending, &data.deprecations_enacted);
write_json(repo_root, &data)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn parse_full_entry() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("foo_opt.md"),
"---\nwhat: The foo option\ndeprecated_since: \"0.57.0\"\n---\n\nDetailed explanation.\n",
)
.unwrap();
let entries = read_deprecation_fragments(tmp.path()).unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.what, "The foo option");
assert_eq!(
e.deprecated_since,
DeprecationVersion(Version::new(0, 57, 0))
);
assert_eq!(e.description, "Detailed explanation.");
}
#[test]
fn rejects_missing_frontmatter() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("bad.md"), "No frontmatter here.\n").unwrap();
assert!(read_deprecation_fragments(tmp.path()).is_err());
}
#[test]
fn rejects_empty_what() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("empty.md"),
"---\nwhat: \" \"\ndeprecated_since: \"0.60.0\"\n---\n",
)
.unwrap();
assert!(read_deprecation_fragments(tmp.path()).is_err());
}
#[test]
fn skips_readme() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("README.md"), "# ignored").unwrap();
let entries = read_deprecation_fragments(tmp.path()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn parse_two_part_version() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("short.md"),
"---\nwhat: Short version\ndeprecated_since: \"0.56\"\n---\n",
)
.unwrap();
let entries = read_deprecation_fragments(tmp.path()).unwrap();
assert_eq!(
entries[0].deprecated_since,
DeprecationVersion(Version::new(0, 56, 0))
);
}
#[test]
fn matches_release_ignores_patch() {
let v = DeprecationVersion(Version::new(0, 56, 0));
assert!(v.matches_release(&Version::new(0, 56, 0)));
assert!(v.matches_release(&Version::new(0, 56, 1)));
assert!(!v.matches_release(&Version::new(0, 57, 0)));
}
#[test]
fn partition_two_buckets() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("new.md"),
"---\nwhat: New announcement\ndeprecated_since: \"0.56\"\n---\n",
)
.unwrap();
fs::write(
tmp.path().join("old.md"),
"---\nwhat: Previously announced\ndeprecated_since: \"0.53\"\n---\n",
)
.unwrap();
let entries = read_deprecation_fragments(tmp.path()).unwrap();
let p = partition_by_release(entries, &Version::new(0, 56, 0));
assert_eq!(p.announcing.len(), 1);
assert_eq!(p.announcing[0].what, "New announcement");
assert_eq!(p.planned.len(), 1);
assert_eq!(p.planned[0].what, "Previously announced");
assert!(p.future.is_empty());
}
#[test]
fn partition_separates_future_from_planned() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("future.md"),
"---\nwhat: Future plan\ndeprecated_since: \"0.99\"\n---\n",
)
.unwrap();
fs::write(
tmp.path().join("old.md"),
"---\nwhat: Old plan\ndeprecated_since: \"0.10\"\n---\n",
)
.unwrap();
let entries = read_deprecation_fragments(tmp.path()).unwrap();
let p = partition_by_release(entries, &Version::new(0, 56, 0));
assert!(p.announcing.is_empty());
assert_eq!(p.planned.len(), 1);
assert_eq!(p.planned[0].what, "Old plan");
assert_eq!(p.future.len(), 1);
assert_eq!(p.future[0].what, "Future plan");
}
#[test]
fn rejects_symlinked_fragments() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("real.md"),
"---\nwhat: real\ndeprecated_since: \"0.56\"\n---\n",
)
.unwrap();
let target = tmp.path().join("real.md");
let link = tmp.path().join("link.md");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&target, &link).unwrap();
let entries = read_deprecation_fragments(tmp.path()).unwrap();
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
assert_eq!(filenames, vec!["real.md"]);
}
#[test]
fn append_enacted_is_idempotent_on_exact_duplicate() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("deprecation.d")).unwrap();
fs::create_dir_all(tmp.path().join("website/data")).unwrap();
let entry = EnactedEntry {
what: "foo".into(),
deprecated_since: "0.55.0".into(),
removed_in: "0.56.0".into(),
description: String::new(),
};
append_enacted(tmp.path(), entry.clone()).unwrap();
append_enacted(tmp.path(), entry).unwrap();
let enacted = read_enacted(tmp.path()).unwrap();
assert_eq!(enacted.len(), 1);
}
#[test]
fn append_enacted_rejects_conflicting_removed_in() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("deprecation.d")).unwrap();
fs::create_dir_all(tmp.path().join("website/data")).unwrap();
let entry = EnactedEntry {
what: "foo".into(),
deprecated_since: "0.55.0".into(),
removed_in: "0.56.0".into(),
description: String::new(),
};
append_enacted(tmp.path(), entry.clone()).unwrap();
let conflict = EnactedEntry {
removed_in: "0.57.0".into(),
..entry
};
let err = append_enacted(tmp.path(), conflict).unwrap_err();
assert!(format!("{err}").contains("Conflicting"));
}
#[test]
fn append_enacted_rejects_mismatched_deprecated_since_or_description() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("deprecation.d")).unwrap();
fs::create_dir_all(tmp.path().join("website/data")).unwrap();
let entry = EnactedEntry {
what: "foo".into(),
deprecated_since: "0.55.0".into(),
removed_in: "0.56.0".into(),
description: "old".into(),
};
append_enacted(tmp.path(), entry.clone()).unwrap();
let edited = EnactedEntry {
description: "new".into(),
..entry.clone()
};
let err = append_enacted(tmp.path(), edited).unwrap_err();
assert!(format!("{err}").contains("Mismatched"));
let edited = EnactedEntry {
deprecated_since: "0.54.0".into(),
..entry
};
let err = append_enacted(tmp.path(), edited).unwrap_err();
assert!(format!("{err}").contains("Mismatched"));
}
#[test]
fn validate_enacted_catches_bad_versions_and_duplicates() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("website/data")).unwrap();
fs::write(
tmp.path().join("website/data/deprecations.json"),
r#"{"deprecations_pending":[],"deprecations_enacted":[
{"what":"x","deprecated_since":"0.55.0","removed_in":"not-a-version"}
]}"#,
)
.unwrap();
assert!(validate_enacted(tmp.path()).is_err());
fs::write(
tmp.path().join("website/data/deprecations.json"),
r#"{"deprecations_pending":[],"deprecations_enacted":[
{"what":"x","deprecated_since":"0.55.0","removed_in":"0.56.0"},
{"what":"x","deprecated_since":"0.55.0","removed_in":"0.57.0"}
]}"#,
)
.unwrap();
let err = validate_enacted(tmp.path()).unwrap_err();
assert!(format!("{err}").contains("Duplicate"));
}
#[test]
fn validate_enacted_rejects_same_minor() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("website/data")).unwrap();
fs::write(
tmp.path().join("website/data/deprecations.json"),
r#"{"deprecations_pending":[],"deprecations_enacted":[
{"what":"x","deprecated_since":"0.57.0","removed_in":"0.57.1"}
]}"#,
)
.unwrap();
let err = validate_enacted(tmp.path()).unwrap_err();
assert!(format!("{err}").contains("later minor release"));
}
#[test]
fn rendered_json_drops_pending_already_enacted() {
let tmp = tempdir().unwrap();
let dir = tmp.path().join("deprecation.d");
fs::create_dir_all(&dir).unwrap();
fs::create_dir_all(tmp.path().join("website/data")).unwrap();
fs::write(
dir.join("foo.md"),
"---\nwhat: foo\ndeprecated_since: \"0.55.0\"\n---\n",
)
.unwrap();
fs::write(
tmp.path().join("website/data/deprecations.json"),
r#"{"deprecations_pending":[],"deprecations_enacted":[
{"what":"foo","deprecated_since":"0.55.0","removed_in":"0.57.0"}
]}"#,
)
.unwrap();
let out = rendered_json(tmp.path()).unwrap();
assert!(out.contains("\"deprecations_pending\": []"));
assert!(out.contains("\"what\": \"foo\""));
}
#[test]
fn rejects_duplicate_what_across_fragments() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("a.md"),
"---\nwhat: \"the foo option\"\ndeprecated_since: \"0.55.0\"\n---\n",
)
.unwrap();
fs::write(
tmp.path().join("b.md"),
"---\nwhat: \"the foo option\"\ndeprecated_since: \"0.56.0\"\n---\n",
)
.unwrap();
let err = read_deprecation_fragments(tmp.path()).unwrap_err();
assert!(format!("{err}").contains("Duplicate"));
}
#[test]
fn rejects_symlinked_dir() {
use tempfile::tempdir;
let outside = tempdir().unwrap();
fs::write(
outside.path().join("evil.md"),
"---\nwhat: \"evil\"\ndeprecated_since: \"0.99.0\"\n---\n",
)
.unwrap();
let host = tempdir().unwrap();
let link = host.path().join("deprecation.d");
#[cfg(unix)]
std::os::unix::fs::symlink(outside.path(), &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(outside.path(), &link).unwrap();
let entries = read_deprecation_fragments(&link).unwrap();
assert!(entries.is_empty());
}
#[test]
fn validate_enacted_rejects_prerelease_versions() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("website/data")).unwrap();
fs::write(
tmp.path().join("website/data/deprecations.json"),
r#"{"deprecations_pending":[],"deprecations_enacted":[
{"what":"x","deprecated_since":"0.55.0","removed_in":"0.56.0-alpha"}
]}"#,
)
.unwrap();
let err = validate_enacted(tmp.path()).unwrap_err();
assert!(format!("{err:?}").contains("prerelease or build metadata"));
}
#[test]
fn rejects_prerelease_and_build_metadata() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("pre.md"),
"---\nwhat: foo\ndeprecated_since: \"0.58.0-alpha\"\n---\n",
)
.unwrap();
let err = read_deprecation_fragments(tmp.path()).unwrap_err();
assert!(format!("{err:?}").contains("prerelease and build metadata"));
let tmp2 = tempdir().unwrap();
fs::write(
tmp2.path().join("build.md"),
"---\nwhat: foo\ndeprecated_since: \"0.58.0+ci\"\n---\n",
)
.unwrap();
let err = read_deprecation_fragments(tmp2.path()).unwrap_err();
assert!(format!("{err:?}").contains("prerelease and build metadata"));
}
#[test]
fn later_minor_semantics() {
assert!(later_minor(
&Version::new(0, 58, 0),
&Version::new(0, 57, 0)
));
assert!(!later_minor(
&Version::new(0, 57, 1),
&Version::new(0, 57, 0)
));
assert!(!later_minor(
&Version::new(0, 57, 0),
&Version::new(0, 57, 0)
));
assert!(!later_minor(
&Version::new(0, 56, 5),
&Version::new(0, 57, 0)
));
}
}