use std::path::Path;
use serde::de::Error as _;
use super::classifier::AllowlistedBackend;
use super::error::SdistError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub version: u32,
pub entries: Vec<ManifestEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestEntry {
pub package: String,
pub version: String,
pub sdist_sha256: String,
pub classification: ManifestClassification,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestClassification {
PurePython {
backend: AllowlistedBackend,
wheel_filename: String,
wheel_sha256: String,
},
Native {
reason: String,
},
}
impl AllowlistedBackend {
fn as_str(self) -> &'static str {
match self {
AllowlistedBackend::FlitCore => "flit-core",
AllowlistedBackend::Hatchling => "hatchling",
AllowlistedBackend::Setuptools => "setuptools",
AllowlistedBackend::PoetryCore => "poetry-core",
AllowlistedBackend::PdmBackend => "pdm-backend",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"flit-core" => Some(AllowlistedBackend::FlitCore),
"hatchling" => Some(AllowlistedBackend::Hatchling),
"setuptools" => Some(AllowlistedBackend::Setuptools),
"poetry-core" => Some(AllowlistedBackend::PoetryCore),
"pdm-backend" => Some(AllowlistedBackend::PdmBackend),
_ => None,
}
}
}
impl Manifest {
pub fn empty() -> Self {
Self {
version: 1,
entries: Vec::new(),
}
}
pub fn save(&self, path: &Path) -> Result<(), SdistError> {
use std::fmt::Write;
let mut sorted = self.entries.clone();
sorted.sort_by(|a, b| {
a.package
.cmp(&b.package)
.then_with(|| a.version.cmp(&b.version))
});
let mut out = String::new();
writeln!(out, "# @generated by muntjac vendor").unwrap();
writeln!(out, "# Edits will be overwritten.").unwrap();
writeln!(out, "version = {}", self.version).unwrap();
writeln!(out).unwrap();
for entry in &sorted {
writeln!(out, "[[entries]]").unwrap();
writeln!(out, "package = {}", toml_str(&entry.package)).unwrap();
writeln!(out, "version = {}", toml_str(&entry.version)).unwrap();
writeln!(out, "sdist_sha256 = {}", toml_str(&entry.sdist_sha256)).unwrap();
match &entry.classification {
ManifestClassification::PurePython {
backend,
wheel_filename,
wheel_sha256,
} => {
writeln!(out, "classification = \"pure-python\"").unwrap();
writeln!(out, "backend = {}", toml_str(backend.as_str())).unwrap();
writeln!(out, "wheel_filename = {}", toml_str(wheel_filename)).unwrap();
writeln!(out, "wheel_sha256 = {}", toml_str(wheel_sha256)).unwrap();
}
ManifestClassification::Native { reason } => {
writeln!(out, "classification = \"native\"").unwrap();
writeln!(out, "native_reason = {}", toml_str(reason)).unwrap();
}
}
writeln!(out).unwrap();
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| SdistError::ManifestWrite {
path: path.to_path_buf(),
source: e,
})?;
}
std::fs::write(path, out).map_err(|e| SdistError::ManifestWrite {
path: path.to_path_buf(),
source: e,
})
}
pub fn load(path: &Path) -> Result<Self, SdistError> {
let text = std::fs::read_to_string(path).map_err(|e| SdistError::ManifestRead {
path: path.to_path_buf(),
source: e,
})?;
let raw: RawManifest = toml::from_str(&text).map_err(|e| SdistError::ManifestParse {
path: path.to_path_buf(),
source: e,
})?;
let mut entries = Vec::with_capacity(raw.entries.len());
for r in raw.entries {
let classification = match r.classification.as_str() {
"pure-python" => {
let backend = AllowlistedBackend::from_str(r.backend.as_deref().unwrap_or(""))
.ok_or_else(|| SdistError::ManifestParse {
path: path.to_path_buf(),
source: toml::de::Error::custom(format!(
"entry `{}`: unknown backend `{}`",
r.package,
r.backend.as_deref().unwrap_or("")
)),
})?;
let wheel_filename =
r.wheel_filename.ok_or_else(|| SdistError::ManifestParse {
path: path.to_path_buf(),
source: toml::de::Error::custom(format!(
"entry `{}`: pure-python classification requires `wheel_filename`",
r.package
)),
})?;
let wheel_sha256 = r.wheel_sha256.ok_or_else(|| SdistError::ManifestParse {
path: path.to_path_buf(),
source: toml::de::Error::custom(format!(
"entry `{}`: pure-python classification requires `wheel_sha256`",
r.package
)),
})?;
ManifestClassification::PurePython {
backend,
wheel_filename,
wheel_sha256,
}
}
"native" => ManifestClassification::Native {
reason: r.native_reason.unwrap_or_default(),
},
other => {
return Err(SdistError::ManifestParse {
path: path.to_path_buf(),
source: toml::de::Error::custom(format!(
"entry `{}`: unknown classification `{}`",
r.package, other
)),
});
}
};
entries.push(ManifestEntry {
package: r.package,
version: r.version,
sdist_sha256: r.sdist_sha256,
classification,
});
}
entries.sort_by(|a, b| {
a.package
.cmp(&b.package)
.then_with(|| a.version.cmp(&b.version))
});
Ok(Manifest {
version: raw.version,
entries,
})
}
}
#[derive(serde::Deserialize)]
struct RawManifest {
version: u32,
#[serde(default)]
entries: Vec<RawEntry>,
}
#[derive(serde::Deserialize)]
struct RawEntry {
package: String,
version: String,
sdist_sha256: String,
classification: String,
backend: Option<String>,
wheel_filename: Option<String>,
wheel_sha256: Option<String>,
native_reason: Option<String>,
}
fn toml_str(s: &str) -> String {
toml::Value::String(s.to_string()).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_manifest() -> Manifest {
Manifest {
version: 1,
entries: vec![
ManifestEntry {
package: "tomli".into(),
version: "2.0.1".into(),
sdist_sha256: "feedface".into(),
classification: ManifestClassification::PurePython {
backend: AllowlistedBackend::FlitCore,
wheel_filename: "tomli-2.0.1-py3-none-any.whl".into(),
wheel_sha256: "cafef00d".into(),
},
},
ManifestEntry {
package: "pillow".into(),
version: "11.0.0".into(),
sdist_sha256: "deadbeef".into(),
classification: ManifestClassification::Native {
reason: "AdjacentNativeSource:CExt@src/_imaging.c".into(),
},
},
],
}
}
#[test]
fn save_then_load_roundtrips_and_sorts() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("manifest.toml");
let m = sample_manifest();
m.save(&path).unwrap();
let loaded = Manifest::load(&path).unwrap();
assert_eq!(loaded.version, 1);
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].package, "pillow");
assert_eq!(loaded.entries[1].package, "tomli");
match &loaded.entries[0].classification {
ManifestClassification::Native { reason } => {
assert!(reason.contains("CExt"));
}
other => panic!("expected Native, got {other:?}"),
}
match &loaded.entries[1].classification {
ManifestClassification::PurePython {
backend,
wheel_filename,
wheel_sha256,
} => {
assert_eq!(*backend, AllowlistedBackend::FlitCore);
assert_eq!(wheel_filename, "tomli-2.0.1-py3-none-any.whl");
assert_eq!(wheel_sha256, "cafef00d");
}
other => panic!("expected PurePython, got {other:?}"),
}
}
#[test]
fn save_writes_generated_header() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("manifest.toml");
Manifest::empty().save(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("@generated by muntjac"));
}
#[test]
fn load_missing_file_returns_error() {
let err =
Manifest::load(std::path::Path::new("/does/not/exist/manifest.toml")).unwrap_err();
match err {
super::super::error::SdistError::ManifestRead { .. } => {}
other => panic!("expected ManifestRead, got {other:?}"),
}
}
#[test]
fn unknown_backend_string_round_trips() {
let m = Manifest {
version: 1,
entries: vec![ManifestEntry {
package: "x".into(),
version: "1.0".into(),
sdist_sha256: "0".into(),
classification: ManifestClassification::PurePython {
backend: AllowlistedBackend::PoetryCore,
wheel_filename: "x-1.0-py3-none-any.whl".into(),
wheel_sha256: "0".into(),
},
}],
};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("m.toml");
m.save(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("backend = \"poetry-core\""));
}
#[test]
fn pure_python_missing_wheel_filename_errors() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("m.toml");
std::fs::write(
&path,
r#"version = 1
[[entries]]
package = "x"
version = "1.0"
sdist_sha256 = "0"
classification = "pure-python"
backend = "flit-core"
wheel_sha256 = "0"
"#,
)
.unwrap();
let err = Manifest::load(&path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("wheel_filename"),
"error message should name the missing field: {msg}"
);
}
#[test]
fn toml_string_escape_handles_control_chars() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("m.toml");
let m = Manifest {
version: 1,
entries: vec![ManifestEntry {
package: "x".into(),
version: "1.0".into(),
sdist_sha256: "0".into(),
classification: ManifestClassification::Native {
reason: "weird\u{200b}backend".into(),
},
}],
};
m.save(&path).unwrap();
let reloaded = Manifest::load(&path).unwrap();
assert_eq!(reloaded.entries.len(), 1);
match &reloaded.entries[0].classification {
ManifestClassification::Native { reason } => {
assert_eq!(reason, "weird\u{200b}backend");
}
other => panic!("expected Native, got {other:?}"),
}
}
}