// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023 The Gleam contributors use std::fmt; use std::str::FromStr; use crate::Error; use crate::error::Result; use crate::io::make_relative; use camino::{Utf8Path, Utf8PathBuf}; use ecow::EcoString; use hexpm::version::Range; use serde::Deserialize; use serde::de::{self, Deserializer, MapAccess, Visitor}; use serde::ser::{Serialize, SerializeMap, Serializer}; #[derive(Deserialize, Debug, PartialEq, Eq, Clone)] #[serde(untagged, remote = "Self", deny_unknown_fields)] pub enum Requirement { Hex { #[serde(deserialize_with = "deserialise_range")] version: Range, }, Path { path: Utf8PathBuf, }, Git { git: EcoString, #[serde(rename = "ref")] ref_: EcoString, #[serde(default)] path: Option, }, } impl Requirement { pub fn hex(range: &str) -> Result { Ok(Requirement::Hex { version: Range::new(range.to_string()).map_err(|e| Error::InvalidVersionFormat { input: range.to_string(), error: e.to_string(), })?, }) } pub fn path(path: &str) -> Requirement { Requirement::Path { path: path.into() } } pub fn git(url: &str, ref_: &str) -> Requirement { Requirement::Git { git: url.into(), ref_: ref_.into(), path: None, } } pub fn git_with_path(url: &str, ref_: &str, path: &str) -> Requirement { Requirement::Git { git: url.into(), ref_: ref_.into(), path: Some(path.into()), } } pub fn to_toml(&self, root_path: &Utf8Path) -> String { match self { Requirement::Hex { version: range } => { format!(r#"{{ version = "{range}" }}"#) } Requirement::Path { path } => { format!( r#"{{ path = "{}" }}"#, make_relative(root_path, path).as_str().replace('\\', "/") ) } Requirement::Git { git: url, ref_, path, } => match path { Some(path) => { let path = path.as_str().replace('\\', "/"); format!(r#"{{ git = "{url}", ref = "{ref_}", path = "{path}" }}"#) } None => format!(r#"{{ git = "{url}", ref = "{ref_}" }}"#), }, } } } // Serialization impl Serialize for Requirement { fn serialize(&self, serializer: S) -> Result where S: Serializer, { let mut map = serializer.serialize_map(Some(1))?; match self { Requirement::Hex { version: range } => map.serialize_entry("version", range)?, Requirement::Path { path } => map.serialize_entry("path", path)?, Requirement::Git { git: url, ref_, path, } => { map.serialize_entry("git", url)?; map.serialize_entry("ref", ref_)?; if let Some(path) = path { map.serialize_entry("path", path)?; } } } map.end() } } // Deserialization fn deserialise_range<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let version = String::deserialize(deserializer)?; Range::new(version).map_err(de::Error::custom) } #[derive(Debug, Copy, Clone)] pub struct Void; impl FromStr for Requirement { type Err = Error; fn from_str(s: &str) -> Result { Requirement::hex(s) } } struct RequirementVisitor; impl<'de> Visitor<'de> for RequirementVisitor { type Value = Requirement; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("string or map") } fn visit_str(self, value: &str) -> Result where E: de::Error, { match value.parse::() { Ok(value) => Ok(value), Err(error) => Err(de::Error::custom(error)), } } fn visit_map(self, visitor: M) -> Result where M: MapAccess<'de>, { Requirement::deserialize(de::value::MapAccessDeserializer::new(visitor)) } } impl<'de> Deserialize<'de> for Requirement { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let requirement = deserializer.deserialize_any(RequirementVisitor)?; if let Requirement::Git { path: Some(path), .. } = &requirement { if path.as_str().is_empty() { return Err(de::Error::custom("git dependency path must not be empty")); } crate::io::validate_safe_relative_path(path).map_err(de::Error::custom)?; } Ok(requirement) } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; #[test] fn read_requirement() { let toml = r#" short = "~> 0.5" hex = { version = "~> 1.0.0" } local = { path = "/path/to/package" } github = { git = "https://github.com/gleam-lang/otp.git", ref = "4d34935" } "#; let deps: HashMap = toml::from_str(toml).unwrap(); assert_eq!(deps["short"], Requirement::hex("~> 0.5").unwrap()); assert_eq!(deps["hex"], Requirement::hex("~> 1.0.0").unwrap()); assert_eq!(deps["local"], Requirement::path("/path/to/package")); assert_eq!( deps["github"], Requirement::git("https://github.com/gleam-lang/otp.git", "4d34935") ); } #[test] fn read_wrong_version() { let toml = r#" short = ">= 2.0 and < 3.0.0" "#; let error = toml::from_str::>(toml).expect_err("invalid version"); insta::assert_snapshot!(error.to_string()); } #[test] fn read_git_requirement_with_escaping_path() { let toml = r#" monorepo = { git = "https://github.com/gleam-lang/gleam.git", ref = "main", path = "../escape" } "#; let error = toml::from_str::>(toml).expect_err("escaping path"); insta::assert_snapshot!(error.to_string()); } #[test] fn read_git_requirement_with_empty_path() { let toml = r#" monorepo = { git = "https://github.com/gleam-lang/gleam.git", ref = "main", path = "" } "#; let error = toml::from_str::>(toml).expect_err("empty path"); insta::assert_snapshot!(error.to_string()); } }