From 54ef445fdd8a1041cb31af77bd916d55cd1d5cbd Mon Sep 17 00:00:00 2001 From: eri Date: Fri, 13 Dec 2024 17:54:03 +0100 Subject: [PATCH] parse project wide metadata --- .gitignore | 4 +- Cargo.toml | 20 +- meta/Cargo.toml | 28 +++ meta/build.rs | 80 ++++++ meta/src/error.rs | 50 ++++ meta/src/lib.rs | 14 ++ meta/src/parse.rs | 169 +++++++++++++ meta/src/test.rs | 626 ++++++++++++++++++++++++++++++++++++++++++++++ meta/src/utils.rs | 110 ++++++++ 9 files changed, 1097 insertions(+), 4 deletions(-) create mode 100644 meta/Cargo.toml create mode 100644 meta/build.rs create mode 100644 meta/src/error.rs create mode 100644 meta/src/lib.rs create mode 100644 meta/src/parse.rs create mode 100644 meta/src/test.rs create mode 100644 meta/src/utils.rs diff --git a/.gitignore b/.gitignore index 1b72444..fa8d85a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/Cargo.lock -/target +Cargo.lock +target diff --git a/Cargo.toml b/Cargo.toml index 013203a..656c213 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,8 @@ -[package] -name = "system-deps" +[workspace] +members = [ "meta" ] +exclude = [ "target" ] + +[workspace.package] version = "7.0.3" authors = [ "Guillaume Desmottes ", @@ -17,6 +20,19 @@ keywords = [ ] edition = "2018" documentation = "https://docs.rs/system-deps/" + +[workspace.dependencies] +system-deps-meta = { path = "./meta" } + +[package] +name = "system-deps" +version.workspace = true +authors.workspace = true +license.workspace = true +description.workspace = true +keywords.workspace = true +edition.workspace = true +documentation.workspace = true readme = "README.md" [dependencies] diff --git a/meta/Cargo.toml b/meta/Cargo.toml new file mode 100644 index 0000000..015d03a --- /dev/null +++ b/meta/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "system-deps-meta" +version.workspace = true +authors.workspace = true +license.workspace = true +description.workspace = true +keywords.workspace = true +edition.workspace = true +documentation.workspace = true + +[dependencies] +cargo_metadata = "0.19" +serde = "1.0" +toml = "0.8" +cfg-expr = { version = "0.17", features = ["targets"] } +sha256 = { version = "1.5", optional = true } +attohttpc = { version = "0.28", optional = true } +flate2 = { version = "1.0", optional = true } +xz = { version = "0.1", optional = true } +tar = { version = "0.4", optional = true } +zip = { version = "2.2", optional = true } + +[features] +binary = [ "dep:sha256", "dep:attohttpc" ] +gz = [ "dep:flate2", "dep:tar" ] +xz = [ "dep:xz", "dep:tar" ] +zip = [ "dep:zip" ] +test = [ ] diff --git a/meta/build.rs b/meta/build.rs new file mode 100644 index 0000000..b32f484 --- /dev/null +++ b/meta/build.rs @@ -0,0 +1,80 @@ +use std::{ + env, + path::{Path, PathBuf}, +}; + +/// Environment variable to override the top level `Cargo.toml`. +const MANIFEST_VAR: &str = "SYSTEM_DEPS_BUILD_MANIFEST"; + +/// Environment variable to override the directory where `system-deps` +/// will store build products such as binary outputs. +const TARGET_VAR: &str = "SYSTEM_DEPS_TARGET_DIR"; + +/// Try to find the project root using locate-project +fn find_with_cargo(dir: &Path) -> Option { + let out = std::process::Command::new(env!("CARGO")) + .current_dir(dir) + .arg("locate-project") + .arg("--workspace") + .arg("--message-format=plain") + .output() + .ok()? + .stdout; + if out.is_empty() { + return None; + } + Some(PathBuf::from(std::str::from_utf8(&out).ok()?.trim())) +} + +/// Get the manifest from the project directory. This is **not** the directory +/// where `system-deps` is cloned, it should point to the top level `Cargo.toml` +/// file. This is needed to obtain metadata from all of dependencies, including +/// those downstream of the package being compiled. +/// +/// If the target directory is not a subfolder of the project it will not be +/// possible to detect it automatically. In this case, the user will be asked +/// to specify the `SYSTEM_DEPS_MANIFEST` variable to point to it. +/// +/// See https://github.com/rust-lang/cargo/issues/3946 for updates on first +/// class support for finding the workspace root. +fn manifest() -> PathBuf { + println!("cargo:rerun-if-env-changed={}", MANIFEST_VAR); + if let Ok(root) = env::var(MANIFEST_VAR) { + return PathBuf::from(&root); + } + + // When build scripts are invoked, they have one argument pointing to the + // build path of the crate in the target directory. This is different than + // the `OUT_DIR` environment variable, that can point to a target directory + // where the checkout of the dependency is. + let mut dir = PathBuf::from( + std::env::args() + .next() + .expect("There should be cargo arguments for determining the root"), + ); + dir.pop(); + + // Try to find the project with cargo + find_with_cargo(&dir).expect( + "Error determining the cargo root manifest.\n\ + Please set `SYSTEM_DEPS_MANIFEST` to the path of your project's Cargo.toml", + ) +} + +/// Set compile time values for the manifest and target paths, and the compile target. +/// Calculating this in a build script is necessary so that they are only calculated +/// once and every invocation of `system-deps` references the same metadata. +pub fn main() { + let manifest = manifest(); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-env=BUILD_MANIFEST={}", manifest.display()); + + let target_dir = env::var(TARGET_VAR).or(env::var("OUT_DIR")).unwrap(); + println!("cargo:rerun-if-env-changed={}", TARGET_VAR); + println!("cargo:rustc-env=BUILD_TARGET_DIR={}", target_dir); + + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); +} diff --git a/meta/src/error.rs b/meta/src/error.rs new file mode 100644 index 0000000..34b3753 --- /dev/null +++ b/meta/src/error.rs @@ -0,0 +1,50 @@ +use std::fmt; + +/// Metadata parsing errors. +#[derive(Debug)] +pub enum Error { + /// The toml object guarded by the cfg() expression is too shallow. + CfgNotObject(String), + /// Error while deserializing metadata. + DeserializeError(toml::de::Error), + /// Merging two incompatible branches. + IncompatibleMerge, + /// Error while parsing the cfg() expression. + InvalidCfg(cfg_expr::ParseError), + /// Tried to find the package but it is not in the metadata tree. + PackageNotFound(String), + /// Error while deserializing metadata. + SerializeError(toml::ser::Error), + /// The cfg() expression is valid, but not currently supported. + UnsupportedCfg(String), +} + +impl From for Error { + fn from(e: toml::de::Error) -> Self { + Self::DeserializeError(e) + } +} + +impl From for Error { + fn from(e: toml::ser::Error) -> Self { + Self::SerializeError(e) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CfgNotObject(s) => { + write!(f, "The expression '{}' is not guarding a package", s) + } + Self::DeserializeError(e) => write!(f, "Error while parsing: {}", e), + Self::IncompatibleMerge => write!(f, "Can't merge metadata"), + Self::PackageNotFound(s) => write!(f, "Package not found: {}", s), + Self::SerializeError(e) => write!(f, "Error while parsing: {}", e), + Self::UnsupportedCfg(s) => { + write!(f, "Unsupported cfg() expression: {}", s) + } + e => e.fmt(f), + } + } +} diff --git a/meta/src/lib.rs b/meta/src/lib.rs new file mode 100644 index 0000000..1ec373a --- /dev/null +++ b/meta/src/lib.rs @@ -0,0 +1,14 @@ +//#![warn(missing_docs)] + +pub mod error; +pub mod parse; +pub mod utils; + +#[cfg(any(test, feature = "test"))] +pub mod test; + +/// Path to the top level Cargo.toml. +pub const BUILD_MANIFEST: &str = env!("BUILD_MANIFEST"); + +/// Directory where `system-deps` related build products will be stored. +pub const BUILD_TARGET_DIR: &str = env!("BUILD_TARGET_DIR"); diff --git a/meta/src/parse.rs b/meta/src/parse.rs new file mode 100644 index 0000000..fd7ae4b --- /dev/null +++ b/meta/src/parse.rs @@ -0,0 +1,169 @@ +use std::{ + collections::{BTreeSet, HashMap, HashSet, VecDeque}, + iter, + path::Path, +}; + +use cargo_metadata::{DependencyKind, MetadataCommand}; +use serde::Serialize; +use toml::Table; + +use crate::{error::Error, utils::reduce}; + +/// Stores a section of metadata found in one package. +#[derive(Clone, Debug, Default, Serialize)] +pub struct MetadataNode { + /// Deserialized metadata. + table: Table, + /// The parents of this package. + parents: BTreeSet, + /// The number of children. + children: usize, +} + +/// Recursively read dependency manifests to find metadata matching a key using cargo_metadata. +/// +/// ```toml +/// [package.metadata.section] +/// some_value = ... +/// other_value = ... +/// ``` +pub fn read_metadata( + manifest: impl AsRef, + section: &str, + merge: impl Fn(&mut Table, Table, bool) -> Result<(), Error>, +) -> Result { + let data = MetadataCommand::new() + .manifest_path(manifest.as_ref()) + .exec() + .unwrap(); + + // Create the root node from the workspace metadata + let value = data.workspace_metadata.get(section).cloned(); + let root_node = MetadataNode { + table: reduce( + value + .and_then(|v| Table::try_from(v).ok()) + .unwrap_or_default(), + )?, + ..Default::default() + }; + + // Use the root package or all the workspace packages as a starting point + let mut packages: VecDeque<_> = if let Some(root) = data.root_package() { + [(root, "")].into() + } else { + data.workspace_packages() + .into_iter() + .zip(iter::repeat("")) + .collect() + }; + + let mut nodes = HashMap::from([("", root_node)]); + + // Iterate through the dependency tree to visit all packages + let mut visited = HashSet::new(); + while let Some((pkg, parent)) = packages.pop_front() { + let name = pkg.name.as_str(); + + // If we already handled this node, update parents and keep going + if !visited.insert(name) { + if let Some(node) = nodes.get_mut(name) { + if node.parents.insert(parent.into()) { + if let Some(p) = nodes.get_mut(parent) { + p.children += 1 + } + } + } + continue; + } + + // Keep track of the local manifests to see if they change + if pkg + .manifest_path + .starts_with(manifest.as_ref().parent().unwrap()) + { + println!("cargo:rerun-if-changed={}", pkg.manifest_path); + }; + + // Get `package.metadata.section` and add it to the metadata graph + let node = match (nodes.get_mut(name), pkg.metadata.get(section).cloned()) { + (None, Some(s)) => { + let node = MetadataNode { + table: reduce(Table::try_from(s)?)?, + ..Default::default() + }; + nodes.insert(name, node); + nodes.get_mut(name) + } + (n, _) => n, + }; + + // Update parents + let next_parent = if let Some(node) = node { + if node.parents.insert(parent.into()) { + if let Some(p) = nodes.get_mut(parent) { + p.children += 1 + } + } + name + } else { + parent + }; + + // Add dependencies to the queue + for dep in &pkg.dependencies { + if !matches!(dep.kind, DependencyKind::Normal) { + continue; + } + if let Some(dep_pkg) = data.packages.iter().find(|p| p.name == dep.name) { + packages.push_back((dep_pkg, next_parent)); + }; + } + } + + // Now that the tree is built, apply the reducing rules + let mut res = Table::new(); + let mut curr = Table::new(); + + // Initialize the queue from the leaves + // NOTE: Use `extract_if` when it is available https://github.com/rust-lang/rust/issues/43244 + let mut queue = VecDeque::new(); + let mut nodes: HashMap<&str, MetadataNode> = nodes + .into_iter() + .filter_map(|(k, v)| { + if v.children == 0 { + queue.push_back(v); + None + } else { + Some((k, v)) + } + }) + .collect(); + + while let Some(node) = queue.pop_front() { + // Push the parents to the queue, avoid unnecessary clones + for p in node.parents.iter().rev() { + let Some(parent) = nodes.get_mut(p.as_str()) else { + return Err(Error::PackageNotFound(p.into())); + }; + let next = if parent.children.checked_sub(1).is_some() { + println!("cargo:warning=clone"); + parent.clone() + } else { + nodes.remove(p.as_str()).expect("Already checked") + }; + queue.push_front(next); + } + + let reduced = reduce(node.table)?; + merge(&mut curr, reduced, true)?; + + if node.parents.is_empty() { + merge(&mut res, curr, false)?; + curr = Table::new(); + } + } + + Ok(res) +} diff --git a/meta/src/test.rs b/meta/src/test.rs new file mode 100644 index 0000000..ba06dc3 --- /dev/null +++ b/meta/src/test.rs @@ -0,0 +1,626 @@ +use std::{ + collections::HashSet, + fs, io, + path::{Path, PathBuf}, +}; + +use toml::{Table, Value}; + +use crate::{error::Error, parse::read_metadata, utils::merge_default}; + +macro_rules! entry { + ($table:expr, $key:expr) => { + $table + .entry($key) + .or_insert_with(|| Value::Table(Table::default())) + .as_table_mut() + .unwrap() + }; +} + +#[derive(Clone, Debug, Default)] +pub struct Package { + pub name: &'static str, + pub deps: Vec<&'static str>, + pub config: Table, +} + +impl Package { + pub fn write_toml(self, test_name: &str) -> io::Result { + let mut table = self.config; + + let package = entry!(table, "package"); + package.insert("name".into(), self.name.into()); + + let lib = entry!(table, "lib"); + lib.insert("path".into(), "".into()); + + if !self.deps.is_empty() { + let dependencies = entry!(table, "dependencies"); + for name in self.deps { + let dep = entry!(dependencies, name); + dep.insert("path".into(), format!("../{}", name).into()); + } + } + + let mut out = Path::new(env!("OUT_DIR")).join(format!("tests/{}/{}", test_name, self.name)); + let _ = fs::remove_dir_all(&out); + + fs::create_dir_all(&out)?; + out.push("Cargo.toml"); + fs::write(&out, table.to_string())?; + + Ok(out) + } +} + +#[derive(Debug)] +pub struct Test { + pub manifest: PathBuf, + pub metadata: Table, +} + +impl Test { + pub fn write_manifest(name: impl AsRef, packages: Vec) -> PathBuf { + assert!(!packages.is_empty()); + + println!("\n# Dependencies\n"); + let mut manifest = None; + for pkg in packages { + let out = pkg + .write_toml(name.as_ref()) + .expect("Error writing Cargo.toml for test package"); + println!("- {}", out.display()); + manifest.get_or_insert(out); + } + + manifest.expect("There is no main test case") + } + + pub fn new(name: impl AsRef, packages: Vec) -> Result { + let manifest = Self::write_manifest(name, packages); + let metadata = read_metadata(&manifest, "system-deps", merge_default)?; + Ok(Self { manifest, metadata }) + } + + pub fn check(&self, key: &str) -> Result<&Table, Error> { + self.metadata + .get(key) + .and_then(|v| v.as_table()) + .ok_or(Error::PackageNotFound(key.into())) + } +} + +pub fn assert_set( + rhs: impl IntoIterator, + lhs: impl IntoIterator, +) { + let r = rhs.into_iter().collect::>(); + let l = lhs.into_iter().collect::>(); + assert_eq!(r, l); +} + +#[test] +fn simple() -> Result<(), Error> { + let pkgs = vec![Package { + name: "dep", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = "simple" + ], + }]; + + let test = Test::new("simple", pkgs)?; + assert_eq!(test.check("dep")?, &toml::toml![value = "simple"]); + + Ok(()) +} + +#[test] +fn inherit() -> Result<(), Error> { + let mut pkgs = vec![ + Package { + name: "main", + deps: vec!["dep"], + config: Default::default(), + }, + Package { + name: "dep", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = "original" + ], + }, + ]; + + let test = Test::new("inherit", pkgs.clone())?; + assert_eq!(test.check("dep")?, &toml::toml![value = "original"]); + + pkgs[0].config = toml::toml![ + [package.metadata.system-deps.dep] + value = "final" + ]; + + let test = Test::new("overwrite", pkgs)?; + assert_eq!(test.check("dep")?, &toml::toml![value = "final"]); + + Ok(()) +} + +#[test] +fn chain() -> Result<(), Error> { + let names = ["final", "a", "b", "c", "d", "e", "original", ""]; + let mut pkgs = names + .windows(2) + .map(|p| { + let manifest = format!( + r#" + [package.metadata.system-deps.original] + value = "{}""#, + p[0] + ); + let mut deps = Vec::new(); + if !p[1].is_empty() { + deps.push(p[1]); + } + Package { + name: p[0], + deps, + config: toml::from_str(&manifest).unwrap(), + } + }) + .collect::>(); + + let test = Test::new("chain", pkgs.clone())?; + assert_eq!(test.check("original")?, &toml::toml![value = "final"]); + + for p in pkgs.iter_mut() { + if !["final", "original"].contains(&p.name) { + p.config.retain(|_, _| false); + } + } + + let test = Test::new("gap", pkgs)?; + assert_eq!(test.check("original")?, &toml::toml![value = "final"]); + + Ok(()) +} + +#[test] +fn merge_some() -> Result<(), Error> { + let pkgs = vec![ + Package { + name: "main", + deps: vec!["dep"], + config: toml::toml![ + [package.metadata.system-deps.dep] + text = "final" + added = "top" + value = false + list = [ "c", "d" ] + other = { different = 3, new = 4 } + ], + }, + Package { + name: "dep", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.dep] + text = "original" + value = true + number = 256 + list = [ "a", "b" ] + other = { same = 1, different = 2 } + ], + }, + ]; + + let test = Test::new("merge_some", pkgs)?; + + assert_eq!( + test.check("dep")?, + &toml::toml! [ + text = "final" + number = 256 + value = false + added = "top" + list = [ "a", "b", "c", "d" ] + other = { same = 1, different = 3, new = 4 } + ] + ); + + Ok(()) +} + +#[test] +fn incompatible_type() -> Result<(), Error> { + let pkgs = vec![ + Package { + name: "main", + deps: vec!["dep"], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = 256 + ], + }, + Package { + name: "dep", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = "simple" + ], + }, + ]; + + let test = Test::new("incompatible", pkgs); + println!("left: {:?}", test); + assert!(matches!(test, Err(Error::IncompatibleMerge))); + + Ok(()) +} + +#[test] +fn root_workspace() -> Result<(), Error> { + let pkgs = vec![Package { + name: "dep", + deps: vec![], + config: toml::toml![ + [workspace.metadata.system-deps.dep] + value = "final" + + [package.metadata.system-deps.dep] + value = "original" + ], + }]; + + let test = Test::new("root_workspace", pkgs)?; + assert_eq!(test.check("dep")?, &toml::toml![value = "final"]); + + Ok(()) +} + +#[test] +fn virtual_workspace() -> Result<(), Error> { + let pkgs = vec![Package { + name: "dep", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = "original" + ], + }]; + + let mut path = Test::write_manifest("virtual_workspace", pkgs); + + path.pop(); + path.pop(); + path.push("Cargo.toml"); + + let manifest = toml::toml![ + [workspace] + members = ["dep"] + resolver = "2" + + [workspace.metadata.system-deps.dep] + value = "final" + ]; + std::fs::write(&path, manifest.to_string()).expect("Failed to write manifest"); + + let metadata = read_metadata(&path, "system-deps", merge_default)?; + let test = Test { + metadata, + manifest: path, + }; + assert_eq!(test.check("dep")?, &toml::toml![value = "final"]); + + Ok(()) +} + +#[test] +fn branch() -> Result<(), Error> { + let mut pkgs = vec![ + Package { + name: "main", + deps: vec!["a", "b"], + config: Default::default(), + }, + Package { + name: "a", + deps: vec!["dep"], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = "final" + ], + }, + Package { + name: "b", + deps: vec!["dep"], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = "final" + ], + }, + Package { + name: "dep", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = "original" + ], + }, + ]; + + let test = Test::new("branch", pkgs.clone())?; + assert_eq!(test.check("dep")?, &toml::toml![value = "final"]); + + pkgs[2].config = toml::toml![ + [package.metadata.system-deps.dep] + value = "different" + ]; + + let test = Test::new("branch_conflict", pkgs); + println!("left: {:?}", test); + assert!(matches!(test, Err(Error::IncompatibleMerge))); + + Ok(()) +} + +#[test] +fn two_dependencies() -> Result<(), Error> { + let mut pkgs = vec![ + Package { + name: "main", + deps: vec!["a", "b"], + config: Default::default(), + }, + Package { + name: "a", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.a] + value = "a" + ], + }, + Package { + name: "b", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.b] + value = "b" + ], + }, + ]; + + let test = Test::new("two_dependencies", pkgs.clone())?; + assert_eq!(test.check("a")?, &toml::toml![value = "a"]); + assert_eq!(test.check("b")?, &toml::toml![value = "b"]); + + pkgs[1].config = toml::toml![ + [package.metadata.system-deps.a] + value = "a" + [package.metadata.system-deps.b] + value = "a" + ]; + + let test = Test::new("two_dependencies_incompatible", pkgs.clone()); + println!("left: {:?}", test); + assert!(matches!(test, Err(Error::IncompatibleMerge))); + + pkgs[0].deps.pop(); + pkgs[1].deps.push("b"); + + let test = Test::new("two_dependencies_nested", pkgs)?; + assert_eq!(test.check("a")?, &toml::toml![value = "a"]); + assert_eq!(test.check("b")?, &toml::toml![value = "a"]); + + Ok(()) +} + +#[test] +fn dependency_types() -> Result<(), Error> { + let pkgs = vec![ + Package { + name: "main", + deps: vec![], + config: toml::toml![ + [dependencies] + regular = { path = "../regular" } + [dev-dependencies] + dev = { path = "../dev" } + [build-dependencies] + build = { path = "../build" } + ], + }, + Package { + name: "regular", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.regular] + value = "regular" + ], + }, + Package { + name: "dev", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.dev] + value = "dev" + ], + }, + Package { + name: "build", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.build] + value = "build" + ], + }, + ]; + + let test = Test::new("dependency_types", pkgs)?; + assert_eq!(test.check("regular")?, &toml::toml![value = "regular"]); + + let dev = test.check("dev"); + println!("left: {:?}", dev); + assert!(matches!(dev, Err(Error::PackageNotFound(_)))); + + let build = test.check("build"); + println!("left: {:?}", build); + assert!(matches!(build, Err(Error::PackageNotFound(_)))); + + Ok(()) +} + +#[test] +fn optional_package() -> Result<(), Error> { + let mut pkgs = vec![ + Package { + name: "main", + deps: vec!["dep"], + config: toml::toml![ + [dependencies.dep] + optional = true + [features] + default = [ "dep:dep" ] + ], + }, + Package { + name: "dep", + deps: vec![], + config: toml::toml![ + [package.metadata.system-deps.dep] + value = "simple" + ], + }, + ]; + + let test = Test::new("optional_package", pkgs.clone())?; + assert_eq!(test.check("dep")?, &toml::toml![value = "simple"]); + + pkgs[0].config.remove("features"); + let test = Test::new("optional_package_disabled", pkgs)?; + + let res = test.check("dep"); + println!("left: {:?}", res); + assert!(matches!(res, Err(Error::PackageNotFound(_)))); + + Ok(()) +} + +#[test] +fn conditional() -> Result<(), Error> { + let manifest = r#" + [package.metadata.system-deps.dep] + value = "default" + other = 32 + + [package.metadata.system-deps.'cfg(all())'.dep] + value = "final" + "#; + + let pkgs = vec![Package { + name: "dep", + deps: vec![], + config: toml::from_str(manifest)?, + }]; + let test = Test::new("conditional_true", pkgs)?; + assert_eq!( + test.check("dep")?, + &toml::toml![ + value = "final" + other = 32 + ] + ); + + let pkgs = vec![Package { + name: "dep", + deps: vec![], + config: toml::from_str(&manifest.replace("all", "any"))?, + }]; + let test = Test::new("conditional_false", pkgs)?; + assert_eq!( + test.check("dep")?, + &toml::toml![ + value = "default" + other = 32 + ] + ); + + Ok(()) +} + +#[test] +#[cfg(target_os = "linux")] +fn conditional_conflict() -> Result<(), Error> { + let pkgs = vec![Package { + name: "dep", + deps: vec![], + config: toml::from_str( + r#" + [package.metadata.system-deps.'cfg(target_os = "linux")'.dep] + value = "linux" + + [package.metadata.system-deps.'cfg(unix)'.dep] + value = "unix" + "#, + )?, + }]; + + let test = Test::new("conditional_conflict", pkgs); + println!("left: {:?}", test); + assert!(matches!(test, Err(Error::IncompatibleMerge))); + + Ok(()) +} + +#[test] +fn conditional_not_map() -> Result<(), Error> { + let mut pkgs = vec![Package { + name: "dep", + deps: vec![], + config: toml::from_str( + r#" + [package.metadata.system-deps.'cfg(all())'] + dep = 1234"#, + )?, + }]; + + let test = Test::new("conditional_not_map", pkgs.clone()); + println!("left: {:?}", test); + assert!(matches!(test, Err(Error::CfgNotObject(_)))); + + pkgs[0].config = toml::from_str( + r#" + [package.metadata.system-deps] + 'cfg(all())' = 1234"#, + )?; + + let test = Test::new("conditional_not_map_ext", pkgs); + println!("left: {:?}", test); + assert!(matches!(test, Err(Error::CfgNotObject(_)))); + + Ok(()) +} + +#[test] +fn conditional_unsupported() -> Result<(), Error> { + let pkgs = vec![Package { + name: "dep", + deps: vec![], + config: toml::from_str( + r#" + [package.metadata.system-deps.'cfg(feature = "a")'.dep] + value = "a" + "#, + )?, + }]; + + let test = Test::new("conditional_unsupported", pkgs); + println!("left: {:?}", test); + assert!(matches!(test, Err(Error::UnsupportedCfg(_)))); + + Ok(()) +} diff --git a/meta/src/utils.rs b/meta/src/utils.rs new file mode 100644 index 0000000..b3165be --- /dev/null +++ b/meta/src/utils.rs @@ -0,0 +1,110 @@ +use cfg_expr::{targets::get_builtin_target_by_triple, Expression, Predicate}; +use toml::{Table, Value}; + +use crate::error::Error; + +/// Base merge function to use with `read_metadata`. +/// It will join `serde_json` values based on some assignment rules. +pub fn merge_default(rhs: &mut Table, lhs: Table, overwrite: bool) -> Result<(), Error> { + for (key, lhs) in lhs { + // 1. None = * will always return the new value. + let Some(rhs) = rhs.get_mut(&key) else { + rhs.insert(key, lhs); + continue; + }; + + // 2. If they are the same, we can stop early + if *rhs == lhs { + continue; + } + + // 3. Assignment from two different types is incompatible. + if std::mem::discriminant(rhs) != std::mem::discriminant(&lhs) { + return Err(Error::IncompatibleMerge); + } + + match (rhs, lhs) { + // 4. Arrays return a combined deduplicated list. + (Value::Array(rhs), Value::Array(lhs)) => { + for value in lhs { + if !rhs.contains(&value) { + rhs.push(value); + } + } + } + // 5. Tables combine keys from both following the previous rules. + (Value::Table(rhs), Value::Table(lhs)) => { + merge_default(rhs, lhs, overwrite)?; + } + // 6. For simple types (Booleans, Numbers and Strings): + // 6.1. If overwrite is true, the new value will be returned. + // 6.2. Otherwise, if the value is not the same there will be an error. + (r, l) => { + if !overwrite { + return Err(Error::IncompatibleMerge); + } + *r = l; + } + } + } + Ok(()) +} + +/// ```toml +/// [package.metadata.'cfg(target = "unix")'] +/// value = ... +/// ``` +pub fn reduce(table: Table) -> Result { + let mut res = Table::new(); + let mut conditionals = Table::new(); + + for (key, value) in table { + // Conditional expressions + if let Some(cfg) = key.strip_prefix("cfg(") { + let pred = cfg + .strip_suffix(")") + .ok_or(Error::UnsupportedCfg(key.clone()))?; + if !check_cfg(pred)? { + continue; + }; + let Value::Table(inner) = value else { + return Err(Error::CfgNotObject(key)); + }; + for (inner_key, value) in inner { + let Value::Table(value) = value else { + return Err(Error::CfgNotObject(key)); + }; + let prev = conditionals + .entry(inner_key) + .or_insert(Value::Table(Table::new())); + + merge_default(prev.as_table_mut().unwrap(), value, false)?; + } + continue; + } + + // General case + res.insert( + key, + match value { + Value::Table(t) => Value::Table(reduce(t)?), + v => v, + }, + ); + } + + // Conditionals can overwrite the default case + merge_default(&mut res, conditionals, true)?; + Ok(res) +} + +fn check_cfg(pred: &str) -> Result { + let target = get_builtin_target_by_triple(env!("TARGET")) + .expect("The target set by the build script should be valid"); + let expr = Expression::parse(pred).map_err(Error::InvalidCfg)?; + let res = expr.eval(|pred| match pred { + Predicate::Target(p) => Some(p.matches(target)), + _ => None, + }); + res.ok_or(Error::UnsupportedCfg(pred.into())) +}