muntjac 0.2.0

Translate uv.lock into Buck2 build rules
Documentation
//! Prebake manifest TOML serde.
//!
//! See `docs/superpowers/specs/2026-05-22-muntjac-s5-sdist-prebake-design.md` §4.

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,
            });
        }
        // Sort on load for safety even if hand-edited.
        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();

        // entries get sorted by (package, version) on save:
        // pillow < tomli lexicographically.
        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() {
        // Manifest stores the backend by short identifier; "flit-core" not "FlitCore".
        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() {
        // Verifies the TOML-safe escape path works for a string containing a
        // non-ASCII codepoint that Rust's Debug would render with `\u{...}`
        // braces (invalid TOML).
        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 {
                    // U+200B (zero-width space) — would be rendered as `\u{200b}`
                    // by Debug, which is not valid TOML.
                    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:?}"),
        }
    }
}