diff --git a/flake-info/src/data/export.rs b/flake-info/src/data/export.rs index 53b9892d..e6876746 100644 --- a/flake-info/src/data/export.rs +++ b/flake-info/src/data/export.rs @@ -48,14 +48,114 @@ impl From for License { fullName, shortName, url, + .. } => License { url, fullName: fullName.unwrap_or(shortName.unwrap_or("custom".into())), }, + // Compound variants should be flattened before reaching this point; + // if one slips through, use its SPDX expression as the display name. + other => License { + url: None, + fullName: other.spdx_expression(), + }, + } + } +} + +/// A tree representation of a compound license expression, preserving the +/// structural AND/OR/WITH/PLUS operators so the frontend can distinguish +/// licenses that are jointly required from licenses the user may choose +/// between. Leaves carry the displayable fullName and (optional) URL so they +/// can still render as links. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum LicenseExpression { + Leaf { + #[serde(rename = "fullName")] + full_name: String, + url: Option, + }, + And { + licenses: Vec, + }, + Or { + licenses: Vec, + }, + With { + license: Box, + exception: Box, + }, + Plus { + license: Box, + }, +} + +impl From for LicenseExpression { + fn from(license: import::License) -> Self { + match license { + import::License::None { .. } => LicenseExpression::Leaf { + full_name: "no license specified".to_string(), + url: None, + }, + import::License::Simple { license } => LicenseExpression::Leaf { + full_name: license, + url: None, + }, + import::License::Full { + fullName, + shortName, + url, + .. + } => LicenseExpression::Leaf { + full_name: fullName + .or(shortName) + .unwrap_or_else(|| "custom".to_string()), + url, + }, + import::License::Compound { + operator, licenses, .. + } => { + let children: Vec = + licenses.into_iter().map(|l| l.0.into()).collect(); + if operator == "OR" { + LicenseExpression::Or { licenses: children } + } else { + LicenseExpression::And { licenses: children } + } + } + import::License::Exception { + license, exception, .. + } => LicenseExpression::With { + license: Box::new((*license).0.into()), + exception: Box::new((*exception).0.into()), + }, + import::License::Plus { license, .. } => LicenseExpression::Plus { + license: Box::new((*license).0.into()), + }, } } } +/// Build an optional license-expression tree from a list of top-level licenses. +/// Returns `None` if no license in the list uses compound operators (the flat +/// `package_license` list is sufficient in that case). Multiple top-level +/// licenses are implicitly joined with AND, matching the historical nixpkgs +/// convention that a list of licenses means "all of them apply". +fn build_license_expression( + licenses: &[import::StringOrStruct], +) -> Option { + if !licenses.iter().any(|l| l.0.is_compound()) { + return None; + } + let items: Vec = licenses.iter().map(|l| l.0.clone().into()).collect(); + if items.len() == 1 { + items.into_iter().next() + } else { + Some(LicenseExpression::And { licenses: items }) + } +} + // ----- Unified derivation representation #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -75,6 +175,7 @@ pub enum Derivation { package_mainProgram: Option, package_license: Vec, package_license_set: Vec, + package_license_expression: Option, package_maintainers: Vec, package_maintainers_set: Vec, package_teams: Vec, @@ -164,15 +265,17 @@ impl TryFrom<(import::FlakeEntry, super::Flake)> for Derivation { let long_description = long_description.map(|s| s.render_markdown()).transpose()?; - let package_license: Vec = license - .map(OneOrMany::into_list) - .unwrap_or_default() + let license_list = license.map(OneOrMany::into_list).unwrap_or_default(); + + let package_license_expression = build_license_expression(&license_list); + + let package_license: Vec = license_list .into_iter() - .map(|sos| sos.0.into()) + .flat_map(|sos| sos.0.flatten()) + .map(|l| l.into()) .collect(); let package_license_set: Vec = package_license .iter() - .clone() .map(|l| l.fullName.to_owned()) .collect(); @@ -190,6 +293,7 @@ impl TryFrom<(import::FlakeEntry, super::Flake)> for Derivation { package_mainProgram: None, package_license, package_license_set, + package_license_expression, package_description: description.clone(), package_maintainers: vec![maintainer.clone()], package_maintainers_set: maintainer.name.map_or(vec![], |n| vec![n]), @@ -256,12 +360,17 @@ impl TryFrom for Derivation { }) .into(); - let package_license: Vec = package + let license_list = package .meta .license - .map_or(Default::default(), OneOrMany::into_list) + .map_or(Default::default(), OneOrMany::into_list); + + let package_license_expression = build_license_expression(&license_list); + + let package_license: Vec = license_list .into_iter() - .map(|sos| sos.0.into()) + .flat_map(|sos| sos.0.flatten()) + .map(|l| l.into()) .collect(); let package_license_set = package_license @@ -330,6 +439,7 @@ impl TryFrom for Derivation { package_mainProgram: package.meta.mainProgram, package_license, package_license_set, + package_license_expression, package_maintainers, package_maintainers_set, package_teams, diff --git a/flake-info/src/data/import.rs b/flake-info/src/data/import.rs index 76784fa5..dd469814 100644 --- a/flake-info/src/data/import.rs +++ b/flake-info/src/data/import.rs @@ -326,7 +326,21 @@ impl Platforms { } } -/// Different representations of the licence attribute +/// Different representations of the licence attribute. +/// +/// Variant ordering matters for `#[serde(untagged)]`: serde tries each variant +/// top-to-bottom and picks the first one whose required fields are all present. +/// +/// - `None` / `Simple` match degenerate cases (null, bare string via +/// `StringOrStruct`). +/// - `Compound` needs `licenses` (a Vec) -- unique among variants. +/// - `Exception` needs both `license` **and** `exception` -- must come before +/// `Plus` whose fields are a subset. +/// - `Plus` needs `license` + `operator` (but not `exception`). +/// - `Full` has only `Option` fields, so it acts as the catch-all for any +/// license object. +/// +/// See NixOS/nixpkgs#468378 for the upstream compound-license proposal. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum License { @@ -337,14 +351,129 @@ pub enum License { Simple { license: String, }, + /// `lib.licenses.AND [...]` / `lib.licenses.OR [...]` + #[allow(non_snake_case)] + Compound { + licenseType: String, + operator: String, + licenses: Vec>, + }, + /// `lib.licenses.WITH license exception` + #[allow(non_snake_case)] + Exception { + licenseType: String, + operator: String, + license: Box>, + exception: Box>, + }, + /// `lib.licenses.PLUS license` + #[allow(non_snake_case)] + Plus { + licenseType: String, + operator: String, + license: Box>, + }, #[allow(non_snake_case)] Full { fullName: Option, shortName: Option, + spdxId: Option, url: Option, }, } +impl License { + /// Whether this license is a compound expression (AND, OR, WITH, PLUS) + /// rather than a simple/full leaf license. + pub fn is_compound(&self) -> bool { + matches!( + self, + License::Compound { .. } | License::Exception { .. } | License::Plus { .. } + ) + } + + /// Recursively collect all leaf (simple/full) licenses from a compound + /// expression. A leaf license returns itself. + pub fn flatten(self) -> Vec { + match self { + License::Compound { licenses, .. } => { + licenses.into_iter().flat_map(|l| l.0.flatten()).collect() + } + License::Exception { + license, exception, .. + } => { + let mut out = license.0.flatten(); + out.extend(exception.0.flatten()); + out + } + License::Plus { license, .. } => license.0.flatten(), + other => vec![other], + } + } + + /// Short display name suitable for use in SPDX-style expressions. + fn display_name(&self) -> String { + match self { + License::Full { + spdxId, + shortName, + fullName, + .. + } => spdxId + .clone() + .or_else(|| shortName.clone()) + .or_else(|| fullName.clone()) + .unwrap_or_else(|| "custom".into()), + License::Simple { license } => license.clone(), + License::None { .. } => String::new(), + // Compound variants delegate to spdx_expression() + _ => self.spdx_expression(), + } + } + + /// Build an SPDX-style expression string (e.g. "MIT AND (Apache-2.0 WITH + /// LLVM-exception)"). + pub fn spdx_expression(&self) -> String { + match self { + License::Compound { + operator, licenses, .. + } => { + let parts: Vec = licenses + .iter() + .map(|l| { + if l.0.is_compound() { + format!("({})", l.0.spdx_expression()) + } else { + l.0.display_name() + } + }) + .collect(); + parts.join(&format!(" {} ", operator)) + } + License::Exception { + license, exception, .. + } => { + let lic = if license.0.is_compound() { + format!("({})", license.0.spdx_expression()) + } else { + license.0.display_name() + }; + let exc = exception.0.display_name(); + format!("{} WITH {}", lic, exc) + } + License::Plus { license, .. } => { + let lic = if license.0.is_compound() { + format!("({})", license.0.spdx_expression()) + } else { + license.0.display_name() + }; + format!("{}+", lic) + } + other => other.display_name(), + } + } +} + impl Default for License { fn default() -> Self { License::None { license: () } diff --git a/flake-info/src/elastic.rs b/flake-info/src/elastic.rs index 1bea5f7e..e73262a9 100644 --- a/flake-info/src/elastic.rs +++ b/flake-info/src/elastic.rs @@ -117,6 +117,7 @@ lazy_static! { "url": {"type": "text"}}, }, "package_license_set": {"type": "keyword"}, + "package_license_expression": {"type": "object", "enabled": false}, "package_maintainers": { "type": "nested", "properties": { diff --git a/version.nix b/version.nix index 95f06d84..c0763426 100644 --- a/version.nix +++ b/version.nix @@ -2,7 +2,7 @@ /** Backend index version used by import jobs when writing data to Elasticsearch */ - import = "46"; + import = "47"; /** Frontend index version used by the UI when querying Elasticsearch