upstream-ontologist 0.3.23

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

/// Extracts upstream metadata from Ruby gemspec file
pub async fn guess_from_gemspec(
    path: &Path,
    _settings: &GuesserSettings,
) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
    let file = File::open(path)?;

    let reader = BufReader::new(file);
    let mut results = Vec::new();

    #[derive(Debug)]
    enum GemValue {
        String(String),
        Array(Vec<GemValue>),
    }

    impl GemValue {
        fn as_str(&self) -> Option<&str> {
            match self {
                GemValue::String(s) => Some(s),
                GemValue::Array(_) => None,
            }
        }

        fn as_array(&self) -> Option<&Vec<GemValue>> {
            match self {
                GemValue::String(_) => None,
                GemValue::Array(a) => Some(a),
            }
        }
    }

    fn parse_value(value: &str) -> Result<GemValue, String> {
        let trimmed = value.trim();
        if (trimmed.starts_with('"') && trimmed.ends_with('"'))
            || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
        {
            return Ok(GemValue::String(trimmed[1..trimmed.len() - 1].to_string()));
        } else if trimmed.starts_with('"') || trimmed.starts_with("'.freeze") {
            return Ok(GemValue::String(trimmed[1..].to_string()));
        } else if trimmed.starts_with('[') && trimmed.ends_with(']') {
            let elements = trimmed[1..trimmed.len() - 1]
                .split(',')
                .map(parse_value)
                .collect::<Result<Vec<GemValue>, _>>()?;
            return Ok(GemValue::Array(elements));
        }
        Err(format!("Could not parse value: {}", value))
    }

    for line in reader.lines().map_while(Result::ok) {
        if line.starts_with('#') {
            continue;
        }
        if line.trim().is_empty() {
            continue;
        }
        if line == "Gem::Specification.new do |s|\n" || line == "end\n" {
            continue;
        }
        if let Some(line) = line.strip_prefix("  s.") {
            let (key, rawval) = match line.split_once('=') {
                Some((key, rawval)) => (key.trim(), rawval),
                _ => continue,
            };

            let val = match parse_value(rawval.trim()) {
                Ok(val) => val,
                Err(_) => {
                    debug!("Could not parse value: {}", rawval);
                    continue;
                }
            };

            match key {
                "name" | "version" | "homepage" | "summary" | "description" | "license" => {
                    if let Some(s) = val.as_str() {
                        let datum = match key {
                            "name" => UpstreamDatum::Name(s.to_string()),
                            "version" => UpstreamDatum::Version(s.to_string()),
                            "homepage" => UpstreamDatum::Homepage(s.to_string()),
                            "summary" => UpstreamDatum::Summary(s.to_string()),
                            "description" => UpstreamDatum::Description(s.to_string()),
                            "license" => UpstreamDatum::License(s.to_string()),
                            _ => unreachable!(),
                        };
                        results.push(UpstreamDatumWithMetadata {
                            datum,
                            certainty: Some(Certainty::Certain),
                            origin: Some(path.into()),
                        });
                    } else {
                        debug!("gemspec: expected string for '{}', got {:?}", key, val);
                    }
                }
                "authors" => {
                    if let Some(arr) = val.as_array() {
                        let persons: Vec<Person> = arr
                            .iter()
                            .filter_map(|p| p.as_str().map(Person::from))
                            .collect();
                        if !persons.is_empty() {
                            results.push(UpstreamDatumWithMetadata {
                                datum: UpstreamDatum::Author(persons),
                                certainty: Some(Certainty::Certain),
                                origin: Some(path.into()),
                            });
                        }
                    } else {
                        debug!("gemspec: expected array for 'authors', got {:?}", val);
                    }
                }
                _ => debug!("unknown field {} ({:?}) in gemspec", key, val),
            }
        } else {
            debug!("ignoring unparsable line in {}: {:?}", path.display(), line);
        }
    }

    Ok(results)
}

/// Ruby gem metadata URIs
#[derive(Deserialize)]
pub struct RubygemMetadata {
    /// Changelog URI
    pub changelog_uri: Option<url::Url>,
    /// Source code URI
    pub source_code_uri: Option<url::Url>,
}

/// Ruby gem dependency information
#[derive(Deserialize)]
pub struct RubygemDependency {
    /// Dependency name
    pub name: String,
    /// Version requirements
    pub requirements: String,
}

/// Ruby gem dependencies collection
#[derive(Deserialize)]
pub struct RubygemDependencies {
    /// Development dependencies
    pub development: Vec<RubygemDependency>,
    /// Runtime dependencies
    pub runtime: Vec<RubygemDependency>,
}

/// Complete Ruby gem metadata
#[derive(Deserialize)]
pub struct Rubygem {
    /// Gem name
    pub name: String,
    /// Total downloads
    pub downloads: usize,
    /// Version number
    pub version: String,
    /// Version creation timestamp
    pub version_created_at: String,
    /// Downloads for this version
    pub version_downloads: usize,
    /// Target platform
    pub platform: String,
    /// Authors
    pub authors: String,
    /// Gem description
    pub info: String,
    /// License information
    pub licenses: Vec<String>,
    /// Additional metadata URIs
    pub metadata: RubygemMetadata,
    /// Whether version is yanked
    pub yanked: bool,
    /// SHA hash
    pub sha: String,
    /// Spec SHA hash
    pub spec_sha: String,
    /// Project URI
    pub project_uri: url::Url,
    /// Gem download URI
    pub gem_uri: url::Url,
    /// Homepage URI
    pub homepage_uri: Option<url::Url>,
    /// Wiki URI
    pub wiki_uri: Option<url::Url>,
    /// Documentation URI
    pub documentation_uri: Option<url::Url>,
    /// Mailing list URI
    pub mailing_list_uri: Option<url::Url>,
    /// Source code URI
    pub source_code_uri: Option<url::Url>,
    /// Bug tracker URI
    pub bug_tracker_uri: Option<url::Url>,
    /// Changelog URI
    pub changelog_uri: Option<url::Url>,
    /// Funding URI
    pub funding_uri: Option<url::Url>,
    /// Dependencies
    pub dependencies: RubygemDependencies,
}

