Skip to main content

upstream_ontologist/
lib.rs

1// pyo3 macros use a gil-refs feature
2#![allow(unexpected_cfgs)]
3#![deny(missing_docs)]
4#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))]
5
6use futures::stream::StreamExt;
7use futures::Stream;
8use lazy_regex::regex;
9use log::{debug, warn};
10use percent_encoding::utf8_percent_encode;
11#[cfg(feature = "pyo3")]
12use pyo3::{
13    exceptions::{PyRuntimeError, PyTypeError, PyValueError},
14    prelude::*,
15    types::PyDict,
16};
17use reqwest::header::HeaderMap;
18use serde::ser::SerializeSeq;
19use std::cmp::Ordering;
20use std::fs::File;
21use std::io::Read;
22use std::pin::Pin;
23use std::str::FromStr;
24
25use std::path::{Path, PathBuf};
26use url::Url;
27
28static USER_AGENT: &str = concat!("upstream-ontologist/", env!("CARGO_PKG_VERSION"));
29
30/// Functionality for extrapolating upstream metadata from various sources
31pub mod extrapolate;
32/// Support for various code forges (GitHub, GitLab, etc.)
33pub mod forges;
34/// GitHub API and raw file access helpers
35pub mod github;
36/// Homepage URL detection and validation
37pub mod homepage;
38/// HTTP utilities for fetching remote resources
39pub mod http;
40/// Various metadata providers for different programming languages and ecosystems
41pub mod providers;
42/// README file parsing and metadata extraction
43pub mod readme;
44/// Integration with Repology package repository aggregator
45pub mod repology;
46/// Version control system utilities and URL handling
47pub mod vcs;
48/// Command-line interface for version control operations
49pub mod vcs_command;
50
51#[cfg(test)]
52mod upstream_tests {
53    include!(concat!(env!("OUT_DIR"), "/upstream_tests.rs"));
54}
55
56#[cfg(test)]
57mod readme_tests {
58    include!(concat!(env!("OUT_DIR"), "/readme_tests.rs"));
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
62/// Certainty levels for the data
63pub enum Certainty {
64    /// This datum is possibly correct, but it is a guess
65    Possible,
66
67    /// This datum is likely to be correct, but we are not sure
68    Likely,
69
70    /// We're confident about this datum, but there is a chance it is wrong
71    Confident,
72
73    /// We're certain about this datum
74    Certain,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
78/// Origin of the data
79pub enum Origin {
80    /// Read from a file
81    Path(PathBuf),
82
83    /// Read from a URL
84    Url(url::Url),
85
86    /// Other origin; described by a string
87    Other(String),
88}
89
90impl std::fmt::Display for Origin {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            Origin::Path(path) => write!(f, "{}", path.display()),
94            Origin::Url(url) => write!(f, "{}", url),
95            Origin::Other(s) => write!(f, "{}", s),
96        }
97    }
98}
99
100impl From<&std::path::Path> for Origin {
101    fn from(path: &std::path::Path) -> Self {
102        Origin::Path(path.to_path_buf())
103    }
104}
105
106impl From<std::path::PathBuf> for Origin {
107    fn from(path: std::path::PathBuf) -> Self {
108        Origin::Path(path)
109    }
110}
111
112impl From<url::Url> for Origin {
113    fn from(url: url::Url) -> Self {
114        Origin::Url(url)
115    }
116}
117
118#[cfg(feature = "pyo3")]
119impl<'py> IntoPyObject<'py> for &Origin {
120    type Target = PyAny;
121    type Output = Bound<'py, Self::Target>;
122    type Error = PyErr;
123
124    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
125        match self {
126            Origin::Path(path) => Ok(path.to_str().unwrap().into_pyobject(py)?.into_any()),
127            Origin::Url(url) => Ok(url.to_string().into_pyobject(py)?.into_any()),
128            Origin::Other(s) => Ok(s.into_pyobject(py)?.into_any()),
129        }
130    }
131}
132
133#[cfg(feature = "pyo3")]
134impl<'py> IntoPyObject<'py> for Origin {
135    type Target = PyAny;
136    type Output = Bound<'py, Self::Target>;
137    type Error = PyErr;
138
139    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
140        match self {
141            Origin::Path(path) => Ok(path.to_str().unwrap().into_pyobject(py)?.into_any()),
142            Origin::Url(url) => Ok(url.to_string().into_pyobject(py)?.into_any()),
143            Origin::Other(s) => Ok(s.into_pyobject(py)?.into_any()),
144        }
145    }
146}
147
148#[cfg(feature = "pyo3")]
149impl<'py> FromPyObject<'_, 'py> for Origin {
150    type Error = PyErr;
151
152    fn extract(ob: pyo3::Borrowed<'_, 'py, PyAny>) -> PyResult<Self> {
153        if let Ok(path) = ob.extract::<PathBuf>() {
154            Ok(Origin::Path(path))
155        } else if let Ok(s) = ob.extract::<String>() {
156            Ok(Origin::Other(s))
157        } else {
158            Err(PyTypeError::new_err("expected str or Path"))
159        }
160    }
161}
162
163impl FromStr for Certainty {
164    type Err = String;
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        match s {
167            "certain" => Ok(Certainty::Certain),
168            "confident" => Ok(Certainty::Confident),
169            "likely" => Ok(Certainty::Likely),
170            "possible" => Ok(Certainty::Possible),
171            _ => Err(format!("unknown certainty: {}", s)),
172        }
173    }
174}
175
176impl std::fmt::Display for Certainty {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            Certainty::Certain => write!(f, "certain"),
180            Certainty::Confident => write!(f, "confident"),
181            Certainty::Likely => write!(f, "likely"),
182            Certainty::Possible => write!(f, "possible"),
183        }
184    }
185}
186
187#[cfg(feature = "pyo3")]
188impl<'py> FromPyObject<'_, 'py> for Certainty {
189    type Error = PyErr;
190
191    fn extract(ob: pyo3::Borrowed<'_, 'py, PyAny>) -> PyResult<Self> {
192        let o: String = ob.extract::<String>()?;
193        o.parse().map_err(PyValueError::new_err)
194    }
195}
196
197/// Represents a person (author, maintainer, etc.) with optional contact information
198#[derive(Default, Clone, Debug, PartialEq, Eq)]
199pub struct Person {
200    /// The person's name
201    pub name: Option<String>,
202    /// The person's email address
203    pub email: Option<String>,
204    /// The person's URL (e.g., personal website, profile)
205    pub url: Option<String>,
206}
207
208/// A bug tracker for an upstream project.
209#[derive(Clone, Debug, PartialEq, Eq)]
210pub enum BugTracker {
211    /// GitHub issue tracker.
212    GitHub {
213        /// GitHub repository owner.
214        owner: String,
215        /// GitHub repository name.
216        repo: String,
217    },
218    /// GitLab issue tracker.
219    GitLab {
220        /// Base URL of the GitLab instance.
221        base_url: String,
222        /// Path to the project on the GitLab instance.
223        path: String,
224    },
225    /// Launchpad bug tracker.
226    Launchpad {
227        /// Launchpad project name.
228        project: String,
229    },
230    /// SourceForge bug tracker.
231    SourceForge {
232        /// SourceForge project name.
233        project: String,
234    },
235    /// Other bug tracker.
236    Other {
237        /// URL to browse/query bugs.
238        database_url: String,
239        /// URL to submit new bugs.
240        submit_url: Option<String>,
241    },
242}
243
244impl serde::ser::Serialize for Person {
245    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
246    where
247        S: serde::ser::Serializer,
248    {
249        let mut map = serde_norway::Mapping::new();
250        if let Some(name) = &self.name {
251            map.insert(
252                serde_norway::Value::String("name".to_string()),
253                serde_norway::Value::String(name.to_string()),
254            );
255        }
256        if let Some(email) = &self.email {
257            map.insert(
258                serde_norway::Value::String("email".to_string()),
259                serde_norway::Value::String(email.to_string()),
260            );
261        }
262        if let Some(url) = &self.url {
263            map.insert(
264                serde_norway::Value::String("url".to_string()),
265                serde_norway::Value::String(url.to_string()),
266            );
267        }
268        let tag = serde_norway::value::TaggedValue {
269            tag: serde_norway::value::Tag::new("!Person"),
270            value: serde_norway::Value::Mapping(map),
271        };
272        tag.serialize(serializer)
273    }
274}
275
276impl<'a> serde::de::Deserialize<'a> for Person {
277    fn deserialize<D>(deserializer: D) -> Result<Person, D::Error>
278    where
279        D: serde::de::Deserializer<'a>,
280    {
281        let value = serde_norway::Value::deserialize(deserializer)?;
282        if let serde_norway::Value::Mapping(map) = value {
283            let mut name = None;
284            let mut email = None;
285            let mut url = None;
286            for (k, v) in map {
287                match k {
288                    serde_norway::Value::String(k) => match k.as_str() {
289                        "name" => {
290                            if let serde_norway::Value::String(s) = v {
291                                name = Some(s);
292                            }
293                        }
294                        "email" => {
295                            if let serde_norway::Value::String(s) = v {
296                                email = Some(s);
297                            }
298                        }
299                        "url" => {
300                            if let serde_norway::Value::String(s) = v {
301                                url = Some(s);
302                            }
303                        }
304                        n => {
305                            return Err(serde::de::Error::custom(format!("unknown key: {}", n)));
306                        }
307                    },
308                    n => {
309                        return Err(serde::de::Error::custom(format!(
310                            "expected string key, got {:?}",
311                            n
312                        )));
313                    }
314                }
315            }
316            Ok(Person { name, email, url })
317        } else {
318            Err(serde::de::Error::custom("expected mapping"))
319        }
320    }
321}
322
323impl std::fmt::Display for Person {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        write!(f, "{}", self.name.as_ref().unwrap_or(&"".to_string()))?;
326        if let Some(email) = &self.email {
327            write!(f, " <{}>", email)?;
328        }
329        if let Some(url) = &self.url {
330            write!(f, " ({})", url)?;
331        }
332        Ok(())
333    }
334}
335
336impl From<&str> for Person {
337    fn from(text: &str) -> Self {
338        let mut text = text.replace(" at ", "@");
339        text = text.replace(" -at- ", "@");
340        text = text.replace(" -dot- ", ".");
341        text = text.replace("[AT]", "@");
342
343        if text.contains('(') && text.ends_with(')') {
344            if let Some((p1, p2)) = text[..text.len() - 1].split_once('(') {
345                if p2.starts_with("https://") || p2.starts_with("http://") {
346                    let url = p2.to_string();
347                    if let Some((name, email)) = parseaddr(p1) {
348                        Person {
349                            name: Some(name),
350                            email: Some(email),
351                            url: Some(url),
352                        }
353                    } else {
354                        Person {
355                            name: Some(p1.to_string()),
356                            url: Some(url),
357                            ..Default::default()
358                        }
359                    }
360                } else if p2.contains('@') {
361                    Person {
362                        name: Some(p1.to_string()),
363                        email: Some(p2.to_string()),
364                        ..Default::default()
365                    }
366                } else {
367                    Person {
368                        name: Some(text.to_string()),
369                        ..Default::default()
370                    }
371                }
372            } else {
373                Person {
374                    name: Some(text.to_string()),
375                    ..Default::default()
376                }
377            }
378        } else if text.contains('<') {
379            if let Some((name, email)) = parseaddr(text.as_str()) {
380                return Person {
381                    name: Some(name),
382                    email: Some(email),
383                    ..Default::default()
384                };
385            } else {
386                Person {
387                    name: Some(text.to_string()),
388                    ..Default::default()
389                }
390            }
391        } else if text.contains('@') && !text.contains(' ') {
392            return Person {
393                email: Some(text),
394                ..Default::default()
395            };
396        } else {
397            Person {
398                name: Some(text),
399                ..Default::default()
400            }
401        }
402    }
403}
404
405#[cfg(feature = "pyo3")]
406impl<'py> IntoPyObject<'py> for &Person {
407    type Target = PyAny;
408    type Output = Bound<'py, Self::Target>;
409    type Error = PyErr;
410
411    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
412        let m = PyModule::import(py, "upstream_ontologist")?;
413        let person_cls = m.getattr("Person")?;
414        person_cls.call1((self.name.as_ref(), self.email.as_ref(), self.url.as_ref()))
415    }
416}
417
418fn parseaddr(text: &str) -> Option<(String, String)> {
419    let re = regex!(r"(.*?)\s*<([^<>]+)>");
420    if let Some(captures) = re.captures(text) {
421        let name = captures.get(1).map(|m| m.as_str().trim().to_string());
422        let email = captures.get(2).map(|m| m.as_str().trim().to_string());
423        if let (Some(name), Some(email)) = (name, email) {
424            return Some((name, email));
425        }
426    }
427    None
428}
429
430#[cfg(feature = "pyo3")]
431impl<'py> FromPyObject<'_, 'py> for Person {
432    type Error = PyErr;
433
434    fn extract(ob: pyo3::Borrowed<'_, 'py, PyAny>) -> PyResult<Self> {
435        let name = ob.getattr("name")?.extract::<Option<String>>()?;
436        let email = ob.getattr("email")?.extract::<Option<String>>()?;
437        let url = ob.getattr("url")?.extract::<Option<String>>()?;
438        Ok(Person { name, email, url })
439    }
440}
441
442/// Represents various types of upstream metadata for a software project
443#[derive(Clone, Debug, PartialEq, Eq)]
444pub enum UpstreamDatum {
445    /// Name of the project.
446    ///
447    /// This is a brief name of the project, as it would be used in a URL.
448    /// Generally speaking it would be lowercase, and may contain dashes or underscores.
449    /// It would commonly be the name of the repository.
450    Name(String),
451
452    /// URL to project homepage.
453    ///
454    /// This is the URL to the project's homepage, which may be a website or a
455    /// repository. It is not a URL to a specific file or page, but rather the main
456    /// entry point for the project.
457    Homepage(String),
458
459    /// URL to the project's source code repository.
460    ///
461    /// This is the URL to the project's source code repository, as it would be used
462    /// in a command line tool to clone the repository. It may be a URL to a specific
463    /// branch or tag, but it is generally the URL to the main repository.
464    Repository(String),
465
466    /// URL to browse the project's source code repository
467    ///
468    /// This is the URL to the project's source code repository, as it would be used
469    /// in a web browser to browse the repository. It may be a URL to a specific
470    /// branch or tag, but it is generally the URL to the main repository.
471    RepositoryBrowse(String),
472
473    /// Long description of the project
474    ///
475    /// This is a long description of the project, which may be several paragraphs
476    /// long. It is generally a more detailed description of the project than the
477    /// summary.
478    Description(String),
479
480    /// Short summary of the project (one line)
481    ///
482    /// This is a short summary of the project, which is generally one line long.
483    /// It is generally a brief description of the project, and may be used in
484    /// search results or in a list of projects.
485    Summary(String),
486
487    /// License name or SPDX identifier
488    ///
489    /// This is the name of the license under which the project is released. It may
490    /// be a full license name, or it may be an SPDX identifier (preferred).
491    ///
492    /// See <https://spdx.org/licenses/> for a list of SPDX identifiers.
493    License(String),
494
495    /// List of authors
496    ///
497    /// This is a list of authors of the project, which may be a list of names,
498    /// email addresses, or URLs.
499    Author(Vec<Person>),
500
501    /// List of maintainers
502    ///
503    /// This is a list of maintainers of the project, which may be a list of names,
504    /// email addresses, or URLs.
505    Maintainer(Person),
506
507    /// URL of the project's issue tracker
508    ///
509    /// This is the URL to the project's issue tracker, which may be a bug tracker,
510    /// feature tracker, or other type of issue tracker. It is not a URL to a
511    /// specific issue, but rather the main entry point for the issue tracker.
512    BugDatabase(String),
513
514    /// URL to submit a new bug
515    ///
516    /// This is the URL to submit a new bug to the project's issue tracker. It
517    /// may be a URL to a specific page or form.
518    ///
519    /// It can also be an email address (mailto:...), in which case it is the email address to send
520    /// the bug report to.
521    BugSubmit(String),
522
523    /// URL to the project's contact page or email address
524    ///
525    /// This is the URL to the project's contact page, which may be a web page or
526    /// an email address. It is not a URL to a specific file or page, but rather
527    /// the main entry point for the contact page.
528    Contact(String),
529
530    /// Cargo crate name
531    ///
532    /// If the project is a Rust crate, this is the name of the crate on
533    /// crates.io. It is not a URL to the crate, but rather the name of the
534    /// crate.
535    CargoCrate(String),
536
537    /// Name of the security page name
538    ///
539    /// This would be the name of a markdown file in the source directory
540    /// that contains security information about the project. It is not a URL to
541    /// a specific file or page, but rather the name of the file.
542    SecurityMD(String),
543
544    /// URL to the security page or email address
545    ///
546    /// This is the URL to the project's security page, which may be a web page or
547    /// an email address. It is not a URL to a specific file or page, but rather
548    /// the main entry point for the security page.
549    ///
550    /// It can also be an email address (mailto:...), in which case it is the email address to send
551    /// the security report to.
552    SecurityContact(String),
553
554    /// Last version of the project
555    ///
556    /// This is the last version of the project, which would generally be a version string
557    ///
558    /// There is no guarantee that this is the last version of the project.
559    ///
560    /// There is no guarantee about which versioning scheme is used, e.g. it may be
561    /// a semantic version, a date-based version, or a commit hash.
562    Version(String),
563
564    /// List of keywords
565    ///
566    /// This is a list of keywords that describe the project. It may be a list of
567    /// words, phrases, or tags.
568    Keywords(Vec<String>),
569
570    /// Copyright notice
571    ///
572    /// This is the copyright notice for the project, which may be a list of
573    /// copyright holders, years, or other information.
574    Copyright(String),
575
576    /// URL to the project's documentation
577    ///
578    /// This is the URL to the project's documentation, which may be a web page or
579    /// a file. It is not a URL to a specific file or page, but rather the main
580    /// entry point for the documentation.
581    Documentation(String),
582
583    /// URL to the project's API documentation
584    ///
585    /// This is the URL to the project's API documentation, which may be a web page or
586    /// a file. It is not a URL to a specific file or page, but rather the main
587    /// entry point for the API documentation.
588    APIDocumentation(String),
589
590    /// Go import path
591    ///
592    /// If this is a Go project, this is the import path for the project. It is not a URL
593    /// to the project, but rather the import path.
594    GoImportPath(String),
595
596    /// URL to the project's download page
597    ///
598    /// This is the URL to the project's download page, which may be a web page or
599    /// a file. It is not a URL to a specific file or page, but rather the main
600    /// entry point for the download page.
601    Download(String),
602
603    /// URL to the project's wiki
604    ///
605    /// This is the URL to the project's wiki.
606    Wiki(String),
607
608    /// URL to the project's mailing list
609    ///
610    /// This is the URL to the project's mailing list, which may be a web page or
611    /// an email address. It is not a URL to a specific file or page, but rather
612    /// the main entry point for the mailing list.
613    ///
614    /// It can also be an email address (mailto:...), in which case it is the email address to send
615    /// email to to subscribe to the mailing list.
616    MailingList(String),
617
618    /// SourceForge project name
619    ///
620    /// This is the name of the project on SourceForge. It is not a URL to the
621    /// project, but rather the name of the project.
622    SourceForgeProject(String),
623
624    /// If this project is provided by a specific archive, this is the name of the archive.
625    ///
626    /// E.g. "CRAN", "CPAN", "PyPI", "RubyGems", "NPM", etc.
627    Archive(String),
628
629    /// URL to a demo instance
630    ///
631    /// This is the URL to a demo instance of the project. This instance will be loaded
632    /// with sample data, and will be used to demonstrate the project. It is not
633    /// a full instance of the project - the Webservice field should be used for that.
634    Demo(String),
635
636    /// PHP PECL package name
637    ///
638    /// If this is a PHP project, this is the name of the package on PECL. It is not a URL
639    /// to the package, but rather the name of the package.
640    PeclPackage(String),
641
642    /// Description of funding sources
643    ///
644    /// This is a description of the funding sources for the project. It may be a
645    /// URL to a page that describes the funding sources, or it may be a list of
646    /// funding sources.
647    ///
648    /// Note that this is different from the Donation field, which is a URL to a
649    /// donation page.
650    Funding(String),
651
652    /// URL to the changelog
653    ///
654    /// This is the URL to the project's changelog, which may be a web page or
655    /// a file. No guarantee is made about the format of the changelog, but it is
656    /// generally a file that contains a list of changes made to the project.
657    Changelog(String),
658
659    /// Haskell package name
660    ///
661    /// If this is a Haskell project, this is the name of the package on Hackage. It is not a URL
662    /// to the package, but rather the name of the package.
663    HaskellPackage(String),
664
665    /// Debian ITP (Intent To Package) bug number
666    ///
667    /// This is the bug number of the ITP bug in the Debian bug tracker. It is not a URL
668    /// to the bug, but rather the bug number.
669    DebianITP(i32),
670
671    /// List of URLs to screenshots
672    ///
673    /// This is a list of URLs to screenshots of the project. It will be a list of
674    /// URLs, which may be web pages or images.
675    Screenshots(Vec<String>),
676
677    /// Name of registry
678    Registry(Vec<(String, String)>),
679
680    /// Recommended way to cite the software
681    ///
682    /// This is the recommended way to cite the software, which may be a URL or a
683    /// DOI.
684    CiteAs(String),
685
686    /// Link for donations (e.g. Paypal, Libera, etc)
687    ///
688    /// This is a URL to a donation page, which should be a web page.
689    /// It is different from the Funding field, which describes
690    /// the funding the project has received.
691    Donation(String),
692
693    /// Link to a life instance of the webservice
694    ///
695    /// This is the URL to the live instance of the project. This should generally
696    /// be the canonical instance of the project.
697    ///
698    /// For demo instances, see the Demo field.
699    Webservice(String),
700
701    /// Name of the buildsystem used
702    ///
703    /// This is the name of the buildsystem used by the project. E.g. "make", "cmake",
704    /// "meson", etc
705    BuildSystem(String),
706
707    /// FAQ
708    ///
709    /// This is the URL to the project's FAQ, which may be a web page or a file.
710    FAQ(String),
711}
712
713/// Upstream datum with additional metadata about its origin and certainty
714#[derive(PartialEq, Eq, Debug, Clone)]
715pub struct UpstreamDatumWithMetadata {
716    /// The upstream datum itself
717    pub datum: UpstreamDatum,
718    /// Where this datum was obtained from
719    pub origin: Option<Origin>,
720    /// How certain we are about this datum
721    pub certainty: Option<Certainty>,
722}
723
724fn known_bad_url(value: &str) -> bool {
725    if value.contains("${") {
726        return true;
727    }
728    false
729}
730
731impl UpstreamDatum {
732    /// Returns the field name for this datum type
733    pub fn field(&self) -> &'static str {
734        match self {
735            UpstreamDatum::Summary(..) => "Summary",
736            UpstreamDatum::Description(..) => "Description",
737            UpstreamDatum::Name(..) => "Name",
738            UpstreamDatum::Homepage(..) => "Homepage",
739            UpstreamDatum::Repository(..) => "Repository",
740            UpstreamDatum::RepositoryBrowse(..) => "Repository-Browse",
741            UpstreamDatum::License(..) => "License",
742            UpstreamDatum::Author(..) => "Author",
743            UpstreamDatum::BugDatabase(..) => "Bug-Database",
744            UpstreamDatum::BugSubmit(..) => "Bug-Submit",
745            UpstreamDatum::Contact(..) => "Contact",
746            UpstreamDatum::CargoCrate(..) => "Cargo-Crate",
747            UpstreamDatum::SecurityMD(..) => "Security-MD",
748            UpstreamDatum::SecurityContact(..) => "Security-Contact",
749            UpstreamDatum::Version(..) => "Version",
750            UpstreamDatum::Keywords(..) => "Keywords",
751            UpstreamDatum::Maintainer(..) => "Maintainer",
752            UpstreamDatum::Copyright(..) => "Copyright",
753            UpstreamDatum::Documentation(..) => "Documentation",
754            UpstreamDatum::APIDocumentation(..) => "API-Documentation",
755            UpstreamDatum::GoImportPath(..) => "Go-Import-Path",
756            UpstreamDatum::Download(..) => "Download",
757            UpstreamDatum::Wiki(..) => "Wiki",
758            UpstreamDatum::MailingList(..) => "MailingList",
759            UpstreamDatum::SourceForgeProject(..) => "SourceForge-Project",
760            UpstreamDatum::Archive(..) => "Archive",
761            UpstreamDatum::Demo(..) => "Demo",
762            UpstreamDatum::PeclPackage(..) => "Pecl-Package",
763            UpstreamDatum::HaskellPackage(..) => "Haskell-Package",
764            UpstreamDatum::Funding(..) => "Funding",
765            UpstreamDatum::Changelog(..) => "Changelog",
766            UpstreamDatum::DebianITP(..) => "Debian-ITP",
767            UpstreamDatum::Screenshots(..) => "Screenshots",
768            UpstreamDatum::Registry(..) => "Registry",
769            UpstreamDatum::CiteAs(..) => "Cite-As",
770            UpstreamDatum::Donation(..) => "Donation",
771            UpstreamDatum::Webservice(..) => "Webservice",
772            UpstreamDatum::BuildSystem(..) => "BuildSystem",
773            UpstreamDatum::FAQ(..) => "FAQ",
774        }
775    }
776
777    /// Returns the string value if this datum contains a simple string
778    pub fn as_str(&self) -> Option<&str> {
779        match self {
780            UpstreamDatum::Name(s) => Some(s),
781            UpstreamDatum::Homepage(s) => Some(s),
782            UpstreamDatum::Repository(s) => Some(s),
783            UpstreamDatum::RepositoryBrowse(s) => Some(s),
784            UpstreamDatum::Description(s) => Some(s),
785            UpstreamDatum::Summary(s) => Some(s),
786            UpstreamDatum::License(s) => Some(s),
787            UpstreamDatum::BugDatabase(s) => Some(s),
788            UpstreamDatum::BugSubmit(s) => Some(s),
789            UpstreamDatum::Contact(s) => Some(s),
790            UpstreamDatum::CargoCrate(s) => Some(s),
791            UpstreamDatum::SecurityMD(s) => Some(s),
792            UpstreamDatum::SecurityContact(s) => Some(s),
793            UpstreamDatum::Version(s) => Some(s),
794            UpstreamDatum::Documentation(s) => Some(s),
795            UpstreamDatum::APIDocumentation(s) => Some(s),
796            UpstreamDatum::GoImportPath(s) => Some(s),
797            UpstreamDatum::Download(s) => Some(s),
798            UpstreamDatum::Wiki(s) => Some(s),
799            UpstreamDatum::MailingList(s) => Some(s),
800            UpstreamDatum::SourceForgeProject(s) => Some(s),
801            UpstreamDatum::Archive(s) => Some(s),
802            UpstreamDatum::Demo(s) => Some(s),
803            UpstreamDatum::PeclPackage(s) => Some(s),
804            UpstreamDatum::HaskellPackage(s) => Some(s),
805            UpstreamDatum::Author(..) => None,
806            UpstreamDatum::Maintainer(..) => None,
807            UpstreamDatum::Keywords(..) => None,
808            UpstreamDatum::Copyright(c) => Some(c),
809            UpstreamDatum::Funding(f) => Some(f),
810            UpstreamDatum::Changelog(c) => Some(c),
811            UpstreamDatum::Screenshots(..) => None,
812            UpstreamDatum::DebianITP(_c) => None,
813            UpstreamDatum::CiteAs(c) => Some(c),
814            UpstreamDatum::Registry(_) => None,
815            UpstreamDatum::Donation(d) => Some(d),
816            UpstreamDatum::Webservice(w) => Some(w),
817            UpstreamDatum::BuildSystem(b) => Some(b),
818            UpstreamDatum::FAQ(f) => Some(f),
819        }
820    }
821
822    /// Converts the datum to a URL if applicable
823    pub fn to_url(&self) -> Option<url::Url> {
824        match self {
825            UpstreamDatum::Name(..) => None,
826            UpstreamDatum::Homepage(s) => Some(s.parse().ok()?),
827            UpstreamDatum::Repository(s) => Some(s.parse().ok()?),
828            UpstreamDatum::RepositoryBrowse(s) => Some(s.parse().ok()?),
829            UpstreamDatum::Description(..) => None,
830            UpstreamDatum::Summary(..) => None,
831            UpstreamDatum::License(..) => None,
832            UpstreamDatum::BugDatabase(s) => Some(s.parse().ok()?),
833            UpstreamDatum::BugSubmit(s) => Some(s.parse().ok()?),
834            UpstreamDatum::Contact(..) => None,
835            UpstreamDatum::CargoCrate(s) => Some(s.parse().ok()?),
836            UpstreamDatum::SecurityMD(..) => None,
837            UpstreamDatum::SecurityContact(..) => None,
838            UpstreamDatum::Version(..) => None,
839            UpstreamDatum::Documentation(s) => Some(s.parse().ok()?),
840            UpstreamDatum::APIDocumentation(s) => Some(s.parse().ok()?),
841            UpstreamDatum::GoImportPath(_s) => None,
842            UpstreamDatum::Download(s) => Some(s.parse().ok()?),
843            UpstreamDatum::Wiki(s) => Some(s.parse().ok()?),
844            UpstreamDatum::MailingList(s) => Some(s.parse().ok()?),
845            UpstreamDatum::SourceForgeProject(s) => Some(s.parse().ok()?),
846            UpstreamDatum::Archive(s) => Some(s.parse().ok()?),
847            UpstreamDatum::Demo(s) => Some(s.parse().ok()?),
848            UpstreamDatum::PeclPackage(_s) => None,
849            UpstreamDatum::HaskellPackage(_s) => None,
850            UpstreamDatum::Author(..) => None,
851            UpstreamDatum::Maintainer(..) => None,
852            UpstreamDatum::Keywords(..) => None,
853            UpstreamDatum::Copyright(..) => None,
854            UpstreamDatum::Funding(s) => Some(s.parse().ok()?),
855            UpstreamDatum::Changelog(s) => Some(s.parse().ok()?),
856            UpstreamDatum::Screenshots(..) => None,
857            UpstreamDatum::DebianITP(_c) => None,
858            UpstreamDatum::Registry(_r) => None,
859            UpstreamDatum::CiteAs(_c) => None,
860            UpstreamDatum::Donation(_d) => None,
861            UpstreamDatum::Webservice(w) => Some(w.parse().ok()?),
862            UpstreamDatum::BuildSystem(_) => None,
863            UpstreamDatum::FAQ(f) => Some(f.parse().ok()?),
864        }
865    }
866
867    /// Returns the person if this datum contains person information
868    pub fn as_person(&self) -> Option<&Person> {
869        match self {
870            UpstreamDatum::Maintainer(p) => Some(p),
871            _ => None,
872        }
873    }
874
875    /// Checks if this datum is known to be a bad guess based on common patterns
876    pub fn known_bad_guess(&self) -> bool {
877        match self {
878            UpstreamDatum::BugDatabase(s) | UpstreamDatum::BugSubmit(s) => {
879                if known_bad_url(s) {
880                    return true;
881                }
882                let url = match Url::parse(s) {
883                    Ok(url) => url,
884                    Err(_) => return false,
885                };
886                if url.host_str() == Some("bugzilla.gnome.org") {
887                    return true;
888                }
889                if url.host_str() == Some("bugs.freedesktop.org") {
890                    return true;
891                }
892                if url.path().ends_with("/sign_in") {
893                    return true;
894                }
895            }
896            UpstreamDatum::Repository(s) => {
897                if known_bad_url(s) {
898                    return true;
899                }
900                let url = match Url::parse(s) {
901                    Ok(url) => url,
902                    Err(_) => return false,
903                };
904                if url.host_str() == Some("anongit.kde.org") {
905                    return true;
906                }
907                if url.host_str() == Some("git.gitorious.org") {
908                    return true;
909                }
910                if url.path().ends_with("/sign_in") {
911                    return true;
912                }
913            }
914            UpstreamDatum::Homepage(s) => {
915                let url = match Url::parse(s) {
916                    Ok(url) => url,
917                    Err(_) => return false,
918                };
919
920                if url.host_str() == Some("pypi.org") {
921                    return true;
922                }
923                if url.host_str() == Some("rubygems.org") {
924                    return true;
925                }
926            }
927            UpstreamDatum::RepositoryBrowse(s) => {
928                if known_bad_url(s) {
929                    return true;
930                }
931                let url = match Url::parse(s) {
932                    Ok(url) => url,
933                    Err(_) => return false,
934                };
935                if url.host_str() == Some("cgit.kde.org") {
936                    return true;
937                }
938                if url.path().ends_with("/sign_in") {
939                    return true;
940                }
941            }
942            UpstreamDatum::Author(authors) => {
943                for a in authors {
944                    if let Some(name) = &a.name {
945                        let lc = name.to_lowercase();
946                        if lc.contains("unknown") {
947                            return true;
948                        }
949                        if lc.contains("maintainer") {
950                            return true;
951                        }
952                        if lc.contains("contributor") {
953                            return true;
954                        }
955                    }
956                }
957            }
958            UpstreamDatum::Name(s) => {
959                let lc = s.to_lowercase();
960                if lc.contains("unknown") {
961                    return true;
962                }
963                if lc == "package" {
964                    return true;
965                }
966            }
967            UpstreamDatum::Version(s) => {
968                let lc = s.to_lowercase();
969                if ["devel", "unknown"].contains(&lc.as_str()) {
970                    return true;
971                }
972            }
973            _ => (),
974        }
975        false
976    }
977}
978
979impl std::fmt::Display for UpstreamDatum {
980    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
981        match self {
982            UpstreamDatum::Name(s) => write!(f, "Name: {}", s),
983            UpstreamDatum::Homepage(s) => write!(f, "Homepage: {}", s),
984            UpstreamDatum::Repository(s) => write!(f, "Repository: {}", s),
985            UpstreamDatum::RepositoryBrowse(s) => write!(f, "RepositoryBrowse: {}", s),
986            UpstreamDatum::Description(s) => write!(f, "Description: {}", s),
987            UpstreamDatum::Summary(s) => write!(f, "Summary: {}", s),
988            UpstreamDatum::License(s) => write!(f, "License: {}", s),
989            UpstreamDatum::BugDatabase(s) => write!(f, "BugDatabase: {}", s),
990            UpstreamDatum::BugSubmit(s) => write!(f, "BugSubmit: {}", s),
991            UpstreamDatum::Contact(s) => write!(f, "Contact: {}", s),
992            UpstreamDatum::CargoCrate(s) => write!(f, "CargoCrate: {}", s),
993            UpstreamDatum::SecurityMD(s) => write!(f, "SecurityMD: {}", s),
994            UpstreamDatum::SecurityContact(s) => write!(f, "SecurityContact: {}", s),
995            UpstreamDatum::Version(s) => write!(f, "Version: {}", s),
996            UpstreamDatum::Documentation(s) => write!(f, "Documentation: {}", s),
997            UpstreamDatum::APIDocumentation(s) => write!(f, "API-Documentation: {}", s),
998            UpstreamDatum::GoImportPath(s) => write!(f, "GoImportPath: {}", s),
999            UpstreamDatum::Download(s) => write!(f, "Download: {}", s),
1000            UpstreamDatum::Wiki(s) => write!(f, "Wiki: {}", s),
1001            UpstreamDatum::MailingList(s) => write!(f, "MailingList: {}", s),
1002            UpstreamDatum::SourceForgeProject(s) => write!(f, "SourceForgeProject: {}", s),
1003            UpstreamDatum::Archive(s) => write!(f, "Archive: {}", s),
1004            UpstreamDatum::Demo(s) => write!(f, "Demo: {}", s),
1005            UpstreamDatum::PeclPackage(s) => write!(f, "PeclPackage: {}", s),
1006            UpstreamDatum::Author(authors) => {
1007                write!(
1008                    f,
1009                    "Author: {}",
1010                    authors
1011                        .iter()
1012                        .map(|a| a.to_string())
1013                        .collect::<Vec<_>>()
1014                        .join(", ")
1015                )
1016            }
1017            UpstreamDatum::Maintainer(maintainer) => {
1018                write!(f, "Maintainer: {}", maintainer)
1019            }
1020            UpstreamDatum::Keywords(keywords) => {
1021                write!(
1022                    f,
1023                    "Keywords: {}",
1024                    keywords
1025                        .iter()
1026                        .map(|a| a.to_string())
1027                        .collect::<Vec<_>>()
1028                        .join(", ")
1029                )
1030            }
1031            UpstreamDatum::Copyright(s) => {
1032                write!(f, "Copyright: {}", s)
1033            }
1034            UpstreamDatum::Funding(s) => {
1035                write!(f, "Funding: {}", s)
1036            }
1037            UpstreamDatum::Changelog(s) => {
1038                write!(f, "Changelog: {}", s)
1039            }
1040            UpstreamDatum::DebianITP(s) => {
1041                write!(f, "DebianITP: {}", s)
1042            }
1043            UpstreamDatum::HaskellPackage(p) => {
1044                write!(f, "HaskellPackage: {}", p)
1045            }
1046            UpstreamDatum::Screenshots(s) => {
1047                write!(f, "Screenshots: {}", s.join(", "))
1048            }
1049            UpstreamDatum::Registry(r) => {
1050                write!(f, "Registry:")?;
1051                for (k, v) in r {
1052                    write!(f, "  - Name: {}", k)?;
1053                    write!(f, "    Entry: {}", v)?;
1054                }
1055                Ok(())
1056            }
1057            UpstreamDatum::CiteAs(c) => {
1058                write!(f, "Cite-As: {}", c)
1059            }
1060            UpstreamDatum::Donation(d) => {
1061                write!(f, "Donation: {}", d)
1062            }
1063            UpstreamDatum::Webservice(w) => {
1064                write!(f, "Webservice: {}", w)
1065            }
1066            UpstreamDatum::BuildSystem(bs) => {
1067                write!(f, "BuildSystem: {}", bs)
1068            }
1069            UpstreamDatum::FAQ(faq) => {
1070                write!(f, "FAQ: {}", faq)
1071            }
1072        }
1073    }
1074}
1075
1076impl serde::ser::Serialize for UpstreamDatum {
1077    fn serialize<S: serde::ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1078        match self {
1079            UpstreamDatum::Name(s) => serializer.serialize_str(s),
1080            UpstreamDatum::Homepage(s) => serializer.serialize_str(s),
1081            UpstreamDatum::Repository(s) => serializer.serialize_str(s),
1082            UpstreamDatum::RepositoryBrowse(s) => serializer.serialize_str(s),
1083            UpstreamDatum::Description(s) => serializer.serialize_str(s),
1084            UpstreamDatum::Summary(s) => serializer.serialize_str(s),
1085            UpstreamDatum::License(s) => serializer.serialize_str(s),
1086            UpstreamDatum::BugDatabase(s) => serializer.serialize_str(s),
1087            UpstreamDatum::BugSubmit(s) => serializer.serialize_str(s),
1088            UpstreamDatum::Contact(s) => serializer.serialize_str(s),
1089            UpstreamDatum::CargoCrate(s) => serializer.serialize_str(s),
1090            UpstreamDatum::SecurityMD(s) => serializer.serialize_str(s),
1091            UpstreamDatum::SecurityContact(s) => serializer.serialize_str(s),
1092            UpstreamDatum::Version(s) => serializer.serialize_str(s),
1093            UpstreamDatum::Documentation(s) => serializer.serialize_str(s),
1094            UpstreamDatum::APIDocumentation(s) => serializer.serialize_str(s),
1095            UpstreamDatum::GoImportPath(s) => serializer.serialize_str(s),
1096            UpstreamDatum::Download(s) => serializer.serialize_str(s),
1097            UpstreamDatum::Wiki(s) => serializer.serialize_str(s),
1098            UpstreamDatum::MailingList(s) => serializer.serialize_str(s),
1099            UpstreamDatum::SourceForgeProject(s) => serializer.serialize_str(s),
1100            UpstreamDatum::Archive(s) => serializer.serialize_str(s),
1101            UpstreamDatum::Demo(s) => serializer.serialize_str(s),
1102            UpstreamDatum::PeclPackage(s) => serializer.serialize_str(s),
1103            UpstreamDatum::Author(authors) => {
1104                let mut seq = serializer.serialize_seq(Some(authors.len()))?;
1105                for a in authors {
1106                    seq.serialize_element(a)?;
1107                }
1108                seq.end()
1109            }
1110            UpstreamDatum::Maintainer(maintainer) => maintainer.serialize(serializer),
1111            UpstreamDatum::Keywords(keywords) => {
1112                let mut seq = serializer.serialize_seq(Some(keywords.len()))?;
1113                for a in keywords {
1114                    seq.serialize_element(a)?;
1115                }
1116                seq.end()
1117            }
1118            UpstreamDatum::Copyright(s) => serializer.serialize_str(s),
1119            UpstreamDatum::Funding(s) => serializer.serialize_str(s),
1120            UpstreamDatum::Changelog(s) => serializer.serialize_str(s),
1121            UpstreamDatum::DebianITP(s) => serializer.serialize_i32(*s),
1122            UpstreamDatum::HaskellPackage(p) => serializer.serialize_str(p),
1123            UpstreamDatum::Screenshots(s) => {
1124                let mut seq = serializer.serialize_seq(Some(s.len()))?;
1125                for s in s {
1126                    seq.serialize_element(s)?;
1127                }
1128                seq.end()
1129            }
1130            UpstreamDatum::CiteAs(c) => serializer.serialize_str(c),
1131            UpstreamDatum::Registry(r) => {
1132                let mut l = serializer.serialize_seq(Some(r.len()))?;
1133                for (k, v) in r {
1134                    let mut m = serde_norway::Mapping::new();
1135                    m.insert(
1136                        serde_norway::Value::String("Name".to_string()),
1137                        serde_norway::to_value(k).unwrap(),
1138                    );
1139                    m.insert(
1140                        serde_norway::Value::String("Entry".to_string()),
1141                        serde_norway::to_value(v).unwrap(),
1142                    );
1143                    l.serialize_element(&m)?;
1144                }
1145                l.end()
1146            }
1147            UpstreamDatum::Donation(d) => serializer.serialize_str(d),
1148            UpstreamDatum::Webservice(w) => serializer.serialize_str(w),
1149            UpstreamDatum::BuildSystem(bs) => serializer.serialize_str(bs),
1150            UpstreamDatum::FAQ(faq) => serializer.serialize_str(faq),
1151        }
1152    }
1153}
1154
1155/// Collection of upstream metadata with convenience methods for accessing specific fields
1156#[derive(PartialEq, Eq, Debug, Clone)]
1157pub struct UpstreamMetadata(Vec<UpstreamDatumWithMetadata>);
1158
1159impl UpstreamMetadata {
1160    /// Creates a new empty UpstreamMetadata instance
1161    pub fn new() -> Self {
1162        UpstreamMetadata(Vec::new())
1163    }
1164
1165    /// Returns true if the metadata collection is empty
1166    pub fn is_empty(&self) -> bool {
1167        self.0.is_empty()
1168    }
1169
1170    /// Returns the number of metadata items
1171    pub fn len(&self) -> usize {
1172        self.0.len()
1173    }
1174
1175    /// Sorts the metadata items by field name
1176    pub fn sort(&mut self) {
1177        self.0.sort_by(|a, b| a.datum.field().cmp(b.datum.field()));
1178    }
1179
1180    /// Creates a new UpstreamMetadata from a vector of data
1181    pub fn from_data(data: Vec<UpstreamDatumWithMetadata>) -> Self {
1182        Self(data)
1183    }
1184
1185    /// Returns a mutable reference to the underlying data vector
1186    pub fn mut_items(&mut self) -> &mut Vec<UpstreamDatumWithMetadata> {
1187        &mut self.0
1188    }
1189
1190    /// Returns an iterator over the metadata items
1191    pub fn iter(&self) -> impl Iterator<Item = &UpstreamDatumWithMetadata> {
1192        self.0.iter()
1193    }
1194
1195    /// Returns a mutable iterator over the metadata items
1196    pub fn mut_iter(&mut self) -> impl Iterator<Item = &mut UpstreamDatumWithMetadata> {
1197        self.0.iter_mut()
1198    }
1199
1200    /// Gets a metadata item by field name
1201    pub fn get(&self, field: &str) -> Option<&UpstreamDatumWithMetadata> {
1202        self.0.iter().find(|d| d.datum.field() == field)
1203    }
1204
1205    /// Gets a mutable reference to a metadata item by field name
1206    pub fn get_mut(&mut self, field: &str) -> Option<&mut UpstreamDatumWithMetadata> {
1207        self.0.iter_mut().find(|d| d.datum.field() == field)
1208    }
1209
1210    /// Inserts a new metadata item
1211    pub fn insert(&mut self, datum: UpstreamDatumWithMetadata) {
1212        self.0.push(datum);
1213    }
1214
1215    /// Checks if a field exists in the metadata
1216    pub fn contains_key(&self, field: &str) -> bool {
1217        self.get(field).is_some()
1218    }
1219
1220    /// Removes metadata items that are known to be bad guesses
1221    pub fn discard_known_bad(&mut self) {
1222        self.0.retain(|d| !d.datum.known_bad_guess());
1223    }
1224
1225    /// Updates the metadata with new items, returning the replaced items
1226    pub fn update(
1227        &mut self,
1228        new_items: impl Iterator<Item = UpstreamDatumWithMetadata>,
1229    ) -> Vec<UpstreamDatumWithMetadata> {
1230        update_from_guesses(&mut self.0, new_items)
1231    }
1232
1233    /// Removes and returns a metadata item by field name
1234    pub fn remove(&mut self, field: &str) -> Option<UpstreamDatumWithMetadata> {
1235        let index = self.0.iter().position(|d| d.datum.field() == field)?;
1236        Some(self.0.remove(index))
1237    }
1238
1239    /// Gets the project name
1240    pub fn name(&self) -> Option<&str> {
1241        self.get("Name").and_then(|d| d.datum.as_str())
1242    }
1243
1244    /// Gets the project homepage URL
1245    pub fn homepage(&self) -> Option<&str> {
1246        self.get("Homepage").and_then(|d| d.datum.as_str())
1247    }
1248
1249    /// Gets the repository URL
1250    pub fn repository(&self) -> Option<&str> {
1251        self.get("Repository").and_then(|d| d.datum.as_str())
1252    }
1253
1254    /// Gets the repository browse URL
1255    pub fn repository_browse(&self) -> Option<&str> {
1256        self.get("Repository-Browse").and_then(|d| d.datum.as_str())
1257    }
1258
1259    /// Gets the project description
1260    pub fn description(&self) -> Option<&str> {
1261        self.get("Description").and_then(|d| d.datum.as_str())
1262    }
1263
1264    /// Gets the project summary
1265    pub fn summary(&self) -> Option<&str> {
1266        self.get("Summary").and_then(|d| d.datum.as_str())
1267    }
1268
1269    /// Gets the project license
1270    pub fn license(&self) -> Option<&str> {
1271        self.get("License").and_then(|d| d.datum.as_str())
1272    }
1273
1274    /// Gets the list of authors
1275    pub fn author(&self) -> Option<&Vec<Person>> {
1276        self.get("Author").map(|d| match &d.datum {
1277            UpstreamDatum::Author(authors) => authors,
1278            _ => unreachable!(),
1279        })
1280    }
1281
1282    /// Gets the maintainer information
1283    pub fn maintainer(&self) -> Option<&Person> {
1284        self.get("Maintainer").map(|d| match &d.datum {
1285            UpstreamDatum::Maintainer(maintainer) => maintainer,
1286            _ => unreachable!(),
1287        })
1288    }
1289
1290    /// Gets the bug database URL
1291    pub fn bug_database(&self) -> Option<&str> {
1292        self.get("Bug-Database").and_then(|d| d.datum.as_str())
1293    }
1294
1295    /// Gets the bug submission URL or email
1296    pub fn bug_submit(&self) -> Option<&str> {
1297        self.get("Bug-Submit").and_then(|d| d.datum.as_str())
1298    }
1299
1300    /// Gets the contact information
1301    pub fn contact(&self) -> Option<&str> {
1302        self.get("Contact").and_then(|d| d.datum.as_str())
1303    }
1304
1305    /// Gets the Cargo crate name
1306    pub fn cargo_crate(&self) -> Option<&str> {
1307        self.get("Cargo-Crate").and_then(|d| d.datum.as_str())
1308    }
1309
1310    /// Gets the security markdown file name
1311    pub fn security_md(&self) -> Option<&str> {
1312        self.get("Security-MD").and_then(|d| d.datum.as_str())
1313    }
1314
1315    /// Gets the security contact information
1316    pub fn security_contact(&self) -> Option<&str> {
1317        self.get("Security-Contact").and_then(|d| d.datum.as_str())
1318    }
1319
1320    /// Gets the project version
1321    pub fn version(&self) -> Option<&str> {
1322        self.get("Version").and_then(|d| d.datum.as_str())
1323    }
1324
1325    /// Gets the list of keywords
1326    pub fn keywords(&self) -> Option<&Vec<String>> {
1327        self.get("Keywords").map(|d| match &d.datum {
1328            UpstreamDatum::Keywords(keywords) => keywords,
1329            _ => unreachable!(),
1330        })
1331    }
1332
1333    /// Gets the documentation URL
1334    pub fn documentation(&self) -> Option<&str> {
1335        self.get("Documentation").and_then(|d| d.datum.as_str())
1336    }
1337
1338    /// Gets the API documentation URL
1339    pub fn api_documentation(&self) -> Option<&str> {
1340        self.get("API-Documentation").and_then(|d| d.datum.as_str())
1341    }
1342
1343    /// Gets the Go import path
1344    pub fn go_import_path(&self) -> Option<&str> {
1345        self.get("Go-Import-Path").and_then(|d| d.datum.as_str())
1346    }
1347
1348    /// Gets the download URL
1349    pub fn download(&self) -> Option<&str> {
1350        self.get("Download").and_then(|d| d.datum.as_str())
1351    }
1352
1353    /// Gets the wiki URL
1354    pub fn wiki(&self) -> Option<&str> {
1355        self.get("Wiki").and_then(|d| d.datum.as_str())
1356    }
1357
1358    /// Gets the mailing list URL or email
1359    pub fn mailing_list(&self) -> Option<&str> {
1360        self.get("MailingList").and_then(|d| d.datum.as_str())
1361    }
1362
1363    /// Gets the SourceForge project name
1364    pub fn sourceforge_project(&self) -> Option<&str> {
1365        self.get("SourceForge-Project")
1366            .and_then(|d| d.datum.as_str())
1367    }
1368
1369    /// Gets the archive name (e.g., CRAN, PyPI)
1370    pub fn archive(&self) -> Option<&str> {
1371        self.get("Archive").and_then(|d| d.datum.as_str())
1372    }
1373
1374    /// Gets the demo URL
1375    pub fn demo(&self) -> Option<&str> {
1376        self.get("Demo").and_then(|d| d.datum.as_str())
1377    }
1378
1379    /// Gets the PECL package name
1380    pub fn pecl_package(&self) -> Option<&str> {
1381        self.get("Pecl-Package").and_then(|d| d.datum.as_str())
1382    }
1383
1384    /// Gets the Haskell package name
1385    pub fn haskell_package(&self) -> Option<&str> {
1386        self.get("Haskell-Package").and_then(|d| d.datum.as_str())
1387    }
1388
1389    /// Gets funding information
1390    pub fn funding(&self) -> Option<&str> {
1391        self.get("Funding").and_then(|d| d.datum.as_str())
1392    }
1393
1394    /// Gets the changelog URL
1395    pub fn changelog(&self) -> Option<&str> {
1396        self.get("Changelog").and_then(|d| d.datum.as_str())
1397    }
1398
1399    /// Gets the Debian ITP bug number
1400    pub fn debian_itp(&self) -> Option<i32> {
1401        self.get("Debian-ITP").and_then(|d| match &d.datum {
1402            UpstreamDatum::DebianITP(itp) => Some(*itp),
1403            _ => unreachable!(),
1404        })
1405    }
1406
1407    /// Gets the list of screenshot URLs
1408    pub fn screenshots(&self) -> Option<&Vec<String>> {
1409        self.get("Screenshots").map(|d| match &d.datum {
1410            UpstreamDatum::Screenshots(screenshots) => screenshots,
1411            _ => unreachable!(),
1412        })
1413    }
1414
1415    /// Gets the donation URL
1416    pub fn donation(&self) -> Option<&str> {
1417        self.get("Donation").and_then(|d| d.datum.as_str())
1418    }
1419
1420    /// Gets the citation information
1421    pub fn cite_as(&self) -> Option<&str> {
1422        self.get("Cite-As").and_then(|d| d.datum.as_str())
1423    }
1424
1425    /// Gets the registry entries
1426    pub fn registry(&self) -> Option<&Vec<(String, String)>> {
1427        self.get("Registry").map(|d| match &d.datum {
1428            UpstreamDatum::Registry(registry) => registry,
1429            _ => unreachable!(),
1430        })
1431    }
1432
1433    /// Gets the webservice URL
1434    pub fn webservice(&self) -> Option<&str> {
1435        self.get("Webservice").and_then(|d| d.datum.as_str())
1436    }
1437
1438    /// Gets the build system name
1439    pub fn buildsystem(&self) -> Option<&str> {
1440        self.get("BuildSystem").and_then(|d| d.datum.as_str())
1441    }
1442
1443    /// Gets the copyright information
1444    pub fn copyright(&self) -> Option<&str> {
1445        self.get("Copyright").and_then(|d| d.datum.as_str())
1446    }
1447
1448    /// Gets the FAQ URL
1449    pub fn faq(&self) -> Option<&str> {
1450        self.get("FAQ").and_then(|d| d.datum.as_str())
1451    }
1452}
1453
1454impl std::ops::Index<&str> for UpstreamMetadata {
1455    type Output = UpstreamDatumWithMetadata;
1456
1457    fn index(&self, index: &str) -> &Self::Output {
1458        self.get(index).unwrap()
1459    }
1460}
1461
1462impl Default for UpstreamMetadata {
1463    fn default() -> Self {
1464        UpstreamMetadata::new()
1465    }
1466}
1467
1468impl Iterator for UpstreamMetadata {
1469    type Item = UpstreamDatumWithMetadata;
1470
1471    fn next(&mut self) -> Option<Self::Item> {
1472        self.0.pop()
1473    }
1474}
1475
1476impl From<UpstreamDatum> for UpstreamDatumWithMetadata {
1477    fn from(d: UpstreamDatum) -> Self {
1478        UpstreamDatumWithMetadata {
1479            datum: d,
1480            certainty: None,
1481            origin: None,
1482        }
1483    }
1484}
1485
1486impl From<Vec<UpstreamDatumWithMetadata>> for UpstreamMetadata {
1487    fn from(v: Vec<UpstreamDatumWithMetadata>) -> Self {
1488        UpstreamMetadata(v)
1489    }
1490}
1491
1492impl From<Vec<UpstreamDatum>> for UpstreamMetadata {
1493    fn from(v: Vec<UpstreamDatum>) -> Self {
1494        UpstreamMetadata(
1495            v.into_iter()
1496                .map(|d| UpstreamDatumWithMetadata {
1497                    datum: d,
1498                    certainty: None,
1499                    origin: None,
1500                })
1501                .collect(),
1502        )
1503    }
1504}
1505
1506impl From<UpstreamMetadata> for Vec<UpstreamDatumWithMetadata> {
1507    fn from(v: UpstreamMetadata) -> Self {
1508        v.0
1509    }
1510}
1511
1512impl From<UpstreamMetadata> for Vec<UpstreamDatum> {
1513    fn from(v: UpstreamMetadata) -> Self {
1514        v.0.into_iter().map(|d| d.datum).collect()
1515    }
1516}
1517
1518impl serde::ser::Serialize for UpstreamMetadata {
1519    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1520    where
1521        S: serde::ser::Serializer,
1522    {
1523        let mut map = serde_norway::Mapping::new();
1524        for datum in &self.0 {
1525            map.insert(
1526                serde_norway::Value::String(datum.datum.field().to_string()),
1527                serde_norway::to_value(datum).unwrap(),
1528            );
1529        }
1530        map.serialize(serializer)
1531    }
1532}
1533
1534#[cfg(feature = "pyo3")]
1535impl<'py> IntoPyObject<'py> for &UpstreamDatumWithMetadata {
1536    type Target = PyAny;
1537    type Output = Bound<'py, Self::Target>;
1538    type Error = PyErr;
1539
1540    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
1541        let m = PyModule::import(py, "upstream_ontologist.guess")?;
1542
1543        let cls = m.getattr("UpstreamDatum")?;
1544
1545        let (field, py_datum) = self
1546            .datum
1547            .into_pyobject(py)?
1548            .extract::<(String, Bound<PyAny>)>()?;
1549
1550        let kwargs = pyo3::types::PyDict::new(py);
1551        kwargs.set_item("certainty", self.certainty.map(|x| x.to_string()))?;
1552        kwargs.set_item("origin", self.origin.as_ref())?;
1553
1554        cls.call((field, py_datum), Some(&kwargs))
1555    }
1556}
1557
1558impl serde::ser::Serialize for UpstreamDatumWithMetadata {
1559    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1560    where
1561        S: serde::ser::Serializer,
1562    {
1563        UpstreamDatum::serialize(&self.datum, serializer)
1564    }
1565}
1566
1567/// Trait for providing upstream metadata
1568pub trait UpstreamDataProvider {
1569    /// Provides upstream metadata from a given path
1570    fn provide(
1571        path: &std::path::Path,
1572        trust_package: bool,
1573    ) -> dyn Iterator<Item = (UpstreamDatum, Certainty)>;
1574}
1575
1576/// Errors that can occur when loading JSON from HTTP
1577#[derive(Debug)]
1578pub enum HTTPJSONError {
1579    /// HTTP request error
1580    HTTPError(reqwest::Error),
1581    /// Request timed out
1582    Timeout(tokio::time::Duration),
1583    /// HTTP error response
1584    Error {
1585        /// The URL that failed
1586        url: reqwest::Url,
1587        /// HTTP status code
1588        status: u16,
1589        /// The response object
1590        response: Box<reqwest::Response>,
1591    },
1592}
1593
1594impl std::fmt::Display for HTTPJSONError {
1595    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1596        match self {
1597            HTTPJSONError::HTTPError(e) => write!(f, "{}", e),
1598            HTTPJSONError::Timeout(timeout) => write!(f, "Timeout after {:?}", timeout),
1599            HTTPJSONError::Error {
1600                url,
1601                status,
1602                response: _,
1603            } => write!(f, "HTTP error {} for {}:", status, url,),
1604        }
1605    }
1606}
1607
1608/// Loads JSON data from a URL with optional timeout
1609pub async fn load_json_url(
1610    http_url: &Url,
1611    timeout: Option<std::time::Duration>,
1612) -> Result<serde_json::Value, HTTPJSONError> {
1613    let mut headers = HeaderMap::new();
1614    headers.insert(reqwest::header::ACCEPT, "application/json".parse().unwrap());
1615
1616    let client = crate::http::build_client()
1617        .default_headers(headers)
1618        .build()
1619        .map_err(HTTPJSONError::HTTPError)?;
1620
1621    let http_url: reqwest::Url = Into::<String>::into(http_url.clone()).parse().unwrap();
1622
1623    let request = client
1624        .get(http_url)
1625        .build()
1626        .map_err(HTTPJSONError::HTTPError)?;
1627
1628    let timeout = timeout.unwrap_or(std::time::Duration::from_secs(30));
1629
1630    let response = tokio::time::timeout(timeout, client.execute(request))
1631        .await
1632        .map_err(|_| HTTPJSONError::Timeout(timeout))?
1633        .map_err(HTTPJSONError::HTTPError)?;
1634
1635    if !response.status().is_success() {
1636        return Err(HTTPJSONError::Error {
1637            url: response.url().clone(),
1638            status: response.status().as_u16(),
1639            response: Box::new(response),
1640        });
1641    }
1642
1643    let json_contents: serde_json::Value =
1644        response.json().await.map_err(HTTPJSONError::HTTPError)?;
1645
1646    Ok(json_contents)
1647}
1648
1649fn xmlparse_simplify_namespaces(path: &Path, namespaces: &[&str]) -> Option<xmltree::Element> {
1650    let namespaces = namespaces
1651        .iter()
1652        .map(|ns| format!("{{{}{}}}", ns, ns))
1653        .collect::<Vec<_>>();
1654    let mut f = std::fs::File::open(path).unwrap();
1655    let mut buf = Vec::new();
1656    f.read_to_end(&mut buf).ok()?;
1657    let mut tree = xmltree::Element::parse(std::io::Cursor::new(buf)).ok()?;
1658    simplify_namespaces(&mut tree, &namespaces);
1659    Some(tree)
1660}
1661
1662fn simplify_namespaces(element: &mut xmltree::Element, namespaces: &[String]) {
1663    use xmltree::XMLNode;
1664    element.prefix = None;
1665    if let Some(namespace) = namespaces.iter().find(|&ns| element.name.starts_with(ns)) {
1666        element.name = element.name[namespace.len()..].to_string();
1667    }
1668    for child in &mut element.children {
1669        if let XMLNode::Element(ref mut child_element) = child {
1670            simplify_namespaces(child_element, namespaces);
1671        }
1672    }
1673}
1674
1675/// Errors that can occur when canonicalizing URLs
1676pub enum CanonicalizeError {
1677    /// URL is invalid with reason
1678    InvalidUrl(Url, String),
1679    /// URL cannot be verified with reason
1680    Unverifiable(Url, String),
1681    /// Request was rate limited
1682    RateLimited(Url),
1683}
1684
1685#[derive(Debug)]
1686/// Error when manipulating URL path segments
1687pub struct PathSegmentError;
1688
1689/// Checks if a URL is canonical by following redirects
1690pub async fn check_url_canonical(url: &Url) -> Result<Url, CanonicalizeError> {
1691    if url.scheme() != "http" && url.scheme() != "https" {
1692        return Err(CanonicalizeError::Unverifiable(
1693            url.clone(),
1694            format!("Unsupported scheme {}", url.scheme()),
1695        ));
1696    }
1697
1698    let client = crate::http::build_client()
1699        .build()
1700        .map_err(|e| CanonicalizeError::Unverifiable(url.clone(), format!("HTTP error {}", e)))?;
1701
1702    let response =
1703        client.get(url.as_str()).send().await.map_err(|e| {
1704            CanonicalizeError::Unverifiable(url.clone(), format!("HTTP error {}", e))
1705        })?;
1706
1707    match response.status() {
1708        status if status.is_success() => Ok(response.url().clone()),
1709        status if status == reqwest::StatusCode::TOO_MANY_REQUESTS => {
1710            Err(CanonicalizeError::RateLimited(url.clone()))
1711        }
1712        status if status == reqwest::StatusCode::NOT_FOUND => Err(CanonicalizeError::InvalidUrl(
1713            url.clone(),
1714            format!("Not found: {}", response.status()),
1715        )),
1716        status if status.is_server_error() => Err(CanonicalizeError::Unverifiable(
1717            url.clone(),
1718            format!("Server down: {}", response.status()),
1719        )),
1720        _ => Err(CanonicalizeError::Unverifiable(
1721            url.clone(),
1722            format!("Unknown HTTP error {}", response.status()),
1723        )),
1724    }
1725}
1726
1727/// Creates a new URL with the specified path segments
1728pub fn with_path_segments(url: &Url, path_segments: &[&str]) -> Result<Url, PathSegmentError> {
1729    let mut url = url.clone();
1730    url.path_segments_mut()
1731        .map_err(|_| PathSegmentError)?
1732        .clear()
1733        .extend(path_segments.iter().copied());
1734    Ok(url)
1735}
1736
1737/// Return `url` rewritten to use the https scheme.
1738///
1739/// `Url::set_scheme` refuses to change a non-special scheme such as `ssh`
1740/// into a special one such as `https`, so the URL is rebuilt from its parts
1741/// instead. Userinfo, query and fragment are dropped.
1742fn to_https_url(url: &Url) -> Option<Url> {
1743    let host = url.host_str()?;
1744    let port = url.port().map(|p| format!(":{}", p)).unwrap_or_default();
1745    Url::parse(&format!("https://{}{}{}", host, port, url.path())).ok()
1746}
1747
1748/// Trait for different code forges (GitHub, GitLab, etc.)
1749#[async_trait::async_trait]
1750pub trait Forge: Send + Sync {
1751    /// Whether the repository browse URL can be used as homepage
1752    fn repository_browse_can_be_homepage(&self) -> bool;
1753
1754    /// Returns the name of the forge
1755    fn name(&self) -> &'static str;
1756
1757    /// Derives the bug database URL from a bug submission URL
1758    fn bug_database_url_from_bug_submit_url(&self, _url: &Url) -> Option<Url> {
1759        None
1760    }
1761
1762    /// Derives the bug submission URL from a bug database URL
1763    fn bug_submit_url_from_bug_database_url(&self, _url: &Url) -> Option<Url> {
1764        None
1765    }
1766
1767    /// Checks if a bug database URL is canonical
1768    async fn check_bug_database_canonical(&self, url: &Url) -> Result<Url, CanonicalizeError> {
1769        Err(CanonicalizeError::Unverifiable(
1770            url.clone(),
1771            "Not implemented".to_string(),
1772        ))
1773    }
1774
1775    /// Checks if a bug submission URL is canonical
1776    async fn check_bug_submit_url_canonical(&self, url: &Url) -> Result<Url, CanonicalizeError> {
1777        Err(CanonicalizeError::Unverifiable(
1778            url.clone(),
1779            "Not implemented".to_string(),
1780        ))
1781    }
1782
1783    /// Gets the bug database URL from an issue URL
1784    fn bug_database_from_issue_url(&self, _url: &Url) -> Option<Url> {
1785        None
1786    }
1787
1788    /// Gets the bug database URL from a repository URL
1789    fn bug_database_url_from_repo_url(&self, _url: &Url) -> Option<Url> {
1790        None
1791    }
1792
1793    /// Gets the repository URL from a merge request URL
1794    fn repo_url_from_merge_request_url(&self, _url: &Url) -> Option<Url> {
1795        None
1796    }
1797
1798    /// Extends metadata with forge-specific information
1799    async fn extend_metadata(
1800        &self,
1801        _metadata: &mut Vec<UpstreamDatumWithMetadata>,
1802        _project: &str,
1803        _max_certainty: Option<Certainty>,
1804    ) {
1805    }
1806}
1807
1808/// GitHub forge implementation
1809pub struct GitHub;
1810
1811impl Default for GitHub {
1812    fn default() -> Self {
1813        Self::new()
1814    }
1815}
1816
1817impl GitHub {
1818    /// Creates a new GitHub forge instance
1819    pub fn new() -> Self {
1820        Self
1821    }
1822}
1823
1824#[async_trait::async_trait]
1825impl Forge for GitHub {
1826    fn name(&self) -> &'static str {
1827        "GitHub"
1828    }
1829
1830    fn repository_browse_can_be_homepage(&self) -> bool {
1831        true
1832    }
1833
1834    fn bug_database_url_from_bug_submit_url(&self, url: &Url) -> Option<Url> {
1835        assert_eq!(url.host(), Some(url::Host::Domain("github.com")));
1836        let path_elements = url.path_segments().unwrap().collect::<Vec<_>>();
1837
1838        if path_elements.len() != 3 && path_elements.len() != 4 {
1839            return None;
1840        }
1841        if path_elements[2] != "issues" {
1842            return None;
1843        }
1844
1845        let url = to_https_url(url)?;
1846
1847        Some(with_path_segments(&url, &path_elements[0..3]).unwrap())
1848    }
1849
1850    fn bug_submit_url_from_bug_database_url(&self, url: &Url) -> Option<Url> {
1851        assert_eq!(url.host(), Some(url::Host::Domain("github.com")));
1852        let path_elements = url.path_segments().unwrap().collect::<Vec<_>>();
1853
1854        if path_elements.len() != 3 {
1855            return None;
1856        }
1857        if path_elements[2] != "issues" {
1858            return None;
1859        }
1860
1861        let mut url = to_https_url(url)?;
1862        url.path_segments_mut().unwrap().push("new");
1863        Some(url)
1864    }
1865
1866    async fn check_bug_database_canonical(&self, url: &Url) -> Result<Url, CanonicalizeError> {
1867        assert_eq!(url.host(), Some(url::Host::Domain("github.com")));
1868        let path_elements = url.path_segments().unwrap().collect::<Vec<_>>();
1869
1870        if path_elements.len() != 3 {
1871            return Err(CanonicalizeError::InvalidUrl(
1872                url.clone(),
1873                "GitHub URL with missing path elements".to_string(),
1874            ));
1875        }
1876        if path_elements[2] != "issues" {
1877            return Err(CanonicalizeError::InvalidUrl(
1878                url.clone(),
1879                "GitHub URL with missing path elements".to_string(),
1880            ));
1881        }
1882
1883        let api_path = format!("repos/{}/{}", path_elements[0], path_elements[1]);
1884
1885        let data = match crate::github::load_github_json(&api_path).await {
1886            Ok(data) => data,
1887            Err(HTTPJSONError::Error { status: 404, .. }) => {
1888                return Err(CanonicalizeError::InvalidUrl(
1889                    url.clone(),
1890                    "Project does not exist".to_string(),
1891                ));
1892            }
1893            Err(HTTPJSONError::Error { status: 403, .. }) => {
1894                // Probably rate limited
1895                warn!("Unable to verify bug database URL {}: rate limited", url);
1896                return Err(CanonicalizeError::RateLimited(url.clone()));
1897            }
1898            Err(e) => {
1899                return Err(CanonicalizeError::Unverifiable(
1900                    url.clone(),
1901                    format!("Unable to verify bug database URL: {}", e),
1902                ));
1903            }
1904        };
1905
1906        if data["has_issues"].as_bool() != Some(true) {
1907            return Err(CanonicalizeError::InvalidUrl(
1908                url.clone(),
1909                "Project does not have issues enabled".to_string(),
1910            ));
1911        }
1912
1913        if data.get("archived").unwrap_or(&serde_json::Value::Null)
1914            == &serde_json::Value::Bool(true)
1915        {
1916            return Err(CanonicalizeError::InvalidUrl(
1917                url.clone(),
1918                "Project is archived".to_string(),
1919            ));
1920        }
1921
1922        let mut url = Url::parse(data["html_url"].as_str().ok_or_else(|| {
1923            CanonicalizeError::Unverifiable(
1924                url.clone(),
1925                "Unable to verify bug database URL: no html_url".to_string(),
1926            )
1927        })?)
1928        .map_err(|e| {
1929            CanonicalizeError::Unverifiable(
1930                url.clone(),
1931                format!("Unable to verify bug database URL: {}", e),
1932            )
1933        })?;
1934
1935        url.set_scheme("https").expect("valid scheme");
1936        url.path_segments_mut()
1937            .expect("path segments")
1938            .push("issues");
1939
1940        Ok(url)
1941    }
1942
1943    async fn check_bug_submit_url_canonical(&self, url: &Url) -> Result<Url, CanonicalizeError> {
1944        let mut path_segments = url.path_segments().unwrap().collect::<Vec<_>>();
1945        path_segments.pop();
1946        let db_url = with_path_segments(url, &path_segments).unwrap();
1947        let mut canonical_db_url = self.check_bug_database_canonical(&db_url).await?;
1948        canonical_db_url.set_scheme("https").expect("valid scheme");
1949        canonical_db_url
1950            .path_segments_mut()
1951            .expect("path segments")
1952            .push("new");
1953        Ok(canonical_db_url)
1954    }
1955
1956    fn bug_database_from_issue_url(&self, url: &Url) -> Option<Url> {
1957        let path_elements = url.path_segments()?.collect::<Vec<_>>();
1958        if path_elements.len() < 4
1959            || path_elements[2] != "issues"
1960            || path_elements[3].parse::<u32>().is_err()
1961        {
1962            return None;
1963        }
1964        let url = to_https_url(url)?;
1965        Some(with_path_segments(&url, &path_elements[0..3]).unwrap())
1966    }
1967
1968    fn bug_database_url_from_repo_url(&self, url: &Url) -> Option<Url> {
1969        let path = url.path_segments()?.take(2).collect::<Vec<&str>>();
1970        if path.len() < 2 {
1971            return None;
1972        }
1973        let repo = path[1].strip_suffix(".git").unwrap_or(path[1]);
1974
1975        let url = to_https_url(url)?;
1976        Some(with_path_segments(&url, &[path[0], repo, "issues"]).unwrap())
1977    }
1978
1979    fn repo_url_from_merge_request_url(&self, url: &Url) -> Option<Url> {
1980        let path_elements = url.path_segments()?.collect::<Vec<_>>();
1981        if path_elements.len() < 4
1982            || path_elements[2] != "pull"
1983            || path_elements[3].parse::<u32>().is_err()
1984        {
1985            return None;
1986        }
1987        let url = to_https_url(url)?;
1988        Some(with_path_segments(&url, &path_elements[0..2]).unwrap())
1989    }
1990}
1991
1992static DEFAULT_ASCII_SET: percent_encoding::AsciiSet = percent_encoding::CONTROLS
1993    .add(b'/')
1994    .add(b'?')
1995    .add(b'#')
1996    .add(b'%');
1997
1998/// GitLab forge implementation
1999pub struct GitLab;
2000
2001impl Default for GitLab {
2002    fn default() -> Self {
2003        Self::new()
2004    }
2005}
2006
2007impl GitLab {
2008    /// Creates a new GitLab forge instance
2009    pub fn new() -> Self {
2010        Self
2011    }
2012}
2013
2014#[async_trait::async_trait]
2015impl Forge for GitLab {
2016    fn name(&self) -> &'static str {
2017        "GitLab"
2018    }
2019
2020    fn repository_browse_can_be_homepage(&self) -> bool {
2021        true
2022    }
2023
2024    fn bug_database_url_from_bug_submit_url(&self, url: &Url) -> Option<Url> {
2025        let mut path_elements = url
2026            .path_segments()
2027            .expect("path segments")
2028            .collect::<Vec<_>>();
2029
2030        if path_elements.len() < 2 {
2031            return None;
2032        }
2033        if path_elements[path_elements.len() - 2] != "issues" {
2034            return None;
2035        }
2036        if path_elements[path_elements.len() - 1] != "new" {
2037            path_elements.pop();
2038        }
2039
2040        Some(with_path_segments(url, &path_elements[0..path_elements.len() - 3]).unwrap())
2041    }
2042
2043    fn bug_submit_url_from_bug_database_url(&self, url: &Url) -> Option<Url> {
2044        let path_elements = url
2045            .path_segments()
2046            .expect("path segments")
2047            .collect::<Vec<_>>();
2048
2049        if path_elements.len() < 2 {
2050            return None;
2051        }
2052        if path_elements[path_elements.len() - 1] != "issues" {
2053            return None;
2054        }
2055
2056        let mut url = url.clone();
2057        url.path_segments_mut().expect("path segments").push("new");
2058
2059        Some(url)
2060    }
2061
2062    async fn check_bug_database_canonical(&self, url: &Url) -> Result<Url, CanonicalizeError> {
2063        let host = url
2064            .host()
2065            .ok_or_else(|| CanonicalizeError::InvalidUrl(url.clone(), "no host".to_string()))?;
2066        let mut path_elements = url
2067            .path_segments()
2068            .expect("path segments")
2069            .collect::<Vec<_>>();
2070        if path_elements.len() < 2 || path_elements[path_elements.len() - 1] != "issues" {
2071            return Err(CanonicalizeError::InvalidUrl(
2072                url.clone(),
2073                "GitLab URL with missing path elements".to_string(),
2074            ));
2075        }
2076
2077        path_elements.pop();
2078
2079        let proj = path_elements.join("/");
2080        let proj_segment = utf8_percent_encode(proj.as_str(), &DEFAULT_ASCII_SET);
2081        let api_url = Url::parse(&format!(
2082            "https://{}/api/v4/projects/{}",
2083            host, proj_segment
2084        ))
2085        .map_err(|_| {
2086            CanonicalizeError::InvalidUrl(
2087                url.clone(),
2088                "GitLab URL with invalid project path".to_string(),
2089            )
2090        })?;
2091        match load_json_url(&api_url, None).await {
2092            Ok(data) => {
2093                // issues_enabled is only provided when the user is authenticated,
2094                // so if we're not then we just fall back to checking the canonical URL
2095                let issues_enabled = data
2096                    .get("issues_enabled")
2097                    .unwrap_or(&serde_json::Value::Null);
2098                if issues_enabled.as_bool() == Some(false) {
2099                    return Err(CanonicalizeError::InvalidUrl(
2100                        url.clone(),
2101                        "Project does not have issues enabled".to_string(),
2102                    ));
2103                }
2104
2105                let mut canonical_url = Url::parse(data["web_url"].as_str().unwrap()).unwrap();
2106                canonical_url
2107                    .path_segments_mut()
2108                    .unwrap()
2109                    .extend(&["-", "issues"]);
2110                if issues_enabled.as_bool() == Some(true) {
2111                    return Ok(canonical_url);
2112                }
2113
2114                check_url_canonical(&canonical_url).await
2115            }
2116            Err(HTTPJSONError::Error { status, .. })
2117                if status == reqwest::StatusCode::NOT_FOUND =>
2118            {
2119                Err(CanonicalizeError::InvalidUrl(
2120                    url.clone(),
2121                    "Project not found".to_string(),
2122                ))
2123            }
2124            Err(e) => Err(CanonicalizeError::Unverifiable(
2125                url.clone(),
2126                format!("Unable to verify bug database URL: {:?}", e),
2127            )),
2128        }
2129    }
2130
2131    async fn check_bug_submit_url_canonical(&self, url: &Url) -> Result<Url, CanonicalizeError> {
2132        let path_elements = url
2133            .path_segments()
2134            .expect("valid segments")
2135            .collect::<Vec<_>>();
2136        if path_elements.len() < 2 || path_elements[path_elements.len() - 2] != "issues" {
2137            return Err(CanonicalizeError::InvalidUrl(
2138                url.clone(),
2139                "GitLab URL with missing path elements".to_string(),
2140            ));
2141        }
2142
2143        if path_elements[path_elements.len() - 1] != "new" {
2144            return Err(CanonicalizeError::InvalidUrl(
2145                url.clone(),
2146                "GitLab URL with missing path elements".to_string(),
2147            ));
2148        }
2149
2150        let db_url = with_path_segments(url, &path_elements[0..path_elements.len() - 1]).unwrap();
2151        let mut canonical_db_url = self.check_bug_database_canonical(&db_url).await?;
2152        canonical_db_url
2153            .path_segments_mut()
2154            .expect("valid segments")
2155            .push("new");
2156        Ok(canonical_db_url)
2157    }
2158
2159    fn bug_database_from_issue_url(&self, url: &Url) -> Option<Url> {
2160        let path_elements = url
2161            .path_segments()
2162            .expect("valid segments")
2163            .collect::<Vec<_>>();
2164        if path_elements.len() < 2
2165            || path_elements[path_elements.len() - 2] != "issues"
2166            || path_elements[path_elements.len() - 1]
2167                .parse::<u32>()
2168                .is_err()
2169        {
2170            return None;
2171        }
2172        Some(with_path_segments(url, &path_elements[0..path_elements.len() - 1]).unwrap())
2173    }
2174
2175    fn bug_database_url_from_repo_url(&self, url: &Url) -> Option<Url> {
2176        let mut url = url.clone();
2177        let last = url
2178            .path_segments()
2179            .expect("valid segments")
2180            .next_back()
2181            .unwrap()
2182            .to_string();
2183        url.path_segments_mut()
2184            .unwrap()
2185            .pop()
2186            .push(last.trim_end_matches(".git"))
2187            .push("issues");
2188        Some(url)
2189    }
2190
2191    fn repo_url_from_merge_request_url(&self, url: &Url) -> Option<Url> {
2192        let path_elements = url
2193            .path_segments()
2194            .expect("path segments")
2195            .collect::<Vec<_>>();
2196        if path_elements.len() < 3
2197            || path_elements[path_elements.len() - 2] != "merge_requests"
2198            || path_elements[path_elements.len() - 1]
2199                .parse::<u32>()
2200                .is_err()
2201        {
2202            return None;
2203        }
2204        Some(with_path_segments(url, &path_elements[0..path_elements.len() - 2]).unwrap())
2205    }
2206}
2207
2208/// Extracts upstream metadata from a Travis CI configuration file
2209pub fn guess_from_travis_yml(
2210    path: &Path,
2211    _settings: &GuesserSettings,
2212) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
2213    let mut file = File::open(path)?;
2214
2215    let mut contents = String::new();
2216    file.read_to_string(&mut contents)?;
2217
2218    let data: serde_norway::Value =
2219        serde_norway::from_str(&contents).map_err(|e| ProviderError::ParseError(e.to_string()))?;
2220
2221    let mut ret = Vec::new();
2222
2223    if let Some(go_import_path) = data.get("go_import_path") {
2224        if let Some(go_import_path) = go_import_path.as_str() {
2225            ret.push(UpstreamDatumWithMetadata {
2226                datum: UpstreamDatum::GoImportPath(go_import_path.to_string()),
2227                certainty: Some(Certainty::Certain),
2228                origin: Some(path.into()),
2229            });
2230        }
2231    }
2232
2233    Ok(ret)
2234}
2235
2236/// Extracts upstream metadata from environment variables
2237pub fn guess_from_environment() -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError>
2238{
2239    let mut results = Vec::new();
2240    if let Ok(url) = std::env::var("UPSTREAM_BRANCH_URL") {
2241        results.push(UpstreamDatumWithMetadata {
2242            datum: UpstreamDatum::Repository(url),
2243            certainty: Some(Certainty::Certain),
2244            origin: Some(Origin::Other("environment".to_string())),
2245        });
2246    }
2247    Ok(results)
2248}
2249
2250fn find_datum<'a>(
2251    metadata: &'a [UpstreamDatumWithMetadata],
2252    field: &str,
2253) -> Option<&'a UpstreamDatumWithMetadata> {
2254    metadata.iter().find(|d| d.datum.field() == field)
2255}
2256
2257fn set_datum(metadata: &mut Vec<UpstreamDatumWithMetadata>, datum: UpstreamDatumWithMetadata) {
2258    if let Some(idx) = metadata
2259        .iter()
2260        .position(|d| d.datum.field() == datum.datum.field())
2261    {
2262        metadata[idx] = datum;
2263    } else {
2264        metadata.push(datum);
2265    }
2266}
2267
2268/// Updates metadata collection with new guesses based on certainty levels
2269pub fn update_from_guesses(
2270    metadata: &mut Vec<UpstreamDatumWithMetadata>,
2271    new_items: impl Iterator<Item = UpstreamDatumWithMetadata>,
2272) -> Vec<UpstreamDatumWithMetadata> {
2273    let mut changed = vec![];
2274    for datum in new_items {
2275        let current_datum = find_datum(metadata, datum.datum.field());
2276        if current_datum.is_none() || datum.certainty > current_datum.unwrap().certainty {
2277            changed.push(datum.clone());
2278            set_datum(metadata, datum);
2279        }
2280    }
2281    changed
2282}
2283
2284fn possible_fields_missing(
2285    upstream_metadata: &[UpstreamDatumWithMetadata],
2286    fields: &[&str],
2287    _field_certainty: Certainty,
2288) -> bool {
2289    for field in fields {
2290        match find_datum(upstream_metadata, field) {
2291            Some(datum) if datum.certainty != Some(Certainty::Certain) => return true,
2292            None => return true,
2293            _ => (),
2294        }
2295    }
2296    false
2297}
2298
2299async fn extend_from_external_guesser<
2300    F: Fn() -> Fut,
2301    Fut: std::future::Future<Output = Vec<UpstreamDatum>>,
2302>(
2303    metadata: &mut Vec<UpstreamDatumWithMetadata>,
2304    max_certainty: Option<Certainty>,
2305    supported_fields: &[&str],
2306    new_items: F,
2307) {
2308    if max_certainty.is_some()
2309        && !possible_fields_missing(metadata, supported_fields, max_certainty.unwrap())
2310    {
2311        return;
2312    }
2313
2314    let new_items = new_items()
2315        .await
2316        .into_iter()
2317        .map(|item| UpstreamDatumWithMetadata {
2318            datum: item,
2319            certainty: max_certainty,
2320            origin: None,
2321        });
2322
2323    update_from_guesses(metadata, new_items);
2324}
2325
2326/// SourceForge forge implementation
2327pub struct SourceForge;
2328
2329impl Default for SourceForge {
2330    fn default() -> Self {
2331        Self::new()
2332    }
2333}
2334
2335impl SourceForge {
2336    /// Creates a new SourceForge forge instance
2337    pub fn new() -> Self {
2338        Self
2339    }
2340}
2341
2342#[async_trait::async_trait]
2343impl Forge for SourceForge {
2344    fn name(&self) -> &'static str {
2345        "SourceForge"
2346    }
2347    fn repository_browse_can_be_homepage(&self) -> bool {
2348        false
2349    }
2350
2351    fn bug_database_url_from_bug_submit_url(&self, url: &Url) -> Option<Url> {
2352        let mut segments = url.path_segments()?;
2353        if segments.next() != Some("p") {
2354            return None;
2355        }
2356        let project = segments.next()?;
2357        if segments.next() != Some("bugs") {
2358            return None;
2359        }
2360        with_path_segments(url, &["p", project, "bugs"]).ok()
2361    }
2362
2363    async fn extend_metadata(
2364        &self,
2365        metadata: &mut Vec<UpstreamDatumWithMetadata>,
2366        project: &str,
2367        max_certainty: Option<Certainty>,
2368    ) {
2369        let subproject = find_datum(metadata, "Name").and_then(|f| match f.datum {
2370            UpstreamDatum::Name(ref name) => Some(name.to_string()),
2371            _ => None,
2372        });
2373
2374        extend_from_external_guesser(
2375            metadata,
2376            max_certainty,
2377            &["Homepage", "Name", "Repository", "Bug-Database"],
2378            || async {
2379                crate::forges::sourceforge::guess_from_sf(project, subproject.as_deref()).await
2380            },
2381        )
2382        .await
2383    }
2384}
2385
2386/// Launchpad forge implementation
2387pub struct Launchpad;
2388
2389impl Default for Launchpad {
2390    fn default() -> Self {
2391        Self::new()
2392    }
2393}
2394
2395impl Launchpad {
2396    /// Creates a new Launchpad forge instance
2397    pub fn new() -> Self {
2398        Self
2399    }
2400}
2401
2402impl Forge for Launchpad {
2403    fn name(&self) -> &'static str {
2404        "launchpad"
2405    }
2406
2407    fn repository_browse_can_be_homepage(&self) -> bool {
2408        false
2409    }
2410    fn bug_database_url_from_bug_submit_url(&self, url: &Url) -> Option<Url> {
2411        if url.host_str()? != "bugs.launchpad.net" {
2412            return None;
2413        }
2414
2415        let mut segments = url.path_segments()?;
2416        let project = segments.next()?;
2417
2418        with_path_segments(url, &[project]).ok()
2419    }
2420
2421    fn bug_submit_url_from_bug_database_url(&self, url: &Url) -> Option<Url> {
2422        if url.host_str()? != "bugs.launchpad.net" {
2423            return None;
2424        }
2425
2426        let mut segments = url.path_segments()?;
2427        let project = segments.next()?;
2428
2429        with_path_segments(url, &[project, "+filebug"]).ok()
2430    }
2431}
2432
2433/// Determines which forge a URL belongs to
2434pub async fn find_forge(url: &Url, net_access: Option<bool>) -> Option<Box<dyn Forge>> {
2435    if url.host_str()? == "sourceforge.net" {
2436        return Some(Box::new(SourceForge::new()));
2437    }
2438
2439    if url.host_str()?.ends_with(".launchpad.net") {
2440        return Some(Box::new(Launchpad::new()));
2441    }
2442
2443    if url.host_str()? == "github.com" {
2444        return Some(Box::new(GitHub::new()));
2445    }
2446
2447    if vcs::is_gitlab_site(url.host_str()?, net_access).await {
2448        return Some(Box::new(GitLab::new()));
2449    }
2450
2451    None
2452}
2453
2454/// Checks if a bug database URL is canonical
2455pub async fn check_bug_database_canonical(
2456    url: &Url,
2457    net_access: Option<bool>,
2458) -> Result<Url, CanonicalizeError> {
2459    if let Some(forge) = find_forge(url, net_access).await {
2460        forge
2461            .bug_database_url_from_bug_submit_url(url)
2462            .ok_or(CanonicalizeError::Unverifiable(
2463                url.clone(),
2464                "no bug database URL found".to_string(),
2465            ))
2466    } else {
2467        Err(CanonicalizeError::Unverifiable(
2468            url.clone(),
2469            "unknown forge".to_string(),
2470        ))
2471    }
2472}
2473
2474/// Derives a bug submission URL from a bug database URL
2475pub async fn bug_submit_url_from_bug_database_url(
2476    url: &Url,
2477    net_access: Option<bool>,
2478) -> Option<Url> {
2479    if let Some(forge) = find_forge(url, net_access).await {
2480        forge.bug_submit_url_from_bug_database_url(url)
2481    } else {
2482        None
2483    }
2484}
2485
2486/// Derives a bug database URL from a bug submission URL
2487pub async fn bug_database_url_from_bug_submit_url(
2488    url: &Url,
2489    net_access: Option<bool>,
2490) -> Option<Url> {
2491    if let Some(forge) = find_forge(url, net_access).await {
2492        forge.bug_database_url_from_bug_submit_url(url)
2493    } else {
2494        None
2495    }
2496}
2497
2498/// Guesses the bug database URL from a repository URL
2499pub async fn guess_bug_database_url_from_repo_url(
2500    url: &Url,
2501    net_access: Option<bool>,
2502) -> Option<Url> {
2503    if let Some(forge) = find_forge(url, net_access).await {
2504        forge.bug_database_url_from_repo_url(url)
2505    } else {
2506        None
2507    }
2508}
2509
2510/// Extracts the repository URL from a merge request URL
2511pub async fn repo_url_from_merge_request_url(url: &Url, net_access: Option<bool>) -> Option<Url> {
2512    if let Some(forge) = find_forge(url, net_access).await {
2513        forge.repo_url_from_merge_request_url(url)
2514    } else {
2515        None
2516    }
2517}
2518
2519/// Extracts the bug database URL from an issue URL
2520pub async fn bug_database_from_issue_url(url: &Url, net_access: Option<bool>) -> Option<Url> {
2521    if let Some(forge) = find_forge(url, net_access).await {
2522        forge.bug_database_from_issue_url(url)
2523    } else {
2524        None
2525    }
2526}
2527
2528/// Checks if a bug submission URL is canonical
2529pub async fn check_bug_submit_url_canonical(
2530    url: &Url,
2531    net_access: Option<bool>,
2532) -> Result<Url, CanonicalizeError> {
2533    if let Some(forge) = find_forge(url, net_access).await {
2534        forge
2535            .bug_submit_url_from_bug_database_url(url)
2536            .ok_or(CanonicalizeError::Unverifiable(
2537                url.clone(),
2538                "no bug submit URL found".to_string(),
2539            ))
2540    } else {
2541        Err(CanonicalizeError::Unverifiable(
2542            url.clone(),
2543            "unknown forge".to_string(),
2544        ))
2545    }
2546}
2547
2548/// Extracts the PECL package name from a URL
2549pub fn extract_pecl_package_name(url: &str) -> Option<String> {
2550    let pecl_regex = regex!(r"https?://pecl\.php\.net/package/(.*)");
2551    if let Some(captures) = pecl_regex.captures(url) {
2552        return captures.get(1).map(|m| m.as_str().to_string());
2553    }
2554    None
2555}
2556
2557/// Extracts the Hackage package name from a URL
2558pub fn extract_hackage_package(url: &str) -> Option<String> {
2559    let hackage_regex = regex!(r"https?://hackage\.haskell\.org/package/([^/]+)/.*");
2560    if let Some(captures) = hackage_regex.captures(url) {
2561        return captures.get(1).map(|m| m.as_str().to_string());
2562    }
2563    None
2564}
2565
2566/// Obtain metadata from a URL related to the project
2567pub fn metadata_from_url(url: &str, origin: &Origin) -> Vec<UpstreamDatumWithMetadata> {
2568    let mut results = Vec::new();
2569    if let Some(sf_project) = crate::forges::sourceforge::extract_sf_project_name(url) {
2570        results.push(UpstreamDatumWithMetadata {
2571            datum: UpstreamDatum::SourceForgeProject(sf_project),
2572            certainty: Some(Certainty::Certain),
2573            origin: Some(origin.clone()),
2574        });
2575        results.push(UpstreamDatumWithMetadata {
2576            datum: UpstreamDatum::Archive("SourceForge".to_string()),
2577            certainty: Some(Certainty::Certain),
2578            origin: Some(origin.clone()),
2579        });
2580    }
2581
2582    if let Some(pecl_package) = extract_pecl_package_name(url) {
2583        results.push(UpstreamDatumWithMetadata {
2584            datum: UpstreamDatum::PeclPackage(pecl_package),
2585            certainty: Some(Certainty::Certain),
2586            origin: Some(origin.clone()),
2587        });
2588        results.push(UpstreamDatumWithMetadata {
2589            datum: UpstreamDatum::Archive("Pecl".to_string()),
2590            certainty: Some(Certainty::Certain),
2591            origin: Some(origin.clone()),
2592        });
2593    }
2594
2595    if let Some(haskell_package) = extract_hackage_package(url) {
2596        results.push(UpstreamDatumWithMetadata {
2597            datum: UpstreamDatum::HaskellPackage(haskell_package),
2598            certainty: Some(Certainty::Certain),
2599            origin: Some(origin.clone()),
2600        });
2601        results.push(UpstreamDatumWithMetadata {
2602            datum: UpstreamDatum::Archive("Hackage".to_string()),
2603            certainty: Some(Certainty::Certain),
2604            origin: Some(origin.clone()),
2605        });
2606    }
2607    results
2608}
2609
2610/// Fetches metadata from the Repology API for a given source package
2611pub async fn get_repology_metadata(srcname: &str, repo: Option<&str>) -> Option<serde_json::Value> {
2612    let repo = repo.unwrap_or("debian_unstable");
2613    let url = format!(
2614        "https://repology.org/tools/project-by?repo={}&name_type=srcname'
2615           '&target_page=api_v1_project&name={}",
2616        repo, srcname
2617    );
2618
2619    match load_json_url(&Url::parse(url.as_str()).unwrap(), None).await {
2620        Ok(json) => Some(json),
2621        Err(HTTPJSONError::Error { status: 404, .. }) => None,
2622        Err(e) => {
2623            debug!("Failed to load repology metadata: {:?}", e);
2624            None
2625        }
2626    }
2627}
2628
2629/// Guesses upstream metadata from a file or directory path
2630pub fn guess_from_path(
2631    path: &Path,
2632    _settings: &GuesserSettings,
2633) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
2634    let basename = path.file_name().and_then(|s| s.to_str());
2635    let mut ret = Vec::new();
2636    if let Some(basename_str) = basename {
2637        let re = regex!(r"(.*)-([0-9.]+)");
2638        if let Some(captures) = re.captures(basename_str) {
2639            if let Some(name) = captures.get(1) {
2640                ret.push(UpstreamDatumWithMetadata {
2641                    datum: UpstreamDatum::Name(name.as_str().to_string()),
2642                    certainty: Some(Certainty::Possible),
2643                    origin: Some(path.into()),
2644                });
2645            }
2646            if let Some(version) = captures.get(2) {
2647                ret.push(UpstreamDatumWithMetadata {
2648                    datum: UpstreamDatum::Version(version.as_str().to_string()),
2649                    certainty: Some(Certainty::Possible),
2650                    origin: Some(path.into()),
2651                });
2652            }
2653        } else {
2654            ret.push(UpstreamDatumWithMetadata {
2655                datum: UpstreamDatum::Name(basename_str.to_string()),
2656                certainty: Some(Certainty::Possible),
2657                origin: Some(path.into()),
2658            });
2659        }
2660    }
2661    Ok(ret)
2662}
2663
2664#[cfg(feature = "pyo3")]
2665impl<'py> FromPyObject<'_, 'py> for UpstreamDatum {
2666    type Error = PyErr;
2667
2668    fn extract(obj: pyo3::Borrowed<'_, 'py, PyAny>) -> PyResult<Self> {
2669        let (field, val): (String, Bound<'py, PyAny>) = if let Ok((field, val)) =
2670            obj.extract::<(String, Bound<'py, PyAny>)>()
2671        {
2672            (field, val)
2673        } else if let Ok(datum) = obj.getattr("datum") {
2674            let field = datum.getattr("field")?.extract::<String>()?;
2675            let val = datum.getattr("value")?;
2676            (field, val)
2677        } else if obj.hasattr("field")? && obj.hasattr("value")? {
2678            let field = obj.getattr("field")?.extract::<String>()?;
2679            let val = obj.getattr("value")?;
2680            (field, val)
2681        } else {
2682            return Err(PyTypeError::new_err((
2683                format!("Expected a tuple of (field, value) or an object with field and value attributesm, found {:?}", obj),
2684            )));
2685        };
2686
2687        match field.as_str() {
2688            "Name" => Ok(UpstreamDatum::Name(val.extract::<String>()?)),
2689            "Version" => Ok(UpstreamDatum::Version(val.extract::<String>()?)),
2690            "Homepage" => Ok(UpstreamDatum::Homepage(val.extract::<String>()?)),
2691            "Bug-Database" => Ok(UpstreamDatum::BugDatabase(val.extract::<String>()?)),
2692            "Bug-Submit" => Ok(UpstreamDatum::BugSubmit(val.extract::<String>()?)),
2693            "Contact" => Ok(UpstreamDatum::Contact(val.extract::<String>()?)),
2694            "Repository" => Ok(UpstreamDatum::Repository(val.extract::<String>()?)),
2695            "Repository-Browse" => Ok(UpstreamDatum::RepositoryBrowse(val.extract::<String>()?)),
2696            "License" => Ok(UpstreamDatum::License(val.extract::<String>()?)),
2697            "Description" => Ok(UpstreamDatum::Description(val.extract::<String>()?)),
2698            "Summary" => Ok(UpstreamDatum::Summary(val.extract::<String>()?)),
2699            "Cargo-Crate" => Ok(UpstreamDatum::CargoCrate(val.extract::<String>()?)),
2700            "Security-MD" => Ok(UpstreamDatum::SecurityMD(val.extract::<String>()?)),
2701            "Security-Contact" => Ok(UpstreamDatum::SecurityContact(val.extract::<String>()?)),
2702            "Keywords" => Ok(UpstreamDatum::Keywords(val.extract::<Vec<String>>()?)),
2703            "Copyright" => Ok(UpstreamDatum::Copyright(val.extract::<String>()?)),
2704            "Documentation" => Ok(UpstreamDatum::Documentation(val.extract::<String>()?)),
2705            "API-Documentation" => Ok(UpstreamDatum::APIDocumentation(val.extract::<String>()?)),
2706            "Go-Import-Path" => Ok(UpstreamDatum::GoImportPath(val.extract::<String>()?)),
2707            "Download" => Ok(UpstreamDatum::Download(val.extract::<String>()?)),
2708            "Wiki" => Ok(UpstreamDatum::Wiki(val.extract::<String>()?)),
2709            "MailingList" => Ok(UpstreamDatum::MailingList(val.extract::<String>()?)),
2710            "Funding" => Ok(UpstreamDatum::Funding(val.extract::<String>()?)),
2711            "SourceForge-Project" => {
2712                Ok(UpstreamDatum::SourceForgeProject(val.extract::<String>()?))
2713            }
2714            "Archive" => Ok(UpstreamDatum::Archive(val.extract::<String>()?)),
2715            "Demo" => Ok(UpstreamDatum::Demo(val.extract::<String>()?)),
2716            "Pecl-Package" => Ok(UpstreamDatum::PeclPackage(val.extract::<String>()?)),
2717            "Haskell-Package" => Ok(UpstreamDatum::HaskellPackage(val.extract::<String>()?)),
2718            "Author" => Ok(UpstreamDatum::Author(val.extract::<Vec<Person>>()?)),
2719            "Maintainer" => Ok(UpstreamDatum::Maintainer(val.extract::<Person>()?)),
2720            "Changelog" => Ok(UpstreamDatum::Changelog(val.extract::<String>()?)),
2721            "Screenshots" => Ok(UpstreamDatum::Screenshots(val.extract::<Vec<String>>()?)),
2722            "Cite-As" => Ok(UpstreamDatum::CiteAs(val.extract::<String>()?)),
2723            "Registry" => {
2724                let v = val.extract::<Vec<Bound<'py, PyAny>>>()?;
2725                let mut registry = Vec::new();
2726                for item in v {
2727                    let name = item.get_item("Name")?.extract::<String>()?;
2728                    let entry = item.get_item("Entry")?.extract::<String>()?;
2729                    registry.push((name, entry));
2730                }
2731                Ok(UpstreamDatum::Registry(registry))
2732            }
2733            "Donation" => Ok(UpstreamDatum::Donation(val.extract::<String>()?)),
2734            "Webservice" => Ok(UpstreamDatum::Webservice(val.extract::<String>()?)),
2735            "BuildSystem" => Ok(UpstreamDatum::BuildSystem(val.extract::<String>()?)),
2736            "FAQ" => Ok(UpstreamDatum::FAQ(val.extract::<String>()?)),
2737            _ => Err(PyRuntimeError::new_err(format!("Unknown field: {}", field))),
2738        }
2739    }
2740}
2741
2742#[cfg(feature = "pyo3")]
2743impl<'py> IntoPyObject<'py> for &UpstreamDatum {
2744    type Target = PyAny;
2745    type Output = Bound<'py, Self::Target>;
2746    type Error = PyErr;
2747
2748    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
2749        let field = self.field().to_string();
2750        let value: Bound<'py, PyAny> = match self {
2751            UpstreamDatum::Name(n) => n.into_pyobject(py)?.into_any(),
2752            UpstreamDatum::Version(v) => v.into_pyobject(py)?.into_any(),
2753            UpstreamDatum::Contact(c) => c.into_pyobject(py)?.into_any(),
2754            UpstreamDatum::Summary(s) => s.into_pyobject(py)?.into_any(),
2755            UpstreamDatum::License(l) => l.into_pyobject(py)?.into_any(),
2756            UpstreamDatum::Homepage(h) => h.into_pyobject(py)?.into_any(),
2757            UpstreamDatum::Description(d) => d.into_pyobject(py)?.into_any(),
2758            UpstreamDatum::BugDatabase(b) => b.into_pyobject(py)?.into_any(),
2759            UpstreamDatum::BugSubmit(b) => b.into_pyobject(py)?.into_any(),
2760            UpstreamDatum::Repository(r) => r.into_pyobject(py)?.into_any(),
2761            UpstreamDatum::RepositoryBrowse(r) => r.into_pyobject(py)?.into_any(),
2762            UpstreamDatum::SecurityMD(s) => s.into_pyobject(py)?.into_any(),
2763            UpstreamDatum::SecurityContact(s) => s.into_pyobject(py)?.into_any(),
2764            UpstreamDatum::CargoCrate(c) => c.into_pyobject(py)?.into_any(),
2765            UpstreamDatum::Keywords(ks) => ks.into_pyobject(py)?,
2766            UpstreamDatum::Copyright(c) => c.into_pyobject(py)?.into_any(),
2767            UpstreamDatum::Documentation(a) => a.into_pyobject(py)?.into_any(),
2768            UpstreamDatum::APIDocumentation(a) => a.into_pyobject(py)?.into_any(),
2769            UpstreamDatum::GoImportPath(ip) => ip.into_pyobject(py)?.into_any(),
2770            UpstreamDatum::Archive(a) => a.into_pyobject(py)?.into_any(),
2771            UpstreamDatum::Demo(d) => d.into_pyobject(py)?.into_any(),
2772            UpstreamDatum::Maintainer(m) => m.into_pyobject(py)?,
2773            UpstreamDatum::Author(a) => a.into_pyobject(py)?,
2774            UpstreamDatum::Wiki(w) => w.into_pyobject(py)?.into_any(),
2775            UpstreamDatum::Download(d) => d.into_pyobject(py)?.into_any(),
2776            UpstreamDatum::MailingList(m) => m.into_pyobject(py)?.into_any(),
2777            UpstreamDatum::SourceForgeProject(m) => m.into_pyobject(py)?.into_any(),
2778            UpstreamDatum::PeclPackage(p) => p.into_pyobject(py)?.into_any(),
2779            UpstreamDatum::Funding(p) => p.into_pyobject(py)?.into_any(),
2780            UpstreamDatum::Changelog(c) => c.into_pyobject(py)?.into_any(),
2781            UpstreamDatum::HaskellPackage(p) => p.into_pyobject(py)?.into_any(),
2782            UpstreamDatum::DebianITP(i) => i.into_pyobject(py)?.into_any(),
2783            UpstreamDatum::Screenshots(s) => s.into_pyobject(py)?,
2784            UpstreamDatum::CiteAs(s) => s.into_pyobject(py)?.into_any(),
2785            UpstreamDatum::Registry(r) => {
2786                let list: Result<Vec<_>, _> = r
2787                    .iter()
2788                    .map(|(name, entry)| {
2789                        let dict = PyDict::new(py);
2790                        dict.set_item("Name", name)?;
2791                        dict.set_item("Entry", entry)?;
2792                        Ok::<Bound<PyAny>, PyErr>(dict.into_any())
2793                    })
2794                    .collect();
2795                list?.into_pyobject(py)?
2796            }
2797            UpstreamDatum::Donation(d) => d.into_pyobject(py)?.into_any(),
2798            UpstreamDatum::Webservice(w) => w.into_pyobject(py)?.into_any(),
2799            UpstreamDatum::BuildSystem(b) => b.into_pyobject(py)?.into_any(),
2800            UpstreamDatum::FAQ(f) => f.into_pyobject(py)?.into_any(),
2801        };
2802        Ok((field, value).into_pyobject(py)?.into_any())
2803    }
2804}
2805
2806#[cfg(feature = "pyo3")]
2807impl<'py> FromPyObject<'_, 'py> for UpstreamDatumWithMetadata {
2808    type Error = PyErr;
2809
2810    fn extract(obj: pyo3::Borrowed<'_, 'py, PyAny>) -> PyResult<Self> {
2811        let certainty = obj.getattr("certainty")?.extract::<Option<String>>()?;
2812        let origin = obj.getattr("origin")?.extract::<Option<Origin>>()?;
2813        let datum = if obj.hasattr("datum")? {
2814            obj.getattr("datum")?.extract::<UpstreamDatum>()
2815        } else {
2816            obj.extract::<UpstreamDatum>()
2817        }?;
2818
2819        Ok(UpstreamDatumWithMetadata {
2820            datum,
2821            certainty: certainty.map(|s| s.parse().unwrap()),
2822            origin,
2823        })
2824    }
2825}
2826
2827/// Errors that can occur when fetching metadata from providers
2828#[derive(Debug)]
2829pub enum ProviderError {
2830    /// Parse error with description
2831    ParseError(String),
2832    /// I/O error
2833    IoError(std::io::Error),
2834    /// Other error with description
2835    Other(String),
2836    /// HTTP JSON fetching error
2837    HttpJsonError(Box<HTTPJSONError>),
2838    /// Extrapolation limit exceeded with limit value
2839    ExtrapolationLimitExceeded(usize),
2840}
2841
2842impl std::fmt::Display for ProviderError {
2843    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
2844        match self {
2845            ProviderError::ParseError(e) => write!(f, "Parse error: {}", e),
2846            ProviderError::IoError(e) => write!(f, "IO error: {}", e),
2847            ProviderError::Other(e) => write!(f, "Other error: {}", e),
2848            ProviderError::HttpJsonError(e) => write!(f, "HTTP JSON error: {}", e),
2849            ProviderError::ExtrapolationLimitExceeded(e) => {
2850                write!(f, "Extrapolation limit exceeded: {}", e)
2851            }
2852        }
2853    }
2854}
2855
2856impl std::error::Error for ProviderError {}
2857
2858impl From<HTTPJSONError> for ProviderError {
2859    fn from(e: HTTPJSONError) -> Self {
2860        ProviderError::HttpJsonError(Box::new(e))
2861    }
2862}
2863
2864impl From<std::io::Error> for ProviderError {
2865    fn from(e: std::io::Error) -> Self {
2866        ProviderError::IoError(e)
2867    }
2868}
2869
2870impl From<reqwest::Error> for ProviderError {
2871    fn from(e: reqwest::Error) -> Self {
2872        ProviderError::Other(e.to_string())
2873    }
2874}
2875
2876#[cfg(feature = "pyo3")]
2877mod py_exceptions {
2878    #![allow(missing_docs)]
2879    pyo3::create_exception!(
2880        upstream_ontologist,
2881        ParseError,
2882        pyo3::exceptions::PyException
2883    );
2884}
2885#[cfg(feature = "pyo3")]
2886pub use py_exceptions::ParseError;
2887
2888#[cfg(feature = "pyo3")]
2889impl From<ProviderError> for PyErr {
2890    fn from(e: ProviderError) -> PyErr {
2891        match e {
2892            ProviderError::IoError(e) => e.into(),
2893            ProviderError::ParseError(e) => ParseError::new_err((e,)),
2894            ProviderError::Other(e) => PyRuntimeError::new_err((e,)),
2895            ProviderError::HttpJsonError(e) => PyRuntimeError::new_err((e.to_string(),)),
2896            ProviderError::ExtrapolationLimitExceeded(e) => {
2897                PyRuntimeError::new_err((e.to_string(),))
2898            }
2899        }
2900    }
2901}
2902
2903/// Settings for upstream metadata guessers
2904#[derive(Debug, Default, Clone)]
2905pub struct GuesserSettings {
2906    /// Whether to trust the package contents and run executables
2907    pub trust_package: bool,
2908}
2909
2910type GuesserFunction =
2911    Box<dyn FnOnce(&GuesserSettings) -> Result<Vec<UpstreamDatumWithMetadata>, ProviderError>>;
2912
2913/// A guesser that can extract upstream metadata from a specific file
2914pub struct UpstreamMetadataGuesser {
2915    /// Name/path of the guesser
2916    pub name: std::path::PathBuf,
2917    /// Function that performs the guessing
2918    pub guess: GuesserFunction,
2919}
2920
2921impl std::fmt::Debug for UpstreamMetadataGuesser {
2922    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2923        f.debug_struct("UpstreamMetadataGuesser")
2924            .field("name", &self.name)
2925            .finish()
2926    }
2927}
2928
2929type OldAsyncGuesser = fn(
2930    PathBuf,
2931    GuesserSettings,
2932) -> Pin<
2933    Box<
2934        dyn std::future::Future<Output = Result<Vec<UpstreamDatumWithMetadata>, ProviderError>>
2935            + Send,
2936    >,
2937>;
2938
2939const OLD_STATIC_GUESSERS: &[(&str, OldAsyncGuesser)] = &[
2940    #[cfg(feature = "debian")]
2941    ("debian/watch", |path, settings| {
2942        Box::pin(async move {
2943            crate::providers::debian::guess_from_debian_watch(&path, &settings).await
2944        })
2945    }),
2946    #[cfg(feature = "debian")]
2947    ("debian/control", |path, settings| {
2948        Box::pin(
2949            async move { crate::providers::debian::guess_from_debian_control(&path, &settings) },
2950        )
2951    }),
2952    #[cfg(feature = "debian")]
2953    ("debian/changelog", |path, settings| {
2954        Box::pin(async move {
2955            crate::providers::debian::guess_from_debian_changelog(&path, &settings).await
2956        })
2957    }),
2958    #[cfg(feature = "debian")]
2959    ("debian/rules", |path, settings| {
2960        Box::pin(async move { crate::providers::debian::guess_from_debian_rules(&path, &settings) })
2961    }),
2962    #[cfg(feature = "python-pkginfo")]
2963    ("PKG-INFO", |path, settings| {
2964        Box::pin(
2965            async move { crate::providers::python::guess_from_pkg_info(&path, &settings).await },
2966        )
2967    }),
2968    ("package.json", |path, settings| {
2969        Box::pin(async move {
2970            crate::providers::package_json::guess_from_package_json(&path, &settings)
2971        })
2972    }),
2973    ("composer.json", |path, settings| {
2974        Box::pin(async move {
2975            crate::providers::composer_json::guess_from_composer_json(&path, &settings)
2976        })
2977    }),
2978    ("package.xml", |path, settings| {
2979        Box::pin(
2980            async move { crate::providers::package_xml::guess_from_package_xml(&path, &settings) },
2981        )
2982    }),
2983    ("package.yaml", |path, settings| {
2984        Box::pin(async move {
2985            crate::providers::package_yaml::guess_from_package_yaml(&path, &settings)
2986        })
2987    }),
2988    #[cfg(feature = "dist-ini")]
2989    ("dist.ini", |path, settings| {
2990        Box::pin(async move { crate::providers::perl::guess_from_dist_ini(&path, &settings) })
2991    }),
2992    #[cfg(feature = "debian")]
2993    ("debian/copyright", |path, settings| {
2994        Box::pin(async move {
2995            crate::providers::debian::guess_from_debian_copyright(&path, &settings).await
2996        })
2997    }),
2998    ("META.json", |path, settings| {
2999        Box::pin(async move { crate::providers::perl::guess_from_meta_json(&path, &settings) })
3000    }),
3001    ("MYMETA.json", |path, settings| {
3002        Box::pin(async move { crate::providers::perl::guess_from_meta_json(&path, &settings) })
3003    }),
3004    ("META.yml", |path, settings| {
3005        Box::pin(async move { crate::providers::perl::guess_from_meta_yml(&path, &settings) })
3006    }),
3007    ("MYMETA.yml", |path, settings| {
3008        Box::pin(async move { crate::providers::perl::guess_from_meta_yml(&path, &settings) })
3009    }),
3010    ("configure", |path, settings| {
3011        Box::pin(async move { crate::providers::autoconf::guess_from_configure(&path, &settings) })
3012    }),
3013    #[cfg(feature = "r-description")]
3014    ("DESCRIPTION", |path, settings| {
3015        Box::pin(
3016            async move { crate::providers::r::guess_from_r_description(&path, &settings).await },
3017        )
3018    }),
3019    #[cfg(feature = "cargo")]
3020    ("Cargo.toml", |path, settings| {
3021        Box::pin(async move { crate::providers::rust::guess_from_cargo(&path, &settings) })
3022    }),
3023    ("pom.xml", |path, settings| {
3024        Box::pin(async move { crate::providers::maven::guess_from_pom_xml(&path, &settings) })
3025    }),
3026    #[cfg(feature = "git-config")]
3027    (".git/config", |path, settings| {
3028        Box::pin(async move { crate::providers::git::guess_from_git_config(&path, &settings) })
3029    }),
3030    ("debian/get-orig-source.sh", |path, settings| {
3031        Box::pin(async move { crate::vcs_command::guess_from_get_orig_source(&path, &settings) })
3032    }),
3033    #[cfg(feature = "pyproject-toml")]
3034    ("pyproject.toml", |path, settings| {
3035        Box::pin(
3036            async move { crate::providers::python::guess_from_pyproject_toml(&path, &settings) },
3037        )
3038    }),
3039    #[cfg(feature = "setup-cfg")]
3040    ("setup.cfg", |path, settings| {
3041        Box::pin(
3042            async move { crate::providers::python::guess_from_setup_cfg(&path, &settings).await },
3043        )
3044    }),
3045    ("go.mod", |path, settings| {
3046        Box::pin(async move { crate::providers::go::guess_from_go_mod(&path, &settings) })
3047    }),
3048    ("Makefile.PL", |path, settings| {
3049        Box::pin(async move { crate::providers::perl::guess_from_makefile_pl(&path, &settings) })
3050    }),
3051    ("wscript", |path, settings| {
3052        Box::pin(async move { crate::providers::waf::guess_from_wscript(&path, &settings) })
3053    }),
3054    ("AUTHORS", |path, settings| {
3055        Box::pin(async move { crate::providers::authors::guess_from_authors(&path, &settings) })
3056    }),
3057    ("INSTALL", |path, settings| {
3058        Box::pin(async move { crate::providers::guess_from_install(&path, &settings).await })
3059    }),
3060    ("pubspec.yaml", |path, settings| {
3061        Box::pin(
3062            async move { crate::providers::pubspec::guess_from_pubspec_yaml(&path, &settings) },
3063        )
3064    }),
3065    ("pubspec.yml", |path, settings| {
3066        Box::pin(
3067            async move { crate::providers::pubspec::guess_from_pubspec_yaml(&path, &settings) },
3068        )
3069    }),
3070    ("meson.build", |path, settings| {
3071        Box::pin(async move { crate::providers::meson::guess_from_meson(&path, &settings) })
3072    }),
3073    ("metadata.json", |path, settings| {
3074        Box::pin(async move {
3075            crate::providers::metadata_json::guess_from_metadata_json(&path, &settings)
3076        })
3077    }),
3078    (".travis.yml", |path, settings| {
3079        Box::pin(async move { crate::guess_from_travis_yml(&path, &settings) })
3080    }),
3081];
3082
3083fn find_guessers(path: &std::path::Path) -> Vec<Box<dyn Guesser>> {
3084    let mut candidates: Vec<Box<dyn Guesser>> = Vec::new();
3085
3086    let path = path.canonicalize().unwrap();
3087
3088    for (name, cb) in OLD_STATIC_GUESSERS {
3089        let subpath = path.join(name);
3090        if subpath.exists() {
3091            candidates.push(Box::new(PathGuesser {
3092                name: name.to_string(),
3093                subpath: subpath.clone(),
3094                cb: Box::new(move |p, s| Box::pin(cb(p.to_path_buf(), s.clone()))),
3095            }));
3096        }
3097    }
3098
3099    for name in ["SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md"].iter() {
3100        if path.join(name).exists() {
3101            let subpath = path.join(name);
3102            candidates.push(Box::new(PathGuesser {
3103                name: name.to_string(),
3104                subpath: subpath.clone(),
3105                cb: Box::new(|p, s| {
3106                    let name = name.to_string();
3107                    Box::pin(async move {
3108                        crate::providers::security_md::guess_from_security_md(&name, &p, &s)
3109                    })
3110                }),
3111            }));
3112        }
3113    }
3114
3115    #[cfg(any(feature = "python-pkginfo", feature = "pyo3"))]
3116    let mut found_pkg_info = path.join("PKG-INFO").exists();
3117    #[cfg(feature = "python-pkginfo")]
3118    for entry in std::fs::read_dir(&path).unwrap() {
3119        let entry = entry.unwrap();
3120        let filename = entry.file_name().to_string_lossy().to_string();
3121        if filename.ends_with(".egg-info") {
3122            candidates.push(Box::new(PathGuesser {
3123                name: format!("{}/PKG-INFO", filename),
3124                subpath: entry.path().join("PKG-INFO"),
3125                cb: Box::new(|p, s| {
3126                    Box::pin(
3127                        async move { crate::providers::python::guess_from_pkg_info(&p, &s).await },
3128                    )
3129                }),
3130            }));
3131            found_pkg_info = true;
3132        } else if filename.ends_with(".dist-info") {
3133            candidates.push(Box::new(PathGuesser {
3134                name: format!("{}/METADATA", filename),
3135                subpath: entry.path().join("METADATA"),
3136                cb: Box::new(|p, s| {
3137                    Box::pin(
3138                        async move { crate::providers::python::guess_from_pkg_info(&p, &s).await },
3139                    )
3140                }),
3141            }));
3142            found_pkg_info = true;
3143        }
3144    }
3145
3146    #[cfg(feature = "pyo3")]
3147    if !found_pkg_info && path.join("setup.py").exists() {
3148        candidates.push(Box::new(PathGuesser {
3149            name: "setup.py".to_string(),
3150            subpath: path.join("setup.py"),
3151            cb: Box::new(|path, s| {
3152                Box::pin(async move {
3153                    crate::providers::python::guess_from_setup_py(&path, s.trust_package).await
3154                })
3155            }),
3156        }));
3157    }
3158
3159    for entry in std::fs::read_dir(&path).unwrap() {
3160        let entry = entry.unwrap();
3161
3162        if entry.file_name().to_string_lossy().ends_with(".gemspec") {
3163            candidates.push(Box::new(PathGuesser {
3164                name: entry.file_name().to_string_lossy().to_string(),
3165                subpath: entry.path(),
3166                cb: Box::new(|p, s| {
3167                    Box::pin(
3168                        async move { crate::providers::ruby::guess_from_gemspec(&p, &s).await },
3169                    )
3170                }),
3171            }));
3172        }
3173    }
3174
3175    // TODO(jelmer): Perhaps scan all directories if no other primary project information file has been found?
3176    #[cfg(feature = "r-description")]
3177    for entry in std::fs::read_dir(&path).unwrap() {
3178        let entry = entry.unwrap();
3179        let path = entry.path();
3180
3181        if entry.file_type().unwrap().is_dir() {
3182            let description_name = format!("{}/DESCRIPTION", entry.file_name().to_string_lossy());
3183            if path.join(&description_name).exists() {
3184                candidates.push(Box::new(PathGuesser {
3185                    name: description_name,
3186                    subpath: path.join("DESCRIPTION"),
3187                    cb: Box::new(|p, s| {
3188                        Box::pin(async move {
3189                            crate::providers::r::guess_from_r_description(&p, &s).await
3190                        })
3191                    }),
3192                }));
3193            }
3194        }
3195    }
3196
3197    let mut doap_filenames = std::fs::read_dir(&path)
3198        .unwrap()
3199        .filter_map(|entry| {
3200            let entry = entry.unwrap();
3201            let filename = entry.file_name().to_string_lossy().to_string();
3202            if filename.ends_with(".doap")
3203                || (filename.ends_with(".xml") && filename.starts_with("doap_XML_"))
3204            {
3205                Some(entry.file_name())
3206            } else {
3207                None
3208            }
3209        })
3210        .collect::<Vec<_>>();
3211
3212    if doap_filenames.len() == 1 {
3213        let doap_filename = doap_filenames.remove(0);
3214        candidates.push(Box::new(PathGuesser {
3215            name: doap_filename.to_string_lossy().to_string(),
3216            subpath: path.join(&doap_filename),
3217            cb: Box::new(|p, s| {
3218                Box::pin(
3219                    async move { crate::providers::doap::guess_from_doap(&p, s.trust_package) },
3220                )
3221            }),
3222        }));
3223    } else if doap_filenames.len() > 1 {
3224        log::warn!(
3225            "Multiple DOAP files found: {:?}, ignoring all.",
3226            doap_filenames
3227        );
3228    }
3229
3230    let mut metainfo_filenames = std::fs::read_dir(&path)
3231        .unwrap()
3232        .filter_map(|entry| {
3233            let entry = entry.unwrap();
3234            if entry
3235                .file_name()
3236                .to_string_lossy()
3237                .ends_with(".metainfo.xml")
3238            {
3239                Some(entry.file_name())
3240            } else {
3241                None
3242            }
3243        })
3244        .collect::<Vec<_>>();
3245
3246    if metainfo_filenames.len() == 1 {
3247        let metainfo_filename = metainfo_filenames.remove(0);
3248        candidates.push(Box::new(PathGuesser {
3249            name: metainfo_filename.to_string_lossy().to_string(),
3250            subpath: path.join(&metainfo_filename),
3251            cb: Box::new(|p, s| {
3252                Box::pin(async move {
3253                    crate::providers::metainfo::guess_from_metainfo(&p, s.trust_package)
3254                })
3255            }),
3256        }));
3257    } else if metainfo_filenames.len() > 1 {
3258        log::warn!(
3259            "Multiple metainfo files found: {:?}, ignoring all.",
3260            metainfo_filenames
3261        );
3262    }
3263
3264    let mut cabal_filenames = std::fs::read_dir(&path)
3265        .unwrap()
3266        .filter_map(|entry| {
3267            let entry = entry.unwrap();
3268            if entry.file_name().to_string_lossy().ends_with(".cabal") {
3269                Some(entry.file_name())
3270            } else {
3271                None
3272            }
3273        })
3274        .collect::<Vec<_>>();
3275
3276    if cabal_filenames.len() == 1 {
3277        let cabal_filename = cabal_filenames.remove(0);
3278        candidates.push(Box::new(PathGuesser {
3279            name: cabal_filename.to_string_lossy().to_string(),
3280            subpath: path.join(&cabal_filename),
3281            cb: Box::new(|path, s| {
3282                Box::pin(async move {
3283                    crate::providers::haskell::guess_from_cabal(&path, s.trust_package)
3284                })
3285            }),
3286        }));
3287    } else if cabal_filenames.len() > 1 {
3288        log::warn!(
3289            "Multiple cabal files found: {:?}, ignoring all.",
3290            cabal_filenames
3291        );
3292    }
3293
3294    let readme_filenames = std::fs::read_dir(&path)
3295        .unwrap()
3296        .filter_map(|entry| {
3297            let entry = entry.unwrap();
3298            let filename = entry.file_name().to_string_lossy().to_string();
3299            if !(filename.to_lowercase().starts_with("readme")
3300                || filename.to_lowercase().starts_with("hacking")
3301                || filename.to_lowercase().starts_with("contributing"))
3302            {
3303                return None;
3304            }
3305
3306            if filename.ends_with('~') {
3307                return None;
3308            }
3309
3310            let extension = entry
3311                .path()
3312                .extension()
3313                .map(|s| s.to_string_lossy().to_string());
3314
3315            if extension.as_deref() == Some("html")
3316                || extension.as_deref() == Some("pdf")
3317                || extension.as_deref() == Some("xml")
3318            {
3319                return None;
3320            }
3321            Some(entry.file_name())
3322        })
3323        .collect::<Vec<_>>();
3324
3325    for filename in readme_filenames {
3326        candidates.push(Box::new(PathGuesser {
3327            name: filename.to_string_lossy().to_string(),
3328            subpath: path.join(&filename),
3329            cb: Box::new(|path, s| {
3330                Box::pin(
3331                    async move { crate::readme::guess_from_readme(&path, s.trust_package).await },
3332                )
3333            }),
3334        }));
3335    }
3336
3337    let mut nuspec_filenames = std::fs::read_dir(&path)
3338        .unwrap()
3339        .filter_map(|entry| {
3340            let entry = entry.unwrap();
3341            if entry.file_name().to_string_lossy().ends_with(".nuspec") {
3342                Some(entry.file_name())
3343            } else {
3344                None
3345            }
3346        })
3347        .collect::<Vec<_>>();
3348
3349    if nuspec_filenames.len() == 1 {
3350        let nuspec_filename = nuspec_filenames.remove(0);
3351        candidates.push(Box::new(PathGuesser {
3352            name: nuspec_filename.to_string_lossy().to_string(),
3353            subpath: path.join(&nuspec_filename),
3354            cb: Box::new(|path, s| {
3355                Box::pin(async move {
3356                    crate::providers::nuspec::guess_from_nuspec(&path, s.trust_package).await
3357                })
3358            }),
3359        }));
3360    } else if nuspec_filenames.len() > 1 {
3361        log::warn!(
3362            "Multiple nuspec files found: {:?}, ignoring all.",
3363            nuspec_filenames
3364        );
3365    }
3366
3367    #[cfg(feature = "opam")]
3368    let mut opam_filenames = std::fs::read_dir(&path)
3369        .unwrap()
3370        .filter_map(|entry| {
3371            let entry = entry.unwrap();
3372            if entry.file_name().to_string_lossy().ends_with(".opam") {
3373                Some(entry.file_name())
3374            } else {
3375                None
3376            }
3377        })
3378        .collect::<Vec<_>>();
3379
3380    #[cfg(feature = "opam")]
3381    match opam_filenames.len().cmp(&1) {
3382        Ordering::Equal => {
3383            let opam_filename = opam_filenames.remove(0);
3384            candidates.push(Box::new(PathGuesser {
3385                name: opam_filename.to_string_lossy().to_string(),
3386                subpath: path.join(&opam_filename),
3387                cb: Box::new(|path, s| {
3388                    Box::pin(async move {
3389                        crate::providers::ocaml::guess_from_opam(&path, s.trust_package)
3390                    })
3391                }),
3392            }));
3393        }
3394        Ordering::Greater => {
3395            log::warn!(
3396                "Multiple opam files found: {:?}, ignoring all.",
3397                opam_filenames
3398            );
3399        }
3400        Ordering::Less => {}
3401    }
3402
3403    let debian_patches = match std::fs::read_dir(path.join("debian").join("patches")) {
3404        Ok(patches) => patches
3405            .filter_map(|entry| {
3406                let entry = entry.unwrap();
3407                if entry.file_name().to_string_lossy().ends_with(".patch") {
3408                    Some(format!(
3409                        "debian/patches/{}",
3410                        entry.file_name().to_string_lossy()
3411                    ))
3412                } else {
3413                    None
3414                }
3415            })
3416            .collect::<Vec<_>>(),
3417        Err(_) => Vec::new(),
3418    };
3419
3420    for filename in debian_patches {
3421        candidates.push(Box::new(PathGuesser {
3422            name: filename.clone(),
3423            subpath: path.join(&filename),
3424            cb: Box::new(|path, s| {
3425                Box::pin(async move {
3426                    crate::providers::debian::guess_from_debian_patch(&path, &s).await
3427                })
3428            }),
3429        }));
3430    }
3431
3432    candidates.push(Box::new(EnvironmentGuesser::new()));
3433    candidates.push(Box::new(PathGuesser {
3434        name: ".".to_string(),
3435        subpath: path.clone(),
3436        cb: Box::new(|p, s| Box::pin(async move { crate::guess_from_path(&p, &s) })),
3437    }));
3438
3439    candidates
3440}
3441
3442pub(crate) fn stream(
3443    path: &Path,
3444    config: &GuesserSettings,
3445    guessers: Vec<Box<dyn Guesser>>,
3446) -> impl Stream<Item = Result<UpstreamDatumWithMetadata, ProviderError>> {
3447    // For each of the guessers, create concurrent tasks that run the guessers in parallel
3448    let abspath = std::env::current_dir().unwrap().join(path);
3449    let config = config.clone();
3450
3451    // Run guessers concurrently using buffered (no tokio::spawn required)
3452    futures::stream::iter(guessers)
3453        .map(move |mut guesser| {
3454            let abspath = abspath.clone();
3455            let config = config.clone();
3456            let guesser_name = guesser.name().to_string();
3457
3458            async move {
3459                let results = match guesser.guess(&config).await {
3460                    Ok(results) => results,
3461                    Err(e) => return futures::stream::iter(vec![Err(e)]).boxed(),
3462                };
3463
3464                futures::stream::iter(results.into_iter().map(move |mut datum| {
3465                    rewrite_upstream_datum(&guesser_name, &mut datum, &abspath);
3466                    Ok(datum)
3467                }))
3468                .boxed()
3469            }
3470        })
3471        .buffered(10) // Run up to 10 guessers concurrently while preserving order
3472        .flatten()
3473}
3474
3475fn rewrite_upstream_datum(
3476    guesser_name: &str,
3477    datum: &mut UpstreamDatumWithMetadata,
3478    abspath: &std::path::Path,
3479) {
3480    log::trace!("{}: {:?}", guesser_name, datum);
3481    datum.origin = datum
3482        .origin
3483        .clone()
3484        .or(Some(Origin::Other(guesser_name.to_string())));
3485    if let Some(Origin::Path(p)) = datum.origin.as_ref() {
3486        if let Ok(suffix) = p.strip_prefix(abspath) {
3487            if suffix.to_str().unwrap().is_empty() {
3488                datum.origin = Some(Origin::Path(PathBuf::from_str(".").unwrap()));
3489            } else {
3490                datum.origin = Some(Origin::Path(PathBuf::from_str(".").unwrap().join(suffix)));
3491            }
3492        }
3493    }
3494}
3495
3496/// Creates a stream of upstream metadata by running all applicable guessers
3497pub fn upstream_metadata_stream(
3498    path: &std::path::Path,
3499    trust_package: Option<bool>,
3500) -> impl Stream<Item = Result<UpstreamDatumWithMetadata, ProviderError>> {
3501    let trust_package = trust_package.unwrap_or(false);
3502
3503    let guessers = find_guessers(path);
3504
3505    stream(path, &GuesserSettings { trust_package }, guessers)
3506}
3507
3508/// Extends upstream metadata with additional information from external sources
3509pub async fn extend_upstream_metadata(
3510    upstream_metadata: &mut UpstreamMetadata,
3511    path: &std::path::Path,
3512    minimum_certainty: Option<Certainty>,
3513    net_access: Option<bool>,
3514    consult_external_directory: Option<bool>,
3515) -> Result<(), ProviderError> {
3516    let net_access = net_access.unwrap_or(false);
3517    let consult_external_directory = consult_external_directory.unwrap_or(false);
3518    let minimum_certainty = minimum_certainty.unwrap_or(Certainty::Confident);
3519
3520    // TODO(jelmer): Use EXTRAPOLATE_FNS mechanism for this?
3521    for field in [
3522        "Homepage",
3523        "Bug-Database",
3524        "Bug-Submit",
3525        "Repository",
3526        "Repository-Browse",
3527        "Download",
3528    ] {
3529        let value = match upstream_metadata.get(field) {
3530            Some(value) => value,
3531            None => continue,
3532        };
3533
3534        if let Some(project) =
3535            crate::forges::sourceforge::extract_sf_project_name(value.datum.as_str().unwrap())
3536        {
3537            let certainty = Some(
3538                std::cmp::min(Some(Certainty::Likely), value.certainty)
3539                    .unwrap_or(Certainty::Likely),
3540            );
3541            upstream_metadata.insert(UpstreamDatumWithMetadata {
3542                datum: UpstreamDatum::Archive("SourceForge".to_string()),
3543                certainty,
3544                origin: Some(Origin::Other(format!("derived from {}", field))),
3545            });
3546            upstream_metadata.insert(UpstreamDatumWithMetadata {
3547                datum: UpstreamDatum::SourceForgeProject(project),
3548                certainty,
3549                origin: Some(Origin::Other(format!("derived from {}", field))),
3550            });
3551            break;
3552        }
3553    }
3554
3555    let archive = upstream_metadata.get("Archive");
3556    if archive.is_some()
3557        && archive.unwrap().datum.as_str().unwrap() == "SourceForge"
3558        && upstream_metadata.contains_key("SourceForge-Project")
3559        && net_access
3560    {
3561        let sf_project = upstream_metadata
3562            .get("SourceForge-Project")
3563            .unwrap()
3564            .datum
3565            .as_str()
3566            .unwrap()
3567            .to_string();
3568        let sf_certainty = archive.unwrap().certainty;
3569        SourceForge::new()
3570            .extend_metadata(
3571                upstream_metadata.mut_items(),
3572                sf_project.as_str(),
3573                sf_certainty,
3574            )
3575            .await;
3576    }
3577
3578    let archive = upstream_metadata.get("Archive");
3579    if archive.is_some()
3580        && archive.unwrap().datum.as_str().unwrap() == "Hackage"
3581        && upstream_metadata.contains_key("Hackage-Package")
3582        && net_access
3583    {
3584        let hackage_package = upstream_metadata
3585            .get("Hackage-Package")
3586            .unwrap()
3587            .datum
3588            .as_str()
3589            .unwrap()
3590            .to_string();
3591        let hackage_certainty = archive.unwrap().certainty;
3592
3593        crate::providers::haskell::Hackage::new()
3594            .extend_metadata(
3595                upstream_metadata.mut_items(),
3596                hackage_package.as_str(),
3597                hackage_certainty,
3598            )
3599            .await
3600            .unwrap();
3601    }
3602
3603    let archive = upstream_metadata.get("Archive");
3604    #[cfg(feature = "cargo")]
3605    if archive.is_some()
3606        && archive.unwrap().datum.as_str().unwrap() == "crates.io"
3607        && upstream_metadata.contains_key("Cargo-Crate")
3608        && net_access
3609    {
3610        let cargo_crate = upstream_metadata
3611            .get("Cargo-Crate")
3612            .unwrap()
3613            .datum
3614            .as_str()
3615            .unwrap()
3616            .to_string();
3617        let crates_io_certainty = upstream_metadata.get("Archive").unwrap().certainty;
3618        crate::providers::rust::CratesIo::new()
3619            .extend_metadata(
3620                upstream_metadata.mut_items(),
3621                cargo_crate.as_str(),
3622                crates_io_certainty,
3623            )
3624            .await
3625            .unwrap();
3626    }
3627
3628    let archive = upstream_metadata.get("Archive");
3629    if archive.is_some()
3630        && archive.unwrap().datum.as_str().unwrap() == "Pecl"
3631        && upstream_metadata.contains_key("Pecl-Package")
3632        && net_access
3633    {
3634        let pecl_package = upstream_metadata
3635            .get("Pecl-Package")
3636            .unwrap()
3637            .datum
3638            .as_str()
3639            .unwrap()
3640            .to_string();
3641        let pecl_certainty = upstream_metadata.get("Archive").unwrap().certainty;
3642        crate::providers::php::Pecl::new()
3643            .extend_metadata(
3644                upstream_metadata.mut_items(),
3645                pecl_package.as_str(),
3646                pecl_certainty,
3647            )
3648            .await
3649            .unwrap();
3650    }
3651
3652    #[cfg(feature = "debian")]
3653    if net_access && consult_external_directory {
3654        // TODO(jelmer): Don't assume debian/control exists
3655        let package = match debian_control::Control::from_file_relaxed(path.join("debian/control"))
3656        {
3657            Ok((control, _)) => control.source().and_then(|s| s.name()),
3658            Err(_) => None,
3659        };
3660
3661        if let Some(package) = package {
3662            #[cfg(feature = "launchpad")]
3663            extend_from_lp(
3664                upstream_metadata.mut_items(),
3665                minimum_certainty,
3666                package.as_str(),
3667                None,
3668                None,
3669            )
3670            .await;
3671            crate::providers::arch::Aur::new()
3672                .extend_metadata(
3673                    upstream_metadata.mut_items(),
3674                    package.as_str(),
3675                    Some(minimum_certainty),
3676                )
3677                .await
3678                .unwrap();
3679            crate::providers::gobo::Gobo::new()
3680                .extend_metadata(
3681                    upstream_metadata.mut_items(),
3682                    package.as_str(),
3683                    Some(minimum_certainty),
3684                )
3685                .await
3686                .unwrap();
3687            extend_from_repology(
3688                upstream_metadata.mut_items(),
3689                minimum_certainty,
3690                package.as_str(),
3691            )
3692            .await;
3693        }
3694    }
3695    crate::extrapolate::extrapolate_fields(upstream_metadata, net_access, None).await?;
3696    Ok(())
3697}
3698
3699/// Trait for third-party repositories that can provide upstream metadata
3700#[async_trait::async_trait]
3701pub trait ThirdPartyRepository {
3702    /// Returns the name of the repository
3703    fn name(&self) -> &'static str;
3704    /// Returns the list of fields this repository can provide
3705    fn supported_fields(&self) -> &'static [&'static str];
3706    /// Returns the maximum certainty level this repository can provide
3707    fn max_supported_certainty(&self) -> Certainty;
3708
3709    /// Extends metadata with information from this repository
3710    async fn extend_metadata(
3711        &self,
3712        metadata: &mut Vec<UpstreamDatumWithMetadata>,
3713        name: &str,
3714        min_certainty: Option<Certainty>,
3715    ) -> Result<(), ProviderError> {
3716        if min_certainty.is_some() && min_certainty.unwrap() > self.max_supported_certainty() {
3717            // Don't bother if we can't meet minimum certainty
3718            return Ok(());
3719        }
3720
3721        extend_from_external_guesser(
3722            metadata,
3723            Some(self.max_supported_certainty()),
3724            self.supported_fields(),
3725            || async { self.guess_metadata(name).await.unwrap() },
3726        )
3727        .await;
3728
3729        Ok(())
3730    }
3731
3732    /// Guesses metadata for a given package name
3733    async fn guess_metadata(&self, name: &str) -> Result<Vec<UpstreamDatum>, ProviderError>;
3734}
3735
3736#[cfg(feature = "launchpad")]
3737async fn extend_from_lp(
3738    upstream_metadata: &mut Vec<UpstreamDatumWithMetadata>,
3739    minimum_certainty: Certainty,
3740    package: &str,
3741    distribution: Option<&str>,
3742    suite: Option<&str>,
3743) {
3744    // The set of fields that Launchpad can possibly provide:
3745    let lp_fields = &["Homepage", "Repository", "Name", "Download"][..];
3746    let lp_certainty = Certainty::Possible;
3747
3748    if lp_certainty < minimum_certainty {
3749        // Don't bother talking to launchpad if we're not
3750        // speculating.
3751        return;
3752    }
3753
3754    extend_from_external_guesser(upstream_metadata, Some(lp_certainty), lp_fields, || async {
3755        crate::providers::launchpad::guess_from_launchpad(package, distribution, suite)
3756            .await
3757            .unwrap()
3758    })
3759    .await
3760}
3761
3762async fn extend_from_repology(
3763    upstream_metadata: &mut Vec<UpstreamDatumWithMetadata>,
3764    minimum_certainty: Certainty,
3765    source_package: &str,
3766) {
3767    // The set of fields that repology can possibly provide:
3768    let repology_fields = &["Homepage", "License", "Summary", "Download"][..];
3769    let certainty = Certainty::Confident;
3770
3771    if certainty < minimum_certainty {
3772        // Don't bother talking to repology if we're not speculating.
3773        return;
3774    }
3775
3776    extend_from_external_guesser(
3777        upstream_metadata,
3778        Some(certainty),
3779        repology_fields,
3780        || async {
3781            crate::providers::repology::guess_from_repology(source_package)
3782                .await
3783                .unwrap()
3784        },
3785    )
3786    .await
3787}
3788
3789/// Fix existing upstream metadata.
3790pub async fn fix_upstream_metadata(upstream_metadata: &mut UpstreamMetadata) {
3791    if let Some(repository) = upstream_metadata.get_mut("Repository") {
3792        if let Some(repo_str) = repository.datum.as_str() {
3793            let url = crate::vcs::sanitize_url(repo_str).await;
3794            repository.datum = UpstreamDatum::Repository(url.to_string());
3795        }
3796    }
3797
3798    if let Some(summary) = upstream_metadata.get_mut("Summary") {
3799        if let Some(s) = summary.datum.as_str() {
3800            let s = s.split_once(". ").map_or(s, |(a, _)| a);
3801            let s = s.trim_end().trim_end_matches('.');
3802            summary.datum = UpstreamDatum::Summary(s.to_string());
3803        }
3804    }
3805}
3806
3807/// Summarize the upstream metadata into a dictionary.
3808///
3809/// # Arguments
3810/// * `metadata_items`: Iterator over metadata items
3811/// * `path`: Path to the package
3812/// * `trust_package`: Whether to trust the package contents and i.e. run executables in it
3813/// * `net_access`: Whether to allow net access
3814/// * `consult_external_directory`: Whether to pull in data from external (user-maintained) directories.
3815pub async fn summarize_upstream_metadata(
3816    metadata_items: impl Stream<Item = UpstreamDatumWithMetadata>,
3817    path: &std::path::Path,
3818    net_access: Option<bool>,
3819    consult_external_directory: Option<bool>,
3820    check: Option<bool>,
3821) -> Result<UpstreamMetadata, ProviderError> {
3822    let check = check.unwrap_or(false);
3823    let mut upstream_metadata = UpstreamMetadata::new();
3824
3825    let metadata_items = metadata_items.filter_map(|item| async move {
3826        let bad: bool = item.datum.known_bad_guess();
3827        if bad {
3828            log::debug!("Excluding known bad item {:?}", item);
3829            None
3830        } else {
3831            Some(item)
3832        }
3833    });
3834
3835    let metadata_items = metadata_items.collect::<Vec<_>>().await;
3836
3837    upstream_metadata.update(metadata_items.into_iter());
3838
3839    extend_upstream_metadata(
3840        &mut upstream_metadata,
3841        path,
3842        None,
3843        net_access,
3844        consult_external_directory,
3845    )
3846    .await?;
3847
3848    if check {
3849        check_upstream_metadata(&mut upstream_metadata, None).await;
3850    }
3851
3852    fix_upstream_metadata(&mut upstream_metadata).await;
3853
3854    // Sort by name
3855    upstream_metadata.sort();
3856
3857    Ok(upstream_metadata)
3858}
3859
3860/// Guess upstream metadata items, in no particular order.
3861///
3862/// # Arguments
3863/// * `path`: Path to the package
3864/// * `trust_package`: Whether to trust the package contents and i.e. run executables in it
3865/// * `minimum_certainty`: Minimum certainty of guesses to return
3866pub fn guess_upstream_metadata_items(
3867    path: &std::path::Path,
3868    trust_package: Option<bool>,
3869    minimum_certainty: Option<Certainty>,
3870) -> impl Stream<Item = Result<UpstreamDatumWithMetadata, ProviderError>> {
3871    let items = upstream_metadata_stream(path, trust_package);
3872
3873    items.filter_map(move |e| async move {
3874        match e {
3875            Err(e) => Some(Err(e)),
3876            Ok(UpstreamDatumWithMetadata {
3877                datum,
3878                certainty,
3879                origin,
3880            }) => {
3881                if minimum_certainty.is_some() && certainty < minimum_certainty {
3882                    None
3883                } else {
3884                    Some(Ok(UpstreamDatumWithMetadata {
3885                        datum,
3886                        certainty,
3887                        origin,
3888                    }))
3889                }
3890            }
3891        }
3892    })
3893}
3894
3895/// Gets upstream information for a project
3896pub async fn get_upstream_info(
3897    path: &std::path::Path,
3898    trust_package: Option<bool>,
3899    net_access: Option<bool>,
3900    consult_external_directory: Option<bool>,
3901    check: Option<bool>,
3902) -> Result<UpstreamMetadata, ProviderError> {
3903    let metadata_items = upstream_metadata_stream(path, trust_package);
3904
3905    let metadata_items = metadata_items.filter_map(|x| async {
3906        match x {
3907            Ok(x) => Some(x),
3908            Err(e) => {
3909                log::error!("{}", e);
3910                None
3911            }
3912        }
3913    });
3914
3915    summarize_upstream_metadata(
3916        metadata_items,
3917        path,
3918        net_access,
3919        consult_external_directory,
3920        check,
3921    )
3922    .await
3923}
3924
3925/// Guess the upstream metadata dictionary.
3926///
3927/// # Arguments
3928/// * `path`: Path to the package
3929/// * `trust_package`: Whether to trust the package contents and i.e. run executables in it
3930/// * `net_access`: Whether to allow net access
3931/// * `consult_external_directory`: Whether to pull in data from external (user-maintained) directories.
3932pub async fn guess_upstream_metadata(
3933    path: &std::path::Path,
3934    trust_package: Option<bool>,
3935    net_access: Option<bool>,
3936    consult_external_directory: Option<bool>,
3937    check: Option<bool>,
3938) -> Result<UpstreamMetadata, ProviderError> {
3939    let metadata_items = guess_upstream_metadata_items(path, trust_package, None);
3940
3941    let metadata_items = metadata_items.filter_map(|x| async {
3942        match x {
3943            Ok(x) => Some(x),
3944            Err(e) => {
3945                log::error!("{}", e);
3946                None
3947            }
3948        }
3949    });
3950    summarize_upstream_metadata(
3951        metadata_items,
3952        path,
3953        net_access,
3954        consult_external_directory,
3955        check,
3956    )
3957    .await
3958}
3959
3960/// Verifies that screenshot URLs are accessible
3961pub async fn verify_screenshots(urls: &[&str]) -> Vec<(String, Option<bool>)> {
3962    let mut ret = Vec::new();
3963    for url in urls {
3964        let mut request = reqwest::Request::new(reqwest::Method::GET, url.parse().unwrap());
3965        request.headers_mut().insert(
3966            reqwest::header::USER_AGENT,
3967            reqwest::header::HeaderValue::from_static(USER_AGENT),
3968        );
3969
3970        match reqwest::Client::new().execute(request).await {
3971            Ok(response) => {
3972                let status = response.status();
3973                if status.is_success() {
3974                    ret.push((url.to_string(), Some(true)));
3975                } else if status.is_client_error() {
3976                    ret.push((url.to_string(), Some(false)));
3977                } else {
3978                    ret.push((url.to_string(), None));
3979                }
3980            }
3981            Err(e) => {
3982                log::debug!("Error fetching {}: {}", url, e);
3983                ret.push((url.to_string(), None));
3984            }
3985        }
3986    }
3987
3988    ret
3989}
3990
3991/// Check upstream metadata.
3992///
3993/// This will make network connections, etc.
3994pub async fn check_upstream_metadata(
3995    upstream_metadata: &mut UpstreamMetadata,
3996    version: Option<&str>,
3997) {
3998    let repository = upstream_metadata.get_mut("Repository");
3999    if let Some(repository) = repository {
4000        if let Some(repo_url) = repository.datum.to_url() {
4001            match vcs::check_repository_url_canonical(repo_url, version).await {
4002                Ok(canonical_url) => {
4003                    repository.datum = UpstreamDatum::Repository(canonical_url.to_string());
4004                    if repository.certainty == Some(Certainty::Confident) {
4005                        repository.certainty = Some(Certainty::Certain);
4006                    }
4007                    if let Some(url) = repository.datum.to_url() {
4008                        let derived_browse_url = vcs::browse_url_from_repo_url(
4009                            &vcs::VcsLocation {
4010                                url,
4011                                branch: None,
4012                                subpath: None,
4013                            },
4014                            Some(true),
4015                        )
4016                        .await;
4017                        let certainty = repository.certainty;
4018                        if let Some(browse_repo) = upstream_metadata.get_mut("Repository-Browse") {
4019                            if derived_browse_url == browse_repo.datum.to_url() {
4020                                browse_repo.certainty = certainty;
4021                            }
4022                        }
4023                    }
4024                }
4025                Err(CanonicalizeError::Unverifiable(u, _))
4026                | Err(CanonicalizeError::RateLimited(u)) => {
4027                    log::debug!("Unverifiable URL: {}", u);
4028                }
4029                Err(CanonicalizeError::InvalidUrl(u, e)) => {
4030                    log::debug!("Deleting invalid Repository URL {}: {}", u, e);
4031                    upstream_metadata.remove("Repository");
4032                }
4033            }
4034        } else {
4035            log::debug!("Repository field is not a valid URL, skipping check");
4036        }
4037    }
4038    let homepage = upstream_metadata.get_mut("Homepage");
4039    if let Some(homepage) = homepage {
4040        if let Some(homepage_url) = homepage.datum.to_url() {
4041            match check_url_canonical(&homepage_url).await {
4042                Ok(canonical_url) => {
4043                    homepage.datum = UpstreamDatum::Homepage(canonical_url.to_string());
4044                    if homepage.certainty >= Some(Certainty::Likely) {
4045                        homepage.certainty = Some(Certainty::Certain);
4046                    }
4047                }
4048                Err(CanonicalizeError::Unverifiable(u, _))
4049                | Err(CanonicalizeError::RateLimited(u)) => {
4050                    log::debug!("Unverifiable URL: {}", u);
4051                }
4052                Err(CanonicalizeError::InvalidUrl(u, e)) => {
4053                    log::debug!("Deleting invalid Homepage URL {}: {}", u, e);
4054                    upstream_metadata.remove("Homepage");
4055                }
4056            }
4057        } else {
4058            log::debug!("Homepage field is not a valid URL, skipping check");
4059        }
4060    }
4061    if let Some(repository_browse) = upstream_metadata.get_mut("Repository-Browse") {
4062        if let Some(browse_url) = repository_browse.datum.to_url() {
4063            match check_url_canonical(&browse_url).await {
4064                Ok(u) => {
4065                    repository_browse.datum = UpstreamDatum::RepositoryBrowse(u.to_string());
4066                    if repository_browse.certainty >= Some(Certainty::Likely) {
4067                        repository_browse.certainty = Some(Certainty::Certain);
4068                    }
4069                }
4070                Err(CanonicalizeError::InvalidUrl(u, e)) => {
4071                    log::debug!("Deleting invalid Repository-Browse URL {}: {}", u, e);
4072                    upstream_metadata.remove("Repository-Browse");
4073                }
4074                Err(CanonicalizeError::Unverifiable(u, _))
4075                | Err(CanonicalizeError::RateLimited(u)) => {
4076                    log::debug!("Unable to verify Repository-Browse URL {}", u);
4077                }
4078            }
4079        } else {
4080            log::debug!("Repository-Browse field is not a valid URL, skipping check");
4081        }
4082    }
4083    if let Some(bug_database) = upstream_metadata.get_mut("Bug-Database") {
4084        if let Some(bug_db_url) = bug_database.datum.to_url() {
4085            match check_bug_database_canonical(&bug_db_url, Some(true)).await {
4086                Ok(u) => {
4087                    bug_database.datum = UpstreamDatum::BugDatabase(u.to_string());
4088                    if bug_database.certainty >= Some(Certainty::Likely) {
4089                        bug_database.certainty = Some(Certainty::Certain);
4090                    }
4091                }
4092                Err(CanonicalizeError::InvalidUrl(u, e)) => {
4093                    log::debug!("Deleting invalid Bug-Database URL {}: {}", u, e);
4094                    upstream_metadata.remove("Bug-Database");
4095                }
4096                Err(CanonicalizeError::Unverifiable(u, _))
4097                | Err(CanonicalizeError::RateLimited(u)) => {
4098                    log::debug!("Unable to verify Bug-Database URL {}", u);
4099                }
4100            }
4101        } else {
4102            log::debug!("Bug-Database field is not a valid URL, skipping check");
4103        }
4104    }
4105    let bug_submit = upstream_metadata.get_mut("Bug-Submit");
4106    if let Some(bug_submit) = bug_submit {
4107        if let Some(bug_submit_url) = bug_submit.datum.to_url() {
4108            match check_bug_submit_url_canonical(&bug_submit_url, Some(true)).await {
4109                Ok(u) => {
4110                    bug_submit.datum = UpstreamDatum::BugSubmit(u.to_string());
4111                    if bug_submit.certainty >= Some(Certainty::Likely) {
4112                        bug_submit.certainty = Some(Certainty::Certain);
4113                    }
4114                }
4115                Err(CanonicalizeError::InvalidUrl(u, e)) => {
4116                    log::debug!("Deleting invalid Bug-Submit URL {}: {}", u, e);
4117                    upstream_metadata.remove("Bug-Submit");
4118                }
4119                Err(CanonicalizeError::Unverifiable(u, _))
4120                | Err(CanonicalizeError::RateLimited(u)) => {
4121                    log::debug!("Unable to verify Bug-Submit URL {}", u);
4122                }
4123            }
4124        } else {
4125            log::debug!("Bug-Submit field is not a valid URL, skipping check");
4126        }
4127    }
4128    let mut screenshots = upstream_metadata.get_mut("Screenshots");
4129    if screenshots.is_some() && screenshots.as_ref().unwrap().certainty == Some(Certainty::Likely) {
4130        let mut newvalue = vec![];
4131        screenshots.as_mut().unwrap().certainty = Some(Certainty::Certain);
4132        let urls = match &screenshots.as_ref().unwrap().datum {
4133            UpstreamDatum::Screenshots(urls) => urls,
4134            _ => unreachable!(),
4135        };
4136        for (url, status) in verify_screenshots(
4137            urls.iter()
4138                .map(|x| x.as_str())
4139                .collect::<Vec<&str>>()
4140                .as_slice(),
4141        )
4142        .await
4143        {
4144            match status {
4145                Some(true) => {
4146                    newvalue.push(url);
4147                }
4148                Some(false) => {}
4149                None => {
4150                    screenshots.as_mut().unwrap().certainty = Some(Certainty::Likely);
4151                }
4152            }
4153        }
4154        screenshots.as_mut().unwrap().datum = UpstreamDatum::Screenshots(newvalue);
4155    }
4156}
4157
4158#[async_trait::async_trait]
4159pub(crate) trait Guesser: Send {
4160    fn name(&self) -> &str;
4161
4162    /// Guess metadata from a given path.
4163    async fn guess(
4164        &mut self,
4165        settings: &GuesserSettings,
4166    ) -> Result<Vec<UpstreamDatumWithMetadata>, ProviderError>;
4167}
4168
4169type AsyncGuesserFunction = Box<
4170    dyn FnMut(
4171            PathBuf,
4172            GuesserSettings,
4173        ) -> Pin<
4174            Box<
4175                dyn std::future::Future<
4176                        Output = Result<Vec<UpstreamDatumWithMetadata>, ProviderError>,
4177                    > + Send,
4178            >,
4179        > + Send,
4180>;
4181
4182/// Guesser that extracts metadata from a specific file path
4183pub struct PathGuesser {
4184    name: String,
4185    subpath: std::path::PathBuf,
4186    cb: AsyncGuesserFunction,
4187}
4188
4189#[async_trait::async_trait]
4190impl Guesser for PathGuesser {
4191    fn name(&self) -> &str {
4192        &self.name
4193    }
4194
4195    async fn guess(
4196        &mut self,
4197        settings: &GuesserSettings,
4198    ) -> Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
4199        (self.cb)(self.subpath.clone(), settings.clone()).await
4200    }
4201}
4202
4203/// Guesser that extracts metadata from environment variables
4204pub struct EnvironmentGuesser;
4205
4206impl EnvironmentGuesser {
4207    /// Creates a new EnvironmentGuesser
4208    pub fn new() -> Self {
4209        Self
4210    }
4211}
4212
4213impl Default for EnvironmentGuesser {
4214    fn default() -> Self {
4215        Self::new()
4216    }
4217}
4218
4219#[async_trait::async_trait]
4220impl Guesser for EnvironmentGuesser {
4221    fn name(&self) -> &str {
4222        "environment"
4223    }
4224
4225    async fn guess(
4226        &mut self,
4227        _settings: &GuesserSettings,
4228    ) -> Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
4229        crate::guess_from_environment()
4230    }
4231}
4232
4233#[cfg(test)]
4234mod tests {
4235    use super::*;
4236
4237    #[test]
4238    fn test_upstream_metadata() {
4239        let mut data = UpstreamMetadata::new();
4240        assert_eq!(data.len(), 0);
4241
4242        data.insert(UpstreamDatumWithMetadata {
4243            datum: UpstreamDatum::Homepage("https://example.com".to_string()),
4244            certainty: Some(Certainty::Certain),
4245            origin: None,
4246        });
4247
4248        assert_eq!(data.len(), 1);
4249        assert_eq!(
4250            data.get("Homepage").unwrap().datum.as_str().unwrap(),
4251            "https://example.com"
4252        );
4253
4254        assert_eq!(data.homepage(), Some("https://example.com"));
4255    }
4256
4257    #[tokio::test]
4258    async fn test_bug_database_url_from_bug_submit_url() {
4259        let url = Url::parse("https://bugs.launchpad.net/bugs/+filebug").unwrap();
4260        assert_eq!(
4261            bug_database_url_from_bug_submit_url(&url, None)
4262                .await
4263                .unwrap(),
4264            Url::parse("https://bugs.launchpad.net/bugs").unwrap()
4265        );
4266
4267        let url = Url::parse("https://github.com/dulwich/dulwich/issues/new").unwrap();
4268
4269        assert_eq!(
4270            bug_database_url_from_bug_submit_url(&url, None)
4271                .await
4272                .unwrap(),
4273            Url::parse("https://github.com/dulwich/dulwich/issues").unwrap()
4274        );
4275
4276        let url = Url::parse("https://sourceforge.net/p/dulwich/bugs/new").unwrap();
4277
4278        assert_eq!(
4279            bug_database_url_from_bug_submit_url(&url, None)
4280                .await
4281                .unwrap(),
4282            Url::parse("https://sourceforge.net/p/dulwich/bugs").unwrap()
4283        );
4284    }
4285
4286    #[test]
4287    fn test_person_from_str() {
4288        assert_eq!(
4289            Person::from("Foo Bar <foo@example.com>"),
4290            Person {
4291                name: Some("Foo Bar".to_string()),
4292                email: Some("foo@example.com".to_string()),
4293                url: None
4294            }
4295        );
4296        assert_eq!(
4297            Person::from("Foo Bar"),
4298            Person {
4299                name: Some("Foo Bar".to_string()),
4300                email: None,
4301                url: None
4302            }
4303        );
4304        assert_eq!(
4305            Person::from("foo@example.com"),
4306            Person {
4307                name: None,
4308                email: Some("foo@example.com".to_string()),
4309                url: None
4310            }
4311        );
4312        // Test person with just email (no name) - parseaddr returns empty name
4313        assert_eq!(
4314            Person::from("<foo@example.com>"),
4315            Person {
4316                name: Some("".to_string()),
4317                email: Some("foo@example.com".to_string()),
4318                url: None
4319            }
4320        );
4321    }
4322
4323    #[test]
4324    fn test_upstream_metadata_accessors() {
4325        let mut metadata = UpstreamMetadata::default();
4326
4327        // Test empty metadata
4328        assert_eq!(metadata.version(), None);
4329        assert_eq!(metadata.description(), None);
4330        assert_eq!(metadata.wiki(), None);
4331        assert_eq!(metadata.download(), None);
4332        assert_eq!(metadata.security_contact(), None);
4333        assert_eq!(metadata.donation(), None);
4334        assert_eq!(metadata.cite_as(), None);
4335        assert_eq!(metadata.webservice(), None);
4336        assert_eq!(metadata.copyright(), None);
4337        assert_eq!(metadata.sourceforge_project(), None);
4338        assert_eq!(metadata.pecl_package(), None);
4339
4340        // Add some data and test again
4341        metadata.insert(UpstreamDatumWithMetadata {
4342            datum: UpstreamDatum::Version("1.0.0".to_string()),
4343            certainty: Some(Certainty::Certain),
4344            origin: None,
4345        });
4346        assert_eq!(metadata.version(), Some("1.0.0"));
4347
4348        metadata.insert(UpstreamDatumWithMetadata {
4349            datum: UpstreamDatum::Description("Test description".to_string()),
4350            certainty: Some(Certainty::Certain),
4351            origin: None,
4352        });
4353        assert_eq!(metadata.description(), Some("Test description"));
4354    }
4355
4356    #[test]
4357    fn test_upstream_metadata_iterators() {
4358        let mut metadata = UpstreamMetadata::default();
4359
4360        // Test empty iterator
4361        assert_eq!(metadata.iter().count(), 0);
4362        assert_eq!(metadata.mut_iter().count(), 0);
4363
4364        // Add data and test again
4365        metadata.insert(UpstreamDatumWithMetadata {
4366            datum: UpstreamDatum::Name("test".to_string()),
4367            certainty: Some(Certainty::Certain),
4368            origin: None,
4369        });
4370
4371        assert_eq!(metadata.iter().count(), 1);
4372        assert_eq!(metadata.mut_iter().count(), 1);
4373    }
4374
4375    #[test]
4376    fn test_extract_pecl_package_name() {
4377        use super::extract_pecl_package_name;
4378
4379        assert_eq!(
4380            extract_pecl_package_name("https://pecl.php.net/package/redis"),
4381            Some("redis".to_string())
4382        );
4383        assert_eq!(
4384            extract_pecl_package_name("https://pecl.php.net/package/xdebug/2.9.0"),
4385            Some("xdebug/2.9.0".to_string())
4386        );
4387        assert_eq!(
4388            extract_pecl_package_name("https://example.com/something"),
4389            None
4390        );
4391    }
4392
4393    #[test]
4394    fn test_github_bug_database_url_from_repo_url() {
4395        let github = GitHub;
4396
4397        let url = Url::parse("https://github.com/dulwich/dulwich.git").unwrap();
4398        assert_eq!(
4399            github.bug_database_url_from_repo_url(&url).unwrap(),
4400            Url::parse("https://github.com/dulwich/dulwich/issues").unwrap()
4401        );
4402
4403        // Url::set_scheme cannot change ssh to https, so the URL has to be
4404        // rebuilt; the userinfo is dropped in the process.
4405        let url = Url::parse("ssh://git@github.com/dulwich/dulwich.git").unwrap();
4406        assert_eq!(
4407            github.bug_database_url_from_repo_url(&url).unwrap(),
4408            Url::parse("https://github.com/dulwich/dulwich/issues").unwrap()
4409        );
4410
4411        let url = Url::parse("git://github.com/dulwich/dulwich").unwrap();
4412        assert_eq!(
4413            github.bug_database_url_from_repo_url(&url).unwrap(),
4414            Url::parse("https://github.com/dulwich/dulwich/issues").unwrap()
4415        );
4416
4417        // Not a repository URL.
4418        let url = Url::parse("https://github.com/dulwich").unwrap();
4419        assert_eq!(github.bug_database_url_from_repo_url(&url), None);
4420    }
4421
4422    #[test]
4423    fn test_github_bug_database_from_issue_url() {
4424        let github = GitHub;
4425
4426        let url = Url::parse("https://github.com/dulwich/dulwich/issues/123").unwrap();
4427        assert_eq!(
4428            github.bug_database_from_issue_url(&url).unwrap(),
4429            Url::parse("https://github.com/dulwich/dulwich/issues").unwrap()
4430        );
4431
4432        let url = Url::parse("ssh://git@github.com/dulwich/dulwich/issues/123").unwrap();
4433        assert_eq!(
4434            github.bug_database_from_issue_url(&url).unwrap(),
4435            Url::parse("https://github.com/dulwich/dulwich/issues").unwrap()
4436        );
4437
4438        // Not an issue URL.
4439        let url = Url::parse("https://github.com/dulwich/dulwich").unwrap();
4440        assert_eq!(github.bug_database_from_issue_url(&url), None);
4441    }
4442
4443    #[test]
4444    fn test_github_repo_url_from_merge_request_url() {
4445        let github = GitHub;
4446
4447        let url = Url::parse("https://github.com/dulwich/dulwich/pull/123").unwrap();
4448        assert_eq!(
4449            github.repo_url_from_merge_request_url(&url).unwrap(),
4450            Url::parse("https://github.com/dulwich/dulwich").unwrap()
4451        );
4452
4453        let url = Url::parse("ssh://git@github.com/dulwich/dulwich/pull/123").unwrap();
4454        assert_eq!(
4455            github.repo_url_from_merge_request_url(&url).unwrap(),
4456            Url::parse("https://github.com/dulwich/dulwich").unwrap()
4457        );
4458
4459        // Not a merge request URL.
4460        let url = Url::parse("https://github.com/dulwich/dulwich/issues/123").unwrap();
4461        assert_eq!(github.repo_url_from_merge_request_url(&url), None);
4462    }
4463
4464    #[test]
4465    fn test_forge_names() {
4466        let github = GitHub;
4467        assert_eq!(github.name(), "GitHub");
4468
4469        let gitlab = GitLab;
4470        assert_eq!(gitlab.name(), "GitLab");
4471
4472        let sourceforge = SourceForge;
4473        assert_eq!(sourceforge.name(), "SourceForge");
4474
4475        let launchpad = Launchpad;
4476        assert_eq!(launchpad.name(), "launchpad");
4477    }
4478}