pub mod edit;
use crate::error::Error;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
pub const MANIFEST_FILE: &str = "lpm.toml";
pub const DEFAULT_INDEX_NAME: &str = "default";
pub const DEFAULT_INDEX_URL: &str = "https://github.com/luaupm/index";
#[derive(Serialize, Deserialize, Debug)]
pub struct Manifest {
pub package: Package,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<Target>,
#[serde(default, skip_serializing_if = "Config::is_default")]
pub config: Config,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub indices: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dependencies: BTreeMap<String, Dependency>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tools: BTreeMap<String, Tool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub scripts: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub studio: Option<Studio>,
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Config {
#[serde(
rename = "shared-packages-out",
skip_serializing_if = "Option::is_none"
)]
pub shared_packages_out: Option<String>,
#[serde(
rename = "server-packages-out",
skip_serializing_if = "Option::is_none"
)]
pub server_packages_out: Option<String>,
#[serde(rename = "lune-packages-out", skip_serializing_if = "Option::is_none")]
pub lune_packages_out: Option<String>,
#[serde(rename = "luau-packages-out", skip_serializing_if = "Option::is_none")]
pub luau_packages_out: Option<String>,
#[serde(rename = "lute-packages-out", skip_serializing_if = "Option::is_none")]
pub lute_packages_out: Option<String>,
}
impl Config {
fn is_default(&self) -> bool {
self.shared_packages_out.is_none()
&& self.server_packages_out.is_none()
&& self.lune_packages_out.is_none()
&& self.luau_packages_out.is_none()
&& self.lute_packages_out.is_none()
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Package {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub private: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Target {
pub environment: Environment,
#[serde(skip_serializing_if = "Option::is_none")]
pub main: Option<String>,
#[serde(
rename = "workspace",
alias = "workspace_members",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub workspace: Vec<String>,
#[serde(default, alias = "include", skip_serializing_if = "Vec::is_empty")]
pub includes: Vec<String>,
#[serde(default, alias = "exclude", skip_serializing_if = "Vec::is_empty")]
pub excludes: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum Environment {
Shared,
Server,
Lune,
Luau,
Lute,
}
impl Environment {
pub const ALL: [Environment; 5] = [
Environment::Shared,
Environment::Server,
Environment::Lune,
Environment::Luau,
Environment::Lute,
];
pub fn dir_name(self) -> &'static str {
match self {
Environment::Shared => "shared",
Environment::Server => "server",
Environment::Lune => "lune",
Environment::Luau => "luau",
Environment::Lute => "lute",
}
}
pub fn from_pesde(environment: &str) -> Result<Self, Error> {
match environment {
"roblox" => Ok(Environment::Shared),
"roblox_server" => Ok(Environment::Server),
"lune" => Ok(Environment::Lune),
"luau" => Ok(Environment::Luau),
"lute" => Ok(Environment::Lute),
other => Err(Error::UnsupportedEnvironment(other.to_string())),
}
}
pub fn from_wally_realm(realm: &str) -> Result<Self, Error> {
match realm {
"shared" => Ok(Environment::Shared),
"server" => Ok(Environment::Server),
other => Err(Error::UnsupportedEnvironment(other.to_string())),
}
}
pub fn from_lpm(environment: &str) -> Result<Self, Error> {
match environment {
"shared" => Ok(Environment::Shared),
"server" => Ok(Environment::Server),
"lune" => Ok(Environment::Lune),
"luau" => Ok(Environment::Luau),
"lute" => Ok(Environment::Lute),
other => Err(Error::UnsupportedEnvironment(other.to_string())),
}
}
}
impl fmt::Display for Environment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.dir_name())
}
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Studio {
#[serde(skip_serializing_if = "Option::is_none")]
pub universe: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub place: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(flatten)]
pub unknown: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StudioTarget {
Place { universe: u64, place: u64 },
File(String),
}
impl Studio {
pub fn target(&self) -> Result<StudioTarget, Error> {
if let Some((key, _)) = self.unknown.first_key_value() {
return Err(Error::StudioUnknownKey(key.clone()));
}
if self.file.is_some() && (self.universe.is_some() || self.place.is_some()) {
return Err(Error::StudioConflict);
}
match (self.file.as_deref(), self.universe, self.place) {
(Some(file), ..) => {
let file = file.trim();
if file.is_empty() {
return Err(Error::StudioEmptyFile);
}
Ok(StudioTarget::File(file.to_string()))
}
(None, Some(universe), Some(place)) => {
for (key, id) in [("universe", universe), ("place", place)] {
if id == 0 {
return Err(Error::StudioInvalidId(key));
}
}
Ok(StudioTarget::Place { universe, place })
}
(None, Some(_), None) => Err(Error::StudioIncomplete {
has: "universe",
needs: "place",
}),
(None, None, Some(_)) => Err(Error::StudioIncomplete {
has: "place",
needs: "universe",
}),
(None, None, None) => Err(Error::StudioUnconfigured),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum Dependency {
Registry {
name: String,
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
index: Option<String>,
},
Workspace {
workspace: String,
#[serde(default = "default_workspace_version")]
version: String,
},
}
fn default_workspace_version() -> String {
"^".to_string()
}
pub fn workspace_version_req(
version: &str,
member_version: &semver::Version,
) -> Result<String, Error> {
let version = version.trim();
Ok(match version {
"*" => "*".to_string(),
"" | "^" => format!("^{member_version}"),
"~" => format!("~{member_version}"),
"=" => format!("={member_version}"),
other => {
semver::VersionReq::parse(other)?;
other.to_string()
}
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tool {
pub repository: String,
pub version: String,
}
impl Tool {
pub fn parse(spec: &str) -> Result<Self, Error> {
let invalid = || Error::InvalidToolSpec(spec.to_string());
let (repository, version) = spec.trim().split_once('@').ok_or_else(invalid)?;
if version.is_empty() || version.contains('@') {
return Err(invalid());
}
Self::split_repository(repository).map_err(|_| invalid())?;
Ok(Tool {
repository: repository.to_string(),
version: version.to_string(),
})
}
pub fn split_repository(repository: &str) -> Result<(&str, &str), Error> {
match repository.split_once('/') {
Some((owner, repo)) if !owner.is_empty() && !repo.is_empty() && !repo.contains('/') => {
Ok((owner, repo))
}
_ => Err(Error::InvalidToolSpec(repository.to_string())),
}
}
}
impl fmt::Display for Tool {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.repository, self.version)
}
}
impl Serialize for Tool {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Tool {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let spec = String::deserialize(deserializer)?;
Tool::parse(&spec).map_err(serde::de::Error::custom)
}
}
impl Manifest {
pub fn load() -> Result<Self, Error> {
Self::load_from(Path::new(MANIFEST_FILE))
}
pub fn workspace_members(&self) -> &[String] {
self.target
.as_ref()
.map(|target| target.workspace.as_slice())
.unwrap_or(&[])
}
pub fn load_from(path: &Path) -> Result<Self, Error> {
if !path.exists() {
return Err(Error::ManifestMissing);
}
Ok(toml::from_str(&std::fs::read_to_string(path)?)?)
}
pub fn packages_out(&self, environment: Environment) -> std::path::PathBuf {
let configured = match environment {
Environment::Shared => &self.config.shared_packages_out,
Environment::Server => &self.config.server_packages_out,
Environment::Lune => &self.config.lune_packages_out,
Environment::Luau => &self.config.luau_packages_out,
Environment::Lute => &self.config.lute_packages_out,
};
match configured {
Some(dir) => std::path::PathBuf::from(dir),
None => std::path::Path::new("packages").join(environment.dir_name()),
}
}
pub fn index_url(&self, index: Option<&str>) -> Result<&str, Error> {
match index {
None => Ok(self
.indices
.get(DEFAULT_INDEX_NAME)
.map(String::as_str)
.unwrap_or(DEFAULT_INDEX_URL)),
Some(key) => self
.indices
.get(key)
.map(String::as_str)
.ok_or_else(|| Error::UnknownIndex(key.to_string())),
}
}
pub fn script(&self, name: &str) -> Result<&str, Error> {
self.scripts
.get(name)
.map(String::as_str)
.ok_or_else(|| Error::ScriptMissing(name.to_string()))
}
}
pub fn is_github_username(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 39
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
&& !name.starts_with('-')
&& !name.ends_with('-')
&& !name.contains("--")
}
pub fn split_package_name(name: &str) -> Result<(&str, &str), Error> {
let is_valid_part = |part: &str| {
!part.is_empty()
&& part
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
};
match name.split('/').collect::<Vec<_>>().as_slice() {
[scope, package] if is_valid_part(scope) && is_valid_part(package) => Ok((scope, package)),
_ => Err(Error::InvalidPackageName(name.to_string())),
}
}
pub fn parse_version_req(req: &str) -> Result<semver::VersionReq, Error> {
let trimmed = req.trim();
if trimmed == "^" || trimmed == "*" || trimmed.is_empty() {
Ok(semver::VersionReq::STAR)
} else {
Ok(semver::VersionReq::parse(trimmed)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_manifest() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
authors = ["Someone"]
license = "MIT"
[target]
environment = "lune"
main = "src/init.luau"
[indices]
wally = "https://github.com/UpliftGames/wally-index"
[dependencies]
Chief = { name = "chief/core", version = "^" }
Other = { name = "user/other_package", version = "^", index = "wally" }
[tools]
stylua = "johnnymorganz/stylua@2.0.0"
StyLua = "JohnnyMorganz/StyLua@2.1.0"
[scripts]
build = "rojo build -o game.rbxl"
"#,
)
.unwrap();
assert_eq!(manifest.package.name, "scope/name");
assert_eq!(
manifest.target.as_ref().unwrap().environment,
Environment::Lune
);
assert!(matches!(
&manifest.dependencies["Chief"],
Dependency::Registry { index: None, .. }
));
assert!(matches!(
&manifest.dependencies["Other"],
Dependency::Registry { index: Some(index), .. } if index == "wally"
));
assert_eq!(manifest.tools["stylua"].repository, "johnnymorganz/stylua");
assert_eq!(manifest.tools["stylua"].version, "2.0.0");
assert_eq!(manifest.tools["StyLua"].repository, "JohnnyMorganz/StyLua");
assert_eq!(manifest.tools["StyLua"].version, "2.1.0");
assert_eq!(manifest.script("build").unwrap(), "rojo build -o game.rbxl");
assert!(matches!(
manifest.script("test"),
Err(Error::ScriptMissing(_))
));
}
#[test]
fn resolves_index_urls() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[indices]
wally = "https://example.com/wally-index"
"#,
)
.unwrap();
assert_eq!(manifest.index_url(None).unwrap(), DEFAULT_INDEX_URL);
assert_eq!(
manifest.index_url(Some("wally")).unwrap(),
"https://example.com/wally-index"
);
assert!(matches!(
manifest.index_url(Some("missing")),
Err(Error::UnknownIndex(_))
));
}
#[test]
fn default_index_key_resolves_bare_dependencies() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[indices]
default = "https://example.com/my-index"
"#,
)
.unwrap();
assert_eq!(
manifest.index_url(None).unwrap(),
"https://example.com/my-index"
);
}
#[test]
fn packages_out_defaults_and_overrides() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[config]
shared-packages-out = "src/ReplicatedStorage/Packages"
"#,
)
.unwrap();
assert_eq!(
manifest.packages_out(Environment::Shared),
std::path::PathBuf::from("src/ReplicatedStorage/Packages")
);
assert_eq!(
manifest.packages_out(Environment::Luau),
std::path::PathBuf::from("packages").join("luau")
);
assert_eq!(
manifest.packages_out(Environment::Lute),
std::path::PathBuf::from("packages").join("lute")
);
}
#[test]
fn translates_environments() {
assert_eq!(
Environment::from_pesde("roblox").unwrap(),
Environment::Shared
);
assert_eq!(
Environment::from_pesde("roblox_server").unwrap(),
Environment::Server
);
assert_eq!(Environment::from_pesde("lune").unwrap(), Environment::Lune);
assert!(Environment::from_pesde("nonsense").is_err());
assert_eq!(
Environment::from_wally_realm("shared").unwrap(),
Environment::Shared
);
assert_eq!(
Environment::from_wally_realm("server").unwrap(),
Environment::Server
);
assert!(Environment::from_wally_realm("lune").is_err());
}
#[test]
fn parses_workspace_manifests_and_dependencies() {
let root: Manifest = toml::from_str(
r#"
[package]
name = "chief/root"
version = "0.0.0"
private = true
[target]
environment = "shared"
workspace = ["packages/*", "!packages/legacy"]
"#,
)
.unwrap();
assert!(root.package.private);
assert!(root.target.as_ref().unwrap().main.is_none());
assert_eq!(root.workspace_members(), ["packages/*", "!packages/legacy"]);
let aliased: Manifest = toml::from_str(
r#"
[package]
name = "chief/root"
version = "0.0.0"
[target]
environment = "shared"
workspace_members = ["packages/*"]
"#,
)
.unwrap();
assert_eq!(aliased.workspace_members(), ["packages/*"]);
let member: Manifest = toml::from_str(
r#"
[package]
name = "chief/dependencies"
version = "0.1.0"
[dependencies]
core = { workspace = "chief/core", version = "^" }
pinned = { workspace = "chief/bin" }
"#,
)
.unwrap();
assert!(matches!(
&member.dependencies["core"],
Dependency::Workspace { workspace, version }
if workspace == "chief/core" && version == "^"
));
assert!(matches!(
&member.dependencies["pinned"],
Dependency::Workspace { version, .. } if version == "^"
));
let plain: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
"#,
)
.unwrap();
assert!(!plain.package.private);
assert!(plain.workspace_members().is_empty());
let serialized = toml::to_string(&plain).unwrap();
assert!(!serialized.contains("private"));
assert!(!serialized.contains("workspace"));
}
#[test]
fn workspace_version_reqs_follow_pesde_rules() {
let version = semver::Version::new(2, 1, 5);
assert_eq!(workspace_version_req("^", &version).unwrap(), "^2.1.5");
assert_eq!(workspace_version_req("~", &version).unwrap(), "~2.1.5");
assert_eq!(workspace_version_req("=", &version).unwrap(), "=2.1.5");
assert_eq!(workspace_version_req("*", &version).unwrap(), "*");
assert_eq!(workspace_version_req("", &version).unwrap(), "^2.1.5");
assert_eq!(workspace_version_req("^2.1.0", &version).unwrap(), "^2.1.0");
assert!(workspace_version_req("not a req", &version).is_err());
}
#[test]
fn recognizes_github_username_shapes() {
for name in ["octocat", "Luau-PM", "a", "user123", "x-1-y"] {
assert!(is_github_username(name), "{name:?} should be accepted");
}
for name in [
"",
"-octocat",
"octocat-",
"double--dash",
"with space",
"name@example.com",
"Jane Doe <jane@example.com>",
"under_score",
&"a".repeat(40),
] {
assert!(!is_github_username(name), "{name:?} should be rejected");
}
}
#[test]
fn splits_package_names() {
assert_eq!(
split_package_name("evaera/promise").unwrap(),
("evaera", "promise")
);
assert_eq!(
split_package_name("scope/pkg-name_2").unwrap(),
("scope", "pkg-name_2")
);
assert!(split_package_name("noslash").is_err());
assert!(split_package_name("Upper/case").is_err());
assert!(split_package_name("a/b/c").is_err());
}
#[test]
fn parses_tool_specs() {
let tool = Tool::parse(" JohnnyMorganz/StyLua@2.0.0 ").unwrap();
assert_eq!(tool.repository, "JohnnyMorganz/StyLua");
assert_eq!(tool.version, "2.0.0");
assert_eq!(tool.to_string(), "JohnnyMorganz/StyLua@2.0.0");
}
#[test]
fn rejects_invalid_tool_specs() {
for spec in [
"norepo@1.0",
"owner/repo",
"owner/repo@",
"a/b/c@1.0",
"@1.0",
"owner/@1.0",
"/repo@1.0",
"owner/repo@1.0@2.0",
"",
] {
assert!(
matches!(Tool::parse(spec), Err(Error::InvalidToolSpec(_))),
"spec {spec:?} should be rejected"
);
}
}
#[test]
fn tool_round_trips_through_toml() {
#[derive(Serialize, Deserialize)]
struct Tools {
stylua: Tool,
}
let tools = Tools {
stylua: Tool {
repository: "JohnnyMorganz/StyLua".to_string(),
version: "2.0.0".to_string(),
},
};
let serialized = toml::to_string(&tools).unwrap();
assert_eq!(
serialized.trim(),
r#"stylua = "JohnnyMorganz/StyLua@2.0.0""#
);
let parsed: Tools = toml::from_str(&serialized).unwrap();
assert_eq!(parsed.stylua, tools.stylua);
}
#[test]
fn includes_and_excludes_round_trip() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[target]
environment = "luau"
includes = ["src", "lpm.toml", "README.md"]
excludes = ["src/tests"]
"#,
)
.unwrap();
let target = manifest.target.as_ref().unwrap();
assert_eq!(target.includes, ["src", "lpm.toml", "README.md"]);
assert_eq!(target.excludes, ["src/tests"]);
let serialized = toml::to_string(&manifest).unwrap();
let parsed: Manifest = toml::from_str(&serialized).unwrap();
let reparsed = parsed.target.as_ref().unwrap();
assert_eq!(reparsed.includes, target.includes);
assert_eq!(reparsed.excludes, target.excludes);
let aliased: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[target]
environment = "luau"
include = ["src"]
exclude = ["tests"]
"#,
)
.unwrap();
assert_eq!(aliased.target.as_ref().unwrap().includes, ["src"]);
assert_eq!(aliased.target.as_ref().unwrap().excludes, ["tests"]);
let bare: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[target]
environment = "luau"
"#,
)
.unwrap();
assert!(bare.target.as_ref().unwrap().includes.is_empty());
let serialized = toml::to_string(&bare).unwrap();
assert!(!serialized.contains("includes"));
assert!(!serialized.contains("excludes"));
}
#[test]
fn parses_studio_place_ids() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[studio]
universe = 13058
place = 1818
"#,
)
.unwrap();
assert_eq!(
manifest.studio.unwrap().target().unwrap(),
StudioTarget::Place {
universe: 13058,
place: 1818
}
);
}
#[test]
fn parses_studio_file() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[studio]
file = "place.rbxl"
"#,
)
.unwrap();
assert_eq!(
manifest.studio.unwrap().target().unwrap(),
StudioTarget::File("place.rbxl".to_string())
);
}
#[test]
fn studio_target_catches_hand_edit_mistakes() {
let studio = |universe, place, file: Option<&str>| Studio {
universe,
place,
file: file.map(str::to_string),
..Default::default()
};
assert!(matches!(
studio(Some(1), Some(2), Some("a.rbxl")).target(),
Err(Error::StudioConflict)
));
assert!(matches!(
studio(Some(1), None, Some("a.rbxl")).target(),
Err(Error::StudioConflict)
));
assert!(matches!(
studio(Some(1), None, None).target(),
Err(Error::StudioIncomplete {
has: "universe",
needs: "place"
})
));
assert!(matches!(
studio(None, Some(2), None).target(),
Err(Error::StudioIncomplete {
has: "place",
needs: "universe"
})
));
assert!(matches!(
studio(None, None, None).target(),
Err(Error::StudioUnconfigured)
));
assert!(matches!(
studio(Some(0), Some(2), None).target(),
Err(Error::StudioInvalidId("universe"))
));
assert!(matches!(
studio(Some(1), Some(0), None).target(),
Err(Error::StudioInvalidId("place"))
));
assert!(matches!(
studio(None, None, Some(" ")).target(),
Err(Error::StudioEmptyFile)
));
assert_eq!(
studio(None, None, Some(" place.rbxl ")).target().unwrap(),
StudioTarget::File("place.rbxl".to_string())
);
}
#[test]
fn studio_flags_unknown_keys_without_failing_the_manifest() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
[studio]
universeId = 13058
place = 1818
"#,
)
.unwrap();
assert!(matches!(
manifest.studio.unwrap().target(),
Err(Error::StudioUnknownKey(key)) if key == "universeId"
));
}
#[test]
fn studio_stays_absent_when_unset() {
let manifest: Manifest = toml::from_str(
r#"
[package]
name = "scope/name"
version = "0.1.0"
"#,
)
.unwrap();
assert!(manifest.studio.is_none());
assert!(!toml::to_string(&manifest).unwrap().contains("studio"));
}
#[test]
fn parses_version_requirements() {
let latest = parse_version_req("^").unwrap();
assert!(latest.matches(&semver::Version::new(99, 0, 0)));
let caret = parse_version_req("^1.2").unwrap();
assert!(caret.matches(&semver::Version::new(1, 9, 0)));
assert!(!caret.matches(&semver::Version::new(2, 0, 0)));
assert!(parse_version_req("not a version").is_err());
}
}