diff --git a/.github/workflows/publish_crate.yml b/.github/workflows/publish_crate.yml new file mode 100644 index 0000000..5d58033 --- /dev/null +++ b/.github/workflows/publish_crate.yml @@ -0,0 +1,19 @@ +Name: publish + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - name: Publish + uses: actions-rs/cargo@v1 + with: + command: publish + args: --token ${{ secrets.CRATES_TOKEN }} diff --git a/.github/workflows/test_and_check.yml b/.github/workflows/test_and_check.yml new file mode 100644 index 0000000..c861e6f --- /dev/null +++ b/.github/workflows/test_and_check.yml @@ -0,0 +1,48 @@ +name: test-and-check + +on: + push: + branches: + - main + - develop + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - name: Run tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --workspace --verbose + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + - name: Run linting + uses: actions-rs/cargo@v1 + with: + command: clippy + args: --all --all-targets --all-features -- -D warnings + format_check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt + override: true + - name: Run format + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..71ff2d9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cc_license" +version = "0.1.0" +authors = ["Javier Arias "] +edition = "2021" +license = "MIT" +description = "Creative Commons license parser" +repository = "https://github.com/thoth-pub/cc-license" +readme = "README.md" + +[dependencies] +regex = { version = "1" } \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9506b2d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2032, Open Book Publishers. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..db4457b --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +cc_license +===== +A Rust library for parsing Creative Commons license URLs. + +[![Build status](https://github.com/thoth-pub/cc-license/workflows/test-and-check/badge.svg)](https://github.com/thoth-pub/cc-license/actions) +[![Crates.io](https://img.shields.io/crates/v/cc_license.svg)](https://crates.io/crates/cc_license) + +### Usage + +To bring this crate into your repository, either add `cc_license` to your +`Cargo.toml`, or run `cargo add cc_license`. + +Here's an example parsing a CC license URL: + +```rust +use cc_license::License; + +fn main() { + let license = License::from_url("https://creativecommons.org/licenses/by-nc-sa/4.0/")?; + + assert_eq!(license.to_string(), "Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International license (CC BY-NC-SA 4.0).".to_string()); + assert_eq!(license.rights(), "CC BY-NC-SA".to_string()); + assert_eq!(license.rights_full(), "Attribution-NonCommercial-ShareAlike".to_string()); + assert_eq!(license.version(), "4.0".to_string()); + assert_eq!(license.short(), "CC BY-NC 4.0".to_string()); +} +``` diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4153419 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,33 @@ +use std::error::Error; +use std::fmt; + +macro_rules! errors { + ($($name: ident => $description: expr,)+) => { + /// Errors that can occur during parsing. + #[derive(PartialEq, Eq, Clone, Copy, Debug)] + pub enum ParseError { + $( + $name, + )+ + } + + impl fmt::Display for ParseError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + $( + ParseError::$name => fmt.write_str($description), + )+ + } + } + } + } +} + +impl Error for ParseError {} + +errors! { + InvalidUrl => "Invalid URL", + InvalidRights => "Invalid rights string", + InvalidVersion => "Invalid version string", + InvalidPublicDomainVersion => "The version of CC0 licenses must be 1.0", +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..815bd6c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,348 @@ +mod error; +mod nomenclature; +mod rights; +mod version; + +pub use crate::error::ParseError; +use crate::nomenclature::Nomenclature; +use crate::rights::Rights; +use crate::version::Version; +use regex::Regex; +use std::str::FromStr; + +const CC_REGEX: &str = r"^https?://(www\.)?creativecommons\.org/(licenses|publicdomain)/(?P[^/]+)/(?P[^/]+)/?$"; + +#[derive(Debug, PartialEq)] +pub struct License { + rights: Rights, + version: Version, +} + +impl License { + /// Parse a Creative Commons license from a URL + /// + /// # Example + /// + /// ```rust + /// # use cc_license::ParseError; + /// use cc_license::License; + /// + /// # fn run() -> Result<(), ParseError> { /// + /// let license = License::from_url("https://creativecommons.org/licenses/by-nc-sa/4.0/")?; + /// assert_eq!(license.to_string(), "Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International license (CC BY-NC-SA 4.0).".to_string()); + /// # Ok(()) + /// # } + /// # run().unwrap(); + /// ``` + pub fn from_url(url: &str) -> Result { + let re = Regex::new(CC_REGEX).unwrap(); + let captures = re.captures(url).ok_or(ParseError::InvalidUrl)?; + let rights = captures + .name("rights") + .ok_or(ParseError::InvalidUrl) + .and_then(|r| Rights::from_str(r.as_str()))?; + let version = captures + .name("version") + .ok_or(ParseError::InvalidUrl) + .and_then(|v| Version::from_str(v.as_str()))?; + + let license = License { rights, version }; + license.check()?; + Ok(license) + } + + /// Obtain the abbreviated rights string from a license + /// + /// # Example + /// + /// ```rust + /// # use cc_license::ParseError; + /// use cc_license::License; + /// + /// # fn run() -> Result<(), ParseError> { /// + /// let license = License::from_url("https://creativecommons.org/licenses/by/4.0/")?; + /// assert_eq!(license.rights(), "CC BY".to_string()); + /// # Ok(()) + /// # } + /// # run().unwrap(); + /// ``` + pub fn rights(&self) -> String { + self.rights.to_string() + } + + /// Obtain the rights string from a license + /// + /// # Example + /// + /// ```rust + /// # use cc_license::ParseError; + /// use cc_license::License; + /// + /// # fn run() -> Result<(), ParseError> { /// + /// let license = License::from_url("https://creativecommons.org/licenses/by-nc-sa/4.0/")?; + /// assert_eq!(license.rights_full(), "Attribution-NonCommercial-ShareAlike".to_string()); + /// # Ok(()) + /// # } + /// # run().unwrap(); + /// ``` + pub fn rights_full(&self) -> String { + self.rights.full_text().to_string() + } + + /// Obtain the version string from a license + /// + /// # Example + /// + /// ```rust + /// # use cc_license::ParseError; + /// use cc_license::License; + /// + /// # fn run() -> Result<(), ParseError> { /// + /// let license = License::from_url("https://creativecommons.org/licenses/by/4.0/")?; + /// assert_eq!(license.version(), "4.0".to_string()); + /// # Ok(()) + /// # } + /// # run().unwrap(); + /// ``` + pub fn version(&self) -> String { + self.version.to_string() + } + + /// Obtain the abbreviation of the license + /// + /// # Example + /// + /// ```rust + /// # use cc_license::ParseError; + /// use cc_license::License; + /// + /// # fn run() -> Result<(), ParseError> { /// + /// let license = License::from_url("https://creativecommons.org/licenses/by-nc/4.0/")?; + /// assert_eq!(license.short(), "CC BY-NC 4.0".to_string()); + /// # Ok(()) + /// # } + /// # run().unwrap(); + /// ``` + pub fn short(&self) -> String { + format!("{} {}", self.rights, self.version) + } + + fn check(&self) -> Result<(), ParseError> { + if self.rights == Rights::Zero && self.version != Version::One { + return Err(ParseError::InvalidPublicDomainVersion); + } + Ok(()) + } +} + +impl From<&License> for Nomenclature { + fn from(license: &License) -> Self { + match license.rights { + Rights::Zero => Nomenclature::Universal, + _ => match license.version { + Version::One => Nomenclature::Generic, + Version::Two => Nomenclature::Generic, + Version::TwoFive => Nomenclature::Generic, + Version::Three => Nomenclature::Unported, + Version::Four => Nomenclature::International, + }, + } + } +} + +impl ToString for License { + fn to_string(&self) -> String { + format!( + "Creative Commons {} {} {} license ({}).", + self.rights_full(), + self.version, + Nomenclature::from(self), + self.short(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_url() { + assert_eq!( + License::from_url("https://creativecommons.org/licenses/by/4.0/").unwrap(), + License { + rights: Rights::By, + version: Version::Four, + } + ); + assert_eq!( + License::from_url("https://creativecommons.org/licenses/by-nc/1.0/").unwrap(), + License { + rights: Rights::ByNc, + version: Version::One, + } + ); + assert_eq!( + License::from_url("http://creativecommons.org/licenses/by-nc-sa/4.0/").unwrap(), + License { + rights: Rights::ByNcSa, + version: Version::Four, + } + ); + assert_eq!( + License::from_url("https://creativecommons.org/licenses/by-nc-nd/3.0").unwrap(), + License { + rights: Rights::ByNcNd, + version: Version::Three, + } + ); + assert_eq!( + License::from_url("https://creativecommons.org/publicdomain/zero/1.0/").unwrap(), + License { + rights: Rights::Zero, + version: Version::One, + } + ); + + assert!(License::from_url("creativecommons.org/licenses/by/1.0/").is_err()); + assert!(License::from_url("https://creativecommons.org/licenses/by/").is_err()); + assert_eq!( + License::from_url("https://creativecommons.org/licenses/attribution/4.0/"), + Err(ParseError::InvalidRights) + ); + assert_eq!( + License::from_url("https://creativecommons.org/licenses/by/5.0/"), + Err(ParseError::InvalidVersion) + ); + assert_eq!( + License::from_url("https://creativecommons.org/publicdomain/zero/2.0/"), + Err(ParseError::InvalidPublicDomainVersion) + ); + } + + #[test] + fn test_to_string() { + let mut test_license = License { + rights: Rights::By, + version: Version::One, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons Attribution 1.0 Generic license (CC BY 1.0).".to_string() + ); + test_license = License { + rights: Rights::By, + version: Version::Two, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons Attribution 2.0 Generic license (CC BY 2.0).".to_string() + ); + test_license = License { + rights: Rights::By, + version: Version::TwoFive, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons Attribution 2.5 Generic license (CC BY 2.5).".to_string() + ); + test_license = License { + rights: Rights::By, + version: Version::Three, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons Attribution 3.0 Unported license (CC BY 3.0).".to_string() + ); + test_license = License { + rights: Rights::By, + version: Version::Four, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons Attribution 4.0 International license (CC BY 4.0).".to_string() + ); + test_license = License { + rights: Rights::ByNc, + version: Version::Four, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons Attribution-NonCommercial 4.0 International license (CC BY-NC 4.0)." + .to_string() + ); + test_license = License { + rights: Rights::ByNd, + version: Version::Four, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons Attribution-NoDerivatives 4.0 International license (CC BY-ND 4.0)." + .to_string() + ); + test_license = License { + rights: Rights::BySa, + version: Version::Four, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons Attribution-ShareAlike 4.0 International license (CC BY-SA 4.0)." + .to_string() + ); + test_license = License { + rights: Rights::ByNcSa, + version: Version::Four, + }; + assert_eq!(test_license.to_string(), "Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International license (CC BY-NC-SA 4.0).".to_string()); + test_license = License { + rights: Rights::ByNcNd, + version: Version::Four, + }; + assert_eq!(test_license.to_string(), "Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International license (CC BY-NC-ND 4.0).".to_string()); + test_license = License { + rights: Rights::Zero, + version: Version::One, + }; + assert_eq!( + test_license.to_string(), + "Creative Commons CC0 1.0 Universal license (CC0 1.0).".to_string() + ); + } + + #[test] + fn to_nomenclature() { + let mut test_license = License { + rights: Rights::By, + version: Version::One, + }; + assert_eq!(Nomenclature::from(&test_license), Nomenclature::Generic); + test_license = License { + rights: Rights::By, + version: Version::Two, + }; + assert_eq!(Nomenclature::from(&test_license), Nomenclature::Generic); + test_license = License { + rights: Rights::By, + version: Version::TwoFive, + }; + assert_eq!(Nomenclature::from(&test_license), Nomenclature::Generic); + test_license = License { + rights: Rights::By, + version: Version::Three, + }; + assert_eq!(Nomenclature::from(&test_license), Nomenclature::Unported); + test_license = License { + rights: Rights::By, + version: Version::Four, + }; + assert_eq!( + Nomenclature::from(&test_license), + Nomenclature::International + ); + test_license = License { + rights: Rights::Zero, + version: Version::One, + }; + assert_eq!(Nomenclature::from(&test_license), Nomenclature::Universal); + } +} diff --git a/src/nomenclature.rs b/src/nomenclature.rs new file mode 100644 index 0000000..2c01f0d --- /dev/null +++ b/src/nomenclature.rs @@ -0,0 +1,43 @@ +use std::fmt; + +#[derive(Debug, PartialEq)] +pub(crate) enum Nomenclature { + Generic, + Unported, + International, + Universal, +} + +impl fmt::Display for Nomenclature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let nomenclature = match self { + Nomenclature::Generic => "Generic", + Nomenclature::Unported => "Unported", + Nomenclature::International => "International", + Nomenclature::Universal => "Universal", + }; + write!(f, "{}", nomenclature) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_string() { + assert_eq!(format!("{}", Nomenclature::Generic), "Generic".to_string()); + assert_eq!( + format!("{}", Nomenclature::Unported), + "Unported".to_string() + ); + assert_eq!( + format!("{}", Nomenclature::International), + "International".to_string() + ); + assert_eq!( + format!("{}", Nomenclature::Universal), + "Universal".to_string() + ); + } +} diff --git a/src/rights.rs b/src/rights.rs new file mode 100644 index 0000000..fb05711 --- /dev/null +++ b/src/rights.rs @@ -0,0 +1,109 @@ +use crate::error::ParseError; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, PartialEq)] +pub(crate) enum Rights { + By, + BySa, + ByNd, + ByNc, + ByNcSa, + ByNcNd, + Zero, +} + +impl Rights { + pub(crate) fn full_text(&self) -> &str { + match self { + Rights::By => "Attribution", + Rights::BySa => "Attribution-ShareAlike", + Rights::ByNd => "Attribution-NoDerivatives", + Rights::ByNc => "Attribution-NonCommercial", + Rights::ByNcSa => "Attribution-NonCommercial-ShareAlike", + Rights::ByNcNd => "Attribution-NonCommercial-NoDerivatives", + Rights::Zero => "CC0", + } + } +} + +impl fmt::Display for Rights { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let rights = match self { + Rights::By => "CC BY", + Rights::BySa => "CC BY-SA", + Rights::ByNd => "CC BY-ND", + Rights::ByNc => "CC BY-NC", + Rights::ByNcSa => "CC BY-NC-SA", + Rights::ByNcNd => "CC BY-NC-ND", + Rights::Zero => "CC0", + }; + write!(f, "{}", rights) + } +} + +impl FromStr for Rights { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s { + "by" => Ok(Rights::By), + "by-sa" => Ok(Rights::BySa), + "by-nd" => Ok(Rights::ByNd), + "by-nc" => Ok(Rights::ByNc), + "by-nc-sa" => Ok(Rights::ByNcSa), + "by-nc-nd" => Ok(Rights::ByNcNd), + "zero" => Ok(Rights::Zero), + &_ => Err(ParseError::InvalidRights), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_to_string() { + assert_eq!(format!("{}", Rights::By), "CC BY".to_string()); + assert_eq!(format!("{}", Rights::BySa), "CC BY-SA".to_string()); + assert_eq!(format!("{}", Rights::ByNd), "CC BY-ND".to_string()); + assert_eq!(format!("{}", Rights::ByNc), "CC BY-NC".to_string()); + assert_eq!(format!("{}", Rights::ByNcSa), "CC BY-NC-SA".to_string()); + assert_eq!(format!("{}", Rights::ByNcNd), "CC BY-NC-ND".to_string()); + assert_eq!(format!("{}", Rights::Zero), "CC0".to_string()); + } + + #[test] + fn test_from_string() { + assert_eq!(Rights::from_str("by").unwrap(), Rights::By); + assert_eq!(Rights::from_str("by-sa").unwrap(), Rights::BySa); + assert_eq!(Rights::from_str("by-nd").unwrap(), Rights::ByNd); + assert_eq!(Rights::from_str("by-nc").unwrap(), Rights::ByNc); + assert_eq!(Rights::from_str("by-nc-sa").unwrap(), Rights::ByNcSa); + assert_eq!(Rights::from_str("by-nc-nd").unwrap(), Rights::ByNcNd); + assert_eq!(Rights::from_str("zero").unwrap(), Rights::Zero); + + assert!(Rights::from_str("CC by").is_err()); + assert!(Rights::from_str("cc By").is_err()); + assert!(Rights::from_str("Creative Commons BY").is_err()); + } + + #[test] + fn test_full_text() { + assert_eq!(Rights::By.full_text(), "Attribution"); + assert_eq!(Rights::BySa.full_text(), "Attribution-ShareAlike"); + assert_eq!(Rights::ByNd.full_text(), "Attribution-NoDerivatives"); + assert_eq!(Rights::ByNc.full_text(), "Attribution-NonCommercial"); + assert_eq!( + Rights::ByNcSa.full_text(), + "Attribution-NonCommercial-ShareAlike" + ); + assert_eq!( + Rights::ByNcNd.full_text(), + "Attribution-NonCommercial-NoDerivatives" + ); + assert_eq!(Rights::Zero.full_text(), "CC0"); + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..49dca83 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,68 @@ +use crate::error::ParseError; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, PartialEq)] +pub(crate) enum Version { + One, + Two, + TwoFive, + Three, + Four, +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let version = match self { + Version::One => "1.0", + Version::Two => "2.0", + Version::TwoFive => "2.5", + Version::Three => "3.0", + Version::Four => "4.0", + }; + write!(f, "{}", version) + } +} + +impl FromStr for Version { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s { + "1.0" => Ok(Version::One), + "2.0" => Ok(Version::Two), + "2.5" => Ok(Version::TwoFive), + "3.0" => Ok(Version::Three), + "4.0" => Ok(Version::Four), + &_ => Err(ParseError::InvalidVersion), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_to_string() { + assert_eq!(format!("{}", Version::One), "1.0".to_string()); + assert_eq!(format!("{}", Version::Two), "2.0".to_string()); + assert_eq!(format!("{}", Version::TwoFive), "2.5".to_string()); + assert_eq!(format!("{}", Version::Three), "3.0".to_string()); + assert_eq!(format!("{}", Version::Four), "4.0".to_string()); + } + + #[test] + fn test_from_string() { + assert_eq!(Version::from_str("1.0").unwrap(), Version::One); + assert_eq!(Version::from_str("2.0").unwrap(), Version::Two); + assert_eq!(Version::from_str("2.5").unwrap(), Version::TwoFive); + assert_eq!(Version::from_str("3.0").unwrap(), Version::Three); + assert_eq!(Version::from_str("4.0").unwrap(), Version::Four); + + assert!(Version::from_str("1").is_err()); + assert!(Version::from_str("2").is_err()); + assert!(Version::from_str("4.5").is_err()); + } +}