use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::{Error, error::IoContext};
pub const PACK_FILE_NAME: &str = "pack.toml";
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all(deserialize = "kebab-case", serialize = "snake_case"))]
pub struct Pack {
pub name: String,
pub author: Option<String>,
pub version: Option<String>,
pub pack_format: Option<String>,
#[serde(default)]
pub versions: PackVersions,
#[serde(skip_serializing)]
pub index: Option<PackIndex>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all(deserialize = "kebab-case", serialize = "snake_case"))]
pub struct PackIndex {
pub file: String,
pub hash_format: Option<String>,
pub hash: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct PackVersions {
pub fabric: Option<String>,
pub forge: Option<String>,
pub minecraft: Option<String>,
pub neoforge: Option<String>,
pub quilt: Option<String>,
#[serde(flatten)]
pub other: BTreeMap<String, String>,
}
impl PackVersions {
pub fn loader(&self) -> Option<(&str, &str)> {
[
("fabric", &self.fabric),
("forge", &self.forge),
("neoforge", &self.neoforge),
("quilt", &self.quilt),
]
.into_iter()
.find_map(|(name, version)| version.as_deref().map(|version| (name, version)))
.or_else(|| {
self.other
.iter()
.next()
.map(|(name, version)| (name.as_str(), version.as_str()))
})
}
}
pub fn find_root<P>(start: P) -> Option<PathBuf>
where
P: AsRef<Path>,
{
let start = start.as_ref();
let resolved = start.canonicalize();
let from = resolved.as_deref().unwrap_or(start);
from.ancestors()
.find(|directory| directory.join(PACK_FILE_NAME).is_file())
.map(Path::to_path_buf)
}
impl Pack {
pub fn discover<P>(start: P) -> Option<Self>
where
P: AsRef<Path>,
{
let root = find_root(start)?;
match Self::read(root.join(PACK_FILE_NAME)) {
Ok(pack) => Some(pack),
Err(err) => {
log::warn!(
"ignoring the {PACK_FILE_NAME} in \"{}\": {err}",
root.display()
);
None
}
}
}
pub fn read<P>(path: P) -> Result<Self, Error>
where
P: AsRef<Path>,
{
let path = path.as_ref();
let data = std::fs::read_to_string(path).path_ctx(path, "read file")?;
toml::from_str(&data).map_err(|err| Error::TomlFile(path.to_owned(), err))
}
pub fn load_from<P>(pack_root: P) -> Result<Self, Error>
where
P: AsRef<Path>,
{
let path: PathBuf = pack_root.as_ref().join(PACK_FILE_NAME);
log::debug!("reading pack metadata from \"{}\"", path.display());
let data = std::fs::read_to_string(&path).path_ctx(&path, "read file")?;
toml::from_str(&data).map_err(|err| Error::TomlFile(path, err))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
name = "Example Pack"
author = "minecraft_steve"
version = "1.2.0"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "deadbeef"
[versions]
minecraft = "1.20.1"
fabric = "0.14.21"
"#;
#[test]
fn parses_a_pack_toml() {
let pack: Pack = toml::from_str(SAMPLE).expect("sample should parse");
assert_eq!(pack.name, "Example Pack");
assert_eq!(pack.author.as_deref(), Some("minecraft_steve"));
assert_eq!(pack.pack_format.as_deref(), Some("packwiz:1.1.0"));
assert_eq!(pack.versions.minecraft.as_deref(), Some("1.20.1"));
}
#[test]
fn finds_the_loader_beside_minecraft() {
let pack: Pack = toml::from_str(SAMPLE).expect("sample should parse");
assert_eq!(pack.versions.loader(), Some(("fabric", "0.14.21")));
}
#[test]
fn a_pack_without_versions_is_still_valid() {
let pack: Pack = toml::from_str(r#"name = "Bare""#).expect("should parse");
assert_eq!(pack.versions.minecraft, None);
assert_eq!(pack.versions.loader(), None);
}
}