licenses 0.6.0

Cargo subcommand for collecting licenses.
use anyhow::Context;
use cargo_metadata::camino::Utf8PathBuf;
use std::collections::BTreeSet;
use std::hash::{Hash, Hasher};

#[derive(Debug, Eq, Clone, PartialOrd, Ord)]
pub struct Package {
    pub normalised_name: String,
    pub path: Utf8PathBuf,
    pub url: Option<String>,
    pub license: Option<String>,
}

#[cfg(test)]
impl Package {
    pub fn called(name: &str) -> Self {
        Self {
            path: Utf8PathBuf::default(),
            normalised_name: name.to_string(),
            url: None,
            license: None,
        }
    }
}

impl Package {
    fn try_from_metadata(package: cargo_metadata::Package) -> anyhow::Result<Self> {
        Ok(Self {
            normalised_name: package.name.to_string().replace('-', "_"),
            path: package
                .manifest_path
                .parent()
                .context("could not get parent path from manifest path")?
                .to_path_buf(),
            url: package.repository,
            license: package.license,
        })
    }
}

impl PartialEq for Package {
    fn eq(&self, other: &Self) -> bool {
        self.normalised_name == other.normalised_name && self.license == other.license
    }
}

impl Hash for Package {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.normalised_name.hash(state);
        self.license.hash(state);
    }
}

pub fn filtered_packages(
    all_packages: Vec<Package>,
    crates_we_want: &BTreeSet<String>,
) -> Vec<Package> {
    all_packages
        .into_iter()
        .filter(|package| crates_we_want.contains(&package.normalised_name))
        .collect()
}

