apply_license/
lib.rs

1use std::borrow::Borrow;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4
5use anyhow::{anyhow, bail, Result};
6use chrono::{Datelike, Local};
7use handlebars::Handlebars;
8use once_cell::sync::Lazy;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11
12static LICENSES: Lazy<Vec<License>> = Lazy::new(|| {
13    let licenses_toml = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/licenses.toml"));
14
15    let mut licenses: BTreeMap<String, Vec<License>> =
16        toml_edit::easy::from_str(licenses_toml).unwrap();
17
18    licenses.remove("license").unwrap()
19});
20
21/// An open-source license.
22#[derive(Debug, PartialEq, Deserialize)]
23pub struct License {
24    /// The identifier for the license on the command line, if multiple licenses are present.
25    ///
26    /// For example, `LICENSE-APACHE` vs `LICENSE-MIT`.
27    pub identifier: String,
28
29    /// The [SPDX license identifier](https://github.com/spdx/license-list-data/tree/v2.4).
30    pub spdx: String,
31
32    /// A handlebars template of the license text.
33    pub text: String,
34}
35
36/// Parses author names from a list of author names, which might include git-style author names
37/// such as `John Doe <jd@example.com>`.
38pub fn parse_author_names<'a>(authors: &[&'a str]) -> Result<Vec<&'a str>> {
39    if authors.is_empty() {
40        bail!("at least one author is required");
41    }
42
43    let names = authors
44        .iter()
45        .map(|author| match parse_git_style_author(author) {
46            Some(name) => name,
47            None => author,
48        })
49        .collect();
50
51    Ok(names)
52}
53
54/// Returns true if the given license ID is known by SPDX 2.4.
55fn is_valid_spdx_id(id: &str) -> bool {
56    #[derive(Debug, Deserialize)]
57    #[serde(rename_all = "camelCase")]
58    struct LicenseList {
59        licenses: Vec<License>,
60    }
61
62    #[derive(Debug, Deserialize)]
63    #[serde(rename_all = "camelCase")]
64    struct License {
65        license_id: String,
66    }
67
68    static SPDX_LICENSE_LIST: Lazy<LicenseList> = Lazy::new(|| {
69        serde_json::from_str(include_str!(concat!(
70            env!("CARGO_MANIFEST_DIR"),
71            "/src/spdx-licenses.json"
72        )))
73        .unwrap()
74    });
75
76    SPDX_LICENSE_LIST
77        .licenses
78        .iter()
79        .any(|license| license.license_id == id)
80}
81
82/// Parse a list of license identifiers from an SPDX license expression.
83///
84/// The cargo manifest format allows combining license expressions with `/`, so we allow it as
85/// well, though it's not valid SPDX.
86pub fn parse_spdx(license_expr: &str) -> Result<Vec<&'static License>> {
87    let split: Box<dyn Iterator<Item = &str>> = if license_expr.contains('/') {
88        Box::new(license_expr.split('/'))
89    } else {
90        Box::new(license_expr.split_whitespace())
91    };
92
93    split
94        .flat_map(|token| match token {
95            "WITH" | "OR" | "AND" => None,
96            token => Some(token),
97        })
98        .map(|id| {
99            if is_valid_spdx_id(id) {
100                LICENSES
101                    .iter()
102                    .find(|license| license.spdx == id)
103                    .ok_or_else(|| anyhow!("SPDX ID '{}' is valid, but unsupported by this program. Please open a PR!", id))
104            } else {
105                Err(anyhow!("invalid SPDX license ID: {}", id))
106            }
107        })
108        .collect()
109}
110
111/// Given a list of authors and SPDX license identifiers, returns a map from file name to contents.
112///
113/// If only one license file is present, writes the file name will be `LICENSE`. If two or more
114/// licenses are present, then each file will be named `LICENSE-{id}` (e.g., `LICENSE-MIT`).
115pub fn render_license_text<S: Borrow<str>>(
116    licenses: &[&License],
117    authors: &[S],
118) -> Result<BTreeMap<PathBuf, String>> {
119    let mut reg = Handlebars::new();
120
121    for license in LICENSES.iter() {
122        reg.register_template_string(&license.spdx, &license.text)
123            .expect("syntax error in license template");
124    }
125
126    #[derive(Debug, Serialize)]
127    struct TemplateData {
128        year: i32,
129        copyright_holders: String,
130    }
131
132    licenses
133        .iter()
134        .map(|license| {
135            let name = if licenses.len() == 1 {
136                String::from("LICENSE")
137            } else {
138                format!("LICENSE-{}", license.identifier)
139            };
140
141            let contents = reg.render(
142                &license.spdx,
143                &TemplateData {
144                    year: Local::today().year(),
145                    copyright_holders: authors.join(", "),
146                },
147            )?;
148
149            Ok((PathBuf::from(name), contents))
150        })
151        .collect()
152}
153
154fn parse_git_style_author(name: &str) -> Option<&str> {
155    static GIT_NAME_RE: Lazy<Regex> =
156        Lazy::new(|| Regex::new(r"(?P<name>.+) <(?P<email>.+)>").unwrap());
157
158    GIT_NAME_RE
159        .captures(name)
160        .map(|caps| caps.name("name").unwrap().as_str())
161}
162
163#[cfg(test)]
164mod tests {
165    use crate::{is_valid_spdx_id, parse_spdx, License, LICENSES};
166
167    fn get_license(id: &str) -> &'static License {
168        LICENSES.iter().find(|l| l.spdx == id).unwrap()
169    }
170
171    #[test]
172    fn parse_licenses() {
173        assert!(LICENSES.iter().any(|l| l.spdx == "MIT"));
174    }
175
176    #[test]
177    fn valid_spdx_ids() {
178        assert!(is_valid_spdx_id("MIT"));
179        assert!(!is_valid_spdx_id("foobar"));
180    }
181
182    #[test]
183    fn simple() {
184        assert_eq!(parse_spdx("GPL-3.0").unwrap(), &[get_license("GPL-3.0")]);
185    }
186
187    #[test]
188    fn compound() {
189        assert_eq!(
190            parse_spdx("MIT OR Apache-2.0").unwrap(),
191            &[get_license("MIT"), get_license("Apache-2.0")],
192        );
193    }
194
195    #[test]
196    fn cargo_manifest_licenses() {
197        assert_eq!(
198            parse_spdx("MIT/Apache-2.0").unwrap(),
199            &[get_license("MIT"), get_license("Apache-2.0")]
200        );
201    }
202}