Files
pkh/src/new/licenses.rs
T

225 lines
8.7 KiB
Rust

//! License reference data for `pkh new`, bundled as `data/licenses.yml`.
//!
//! The license knowledge of the scaffolder used to live in three places —
//! the wizard menu (`questions.rs`), the parse/SPDX mapping (`options.rs`)
//! and the license-file sniffing inputs (`detect.rs`) — kept in sync by
//! comments only. It is one table now: [`entries`] carries each curated
//! license's menu label, accepted spellings and detection markers,
//! [`detect_files`] the candidate license file names and [`url_template`]
//! the SPDX page URL template, so the lists cannot drift apart and adding
//! a license is a YAML entry.
//!
//! The `License` enum stays in `options.rs` (`NewOptions` and the template
//! rendering match on its variants); the enum's variants and the table's
//! ids are locked together by a consistency test there. The free-text
//! "Other (enter a SPDX identifier)" wizard entry is UX, not data, and
//! stays in Rust — like `License::Custom`'s code-driven parse path.
use serde::Deserialize;
use crate::data::embed_data;
/// One marker set of the license-text sniff: the set matches when every
/// marker of `all` occurs in the lowercased license text and none of
/// `unless` does (all-markers-within-a-list, any-marker-list semantics).
#[derive(Debug, Deserialize)]
pub(crate) struct DetectMarkerSet {
/// Substrings that must all occur in the license text
pub(crate) all: Vec<String>,
/// Substrings that must all be absent for the set to match
#[serde(default)]
pub(crate) unless: Vec<String>,
}
/// One curated license of the bundled table: everything `pkh new` knows
/// about it — identifier, menu label, accepted spellings and sniff markers
#[derive(Debug, Deserialize)]
pub(crate) struct LicenseEntry {
/// SPDX identifier: written to `debian/copyright`, returned by the
/// license sniff, substituted into [`url_template`]
pub(crate) id: String,
/// Label offered by the wizard license menu
pub(crate) menu: String,
/// Inputs accepted by `License::parse` (case-insensitive); must
/// include the id itself
pub(crate) spellings: Vec<String>,
/// Marker sets of the license-text sniff (see [`detect_from_text`])
pub(crate) detect_markers: Vec<DetectMarkerSet>,
}
/// The bundled license table (`data/licenses.yml`): the curated licenses
/// plus the shared sniffing inputs and the SPDX URL template
#[derive(Debug, Deserialize)]
struct LicensesData {
/// SPDX license page URL template (`{id}` placeholder)
license_url_template: String,
/// Candidate license file names of the sniff, in preference order
detect_files: Vec<String>,
/// The curated licenses, in wizard-menu order
licenses: Vec<LicenseEntry>,
}
embed_data! {
static ref LICENSES_DATA: LicensesData = "../../data/licenses.yml"
}
/// The curated license entries, in the order offered by the wizard menu
pub(crate) fn entries() -> &'static [LicenseEntry] {
&LICENSES_DATA.licenses
}
/// The entry whose `spellings` contain `input` (case-insensitive): the
/// table lookup behind `License::parse`'s curated arm
pub(crate) fn entry_for_spelling(input: &str) -> Option<&'static LicenseEntry> {
entries().iter().find(|entry| {
entry
.spellings
.iter()
.any(|s| s.eq_ignore_ascii_case(input))
})
}
/// The candidate license file names looked at by the license sniff, in
/// preference order, shared by every license
pub(crate) fn detect_files() -> impl Iterator<Item = &'static str> {
LICENSES_DATA.detect_files.iter().map(String::as_str)
}
/// The SPDX identifier of the (lowercased) license `text`, `None` when
/// nothing is recognizable: the first entry (menu order) with a matching
/// marker set wins. The caller lowercases the text; the markers are
/// lowercase substrings.
pub(crate) fn detect_from_text(text: &str) -> Option<&'static str> {
entries()
.iter()
.find(|entry| markers_match(entry, text))
.map(|entry| entry.id.as_str())
}
/// Whether any marker set of `entry` matches `text` (see
/// [`DetectMarkerSet`] for the semantics)
fn markers_match(entry: &LicenseEntry, text: &str) -> bool {
entry.detect_markers.iter().any(|set| {
set.all.iter().all(|marker| text.contains(marker))
&& set.unless.iter().all(|marker| !text.contains(marker))
})
}
/// The SPDX license page URL template of the table, carrying the license
/// identifier as an `{id}` placeholder
pub(crate) fn url_template() -> &'static str {
LICENSES_DATA.license_url_template.as_str()
}
#[cfg(test)]
mod tests {
use super::*;
/// Every entry is menu-ready: non-empty id/menu, at least one spelling
/// and one non-empty marker set, and no duplicate ids or menus (the
/// wizard select would silently shadow a duplicate menu label).
#[test]
fn entries_are_well_formed() {
assert!(!entries().is_empty());
let mut ids: Vec<&str> = Vec::new();
let mut menus: Vec<&str> = Vec::new();
for entry in entries() {
assert!(!entry.id.is_empty());
assert!(!entry.menu.is_empty());
assert!(!entry.spellings.is_empty());
assert!(
!entry.detect_markers.is_empty(),
"entry '{}' has no marker set",
entry.id
);
for set in &entry.detect_markers {
assert!(
!set.all.is_empty(),
"entry '{}' has an empty marker set",
entry.id
);
}
ids.push(entry.id.as_str());
menus.push(entry.menu.as_str());
}
ids.sort_unstable();
ids.dedup();
menus.sort_unstable();
menus.dedup();
assert_eq!(ids.len(), entries().len(), "duplicate ids");
assert_eq!(menus.len(), entries().len(), "duplicate menus");
}
/// The shared sniffing inputs and the URL template: the canonical file
/// names in preference order, and a template the spdx_url
/// substitution can render.
#[test]
fn sniff_inputs_and_url_template() {
let files: Vec<&str> = detect_files().collect();
assert!(!files.is_empty());
assert_eq!(files[0], "LICENSE");
assert!(files.contains(&"COPYING"));
assert!(url_template().starts_with("https://spdx.org/licenses/"));
assert!(url_template().contains("{id}"));
}
/// The marker semantics on hand-built texts: every `all` marker must
/// occur, every `unless` marker must not, any set of an entry
/// suffices, and the sets are mutually exclusive across entries —
/// the LGPL/GPL substring relation and the multi-license texts land
/// on the same entry the old hardcoded cascade picked.
#[test]
fn marker_sets_keep_the_old_cascade_results() {
// Nothing recognizable.
assert_eq!(detect_from_text("do whatever you want"), None);
// Version discrimination of the GPL family.
assert_eq!(
detect_from_text("gnu general public license\nversion 3"),
Some("GPL-3.0+")
);
assert_eq!(
detect_from_text("gnu general public license\nversion 2, june 1991"),
Some("GPL-2.0+")
);
// "lesser general public license" contains "general public
// license": LGPL texts must stay on their entries.
assert_eq!(
detect_from_text("gnu lesser general public license\nversion 3"),
Some("LGPL-3.0+")
);
assert_eq!(
detect_from_text("gnu lesser general public license\nversion 2.1"),
Some("LGPL-2.1+")
);
// A text naming both versions is the 2.1 wording (version 2.1
// wins), like the old unless-less branch pair did.
assert_eq!(
detect_from_text("gnu lesser general public license\nversion 3, like version 2.1"),
Some("LGPL-2.1+")
);
// Multi-license texts: the GPL/Apache reference is the stronger
// one, so MIT does not steal them.
assert_eq!(
detect_from_text("mit license\nunder the gnu general public license, version 2"),
Some("GPL-2.0+")
);
assert_eq!(
detect_from_text(
"permission is hereby granted, free of charge\ndual-licensed under the apache license version 2"
),
Some("Apache-2.0")
);
// BSD-2 vs BSD-3: the endorsement clause is the discriminator.
assert_eq!(
detect_from_text("redistribution and use in source and binary forms"),
Some("BSD-2-Clause")
);
assert_eq!(
detect_from_text(
"redistribution and use in source and binary forms\nmay be used to endorse or promote"
),
Some("BSD-3-Clause")
);
}
}