upstream-ontologist 0.3.0

tracking of upstream project metadata
Documentation
use crate::{
    Certainty, Person, ProviderError, UpstreamDatum, UpstreamDatumWithMetadata, UpstreamMetadata,
};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;

/// Parses lines from a Cabal file
pub fn parse_cabal_lines(
    lines: impl Iterator<Item = String>,
) -> Vec<(Option<String>, String, String)> {
    let mut ret = Vec::new();
    let mut section = None;
    for line in lines {
        if line.trim_start().starts_with("--") {
            // Comment
            continue;
        }
        // Empty line
        if line.trim().is_empty() {
            section = None;
            continue;
        }

        let (field, value) = match line.split_once(':') {
            Some((field, value)) => (field.to_lowercase(), value.trim()),
            None => {
                if !line.starts_with(' ') {
                    section = Some(line.trim().to_lowercase());
                } else {
                    log::debug!("Failed to parse line: {}", line);
                }
                continue;
            }
        };

        if section.is_none() && !field.starts_with(' ') {
            ret.push((None, field.trim().to_string(), value.to_owned()));
        } else if field.starts_with(' ') {
            ret.push((
                section.clone(),
                field.trim().to_lowercase(),
                value.to_owned(),
            ));
        } else {
            log::debug!("Invalid field {}", field);
        }
    }
    ret
}

/// Extracts upstream metadata from parsed Cabal file lines
pub fn guess_from_cabal_lines(
    lines: impl Iterator<Item = String>,
) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
    let mut repo_url = None;
    let mut repo_branch = None;
    let mut repo_subpath = None;

    let mut results = Vec::new();

    for (section, key, value) in parse_cabal_lines(lines) {
        match (section.as_deref(), key.as_str()) {
            (None, "homepage") => results.push((
                UpstreamDatum::Homepage(value.to_owned()),
                Certainty::Certain,
            )),
            (None, "bug-reports") => results.push((
                UpstreamDatum::BugDatabase(value.to_owned()),
                Certainty::Certain,
            )),
            (None, "name") => {
                results.push((UpstreamDatum::Name(value.to_owned()), Certainty::Certain))
            }
            (None, "maintainer") => results.push((
                UpstreamDatum::Maintainer(Person::from(value.as_str())),
                Certainty::Certain,
            )),
            (None, "copyright") => results.push((
                UpstreamDatum::Copyright(value.to_owned()),
                Certainty::Certain,
            )),
            (None, "license") => {
                results.push((UpstreamDatum::License(value.to_owned()), Certainty::Certain))
            }
            (None, "author") => results.push((
                UpstreamDatum::Author(vec![Person::from(value.as_str())]),
                Certainty::Certain,
            )),
            (None, "synopsis") => {
                results.push((UpstreamDatum::Summary(value.to_owned()), Certainty::Certain))
            }
            (None, "cabal-version") => {}
            (None, "build-depends") => {}
            (None, "build-type") => {}
            (Some("source-repository head"), "location") => repo_url = Some(value.to_owned()),
            (Some("source-repository head"), "branch") => repo_branch = Some(value.to_owned()),
            (Some("source-repository head"), "subdir") => repo_subpath = Some(value.to_owned()),
            (s, _) if s.is_some() && s.unwrap().starts_with("executable ") => {}
            _ => {
                log::debug!("Unknown field {:?} in section {:?}", key, section);
            }
        }
    }

    if let Some(repo_url) = repo_url {
        results.push((
            UpstreamDatum::Repository(crate::vcs::unsplit_vcs_url(&crate::vcs::VcsLocation {
                url: repo_url.parse().unwrap(),
                branch: repo_branch,
                subpath: repo_subpath,
            })),
            Certainty::Certain,
        ));
    }

    Ok(results
        .into_iter()
        .map(|(datum, certainty)| UpstreamDatumWithMetadata {
            datum,
            certainty: Some(certainty),
            origin: None,
        })
        .collect())
}

/// Extracts upstream metadata from a .cabal file
pub fn guess_from_cabal(
    path: &Path,
    _trust_package: bool,
) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);

    guess_from_cabal_lines(
        reader
            .lines()
            .map(|line| line.expect("Failed to read line")),
    )
}

