Skip to main content

zng_tp_licenses/
lib.rs

1#![doc(html_favicon_url = "https://zng-ui.github.io/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://zng-ui.github.io/res/zng-logo.png")]
3//!
4//! Third party license management and collection.
5//!
6//! # Crate
7//!
8#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13use zng_txt::Txt;
14
15/// Represents a license and dependencies that use it.
16#[derive(Serialize, Deserialize, Clone)]
17pub struct LicenseUsed {
18    /// License name and text.
19    pub license: License,
20    /// Project or packages that use this license.
21    pub used_by: Vec<User>,
22}
23impl fmt::Debug for LicenseUsed {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        f.debug_struct("License")
26            .field("license.id", &self.license.id)
27            .field("used_by", &self.used_by)
28            .finish_non_exhaustive()
29    }
30}
31impl LicenseUsed {
32    /// Invert data to be keyed by user.
33    pub fn user_licenses(&self) -> Vec<UserLicense> {
34        self.used_by
35            .iter()
36            .map(|u| UserLicense {
37                user: u.clone(),
38                license: self.license.clone(),
39            })
40            .collect()
41    }
42}
43
44/// Invert data to be keyed by user, also sorts by user name.
45pub fn user_licenses(licenses: &[LicenseUsed]) -> Vec<UserLicense> {
46    let mut r: Vec<_> = licenses.iter().flat_map(|l| l.user_licenses()).collect();
47    r.sort_by(|a, b| a.user.name.cmp(&b.user.name));
48    r
49}
50
51/// Represents a license user with license.
52#[derive(Clone, PartialEq, Eq)]
53pub struct UserLicense {
54    /// License user.
55    pub user: User,
56    /// License used.
57    pub license: License,
58}
59impl fmt::Debug for UserLicense {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.debug_struct("UserLicense")
62            .field("user", &self.user)
63            .field("license.id", &self.license.id)
64            .finish()
65    }
66}
67
68/// Represents a license id, name and text.
69#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Hash)]
70#[non_exhaustive]
71pub struct License {
72    /// License [SPDX] id.
73    ///
74    /// [SPDX]: https://spdx.org/licenses/
75    pub id: Txt,
76    /// License name.
77    pub name: Txt,
78    /// License text.
79    pub text: Txt,
80}
81
82impl License {
83    /// New License.
84    pub fn new(id: impl Into<Txt>, name: impl Into<Txt>, text: impl Into<Txt>) -> Self {
85        Self {
86            id: id.into(),
87            name: name.into(),
88            text: text.into(),
89        }
90    }
91}
92
93/// Represents a project or package that uses a license.
94#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Debug, Clone)]
95#[non_exhaustive]
96pub struct User {
97    /// Project or package name.
98    pub name: Txt,
99    /// Package version.
100    #[serde(default)]
101    pub version: Txt,
102    /// Project or package URL.
103    #[serde(default)]
104    pub url: Txt,
105}
106impl User {
107    /// New license user.
108    pub fn new(name: impl Into<Txt>, version: impl Into<Txt>, url: impl Into<Txt>) -> Self {
109        Self {
110            name: name.into(),
111            version: version.into(),
112            url: url.into(),
113        }
114    }
115}
116
117/// Merge `licenses` into `into`.
118///
119/// The licenses and users are not sorted, call [`sort_licenses`] after merging all licenses.
120pub fn merge_licenses(into: &mut Vec<LicenseUsed>, licenses: Vec<LicenseUsed>) {
121    for license in licenses {
122        if let Some(l) = into.iter_mut().find(|l| l.license == license.license) {
123            for user in license.used_by {
124                if !l.used_by.contains(&user) {
125                    l.used_by.push(user);
126                }
127            }
128        } else {
129            into.push(license);
130        }
131    }
132}
133
134/// Sort vec by license name, and users of each license by name.
135pub fn sort_licenses(l: &mut Vec<LicenseUsed>) {
136    l.sort_by(|a, b| a.license.name.cmp(&b.license.name));
137    for l in l {
138        l.used_by.sort_by(|a, b| a.name.cmp(&b.name));
139    }
140}
141
142/// Calls [`cargo about`] for the building crate and features.
143///
144/// This method must be used in build scripts (`build.rs`).
145///
146/// Returns an empty vec if the [`DOCS_RS`] env var is set to any value or if `"ZNG_TP_LICENSES=false"` is set.
147///
148/// # Panics
149///
150/// Panics for any error, including `cargo about` errors and JSON deserialization errors.
151///
152/// [`cargo about`]: https://github.com/EmbarkStudios/cargo-about
153/// [`DOCS_RS`]: https://docs.rs/about/builds#detecting-docsrs
154#[cfg(feature = "build")]
155pub fn collect_cargo_about(about_cfg_path: impl AsRef<std::path::Path>) -> Vec<LicenseUsed> {
156    // build.rs current_dir is crate folder
157    collect_cargo_about_for_impl(
158        about_cfg_path.as_ref(),
159        "Cargo.toml".as_ref(),
160        &std::env::var("CARGO_CFG_FEATURE").unwrap_or_default(),
161    )
162}
163/// Calls [`cargo about`] for the given crate and features.
164///
165/// The `features` must be a comma separated list of cargo features.
166///
167/// Returns an empty vec if the [`DOCS_RS`] env var is set to any value or if `"ZNG_TP_LICENSES=false"` is set.
168///
169/// # Panics
170///
171/// Panics for any error, including `cargo about` errors and JSON deserialization errors.
172///
173/// [`cargo about`]: https://github.com/EmbarkStudios/cargo-about
174/// [`DOCS_RS`]: https://docs.rs/about/builds#detecting-docsrs
175#[cfg(feature = "build")]
176pub fn collect_cargo_about_for(
177    about_cfg_path: impl AsRef<std::path::Path>,
178    manifest_path: impl AsRef<std::path::Path>,
179    features: &str,
180) -> Vec<LicenseUsed> {
181    collect_cargo_about_for_impl(about_cfg_path.as_ref(), manifest_path.as_ref(), features)
182}
183#[cfg(feature = "build")]
184fn collect_cargo_about_for_impl(about_cfg_path: &std::path::Path, manifest_path: &std::path::Path, features: &str) -> Vec<LicenseUsed> {
185    if std::env::var("DOCS_RS").is_ok() || std::env::var("ZNG_TP_LICENSES").unwrap_or_default() == "false" {
186        return vec![];
187    }
188
189    let mut cargo_about = std::process::Command::new("cargo");
190    cargo_about
191        .arg("about")
192        .arg("generate")
193        .arg("--manifest-path")
194        .arg(manifest_path)
195        .arg("--format")
196        .arg("json")
197        .arg("--no-default-features");
198
199    // only include licenses from actually used dependencies
200    for feature in features.split(',') {
201        cargo_about.arg("--features");
202        cargo_about.arg(feature);
203    }
204
205    // cargo about returns an error on stdout redirect in PowerShell
206    #[cfg(windows)]
207    let temp_file = tempfile::NamedTempFile::new().expect("cannot crate temp file for windows output");
208    #[cfg(windows)]
209    {
210        cargo_about.arg("--output-file").arg(temp_file.path());
211    }
212
213    if !about_cfg_path.as_os_str().is_empty() {
214        cargo_about.arg("--config").arg(about_cfg_path);
215    }
216
217    let output = cargo_about.output().expect("error calling `cargo about`");
218    let error = String::from_utf8(output.stderr).unwrap();
219    assert!(
220        output.status.success(),
221        "error code calling `cargo about`, {:?}\nstderr:\n{error}",
222        output.status
223    );
224
225    #[cfg(windows)]
226    let json = std::fs::read_to_string(temp_file.path()).expect("cannot read temp file with windows output");
227    #[cfg(not(windows))]
228    let json = String::from_utf8(output.stdout).unwrap();
229
230    let mut entries = parse_cargo_about(&json).expect("error parsing `cargo about` output");
231
232    // don't include local crates
233    entries.retain_mut(|e| {
234        e.used_by.retain(|u| !u.version.ends_with("-local"));
235        !e.used_by.is_empty()
236    });
237
238    entries
239}
240
241/// Parse the output of [`cargo about`].
242///
243/// Example command:
244///
245/// ```console
246/// cargo about generate -c .cargo/about.toml --format json --workspace --all-features
247/// ```
248///
249/// See also [`collect_cargo_about`] that calls the command.
250///
251/// [`cargo about`]: https://github.com/EmbarkStudios/cargo-about
252#[cfg(feature = "build")]
253pub fn parse_cargo_about(json: &str) -> Result<Vec<LicenseUsed>, serde_json::Error> {
254    #[derive(Deserialize)]
255    struct Output {
256        licenses: Vec<LicenseJson>,
257    }
258    #[derive(Deserialize)]
259    struct LicenseJson {
260        id: Txt,
261        name: Txt,
262        text: Txt,
263        used_by: Vec<UsedBy>,
264    }
265    impl LicenseJson {
266        fn into(self) -> LicenseUsed {
267            LicenseUsed {
268                license: License {
269                    id: self.id,
270                    name: self.name,
271                    text: self.text,
272                },
273                used_by: self.used_by.into_iter().map(UsedBy::into).collect(),
274            }
275        }
276    }
277    #[derive(Deserialize)]
278    struct UsedBy {
279        #[serde(rename = "crate")]
280        crate_: Crate,
281    }
282    #[derive(Deserialize)]
283    struct Crate {
284        name: Txt,
285        version: Txt,
286        #[serde(default)]
287        repository: Option<Txt>,
288    }
289    impl UsedBy {
290        fn into(self) -> User {
291            let repo = self.crate_.repository.unwrap_or_default();
292            User {
293                version: self.crate_.version,
294                url: if repo.is_empty() {
295                    zng_txt::formatx!("https://crates.io/crates/{}", self.crate_.name)
296                } else {
297                    repo
298                },
299                name: self.crate_.name,
300            }
301        }
302    }
303
304    serde_json::from_str::<Output>(json).map(|o| o.licenses.into_iter().map(LicenseJson::into).collect())
305}
306
307/// Bincode serialize and deflate the licenses.
308///
309/// # Panics
310///
311/// Panics in case of any error.
312#[cfg(feature = "build")]
313pub fn encode_licenses(licenses: &[LicenseUsed]) -> Vec<u8> {
314    deflate::deflate_bytes(&postcard::to_allocvec(licenses).expect("postard error"))
315}
316
317/// Encode licenses and write to the output file that is included by [`decode_embedding!`].
318///
319/// # Panics
320///
321/// Panics in case of any error.
322#[cfg(feature = "build")]
323pub fn write_embedding(licenses: &[LicenseUsed]) {
324    let bin = encode_licenses(licenses);
325    std::fs::write(format!("{}/zng-tp-licenses.bin", std::env::var("OUT_DIR").unwrap()), bin).expect("error writing file");
326}
327
328/// Embed the file generated using [`write_embedding`] and expands to a [`decode_licenses`] that decodes the embedding.
329///
330/// This macro output is a `Vec<LicenseUsed>`.
331#[macro_export]
332#[cfg(feature = "embed")]
333macro_rules! decode_embedding {
334    () => {
335        $crate::decode_embedding!(concat!(env!("OUT_DIR"), "/zng-tp-licenses.bin"))
336    };
337    ($custom_name:expr) => {{ $crate::decode_licenses(include_bytes!($custom_name)) }};
338}
339
340/// Decode licenses encoded with [`encode_licenses`]. Note that the encoded format is only guaranteed to work
341/// if both encoding and decoding is made with the same `Cargo.lock` dependencies.
342#[cfg(feature = "embed")]
343pub fn decode_licenses(bin: &[u8]) -> Vec<LicenseUsed> {
344    let bin = inflate::inflate_bytes(bin).expect("invalid deflate binary");
345    postcard::from_bytes(&bin).expect("invalid postard binary")
346}