upstream-ontologist 0.3.23

tracking of upstream project metadata
Documentation
use crate::xmlparse_simplify_namespaces;
use crate::{Certainty, Person, ProviderError, UpstreamDatum, UpstreamDatumWithMetadata};
use std::path::Path;

// Documentation: https://docs.microsoft.com/en-us/nuget/reference/nuspec
/// Parse upstream metadata from a NuGet package specification (.nuspec) file
///
/// Extracts package information like version, description, authors, and URLs from
/// a NuSpec XML file following the NuGet package specification format.
pub async fn guess_from_nuspec(
    path: &Path,
    _trust_package: bool,
) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
    const NAMESPACES: &[&str] = &["http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd"];
    // XML parsing and other logic
    let root = match xmlparse_simplify_namespaces(path, NAMESPACES) {
        Some(root) => root,
        None => {
            return Err(crate::ProviderError::ParseError(
                "Unable to parse nuspec".to_string(),
            ));
        }
    };

    if root.name != "package" {
        return Err(ProviderError::ParseError(format!(
            "Expected 'package' root tag, got {:?}",
            root.name
        )));
    }
    let metadata = match root.get_child("metadata") {
        Some(metadata) => metadata,
        None => {
            return Err(ProviderError::ParseError(
                "Unable to find metadata tag".to_string(),
            ));
        }
    };

    let mut result = Vec::new();

    if let Some(version_tag) = metadata.get_child("version") {
        if let Some(version) = version_tag.get_text() {
            result.push(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Version(version.into_owned()),
                certainty: Some(Certainty::Certain),
                origin: Some(path.into()),
            });
        }
    }

    if let Some(description_tag) = metadata.get_child("description") {
        if let Some(description) = description_tag.get_text() {
            result.push(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Description(description.into_owned()),
                certainty: Some(Certainty::Certain),
                origin: Some(path.into()),
            });
        }
    }

    if let Some(authors_tag) = metadata.get_child("authors") {
        if let Some(authors) = authors_tag.get_text() {
            let authors = authors.split(',').map(Person::from).collect();
            result.push(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Author(authors),
                certainty: Some(Certainty::Certain),
                origin: Some(path.into()),
            });
        }
    }

    if let Some(project_url_tag) = metadata.get_child("projectUrl") {
        if let Some(project_url) = project_url_tag.get_text() {
            let parsed_url = match url::Url::parse(&project_url) {
                Ok(url) => Some(url),
                Err(e) => {
                    log::warn!(
                        "nuspec: failed to parse projectUrl {:?}: {}",
                        project_url,
                        e
                    );
                    None
                }
            };
            let repo_url = if let Some(parsed_url) = &parsed_url {
                crate::vcs::guess_repo_from_url(parsed_url, None).await
            } else {
                None
            };
            if let Some(repo_url) = repo_url {
                result.push(UpstreamDatumWithMetadata {
                    datum: UpstreamDatum::Repository(repo_url),
                    certainty: Some(Certainty::Confident),
                    origin: Some(path.into()),
                });
            }
            result.push(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Homepage(project_url.into_owned()),
                certainty: Some(Certainty::Certain),
                origin: Some(path.into()),
            });
        }
    }

    if let Some(license_tag) = metadata.get_child("license") {
        if let Some(license) = license_tag.get_text() {
            result.push(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::License(license.into_owned()),
                certainty: Some(Certainty::Certain),
                origin: Some(path.into()),
            });
        }
    }

    if let Some(copyright_tag) = metadata.get_child("copyright") {
        if let Some(copyright) = copyright_tag.get_text() {
            result.push(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Copyright(copyright.into_owned()),
                certainty: Some(Certainty::Certain),
                origin: Some(path.into()),
            });
        }
    }

    if let Some(title_tag) = metadata.get_child("title") {
        if let Some(title) = title_tag.get_text() {
            result.push(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Name(title.into_owned()),
                certainty: Some(Certainty::Likely),
                origin: Some(path.into()),
            });
        }
    }

    if let Some(summary_tag) = metadata.get_child("summary") {
        if let Some(summary) = summary_tag.get_text() {
            result.push(UpstreamDatumWithMetadata {
                datum: UpstreamDatum::Summary(summary.into_owned()),
                certainty: Some(Certainty::Likely),
                origin: Some(path.into()),
            });
        }
    }

    if let Some(repository_tag) = metadata.get_child("repository") {
        if let Some(repo_url) = repository_tag.attributes.get("url") {
            match repo_url.parse() {
                Ok(url) => {
                    let branch = repository_tag.attributes.get("branch");
                    result.push(UpstreamDatumWithMetadata {
                        datum: UpstreamDatum::Repository(crate::vcs::unsplit_vcs_url(
                            &crate::vcs::VcsLocation {
                                url,
                                branch: branch.cloned(),
                                subpath: None,
                            },
                        )),
                        certainty: Some(Certainty::Certain),
                        origin: Some(path.into()),
                    });
                }
                Err(e) => {
                    log::warn!(
                        "nuspec: failed to parse repository URL {:?}: {}",
                        repo_url,
                        e
                    );
                }
            }
        }
    }

    Ok(result)
}

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

    fn write_nuspec(td: &tempfile::TempDir, content: &str) -> std::path::PathBuf {
        let path = td.path().join("test.nuspec");
        std::fs::write(&path, content).unwrap();
        path
    }

    #[tokio::test]
    async fn test_wrong_root_element() {
        let td = tempfile::tempdir().unwrap();
        let path = write_nuspec(&td, r#"<project><metadata/></project>"#);
        let result = guess_from_nuspec(&path, false).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_no_metadata_element() {
        let td = tempfile::tempdir().unwrap();
        let path = write_nuspec(&td, r#"<package><files/></package>"#);
        let result = guess_from_nuspec(&path, false).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_invalid_project_url() {
        let td = tempfile::tempdir().unwrap();
        let path = write_nuspec(
            &td,
            r#"<package><metadata>
  <projectUrl>not a valid url at all</projectUrl>
</metadata></package>"#,
        );
        let result = guess_from_nuspec(&path, false).await.unwrap();
        // Should still produce a Homepage datum even if URL parsing fails
        assert!(result
            .iter()
            .any(|d| matches!(&d.datum, UpstreamDatum::Homepage(_))));
    }

    #[tokio::test]
    async fn test_invalid_repository_url() {
        let td = tempfile::tempdir().unwrap();
        let path = write_nuspec(
            &td,
            r#"<package><metadata>
  <repository url=":::invalid"/>
</metadata></package>"#,
        );
        let result = guess_from_nuspec(&path, false).await.unwrap();
        // Should not panic, just skip the invalid URL
        assert!(result.is_empty());
    }
}