/// Fetches upstream metadata for a package from Hackage
pub async fn remote_hackage_data(package: &str) -> Result<UpstreamMetadata, ProviderError> {
    let mut ret = UpstreamMetadata::new();
    for datum in guess_from_hackage(package).await? {
        ret.insert(datum);
    }
    Ok(ret)
}

/// Extracts upstream metadata from Hackage for a specific package
pub async fn guess_from_hackage(
    package: &str,
) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
    let client = reqwest::Client::builder()
        .user_agent(crate::USER_AGENT)
        .build()
        .unwrap();

    let url: url::Url = format!(
        "https://hackage.haskell.org/package/{}/{}.cabal",
        package, package
    )
    .parse()
    .unwrap();

    match client.get(url).send().await {
        Ok(response) => {
            let bytes = response.bytes().await?;
            let reader = BufReader::new(&bytes[..]);
            guess_from_cabal_lines(
                reader
                    .lines()
                    .map(|line| line.expect("Failed to read line")),
            )
        }
        Err(e) => match e.status() {
            Some(reqwest::StatusCode::NOT_FOUND) => {
                log::warn!("Package {} not found on Hackage", package);
                Ok(Vec::new())
            }
            _ => {
                log::warn!("Failed to fetch package {} from Hackage: {}", package, e);
                Err(ProviderError::Other(format!(
                    "Failed to fetch package {} from Hackage: {}",
                    package, e
                )))
            }
        },
    }
}

/// Hackage (Haskell package repository) metadata provider
pub struct Hackage;

impl Default for Hackage {
    fn default() -> Self {
        Self::new()
    }
}

impl Hackage {
    /// Creates a new Hackage metadata provider
    pub fn new() -> Self {
        Self
    }
}

#[async_trait::async_trait]
impl crate::ThirdPartyRepository for Hackage {
    fn name(&self) -> &'static str {
        "Hackage"
    }

    fn max_supported_certainty(&self) -> Certainty {
        Certainty::Certain
    }

    fn supported_fields(&self) -> &'static [&'static str] {
        &[
            "Homepage",
            "Name",
            "Repository",
            "Maintainer",
            "Copyright",
            "License",
            "Bug-Database",
        ][..]
    }

    async fn guess_metadata(&self, name: &str) -> Result<Vec<UpstreamDatum>, ProviderError> {
        Ok(guess_from_hackage(name)
            .await?
            .into_iter()
            .map(|v| v.datum)
            .collect())
    }
}

#[cfg(test)]
mod parse_tests {
    use super::*;

    #[test]
    fn test_parse_cabal_lines() {
        let lines = r#"Name:          foo
Version:    0.0
License: BSD3
Author: John Doe
Maintainer: John Doe <joe@example.com>
Cabal-Version: >= 1.10
Homepage: https://example.com

Executable program1
  Build-Depends:  HUnit
  Main-Is:       Main.hs

source-repository head
  type: git
  location: https://github.com/example/blah
"#;
        let parsed = parse_cabal_lines(lines.lines().map(|s| s.to_owned()));

        assert_eq!(
            parsed,
            vec![
                (None, "name".to_owned(), "foo".to_owned()),
                (None, "version".to_owned(), "0.0".to_owned()),
                (None, "license".to_owned(), "BSD3".to_owned()),
                (None, "author".to_owned(), "John Doe".to_owned()),
                (
                    None,
                    "maintainer".to_owned(),
                    "John Doe <joe@example.com>".to_owned()
                ),
                (None, "cabal-version".to_owned(), ">= 1.10".to_owned()),
                (
                    None,
                    "homepage".to_owned(),
                    "https://example.com".to_owned()
                ),
                (
                    Some("executable program1".to_owned()),
                    "build-depends".to_owned(),
                    "HUnit".to_owned()
                ),
                (
                    Some("executable program1".to_owned()),
                    "main-is".to_owned(),
                    "Main.hs".to_owned()
                ),
                (
                    Some("source-repository head".to_owned()),
                    "type".to_owned(),
                    "git".to_owned()
                ),
                (
                    Some("source-repository head".to_owned()),
                    "location".to_owned(),
                    "https://github.com/example/blah".to_owned()
                )
            ]
        );
    }
}