impl TryFrom<Rubygem> for UpstreamMetadata {
    type Error = ProviderError;

    fn try_from(gem: Rubygem) -> Result<Self, ProviderError> {
        let mut metadata = UpstreamMetadata::default();
        metadata.insert(UpstreamDatumWithMetadata {
            datum: UpstreamDatum::Name(gem.name),
            certainty: Some(Certainty::Certain),
            origin: None,
        });

        metadata.insert(UpstreamDatumWithMetadata {
            datum: UpstreamDatum::Version(gem.version),
            certainty: Some(Certainty::Certain),
            origin: None,
        });

        metadata.insert(UpstreamDatumWithMetadata {
            datum: UpstreamDatum::Author(vec![Person::from(gem.authors.as_str())]),
            certainty: Some(Certainty::Certain),
            origin: None,
        });

        metadata.insert(UpstreamDatumWithMetadata {
            datum: UpstreamDatum::Homepage(gem.homepage_uri.unwrap_or(gem.project_uri).to_string()),
            certainty: Some(Certainty::Certain),
            origin: None,
        });

        if let Some(wiki_uri) = gem.wiki_uri {
            metadata.insert(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Wiki(wiki_uri.to_string()),
                certainty: Some(Certainty::Certain),
                origin: None,
            });
        }

        if let Some(mailing_list_uri) = gem.mailing_list_uri {
            metadata.insert(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::MailingList(mailing_list_uri.to_string()),
                certainty: Some(Certainty::Certain),
                origin: None,
            });
        }

        if let Some(bug_tracker_uri) = gem.bug_tracker_uri {
            metadata.insert(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::BugDatabase(bug_tracker_uri.to_string()),
                certainty: Some(Certainty::Certain),
                origin: None,
            });
        }

        if let Some(funding_uri) = gem.funding_uri {
            metadata.insert(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Funding(funding_uri.to_string()),
                certainty: Some(Certainty::Certain),
                origin: None,
            });
        }

        if let Some(source_code_uri) = gem.source_code_uri {
            metadata.insert(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Repository(source_code_uri.to_string()),
                certainty: Some(Certainty::Certain),
                origin: None,
            });
        }

        metadata.insert(UpstreamDatumWithMetadata {
            datum: UpstreamDatum::License(gem.licenses.join(", ")),
            certainty: Some(Certainty::Certain),
            origin: None,
        });

        if let Some(documentation_uri) = gem.documentation_uri {
            metadata.insert(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Documentation(documentation_uri.to_string()),
                certainty: Some(Certainty::Certain),
                origin: None,
            });
        }

        if let Some(changelog_uri) = gem.changelog_uri {
            metadata.insert(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Changelog(changelog_uri.to_string()),
                certainty: Some(Certainty::Certain),
                origin: None,
            });
        }

        Ok(metadata)
    }
}

/// Loads Ruby gem data from the RubyGems API
pub async fn load_rubygem(name: &str) -> Result<Option<Rubygem>, ProviderError> {
    let url: url::Url = format!("https://rubygems.org/api/v1/gems/{}.json", name)
        .parse()
        .map_err(|e: url::ParseError| ProviderError::Other(e.to_string()))?;
    let data = crate::load_json_url(&url, None).await?;
    let gem: Rubygem = serde_json::from_value(data)
        .map_err(|e| ProviderError::ParseError(format!("Failed to parse rubygem data: {}", e)))?;
    Ok(Some(gem))
}

/// Retrieves upstream metadata for a Ruby gem from RubyGems
pub async fn remote_rubygem_metadata(name: &str) -> Result<UpstreamMetadata, ProviderError> {
    let gem = load_rubygem(name).await?;

    match gem {
        Some(gem) => gem.try_into(),
        None => Ok(UpstreamMetadata::default()),
    }
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn test_gemspec_array_where_string_expected() {
        let td = tempfile::tempdir().unwrap();
        let path = td.path().join("test.gemspec");
        std::fs::write(
            &path,
            r#"Gem::Specification.new do |s|
  s.name = ['not', 'a', 'string']
  s.version = '1.0'
  s.authors = ['Alice', 'Bob']
end
"#,
        )
        .unwrap();
        let result = super::guess_from_gemspec(&path, &super::GuesserSettings::default())
            .await
            .unwrap();
        // name should be skipped (array, not string), version and authors should parse
        assert!(!result
            .iter()
            .any(|d| matches!(&d.datum, super::UpstreamDatum::Name(_))));
        assert!(result
            .iter()
            .any(|d| matches!(&d.datum, super::UpstreamDatum::Version(v) if v == "1.0")));
        assert!(result
            .iter()
            .any(|d| matches!(&d.datum, super::UpstreamDatum::Author(_))));
    }

    #[test]
    fn test_parse_gem() {
        let gemspec = include_str!("../testdata/rubygem.json");

        let gem: super::Rubygem = serde_json::from_str(gemspec).unwrap();

        assert_eq!(gem.name, "bullet");
    }
}