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: &str) -> Vec<LicenseUsed> {
156    // TODO(breaking) &Path
157    // build.rs current_dir is crate folder
158    collect_cargo_about_for(
159        about_cfg_path,
160        "Cargo.toml",
161        &std::env::var("CARGO_CFG_FEATURE").unwrap_or_default(),
162    )
163}
164/// Calls [`cargo about`] for the given crate and features.
165///
166/// The `features` must be a comma separated list of cargo features.
167///
168/// Returns an empty vec if the [`DOCS_RS`] env var is set to any value or if `"ZNG_TP_LICENSES=false"` is set.
169///
170/// # Panics
171///
172/// Panics for any error, including `cargo about` errors and JSON deserialization errors.
173///
174/// [`cargo about`]: https://github.com/EmbarkStudios/cargo-about
175/// [`DOCS_RS`]: https://docs.rs/about/builds#detecting-docsrs
176#[cfg(feature = "build")]
177pub fn collect_cargo_about_for(
178    about_cfg_path: impl AsRef<std::path::Path>,
179    manifest_path: impl AsRef<std::path::Path>,
180    features: &str,
181) -> Vec<LicenseUsed> {
182    collect_cargo_about_for_impl(about_cfg_path.as_ref(), manifest_path.as_ref(), features)
183}
184#[cfg(feature = "build")]
185fn collect_cargo_about_for_impl(about_cfg_path: &std::path::Path, manifest_path: &std::path::Path, features: &str) -> Vec<LicenseUsed> {
186    if std::env::var("DOCS_RS").is_ok() || std::env::var("ZNG_TP_LICENSES").unwrap_or_default() == "false" {
187        return vec![];
188    }
189
190    let mut cargo_about = std::process::Command::new("cargo");
191    cargo_about
192        .arg("about")
193        .arg("generate")
194        .arg("--manifest-path")
195        .arg(manifest_path)
196        .arg("--format")
197        .arg("json")
198        .arg("--no-default-features");
199
200    // only include licenses from actually used dependencies
201    for feature in features.split(',') {
202        cargo_about.arg("--features");
203        cargo_about.arg(feature);
204    }
205
206    // cargo about returns an error on stdout redirect in PowerShell
207    #[cfg(windows)]
208    let temp_file = tempfile::NamedTempFile::new().expect("cannot crate temp file for windows output");
209    #[cfg(windows)]
210    {
211        cargo_about.arg("--output-file").arg(temp_file.path());
212    }
213
214    if !about_cfg_path.as_os_str().is_empty() {
215        cargo_about.arg("--config").arg(about_cfg_path);
216    }
217
218    let output = cargo_about.output().expect("error calling `cargo about`");
219    let error = String::from_utf8(output.stderr).unwrap();
220    assert!(
221        output.status.success(),
222        "error code calling `cargo about`, {:?}\nstderr:\n{error}",
223        output.status
224    );
225
226    #[cfg(windows)]
227    let json = std::fs::read_to_string(temp_file.path()).expect("cannot read temp file with windows output");
228    #[cfg(not(windows))]
229    let json = String::from_utf8(output.stdout).unwrap();
230
231    let mut entries = parse_cargo_about(&json).expect("error parsing `cargo about` output");
232
233    // don't include local crates
234    entries.retain_mut(|e| {
235        e.used_by.retain(|u| !u.version.ends_with("-local"));
236        !e.used_by.is_empty()
237    });
238
239    entries
240}
241
242/// Parse the output of [`cargo about`].
243///
244/// Example command:
245///
246/// ```console
247/// cargo about generate -c .cargo/about.toml --format json --workspace --all-features
248/// ```
249///
250/// See also [`collect_cargo_about`] that calls the command.
251///
252/// [`cargo about`]: https://github.com/EmbarkStudios/cargo-about
253#[cfg(feature = "build")]
254pub fn parse_cargo_about(json: &str) -> Result<Vec<LicenseUsed>, serde_json::Error> {
255    #[derive(Deserialize)]
256    struct Output {
257        licenses: Vec<LicenseJson>,
258    }
259    #[derive(Deserialize)]
260    struct LicenseJson {
261        id: Txt,
262        name: Txt,
263        text: Txt,
264        used_by: Vec<UsedBy>,
265    }
266    impl LicenseJson {
267        fn into(self) -> LicenseUsed {
268            LicenseUsed {
269                license: License {
270                    id: self.id,
271                    name: self.name,
272                    text: self.text,
273                },
274                used_by: self.used_by.into_iter().map(UsedBy::into).collect(),
275            }
276        }
277    }
278    #[derive(Deserialize)]
279    struct UsedBy {
280        #[serde(rename = "crate")]
281        crate_: Crate,
282    }
283    #[derive(Deserialize)]
284    struct Crate {
285        name: Txt,
286        version: Txt,
287        #[serde(default)]
288        repository: Option<Txt>,
289    }
290    impl UsedBy {
291        fn into(self) -> User {
292            let repo = self.crate_.repository.unwrap_or_default();
293            User {
294                version: self.crate_.version,
295                url: if repo.is_empty() {
296                    zng_txt::formatx!("https://crates.io/crates/{}", self.crate_.name)
297                } else {
298                    repo
299                },
300                name: self.crate_.name,
301            }
302        }
303    }
304
305    serde_json::from_str::<Output>(json).map(|o| o.licenses.into_iter().map(LicenseJson::into).collect())
306}
307
308/// Bincode serialize and deflate the licenses.
309///
310/// # Panics
311///
312/// Panics in case of any error.
313#[cfg(feature = "build")]
314pub fn encode_licenses(licenses: &[LicenseUsed]) -> Vec<u8> {
315    deflate::deflate_bytes(&postcard::to_allocvec(licenses).expect("postard error"))
316}
317
318/// Deprecated
319#[deprecated = "renamed to `write_embedding`"]
320#[cfg(feature = "build")]
321pub fn write_bundle(licenses: &[LicenseUsed]) {
322    write_embedding(licenses);
323}
324
325/// Encode licenses and write to the output file that is included by [`decode_embedding!`].
326///
327/// # Panics
328///
329/// Panics in case of any error.
330#[cfg(feature = "build")]
331pub fn write_embedding(licenses: &[LicenseUsed]) {
332    let bin = encode_licenses(licenses);
333    std::fs::write(format!("{}/zng-tp-licenses.bin", std::env::var("OUT_DIR").unwrap()), bin).expect("error writing file");
334}
335
336/// Embed the file generated using [`write_embedding`] and expands to a [`decode_licenses`] that decodes the embedding.
337///
338/// This macro output is a `Vec<LicenseUsed>`.
339#[macro_export]
340#[cfg(feature = "embed")]
341macro_rules! decode_embedding {
342    () => {
343        $crate::decode_embedding!(concat!(env!("OUT_DIR"), "/zng-tp-licenses.bin"))
344    };
345    ($custom_name:expr) => {{ $crate::decode_licenses(include_bytes!($custom_name)) }};
346}
347
348/// Deprecated
349#[deprecated = "renamed to decode_embedding"]
350#[macro_export]
351#[cfg(feature = "embed")]
352macro_rules! include_bundle {
353    ($($tt:tt)*) => {
354        $crate::decode_embedding!($($tt)*)
355    };
356}
357
358/// Decode licenses encoded with [`encode_licenses`]. Note that the encoded format is only guaranteed to work
359/// if both encoding and decoding is made with the same `Cargo.lock` dependencies.
360#[cfg(feature = "embed")]
361pub fn decode_licenses(bin: &[u8]) -> Vec<LicenseUsed> {
362    let bin = inflate::inflate_bytes(bin).expect("invalid deflate binary");
363    postcard::from_bytes(&bin).expect("invalid postard binary")
364}