new: drive the license menu, parsing and sniffing from data/licenses.yml
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
//! 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user