//! 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, /// Substrings that must all be absent for the set to match #[serde(default)] pub(crate) unless: Vec, } /// 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, /// Marker sets of the license-text sniff (see [`detect_from_text`]) pub(crate) detect_markers: Vec, } /// 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, /// The curated licenses, in wizard-menu order licenses: Vec, } 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 { 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") ); } }