use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::build_flags::ProfileSettings;
use crate::compiler_wrapper::CompilerWrapperManifestSettings;
use crate::config::Features;
use crate::error::ValidationError;
use crate::patch::PatchManifestSettings;
use crate::profile::{ProfileDefinition, ProfileName};
use crate::toolchain::ToolchainSettings;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct PackageName(String);
impl PackageName {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into();
if value.is_empty() {
return Err(ValidationError::EmptyPackageName);
}
if value.chars().any(char::is_whitespace) {
return Err(ValidationError::PackageNameContainsWhitespace(value));
}
if !is_path_safe_package_name(&value) {
return Err(ValidationError::UnsafePackageName(value));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
pub fn is_path_safe_package_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
if name == "." || name == ".." {
return false;
}
if name.starts_with('.') || name.starts_with('-') {
return false;
}
name.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
}
impl AsRef<str> for PackageName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for PackageName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for PackageName {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
PackageName::new(value)
}
}
impl From<PackageName> for String {
fn from(value: PackageName) -> Self {
value.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct TargetName(String);
impl TargetName {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let value = value.into();
if value.is_empty() {
return Err(ValidationError::EmptyTargetName);
}
if value.chars().any(char::is_whitespace) {
return Err(ValidationError::TargetNameContainsWhitespace(value));
}
if !is_path_safe_package_name(&value) {
return Err(ValidationError::UnsafeTargetName(value));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for TargetName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TargetName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for TargetName {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
TargetName::new(value)
}
}
impl From<TargetName> for String {
fn from(value: TargetName) -> Self {
value.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TargetKind {
#[serde(rename = "library")]
Library,
#[serde(rename = "header_only")]
HeaderOnly,
#[serde(rename = "executable")]
Executable,
#[serde(rename = "test")]
Test,
#[serde(rename = "example")]
Example,
}
impl TargetKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Library => "library",
Self::HeaderOnly => "header_only",
Self::Executable => "executable",
Self::Test => "test",
Self::Example => "example",
}
}
pub const fn all() -> &'static [TargetKind] {
&[
Self::Library,
Self::HeaderOnly,
Self::Executable,
Self::Test,
Self::Example,
]
}
pub const fn produces_executable(self) -> bool {
matches!(self, Self::Executable | Self::Test | Self::Example)
}
pub const fn produces_archive(self) -> bool {
matches!(self, Self::Library)
}
pub const fn is_header_only(self) -> bool {
matches!(self, Self::HeaderOnly)
}
pub const fn is_default_buildable(self) -> bool {
matches!(self, Self::Library | Self::HeaderOnly | Self::Executable)
}
pub const fn is_dev_only(self) -> bool {
matches!(self, Self::Test | Self::Example)
}
pub const fn is_test(self) -> bool {
matches!(self, Self::Test)
}
}
impl std::fmt::Display for TargetKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
pub name: TargetName,
pub kind: TargetKind,
#[serde(default)]
pub sources: Vec<PathBuf>,
#[serde(default)]
pub include_dirs: Vec<PathBuf>,
#[serde(default)]
pub defines: Vec<String>,
#[serde(default)]
pub deps: Vec<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Dependency {
pub name: PackageName,
pub source: DependencySource,
#[serde(default, skip_serializing_if = "DependencyKind::is_normal")]
pub kind: DependencyKind,
#[serde(default, skip_serializing_if = "is_false")]
pub optional: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub default_features: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<crate::Condition>,
}
fn is_false<T>(value: &T) -> bool
where
T: PartialEq + Default,
{
*value == T::default()
}
fn is_true<T>(value: &T) -> bool
where
T: PartialEq + Default + std::ops::Not<Output = T>,
{
*value == !T::default()
}
impl Dependency {
pub fn matches_platform(&self, platform: &crate::TargetPlatform) -> bool {
match &self.condition {
None => true,
Some(cond) => cond.evaluate(platform),
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum DependencyKind {
#[default]
Normal,
Dev,
}
impl DependencyKind {
pub const fn as_str(self) -> &'static str {
match self {
DependencyKind::Normal => "normal",
DependencyKind::Dev => "dev",
}
}
pub const fn all() -> &'static [DependencyKind] {
&[DependencyKind::Normal, DependencyKind::Dev]
}
pub const fn is_resolved_by_default(self) -> bool {
matches!(self, DependencyKind::Normal)
}
pub const fn affects_ordinary_build(self) -> bool {
matches!(self, DependencyKind::Normal)
}
pub fn is_normal(&self) -> bool {
matches!(self, DependencyKind::Normal)
}
pub const fn manifest_section(self) -> &'static str {
match self {
DependencyKind::Normal => "[dependencies]",
DependencyKind::Dev => "[dev-dependencies]",
}
}
}
impl std::fmt::Display for DependencyKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SystemDependency {
pub name: PackageName,
pub version: String,
#[serde(default)]
pub kind: DependencyKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<crate::Condition>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortDepSource {
Builtin {
name: PackageName,
version_req: semver::VersionReq,
},
Path(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DependencySource {
#[serde(rename = "path")]
Path(PathBuf),
#[serde(rename = "version")]
Version(semver::VersionReq),
#[serde(rename = "port")]
Port(PortDepSource),
#[serde(rename = "workspace")]
Workspace,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Package {
pub name: PackageName,
pub version: semver::Version,
pub targets: Vec<Target>,
#[serde(default)]
pub dependencies: Vec<Dependency>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub system_dependencies: Vec<SystemDependency>,
#[serde(default, skip_serializing_if = "is_empty_features")]
pub features: Features,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub profiles: BTreeMap<ProfileName, ProfileDefinition>,
#[serde(default, skip_serializing_if = "ToolchainSettings::is_empty")]
pub toolchain: ToolchainSettings,
#[serde(default, skip_serializing_if = "ProfileSettings::is_empty")]
pub build: ProfileSettings,
#[serde(
default,
skip_serializing_if = "CompilerWrapperManifestSettings::is_empty"
)]
pub compiler_wrapper: CompilerWrapperManifestSettings,
#[serde(default, skip_serializing_if = "PatchManifestSettings::is_empty")]
pub patches: PatchManifestSettings,
}
fn is_empty_features(f: &Features) -> bool {
f.default.is_empty() && f.features.is_empty()
}
impl Package {
pub fn new(
name: PackageName,
version: semver::Version,
targets: Vec<Target>,
dependencies: Vec<Dependency>,
) -> Result<Self, ValidationError> {
Self::with_config(PackageConfigInput {
name,
version,
targets,
dependencies,
system_dependencies: Vec::new(),
features: Features::default(),
})
}
pub fn with_config(input: PackageConfigInput) -> Result<Self, ValidationError> {
let PackageConfigInput {
name,
version,
targets,
dependencies,
system_dependencies,
features,
} = input;
Self::validate_targets(&targets)?;
Self::validate_dependencies(&dependencies)?;
Self::validate_system_dependencies(&system_dependencies)?;
features.validate()?;
Ok(Self {
name,
version,
targets,
dependencies,
system_dependencies,
features,
profiles: BTreeMap::new(),
toolchain: ToolchainSettings::default(),
build: ProfileSettings::default(),
compiler_wrapper: CompilerWrapperManifestSettings::default(),
patches: PatchManifestSettings::default(),
})
}
pub fn with_profiles(mut self, profiles: BTreeMap<ProfileName, ProfileDefinition>) -> Self {
self.profiles = profiles;
self
}
}
#[derive(Debug, Clone)]
pub struct PackageConfigInput {
pub name: PackageName,
pub version: semver::Version,
pub targets: Vec<Target>,
pub dependencies: Vec<Dependency>,
pub system_dependencies: Vec<SystemDependency>,
pub features: Features,
}
impl Package {
pub fn with_toolchain(mut self, toolchain: ToolchainSettings) -> Self {
self.toolchain = toolchain;
self
}
pub fn with_build(mut self, build: ProfileSettings) -> Self {
self.build = build;
self
}
pub fn with_compiler_wrapper(mut self, settings: CompilerWrapperManifestSettings) -> Self {
self.compiler_wrapper = settings;
self
}
pub fn with_patches(mut self, patches: PatchManifestSettings) -> Self {
self.patches = patches;
self
}
fn validate_targets(targets: &[Target]) -> Result<(), ValidationError> {
let mut seen: HashSet<&str> = HashSet::with_capacity(targets.len());
for target in targets {
if !seen.insert(target.name.as_str()) {
return Err(ValidationError::DuplicateTargetName(
target.name.as_str().to_owned(),
));
}
}
Ok(())
}
fn validate_dependencies(deps: &[Dependency]) -> Result<(), ValidationError> {
let mut seen: HashSet<(DependencyKind, &str)> = HashSet::with_capacity(deps.len());
for dep in deps {
if !seen.insert((dep.kind, dep.name.as_str())) {
return Err(ValidationError::DuplicateDependency {
name: dep.name.as_str().to_owned(),
kind: dep.kind,
});
}
}
Ok(())
}
fn validate_system_dependencies(deps: &[SystemDependency]) -> Result<(), ValidationError> {
let mut seen: HashSet<&str> = HashSet::with_capacity(deps.len());
for dep in deps {
if !seen.insert(dep.name.as_str()) {
return Err(ValidationError::DuplicateSystemDependency(
dep.name.as_str().to_owned(),
));
}
}
Ok(())
}
pub fn resolved_dependencies(&self) -> impl Iterator<Item = &Dependency> {
self.dependencies
.iter()
.filter(|d| d.kind.is_resolved_by_default())
}
pub fn dependencies_of_kind(&self, kind: DependencyKind) -> impl Iterator<Item = &Dependency> {
self.dependencies.iter().filter(move |d| d.kind == kind)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn version() -> semver::Version {
semver::Version::parse("0.1.0").unwrap()
}
fn pkg(name: &str) -> PackageName {
PackageName::new(name).unwrap()
}
fn tgt(name: &str) -> TargetName {
TargetName::new(name).unwrap()
}
fn target(name: &str, kind: TargetKind, deps: &[&str]) -> Target {
Target {
name: tgt(name),
kind,
sources: Vec::new(),
include_dirs: Vec::new(),
defines: Vec::new(),
deps: deps.iter().map(|d| (*d).to_owned()).collect(),
}
}
#[test]
fn package_name_rejects_empty() {
assert_eq!(
PackageName::new("").unwrap_err(),
ValidationError::EmptyPackageName
);
}
#[test]
fn package_name_rejects_whitespace() {
let err = PackageName::new("hello world").unwrap_err();
assert!(matches!(
err,
ValidationError::PackageNameContainsWhitespace(_)
));
}
#[test]
fn package_name_error_describes_grammar() {
let err = PackageName::new("foo?bar").unwrap_err();
let displayed = err.to_string();
assert!(
displayed.contains("\"foo?bar\""),
"error must echo the offending name: {displayed}"
);
assert!(
displayed.contains("ASCII letters")
&& displayed.contains("ASCII digits")
&& displayed.contains("`_`")
&& displayed.contains("`-`")
&& displayed.contains("`.`"),
"error must describe the allowed alphabet: {displayed}"
);
assert!(
displayed.contains("must not start with `.` or `-`")
&& displayed.contains("must not be `.` or `..`"),
"error must describe the structural restrictions: {displayed}"
);
}
#[test]
fn package_name_accepts_simple_alphanumeric() {
assert!(PackageName::new("fmt").is_ok());
}
#[test]
fn package_name_accepts_hyphen_and_underscore() {
assert!(PackageName::new("foo-bar").is_ok());
assert!(PackageName::new("foo_bar").is_ok());
assert!(PackageName::new("foo-bar-baz").is_ok());
}
#[test]
fn package_name_accepts_dot_in_middle() {
assert!(PackageName::new("foo.bar").is_ok());
assert!(PackageName::new("foo..bar").is_ok());
}
#[test]
fn package_name_rejects_path_traversal() {
for raw in [".", "..", "../evil", ".hidden", "foo/bar", "foo\\bar"] {
assert!(
matches!(
PackageName::new(raw).unwrap_err(),
ValidationError::UnsafePackageName(_)
),
"{raw:?} should be rejected as unsafe"
);
}
}
#[test]
fn package_name_rejects_leading_dash() {
for raw in ["-foo", "--list-all", "-Lfoo", "-"] {
assert!(
matches!(
PackageName::new(raw).unwrap_err(),
ValidationError::UnsafePackageName(_)
),
"{raw:?} must be rejected because of the leading `-`"
);
}
assert!(PackageName::new("foo-bar").is_ok());
assert!(PackageName::new("foo--bar").is_ok());
}
#[test]
fn package_name_rejects_url_reserved() {
for raw in [
"foo?bar",
"foo#bar",
"foo%2Fbar",
"foo:bar",
"foo&bar",
"foo=bar",
"foo+bar",
"foo@bar",
] {
assert!(
matches!(
PackageName::new(raw).unwrap_err(),
ValidationError::UnsafePackageName(_)
),
"{raw:?} should be rejected as URL-reserved / outside grammar"
);
}
}
#[test]
fn package_name_rejects_windows_reserved_filename_chars() {
for raw in [
"foo<bar", "foo>bar", "foo|bar", "foo\"bar", "foo*bar", "foo:bar",
] {
assert!(
matches!(
PackageName::new(raw).unwrap_err(),
ValidationError::UnsafePackageName(_)
),
"{raw:?} should be rejected as Windows-reserved filename char"
);
}
}
#[test]
fn package_name_rejects_non_ascii() {
for raw in ["foo\u{00E9}bar", "\u{4E2D}\u{6587}", "emoji\u{1F600}"] {
assert!(
matches!(
PackageName::new(raw).unwrap_err(),
ValidationError::UnsafePackageName(_)
),
"{raw:?} should be rejected as non-ASCII"
);
}
}
#[test]
fn package_name_rejects_control_chars() {
for raw in ["foo\u{0000}bar", "foo\u{0007}bar", "foo\u{007F}bar"] {
assert!(PackageName::new(raw).is_err(), "{raw:?} should be rejected");
}
}
#[test]
fn target_name_rejects_empty() {
assert_eq!(
TargetName::new("").unwrap_err(),
ValidationError::EmptyTargetName
);
}
#[test]
fn target_name_rejects_whitespace() {
let err = TargetName::new("a b").unwrap_err();
assert!(matches!(
err,
ValidationError::TargetNameContainsWhitespace(_)
));
}
#[test]
fn target_name_rejects_leading_dash() {
for raw in ["-foo", "--release", "-"] {
assert!(
matches!(
TargetName::new(raw).unwrap_err(),
ValidationError::UnsafeTargetName(_)
),
"{raw:?} must be rejected because of the leading `-`"
);
}
assert!(TargetName::new("foo-bar").is_ok());
}
#[test]
fn target_name_rejects_path_unsafe_values() {
for raw in [
"/foo",
"foo/bar",
"\\foo",
"foo\\bar",
"..",
"../evil",
".",
".hidden",
"/tmp/out",
"C:foo",
"foo\u{00E9}bar",
"foo\u{0000}bar",
] {
assert!(
matches!(
TargetName::new(raw).unwrap_err(),
ValidationError::UnsafeTargetName(_)
),
"{raw:?} should be rejected as path-unsafe"
);
}
}
#[test]
fn target_name_accepts_path_safe_values() {
for raw in ["foo", "foo-bar", "foo_bar", "foo.bar", "lib1", "a"] {
assert!(TargetName::new(raw).is_ok(), "{raw:?} should be accepted");
}
}
#[test]
fn project_accepts_valid_targets() {
let package = Package::new(
pkg("hello"),
version(),
vec![
target("lib", TargetKind::Library, &[]),
target("exe", TargetKind::Executable, &["lib"]),
],
Vec::new(),
)
.unwrap();
assert_eq!(package.targets.len(), 2);
assert!(package.dependencies.is_empty());
}
#[test]
fn project_rejects_duplicate_targets() {
let err = Package::new(
pkg("hello"),
version(),
vec![
target("a", TargetKind::Library, &[]),
target("a", TargetKind::Executable, &[]),
],
Vec::new(),
)
.unwrap_err();
assert_eq!(err, ValidationError::DuplicateTargetName("a".into()));
}
#[test]
fn project_accepts_unknown_target_dep_for_planner_resolution() {
let package = Package::new(
pkg("hello"),
version(),
vec![target("exe", TargetKind::Executable, &["external"])],
Vec::new(),
)
.unwrap();
assert_eq!(package.targets[0].deps[0].as_str(), "external");
}
fn dep(name: &str, kind: DependencyKind) -> Dependency {
Dependency {
name: pkg(name),
source: DependencySource::Path(PathBuf::from("../somewhere")),
kind,
optional: false,
features: Vec::new(),
default_features: true,
condition: None,
}
}
#[test]
fn project_rejects_duplicate_dependencies_within_a_kind() {
let err = Package::new(
pkg("hello"),
version(),
Vec::new(),
vec![
dep("greet", DependencyKind::Normal),
dep("greet", DependencyKind::Normal),
],
)
.unwrap_err();
assert_eq!(
err,
ValidationError::DuplicateDependency {
name: "greet".into(),
kind: DependencyKind::Normal,
}
);
}
#[test]
fn project_accepts_same_name_across_different_kinds() {
let package = Package::new(
pkg("hello"),
version(),
Vec::new(),
vec![
dep("fmt", DependencyKind::Normal),
dep("fmt", DependencyKind::Dev),
],
)
.expect("same name across distinct kinds is allowed");
assert_eq!(package.dependencies.len(), 2);
}
#[test]
fn project_rejects_duplicate_system_dependencies() {
let sys = |n: &str| SystemDependency {
name: pkg(n),
version: ">=1".into(),
kind: DependencyKind::Normal,
condition: None,
};
let err = Package::with_config(PackageConfigInput {
name: pkg("hello"),
version: version(),
targets: Vec::new(),
dependencies: Vec::new(),
system_dependencies: vec![sys("zlib"), sys("zlib")],
features: Features::default(),
})
.unwrap_err();
assert_eq!(
err,
ValidationError::DuplicateSystemDependency("zlib".into())
);
}
#[test]
fn dependency_kind_lists_are_consistent() {
let all = DependencyKind::all();
assert_eq!(all.len(), 2);
assert!(DependencyKind::Normal.is_resolved_by_default());
assert!(!DependencyKind::Dev.is_resolved_by_default());
assert!(DependencyKind::Normal.affects_ordinary_build());
assert!(!DependencyKind::Dev.affects_ordinary_build());
}
#[test]
fn target_kind_str_round_trip() {
for kind in TargetKind::all() {
assert_eq!(kind.to_string(), kind.as_str());
}
}
#[test]
fn target_kind_classification_matches_documented_policy() {
for kind in [TargetKind::Library, TargetKind::Executable] {
assert!(
kind.is_default_buildable(),
"{kind} must be default-buildable"
);
assert!(!kind.is_dev_only(), "{kind} must not be dev-only");
assert!(!kind.is_test(), "{kind} must not be classed as a test");
}
for kind in [TargetKind::Test, TargetKind::Example] {
assert!(
!kind.is_default_buildable(),
"{kind} must NOT be default-buildable"
);
assert!(kind.is_dev_only(), "{kind} must be dev-only");
assert!(kind.produces_executable(), "{kind} produces an executable");
}
assert!(TargetKind::Test.is_test());
assert!(!TargetKind::Example.is_test());
}
#[test]
fn produces_executable_matches_kind_intent() {
assert!(!TargetKind::Library.produces_executable());
assert!(!TargetKind::HeaderOnly.produces_executable());
assert!(TargetKind::Executable.produces_executable());
assert!(TargetKind::Test.produces_executable());
assert!(TargetKind::Example.produces_executable());
}
#[test]
fn header_only_is_default_buildable_but_produces_nothing() {
assert!(TargetKind::HeaderOnly.is_default_buildable());
assert!(TargetKind::HeaderOnly.is_header_only());
assert!(!TargetKind::HeaderOnly.produces_archive());
assert!(!TargetKind::HeaderOnly.produces_executable());
}
}