use std::io::prelude::*;
use std::{fs::File, path::Path, io::{BufReader, BufWriter}};
use anyhow::{Result, bail};
use pyo3::exceptions::PyValueError;
use pyo3::types::PyDict;
use serde::{Deserialize, Serialize};
pub use semver::{Version, VersionReq};
use pyo3::prelude::*;
pub mod name;
use name::Name;
mod id;
pub use id::Id;
use super::Package;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[pyclass(module = "merlon.package.manifest")]
pub struct Manifest {
#[serde(rename = "package")]
metadata: Metadata,
dependencies: Vec<Dependency>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[pyclass(module = "merlon.package.manifest")]
pub struct Metadata {
id: Id,
name: Name,
version: Version,
authors: Vec<String>,
description: String,
license: String,
keywords: Vec<String>,
}
#[pymethods]
impl Metadata {
#[getter]
pub fn id(&self) -> Id {
self.id
}
#[getter]
fn get_name(&self) -> Name {
self.name.clone()
}
#[getter]
fn get_version(&self) -> String {
self.version.to_string()
}
#[setter(version)]
fn py_set_version(&mut self, version: String) -> Result<()> {
self.version = version.parse()?;
Ok(())
}
#[getter]
pub fn description(&self) -> &str {
&self.description
}
pub fn validate(&self) -> Vec<String> {
let mut errors = Vec::new();
if self.authors.is_empty() {
errors.push("authors cannot be empty".to_owned());
}
if self.description.is_empty() {
errors.push("description cannot be empty".to_owned());
}
if self.description.len() > 100 {
errors.push("description must be less than 100 characters".to_owned());
}
if self.license.is_empty() {
errors.push("license cannot be empty".to_owned());
}
for keyword in &self.keywords {
const VALID_KEYWORDS: &[&str] = &["qol", "cheat", "bugfix", "cosmetic", "feature"];
if !VALID_KEYWORDS.contains(&keyword.as_str()) {
errors.push(format!("invalid keyword: {} (valid keywords: {:?})", keyword, VALID_KEYWORDS));
}
}
errors
}
pub fn is_valid(&self) -> bool {
self.validate().is_empty()
}
#[getter]
fn get_authors(&self) -> Vec<String> {
self.authors.clone()
}
}
impl Metadata {
pub fn name(&self) -> &Name {
&self.name
}
pub fn version(&self) -> &Version {
&self.version
}
pub fn set_version(&mut self, version: Version) {
self.version = version;
}
pub fn authors(&self) -> &Vec<String> {
&self.authors
}
#[deprecated(since = "1.1.0", note = "iterate over validate() instead")]
pub fn print_validation_warnings(&self) {
for error in self.validate() {
eprintln!("warning: {}", error);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(tag = "type")]
#[serde(rename_all = "snake_case")]
pub enum Dependency {
Package {
id: Id,
version: VersionReq,
},
Decomp {
rev: String,
},
}
impl From<&Metadata> for Dependency {
fn from(metadata: &Metadata) -> Self {
let version = metadata.version();
Self::Package {
id: metadata.id(),
version: VersionReq {
comparators: vec![
semver::Comparator {
op: semver::Op::Tilde,
major: version.major,
minor: Some(version.minor),
patch: Some(version.patch),
pre: version.pre.clone(),
}
]
}
}
}
}
impl TryFrom<&Package> for Dependency {
type Error = anyhow::Error;
fn try_from(package: &Package) -> Result<Self> {
let manifest = package.manifest()?;
let metadata = manifest.metadata();
Ok(metadata.into())
}
}
impl ToPyObject for Dependency {
fn to_object(&self, py: Python<'_>) -> PyObject {
match self {
Self::Package { id, version } => {
let dict = PyDict::new(py);
dict.set_item("type", "package").unwrap();
dict.set_item("id", id.to_string()).unwrap();
dict.set_item("version", version.to_string()).unwrap();
dict.into()
}
Self::Decomp { rev } => {
let dict = PyDict::new(py);
dict.set_item("type", "decomp").unwrap();
dict.set_item("rev", rev).unwrap();
dict.into()
}
}
}
}
impl FromPyObject<'_> for Dependency {
fn extract(ob: &PyAny) -> PyResult<Self> {
let dict = ob.downcast::<PyDict>()?;
let type_: &str = dict.get_item("type")
.ok_or(PyValueError::new_err("missing dependency type"))?
.extract()?;
match type_ {
"package" => {
let id: Id = dict.get_item("id")
.ok_or(PyValueError::new_err("missing dependency id"))?
.extract()?;
let version: String = dict.get_item("version")
.ok_or(PyValueError::new_err("missing dependency version"))?
.extract()?;
let version: VersionReq = version.parse()
.map_err(|e| PyValueError::new_err(format!("invalid dependency version: {}", e)))?;
Ok(Self::Package { id, version })
}
"decomp" => {
let rev: String = dict.get_item("rev")
.ok_or(PyValueError::new_err("missing dependency rev"))?
.extract()?;
Ok(Self::Decomp { rev })
}
_ => Err(PyValueError::new_err(format!("invalid dependency type: {}", type_))),
}
}
}
impl IntoPy<PyObject> for Dependency {
fn into_py(self, py: Python<'_>) -> PyObject {
self.to_object(py)
}
}
#[pymethods]
impl Manifest {
#[new]
pub fn new(name: Name) -> Result<Self> {
Ok(Self {
metadata: Metadata {
id: Id::new(),
name,
version: "0.1.0".parse()?,
authors: vec![get_author()?],
description: "An amazing mod".to_owned(),
license: "CC-BY-SA-4.0".to_owned(),
keywords: vec![],
},
dependencies: vec![], })
}
#[getter]
fn get_metadata(&self) -> Metadata {
self.metadata.clone()
}
#[setter]
pub fn set_metadata(&mut self, metadata: Metadata) {
self.metadata = metadata;
}
}
impl Manifest {
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
pub fn metadata_mut(&mut self) -> &mut Metadata {
&mut self.metadata
}
pub fn read_from_path(path: &Path) -> Result<Self> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut toml_string = String::new();
reader.read_to_string(&mut toml_string)?;
let config = toml::from_str(&toml_string)?;
Ok(config)
}
pub fn write_to_file(&self, path: &Path) -> Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
let toml_string = toml::to_string_pretty(self)?;
writer.write_all(toml_string.as_bytes())?;
Ok(())
}
pub fn declare_direct_dependency(&mut self, dependency: Dependency) -> Result<()> {
match &dependency {
Dependency::Package { id, version } => {
if let Some(Dependency::Package { version: existing_version, .. }) = self.dependencies
.iter_mut()
.find(|dep| matches!(dep, Dependency::Package { id: dep_id, .. } if *id == *dep_id))
{
if *existing_version != *version {
bail!("dependency on package ID {} already declared with incompatible version", id);
}
return Ok(());
}
}
Dependency::Decomp { rev } => {
if let Some(Dependency::Decomp { rev: existing_rev, .. }) = self.dependencies
.iter_mut()
.find(|dep| matches!(dep, Dependency::Decomp { .. }))
{
if *existing_rev != *rev {
bail!("dependency on decomp already declared with incompatible revision");
}
return Ok(());
}
}
}
self.dependencies.push(dependency);
Ok(())
}
pub fn iter_direct_dependencies(&self) -> impl Iterator<Item = &Dependency> {
self.dependencies.iter()
}
pub fn has_direct_decomp_dependency(&self) -> bool {
self.dependencies.iter().any(|dep| matches!(dep, Dependency::Decomp { .. }))
}
pub fn upsert_decomp_dependency(&mut self, rev: String) -> Result<()> {
if let Some(dep) = self.dependencies.iter_mut().find(|dep| matches!(dep, Dependency::Decomp { .. })) {
if let Dependency::Decomp { rev: existing_rev } = dep {
*existing_rev = rev;
return Ok(());
}
}
self.declare_direct_dependency(Dependency::Decomp { rev })
}
pub fn get_direct_decomp_dependency_rev(&self) -> Option<&str> {
if let Some(dep) = self.dependencies.iter().find(|dep| matches!(dep, Dependency::Decomp { .. })) {
if let Dependency::Decomp { rev } = dep {
return Some(rev);
}
}
None
}
}
fn get_author() -> Result<String> {
let git_user_name = std::process::Command::new("git")
.arg("config")
.arg("user.name")
.output()?
.stdout;
let git_user_name = String::from_utf8(git_user_name)?;
let git_user_name = git_user_name.trim().to_owned();
let git_user_email = std::process::Command::new("git")
.arg("config")
.arg("user.email")
.output()?
.stdout;
let git_user_email = String::from_utf8(git_user_email)?;
let git_user_email = git_user_email.trim().to_owned();
Ok(format!("{} <{}>", git_user_name, git_user_email))
}