pub fn try_get_packages() -> anyhow::Result<Vec<Package>> {
    cargo_metadata::MetadataCommand::new()
        .exec()
        .context("failed to call cargo metadata")?
        .packages
        .into_iter()
        .map(Package::try_from_metadata)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::Package;
    use cargo_metadata::PackageName;
    use cargo_metadata::camino::Utf8PathBuf;
    use std::collections::HashSet;
    use std::str::FromStr;

    fn metadata_package() -> cargo_metadata::Package {
        serde_json::from_str(
            r#"{
            "name": "example",
            "version": "0.0.0",
            "authors": [],
            "id": "dummy-id 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
            "source": null,
            "description": null,
            "dependencies": [],
            "license": null,
            "license_file": null,
            "targets": [],
            "features": {},
            "manifest_path": "some/path/Cargo.toml",
            "categories": [],
            "keywords": [],
            "readme": null,
            "repository": null,
            "homepage": null,
            "documentation": null,
            "edition": "2021",
            "metadata": null,
            "links": null,
            "publish": null,
            "default_run": null,
            "rust_version": null
        }"#,
        )
        .unwrap()
    }

    #[test]
    fn package_names_are_normalised() {
        let mut metadata_package = metadata_package();
        metadata_package.name = PackageName::from_str("normalised-name").unwrap();
        assert_eq!(
            "normalised_name",
            Package::try_from_metadata(metadata_package)
                .unwrap()
                .normalised_name
        );
    }

    #[test]
    fn packages_without_valid_manifest_path_parent_fail() {
        let mut metadata_package = metadata_package();
        metadata_package.manifest_path = Utf8PathBuf::from("/");
        assert_eq!(
            "could not get parent path from manifest path",
            Package::try_from_metadata(metadata_package)
                .unwrap_err()
                .to_string()
        );
    }

    #[test]
    fn packages_without_repository_sets_link_to_none() {
        assert!(
            Package::try_from_metadata(metadata_package())
                .unwrap()
                .url
                .is_none()
        );
    }

    #[test]
    fn packages_without_license_set_to_none() {
        assert!(
            Package::try_from_metadata(metadata_package())
                .unwrap()
                .license
                .is_none()
        );
    }

    #[test]
    fn packages_with_same_name_and_license_are_equal() {
        assert_eq!(
            Package {
                normalised_name: "toml".to_string(),
                path: Utf8PathBuf::from("/some/path/1"),
                url: Some("https://github.com/toml-rs/toml".to_string()),
                license: Some("MIT".to_string()),
            },
            Package {
                normalised_name: "toml".to_string(),
                path: Utf8PathBuf::from("/some/path/2"),
                url: Some("https://github.com/toml-rs/toml".to_string()),
                license: Some("MIT".to_string()),
            }
        );
    }

    #[test]
    fn packages_with_same_name_different_license_are_not_equal() {
        assert_ne!(
            Package {
                normalised_name: "toml".to_string(),
                path: Utf8PathBuf::from("/some/path/1"),
                url: Some("https://github.com/toml-rs/toml".to_string()),
                license: Some("MIT".to_string()),
            },
            Package {
                normalised_name: "toml".to_string(),
                path: Utf8PathBuf::from("/some/path/2"),
                url: Some("https://github.com/toml-rs/toml".to_string()),
                license: Some("Apache-2.0".to_string()),
            }
        );
    }

    #[test]
    fn packages_are_hashed_based_on_name_and_license() {
        let package_1 = Package {
            normalised_name: "toml".to_string(),
            path: Utf8PathBuf::from("/some/path/1"),
            url: None,
            license: Some("MIT".to_string()),
        };
        let package_2 = Package {
            normalised_name: "toml".to_string(),
            path: Utf8PathBuf::from("/some/path/2"),
            url: None,
            license: Some("MIT".to_string()),
        };
        let package_3 = Package {
            normalised_name: "toml".to_string(),
            path: Utf8PathBuf::from("/some/path/3"),
            url: None,
            license: Some("Apache-2.0".to_string()),
        };

        let mut set = HashSet::new();
        set.insert(package_1.clone());
        set.insert(package_2);
        set.insert(package_3.clone());

        assert_eq!(set.len(), 2);
        assert!(set.contains(&package_1));
        assert!(set.contains(&package_3));
    }

    #[test]
    fn filtered_packages_returns_only_matching_crates() {
        use super::filtered_packages;
        use std::collections::BTreeSet;

        let packages = vec![
            Package::called("alpha"),
            Package::called("beta"),
            Package::called("gamma"),
        ];

        let crates_we_want: BTreeSet<String> =
            ["alpha", "gamma"].into_iter().map(String::from).collect();

        let result = filtered_packages(packages, &crates_we_want);
        assert_eq!(2, result.len());
        assert!(result.iter().any(|p| p.normalised_name == "alpha"));
        assert!(result.iter().any(|p| p.normalised_name == "gamma"));
    }

    #[test]
    fn filtered_packages_returns_empty_when_no_matches() {
        use super::filtered_packages;
        use std::collections::BTreeSet;

        let packages = vec![Package::called("alpha")];
        let crates_we_want: BTreeSet<String> = ["beta"].into_iter().map(String::from).collect();

        assert!(filtered_packages(packages, &crates_we_want).is_empty());
    }

    #[test]
    fn filtered_packages_returns_empty_for_empty_inputs() {
        use super::filtered_packages;
        use std::collections::BTreeSet;

        assert!(filtered_packages(vec![], &BTreeSet::new()).is_empty());
    }

    #[test]
    fn packages_with_repository_sets_url() {
        let mut metadata_package = metadata_package();
        metadata_package.repository = Some("https://github.com/example/repo".to_string());
        assert_eq!(
            Some("https://github.com/example/repo".to_string()),
            Package::try_from_metadata(metadata_package).unwrap().url
        );
    }

    #[test]
    fn packages_with_license_sets_license() {
        let mut metadata_package = metadata_package();
        metadata_package.license = Some("MIT OR Apache-2.0".to_string());
        assert_eq!(
            Some("MIT OR Apache-2.0".to_string()),
            Package::try_from_metadata(metadata_package)
                .unwrap()
                .license
        );
    }

    #[test]
    fn packages_are_ordered_by_name_then_license() {
        let a = Package {
            normalised_name: "alpha".to_string(),
            path: Utf8PathBuf::new(),
            url: None,
            license: Some("MIT".to_string()),
        };
        let b = Package {
            normalised_name: "beta".to_string(),
            path: Utf8PathBuf::new(),
            url: None,
            license: Some("MIT".to_string()),
        };
        assert!(a < b);
    }
}