Skip to main content

doiget_core/
software.rs

1//! Software citations (#614, ADR-0058): a GitHub release or tag cited as
2//! `@software`, and which Zenodo DOI a software record is cited by.
3//!
4//! Papers cite their code, often as a GitHub release with no DOI
5//! (`https://github.com/srwhite59/HFDMRG.jl/releases/tag/v0.1.0`). `cite`
6//! took DOIs and arXiv ids only, so those entries were written by hand.
7//!
8//! What is read, and from where -- only when the caller names a GitHub URL:
9//!
10//! - `api.github.com` `GET /repos/{owner}/{repo}` for the repository, then
11//!   `/releases/tags/{tag}` (or `/releases/latest` for a bare repository
12//!   URL). A tag with no release falls back to the tag's commit date
13//!   (`/commits/{tag}`).
14//! - `raw.githubusercontent.com` `/{owner}/{repo}/{tag or HEAD}/CITATION.cff`,
15//!   the authors' own statement of how to cite them. Its authors and title
16//!   win over the repository's owner and name.
17//!
18//! `CITATION.cff` is YAML. Only its top-level scalars and the top-level
19//! `authors` list are read ([`parse_cff`]), by a line reader rather than a
20//! YAML crate: those are the fields a citation needs, CFF writers emit them
21//! in plain block style, and anything the reader cannot follow is skipped,
22//! never guessed -- an unreadable file cites as if it were absent.
23
24use serde_json::Value;
25use url::Url;
26
27use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
28use crate::source::{FetchContext, FetchError};
29use crate::store::Metadata;
30
31/// HTTP source key for the GitHub REST API.
32pub const GITHUB_API: &str = "github";
33/// HTTP source key for `raw.githubusercontent.com`.
34pub const GITHUB_RAW: &str = "github-raw";
35/// Base-URL override for [`GITHUB_API`].
36pub const GITHUB_API_BASE_ENV: &str = "DOIGET_GITHUB_API_BASE";
37/// Base-URL override for [`GITHUB_RAW`].
38pub const GITHUB_RAW_BASE_ENV: &str = "DOIGET_GITHUB_RAW_BASE";
39
40const API_DEFAULT: &str = "https://api.github.com";
41const RAW_DEFAULT: &str = "https://raw.githubusercontent.com";
42
43/// Whether `t` can be a git tag name and is safe to put in a GitHub API or
44/// raw path: git's own ref-name rules (no `..`, no space, control or
45/// `~^:?*[\\`), plus no `#` or `%`, and no empty or dot-only segment, so
46/// the tag cannot reach outside the `repos/{owner}/{repo}` path it is
47/// joined onto.
48fn valid_tag(t: &str) -> bool {
49    !t.is_empty()
50        && !t.contains("..")
51        && t.split('/').all(|seg| !seg.is_empty() && seg != ".")
52        && !t.chars().any(|c| {
53            c.is_whitespace()
54                || c.is_control()
55                || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\' | '#' | '%')
56        })
57}
58
59/// A GitHub repository, optionally at a tag. The fields are private: only
60/// [`GithubRef::parse`] makes one from outside this module, so the owner,
61/// repository and tag always passed its checks before they are joined onto
62/// an API path (#649 review).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct GithubRef {
65    owner: String,
66    repo: String,
67    tag: Option<String>,
68}
69
70impl GithubRef {
71    /// Repository owner.
72    #[must_use]
73    pub fn owner(&self) -> &str {
74        &self.owner
75    }
76
77    /// Repository name.
78    #[must_use]
79    pub fn repo(&self) -> &str {
80        &self.repo
81    }
82
83    /// The release tag, when the URL named one.
84    #[must_use]
85    pub fn tag(&self) -> Option<&str> {
86        self.tag.as_deref()
87    }
88
89    /// `repos/{owner}/{repo}`, the API path every request starts from.
90    fn repo_path(&self) -> String {
91        format!("repos/{}/{}", self.owner, self.repo)
92    }
93
94    /// Parse `https://github.com/{owner}/{repo}`, optionally followed by
95    /// `/releases/tag/{tag}` or `/tree/{tag}`. `None` for anything else,
96    /// including other hosts and non-repository GitHub pages.
97    #[must_use]
98    pub fn parse(input: &str) -> Option<Self> {
99        let url = Url::parse(input.trim()).ok()?;
100        if !matches!(url.scheme(), "https" | "http")
101            || !matches!(url.host_str(), Some("github.com" | "www.github.com"))
102        {
103            return None;
104        }
105        let segs: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect();
106        let (owner, repo) = match segs.as_slice() {
107            [o, r, ..] => (*o, r.trim_end_matches(".git")),
108            _ => return None,
109        };
110        let valid = |s: &str| {
111            !s.is_empty()
112                && s.chars()
113                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
114        };
115        if !valid(owner) || !valid(repo) {
116            return None;
117        }
118        let tag = match &segs[2..] {
119            [] => None,
120            ["releases", "tag", tag @ ..] | ["tree", tag @ ..] if !tag.is_empty() => {
121                let t = tag.join("/");
122                if !valid_tag(&t) {
123                    return None;
124                }
125                Some(t)
126            }
127            _ => return None,
128        };
129        Some(Self {
130            owner: owner.to_string(),
131            repo: repo.to_string(),
132            tag,
133        })
134    }
135
136    /// The page a reader follows: the release when there is a tag, else the
137    /// repository.
138    #[must_use]
139    pub fn html_url(&self) -> String {
140        match &self.tag {
141            Some(t) => format!(
142                "https://github.com/{}/{}/releases/tag/{t}",
143                self.owner, self.repo
144            ),
145            None => format!("https://github.com/{}/{}", self.owner, self.repo),
146        }
147    }
148
149    /// A citation key when no template or `--key` gives one:
150    /// `{repo}_{tag}` reduced to what every BibTeX processor accepts.
151    #[must_use]
152    pub fn default_key(&self) -> String {
153        let raw = match &self.tag {
154            Some(t) => format!("{}_{t}", self.repo),
155            None => self.repo.clone(),
156        };
157        raw.chars()
158            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
159            .collect()
160    }
161}
162
163/// What `CITATION.cff` says, as far as a citation needs it.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct Cff {
166    /// `title`.
167    pub title: Option<String>,
168    /// `authors`, each as `Given Family` or an entity `name`.
169    pub authors: Vec<String>,
170    /// `version`.
171    pub version: Option<String>,
172    /// `doi` -- usually the Zenodo concept DOI.
173    pub doi: Option<String>,
174    /// `date-released`, `YYYY-MM-DD`.
175    pub date_released: Option<String>,
176}
177
178/// Read the citation fields of a `CITATION.cff`: top-level `title`,
179/// `version`, `doi`, `date-released`, and the top-level `authors` list.
180/// Nested blocks (`preferred-citation`, `identifiers`, `references`) are
181/// skipped, so their own `authors` and `title` are never mistaken for the
182/// software's.
183#[must_use]
184pub fn parse_cff(text: &str) -> Cff {
185    let mut cff = Cff::default();
186    let mut in_authors = false;
187    let mut current: Option<AuthorParts> = None;
188    for raw in text.lines() {
189        let line = strip_comment(raw);
190        if line.trim().is_empty() {
191            continue;
192        }
193        let indent = line.len() - line.trim_start().len();
194        if indent == 0 {
195            if let Some(a) = current.take() {
196                cff.authors.extend(a.render());
197            }
198            in_authors = false;
199            let Some((key, value)) = split_key(line) else {
200                continue;
201            };
202            match key {
203                "authors" => in_authors = value.is_empty(),
204                "title" => cff.title = scalar(value),
205                "version" => cff.version = scalar(value),
206                "doi" => cff.doi = scalar(value),
207                "date-released" => cff.date_released = scalar(value),
208                _ => {}
209            }
210            continue;
211        }
212        if !in_authors {
213            continue;
214        }
215        let body = line.trim_start();
216        let item = if let Some(rest) = body.strip_prefix("- ") {
217            if let Some(a) = current.take() {
218                cff.authors.extend(a.render());
219            }
220            current = Some(AuthorParts::default());
221            rest.trim_start()
222        } else {
223            body
224        };
225        if let (Some(a), Some((key, value))) = (current.as_mut(), split_key(item)) {
226            let v = scalar(value);
227            match key {
228                "given-names" => a.given = v,
229                "family-names" => a.family = v,
230                "name-particle" => a.particle = v,
231                "name" => a.name = v,
232                _ => {}
233            }
234        }
235    }
236    if let Some(a) = current.take() {
237        cff.authors.extend(a.render());
238    }
239    cff
240}
241
242#[derive(Debug, Default)]
243struct AuthorParts {
244    given: Option<String>,
245    family: Option<String>,
246    particle: Option<String>,
247    name: Option<String>,
248}
249
250impl AuthorParts {
251    fn render(self) -> Option<String> {
252        let family = match (self.particle, self.family) {
253            (Some(p), Some(f)) => Some(format!("{p} {f}")),
254            (None, f) => f,
255            (Some(_), None) => None,
256        };
257        match (self.given, family, self.name) {
258            (Some(g), Some(f), _) => Some(format!("{g} {f}")),
259            (None, Some(f), _) => Some(f),
260            (_, None, Some(n)) => Some(n),
261            _ => None,
262        }
263    }
264}
265
266/// `line` without a trailing ` # comment` outside quotes.
267fn strip_comment(line: &str) -> &str {
268    let mut quote = None;
269    let mut prev_space = true;
270    for (i, c) in line.char_indices() {
271        match (quote, c) {
272            (None, '"' | '\'') => quote = Some(c),
273            (Some(q), c) if c == q => quote = None,
274            (None, '#') if prev_space => return &line[..i],
275            _ => {}
276        }
277        prev_space = c.is_whitespace();
278    }
279    line
280}
281
282/// `key: value` at the start of `line`.
283fn split_key(line: &str) -> Option<(&str, &str)> {
284    let (key, value) = line.trim().split_once(':')?;
285    let key = key.trim();
286    (!key.is_empty() && !key.contains(' ')).then(|| (key, value.trim()))
287}
288
289/// A YAML scalar with its quotes removed; `None` for an empty value or the
290/// start of a block (`|`, `>`), which this reader does not follow.
291fn scalar(value: &str) -> Option<String> {
292    let v = value.trim();
293    if v.is_empty() || v.starts_with(['|', '>', '[', '{']) {
294        return None;
295    }
296    let unquoted = v
297        .strip_prefix('"')
298        .and_then(|s| s.strip_suffix('"'))
299        .or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
300        .unwrap_or(v);
301    Some(unquoted.to_string()).filter(|s| !s.is_empty())
302}
303
304/// A software citation resolved from GitHub.
305#[derive(Debug, Clone)]
306pub struct SoftwareCitation {
307    /// The entry, `type_ = "software"`, with `version` in `other`.
308    pub metadata: Metadata,
309    /// Whether a `CITATION.cff` supplied the authors and title.
310    pub from_cff: bool,
311    /// Notes for the user: where each field came from when it matters.
312    pub notes: Vec<String>,
313}
314
315/// Resolve `g` against GitHub: repository, release (or the tag's commit),
316/// and `CITATION.cff`.
317///
318/// # Errors
319///
320/// [`FetchError`] from the repository lookup -- a 404 is `NOT_FOUND`, a 429
321/// is `retry_after`, and a 403 stays `CAPABILITY_DENIED` ([`explain`] says
322/// why). A missing release or `CITATION.cff` is not an error.
323pub async fn resolve_github(
324    g: &GithubRef,
325    ctx: &FetchContext,
326) -> Result<SoftwareCitation, FetchError> {
327    let api = base(GITHUB_API_BASE_ENV, API_DEFAULT)?;
328    let raw = base(GITHUB_RAW_BASE_ENV, RAW_DEFAULT)?;
329    let repo_path = g.repo_path();
330    let repo = api_json(ctx, &api, &repo_path, g)
331        .await?
332        .ok_or_else(|| not_found(g))?;
333
334    let mut notes = Vec::new();
335    let (release, tag) = match &g.tag {
336        Some(t) => (
337            api_json(ctx, &api, &format!("{repo_path}/releases/tags/{t}"), g).await?,
338            Some(t.clone()),
339        ),
340        None => {
341            let latest = api_json(ctx, &api, &format!("{repo_path}/releases/latest"), g).await?;
342            let tag = latest
343                .as_ref()
344                .and_then(|r| r.get("tag_name"))
345                .and_then(Value::as_str)
346                .filter(|t| valid_tag(t))
347                .map(str::to_string);
348            if let Some(t) = &tag {
349                notes.push(format!(
350                    "no tag in the URL: citing the latest release, {t}; cite a release URL to pin another"
351                ));
352            }
353            (latest, tag)
354        }
355    };
356    let mut date = release
357        .as_ref()
358        .and_then(|r| r.get("published_at").or_else(|| r.get("created_at")))
359        .and_then(Value::as_str)
360        .map(str::to_string);
361    if release.is_none() {
362        if let Some(t) = &tag {
363            // A tag with no release object: its commit's date is the date.
364            let commit = api_json(ctx, &api, &format!("{repo_path}/commits/{t}"), g).await?;
365            date = commit
366                .as_ref()
367                .and_then(|c| c.pointer("/commit/committer/date"))
368                .and_then(Value::as_str)
369                .map(str::to_string);
370            if commit.is_none() {
371                return Err(not_found(g));
372            }
373            notes.push(format!(
374                "{t} is a tag with no GitHub release; its date is the tag's commit"
375            ));
376        }
377    }
378
379    let cff_ref = tag.as_deref().unwrap_or("HEAD");
380    let cff_text = raw_text(
381        ctx,
382        &raw,
383        &format!("{}/{}/{cff_ref}/CITATION.cff", g.owner, g.repo),
384        g,
385    )
386    .await?;
387    let cff = cff_text.as_deref().map(parse_cff);
388
389    let owner = repo
390        .pointer("/owner/login")
391        .and_then(Value::as_str)
392        .unwrap_or(&g.owner)
393        .to_string();
394    let name = repo
395        .get("name")
396        .and_then(Value::as_str)
397        .unwrap_or(&g.repo)
398        .to_string();
399    let from_cff = cff.as_ref().is_some_and(|c| !c.authors.is_empty());
400
401    let mut m = Metadata {
402        schema_version: "1.0".into(),
403        title: cff
404            .as_ref()
405            .and_then(|c| c.title.clone())
406            .unwrap_or_else(|| name.clone()),
407        authors: match cff.as_ref() {
408            Some(c) if !c.authors.is_empty() => c.authors.clone(),
409            _ => vec![owner.clone()],
410        },
411        year: date
412            .as_deref()
413            .or_else(|| cff.as_ref().and_then(|c| c.date_released.as_deref()))
414            .and_then(year_of),
415        url: Some(
416            release
417                .as_ref()
418                .and_then(|r| r.get("html_url"))
419                .and_then(Value::as_str)
420                .map_or_else(
421                    || {
422                        GithubRef {
423                            tag: tag.clone(),
424                            ..g.clone()
425                        }
426                        .html_url()
427                    },
428                    str::to_string,
429                ),
430        ),
431        publisher: Some("GitHub".into()),
432        type_: Some("software".into()),
433        ..Metadata::default()
434    };
435    let version = tag.or_else(|| cff.as_ref().and_then(|c| c.version.clone()));
436    if let Some(v) = version {
437        m.other.insert("version".into(), toml::Value::String(v));
438    }
439    if !from_cff {
440        notes.push(format!(
441            "no CITATION.cff with authors in {owner}/{name}: the author is the repository owner, {owner}"
442        ));
443    }
444    if let Some(doi) = cff.as_ref().and_then(|c| c.doi.as_deref()) {
445        match crate::Doi::parse(doi) {
446            Ok(d) => {
447                notes.push(format!(
448                    "CITATION.cff names DOI {doi}; it is included, and `doiget cite {doi}` cites the archived record itself"
449                ));
450                m.doi = Some(d);
451            }
452            Err(_) => notes.push(format!("CITATION.cff's doi {doi:?} is not a DOI; left out")),
453        }
454    }
455    Ok(SoftwareCitation {
456        metadata: m,
457        from_cff,
458        notes,
459    })
460}
461
462/// Whether `g` still resolves: the repository, and the release or tag the
463/// URL names. `Ok(false)` for a 404 at either; transport failures are
464/// errors.
465///
466/// # Errors
467///
468/// [`FetchError`] for anything other than a clean found / not-found answer.
469pub async fn github_resolves(g: &GithubRef, ctx: &FetchContext) -> Result<bool, FetchError> {
470    let api = base(GITHUB_API_BASE_ENV, API_DEFAULT)?;
471    let repo_path = g.repo_path();
472    if api_json(ctx, &api, &repo_path, g).await?.is_none() {
473        return Ok(false);
474    }
475    let Some(t) = &g.tag else {
476        return Ok(true);
477    };
478    if api_json(ctx, &api, &format!("{repo_path}/releases/tags/{t}"), g)
479        .await?
480        .is_some()
481    {
482        return Ok(true);
483    }
484    Ok(api_json(ctx, &api, &format!("{repo_path}/commits/{t}"), g)
485        .await?
486        .is_some())
487}
488
489fn base(env: &str, default: &str) -> Result<Url, FetchError> {
490    let raw = std::env::var(env).unwrap_or_else(|_| default.to_string());
491    let mut url = Url::parse(&raw).map_err(|e| FetchError::SourceSchema {
492        hint: format!("{env}={raw:?} is not a URL: {e}"),
493    })?;
494    if !url.path().ends_with('/') {
495        url.set_path(&format!("{}/", url.path()));
496    }
497    Ok(url)
498}
499
500fn not_found(g: &GithubRef) -> FetchError {
501    FetchError::Http(crate::http::HttpError::HttpStatus {
502        status: 404,
503        url: g.html_url(),
504        retry_after_ms: None,
505    })
506}
507
508/// `GET` a GitHub API path as JSON; `Ok(None)` for a 404.
509async fn api_json(
510    ctx: &FetchContext,
511    api: &Url,
512    path: &str,
513    g: &GithubRef,
514) -> Result<Option<Value>, FetchError> {
515    let url = api.join(path).map_err(|e| FetchError::SourceSchema {
516        hint: format!("building a GitHub API URL for {path}: {e}"),
517    })?;
518    let headers = [
519        ("Accept", "application/vnd.github+json"),
520        ("X-GitHub-Api-Version", "2022-11-28"),
521    ];
522    let Some(body) = get(ctx, GITHUB_API, url, &headers, g).await? else {
523        return Ok(None);
524    };
525    serde_json::from_slice(&body)
526        .map(Some)
527        .map_err(|e| FetchError::SourceSchema {
528            hint: format!("GitHub returned non-JSON for {path}: {e}"),
529        })
530}
531
532/// `GET` a raw file as UTF-8 text; `Ok(None)` for a 404.
533async fn raw_text(
534    ctx: &FetchContext,
535    raw: &Url,
536    path: &str,
537    g: &GithubRef,
538) -> Result<Option<String>, FetchError> {
539    let url = raw.join(path).map_err(|e| FetchError::SourceSchema {
540        hint: format!("building a raw.githubusercontent.com URL for {path}: {e}"),
541    })?;
542    Ok(get(ctx, GITHUB_RAW, url, &[], g)
543        .await?
544        .map(|b| String::from_utf8_lossy(&b).into_owned()))
545}
546
547async fn get(
548    ctx: &FetchContext,
549    source: &'static str,
550    url: Url,
551    headers: &[(&str, &str)],
552    g: &GithubRef,
553) -> Result<Option<bytes::Bytes>, FetchError> {
554    use crate::http::HttpError;
555    let _permit = ctx.rate_limiter.acquire(source).await;
556    let target = g.html_url();
557    let row = |result, size, error_code| RowInput {
558        event: LogEvent::Fetch,
559        result,
560        capability: Capability::Metadata,
561        ref_: Some(target.as_str()),
562        source: Some(source),
563        error_code,
564        size_bytes: size,
565        license: None,
566        store_path: None,
567        canonical_digest: None,
568    };
569    match ctx
570        .http
571        .fetch_bytes_with_headers(source, url, headers)
572        .await
573    {
574        Ok((body, _)) => {
575            ctx.log
576                .append(row(LogResult::Ok, Some(body.len() as u64), None))?;
577            Ok(Some(body))
578        }
579        Err(HttpError::HttpStatus { status: 404, .. }) => {
580            ctx.log
581                .append(row(LogResult::Err, None, Some("NOT_FOUND")))?;
582            Ok(None)
583        }
584        // A 429 is the rate limit and is reported as one, with a wait when
585        // GitHub named none. A 403 is left a 403 (CAPABILITY_DENIED,
586        // needs_config): GitHub sends it both for the spent unauthenticated
587        // limit (60 an hour) and for a repository that is not public, and
588        // without the response headers the two cannot be told apart. Calling
589        // every 403 retryable would have a private repository retried every
590        // 30 s under repeat suppression; [`explain`] names both causes.
591        Err(HttpError::HttpStatus {
592            status: 429,
593            url,
594            retry_after_ms,
595        }) => {
596            let e = FetchError::Http(HttpError::HttpStatus {
597                status: 429,
598                url,
599                retry_after_ms: retry_after_ms.or(Some(60_000)),
600            });
601            let code = crate::ErrorCode::from(&e);
602            ctx.log
603                .append(row(LogResult::Err, None, Some(code.as_wire())))?;
604            Err(e)
605        }
606        Err(e) => {
607            let e = FetchError::Http(e);
608            let code = crate::ErrorCode::from(&e);
609            ctx.log
610                .append(row(LogResult::Err, None, Some(code.as_wire())))?;
611            Err(e)
612        }
613    }
614}
615
616/// What a GitHub error means, when the status alone would mislead: a 403
617/// is either the spent unauthenticated limit or a repository that is not
618/// public, and GitHub's status does not say which.
619#[must_use]
620pub fn explain(e: &FetchError) -> Option<&'static str> {
621    match e {
622        FetchError::Http(crate::http::HttpError::HttpStatus { status: 403, .. }) => Some(
623            "GitHub answers 403 both when the unauthenticated limit (60 requests an hour per \
624             address) is spent -- it resets within the hour -- and when the repository is not \
625             public; doiget sends no token",
626        ),
627        _ => None,
628    }
629}
630
631fn year_of(date: &str) -> Option<i32> {
632    date.get(..4)?.parse().ok()
633}
634
635/// Which DOI a Zenodo software record is cited by (#614).
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum ZenodoDoi {
638    /// The DOI given is a version DOI; `concept` names every version.
639    Version {
640        /// The concept DOI, from `relatedIdentifiers` `IsVersionOf`.
641        concept: String,
642    },
643    /// The DOI given is itself the concept DOI (the record lists versions).
644    Concept,
645}
646
647/// Classify a DataCite record's DOI by its `relatedIdentifiers`: a version
648/// DOI points `IsVersionOf` at its concept; a concept DOI `HasVersion`s.
649/// `None` when the record says neither (not Zenodo-style versioning).
650#[must_use]
651pub fn zenodo_doi(attributes: &Value) -> Option<ZenodoDoi> {
652    let related = attributes.get("relatedIdentifiers")?.as_array()?;
653    let doi_rel = |kind: &str| {
654        related.iter().find(|r| {
655            r.get("relationType").and_then(Value::as_str) == Some(kind)
656                && r.get("relatedIdentifierType")
657                    .and_then(Value::as_str)
658                    .is_some_and(|t| t.eq_ignore_ascii_case("DOI"))
659        })
660    };
661    if let Some(concept) = doi_rel("IsVersionOf")
662        .and_then(|r| r.get("relatedIdentifier"))
663        .and_then(Value::as_str)
664    {
665        return Some(ZenodoDoi::Version {
666            concept: concept.to_string(),
667        });
668    }
669    doi_rel("HasVersion").map(|_| ZenodoDoi::Concept)
670}
671
672#[cfg(test)]
673#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn a_release_tag_or_repository_url_parses_and_nothing_else_does() {
679        let g = GithubRef::parse("https://github.com/srwhite59/HFDMRG.jl/releases/tag/v0.1.0")
680            .expect("release");
681        assert_eq!(
682            (g.owner.as_str(), g.repo.as_str(), g.tag.as_deref()),
683            ("srwhite59", "HFDMRG.jl", Some("v0.1.0"))
684        );
685        assert_eq!(g.default_key(), "HFDMRG_jl_v0_1_0");
686        assert_eq!(
687            GithubRef::parse("https://github.com/o/r/tree/release/2.0")
688                .unwrap()
689                .tag
690                .as_deref(),
691            Some("release/2.0")
692        );
693        let bare = GithubRef::parse("https://github.com/o/r.git").unwrap();
694        assert_eq!((bare.repo.as_str(), bare.tag), ("r", None));
695        for no in [
696            "https://gitlab.com/o/r",
697            "https://github.com/o",
698            "https://github.com/o/r/issues/3",
699            "10.5281/zenodo.123",
700            "github.com/o/r",
701            "https://github.com/o/r/tree/v1%2F..%2F..%2Fx",
702        ] {
703            assert_eq!(GithubRef::parse(no), None, "{no}");
704        }
705    }
706
707    #[test]
708    fn a_tag_that_could_leave_the_repository_path_is_refused() {
709        for ok in ["v0.1.0", "release/2.0", "v1.0+build.5", "2024-01_rc1"] {
710            assert!(valid_tag(ok), "{ok}");
711        }
712        for no in [
713            "", "..", "a/../b", "a//b", "./a", "a b", "a?b", "a#b", "a%2Fb", "a:b", "a\\b",
714        ] {
715            assert!(!valid_tag(no), "{no:?}");
716        }
717    }
718
719    #[test]
720    fn cff_top_level_fields_and_authors_are_read_and_nested_blocks_are_not() {
721        let cff = parse_cff(
722            r#"cff-version: 1.2.0
723message: "If you use this software, please cite it as below."
724title: "HFDMRG.jl: Hartree-Fock DMRG"   # the software
725version: 0.1.0
726doi: 10.5281/zenodo.1234567
727date-released: 2023-05-02
728authors:
729  - family-names: White
730    given-names: Steven R.
731  - family-names: Beethoven
732    name-particle: van
733    given-names: Ludwig
734  - name: "The HFDMRG Team"
735preferred-citation:
736  type: article
737  title: "Not the software"
738  authors:
739    - family-names: Other
740      given-names: Paper
741"#,
742        );
743        assert_eq!(cff.title.as_deref(), Some("HFDMRG.jl: Hartree-Fock DMRG"));
744        assert_eq!(cff.version.as_deref(), Some("0.1.0"));
745        assert_eq!(cff.doi.as_deref(), Some("10.5281/zenodo.1234567"));
746        assert_eq!(cff.date_released.as_deref(), Some("2023-05-02"));
747        assert_eq!(
748            cff.authors,
749            vec!["Steven R. White", "Ludwig van Beethoven", "The HFDMRG Team"]
750        );
751    }
752
753    #[test]
754    fn an_unreadable_cff_is_an_empty_one() {
755        assert_eq!(parse_cff("{not: yaml at all"), Cff::default());
756        assert_eq!(parse_cff("authors: [{name: x}]\n"), Cff::default());
757    }
758
759    #[test]
760    fn a_zenodo_record_says_whether_its_doi_is_a_version_or_the_concept() {
761        let version = serde_json::json!({"relatedIdentifiers": [
762            {"relationType": "IsVersionOf", "relatedIdentifierType": "DOI",
763             "relatedIdentifier": "10.5281/zenodo.100"}
764        ]});
765        assert_eq!(
766            zenodo_doi(&version),
767            Some(ZenodoDoi::Version {
768                concept: "10.5281/zenodo.100".into()
769            })
770        );
771        let concept = serde_json::json!({"relatedIdentifiers": [
772            {"relationType": "HasVersion", "relatedIdentifierType": "DOI",
773             "relatedIdentifier": "10.5281/zenodo.101"}
774        ]});
775        assert_eq!(zenodo_doi(&concept), Some(ZenodoDoi::Concept));
776        assert_eq!(zenodo_doi(&serde_json::json!({})), None);
777    }
778
779    mod live_shape {
780        //! Through the real HTTP client and log, against a mock GitHub.
781        use super::super::*;
782        use std::sync::Arc;
783        use wiremock::matchers::{method, path};
784        use wiremock::{Mock, MockServer, ResponseTemplate};
785
786        async fn ctx_for(server: &MockServer) -> (FetchContext, tempfile::TempDir) {
787            let host = server.address().to_string();
788            let td = tempfile::TempDir::new().expect("tempdir");
789            let log = camino::Utf8PathBuf::try_from(td.path().join("log.jsonl")).expect("utf-8");
790            std::env::set_var(GITHUB_API_BASE_ENV, server.uri());
791            std::env::set_var(GITHUB_RAW_BASE_ENV, server.uri());
792            let session_id = "01J0000000000000000000GH14".to_string();
793            let ctx = FetchContext {
794                http: Arc::new(crate::http::HttpClient::new_for_tests_allow_http_multi(&[
795                    (GITHUB_API, host.as_str()),
796                    (GITHUB_RAW, host.as_str()),
797                ])),
798                rate_limiter: Arc::new(crate::rate_limiter::RateLimiter::new(
799                    crate::RateLimits::HARD_CODED,
800                )),
801                log: Arc::new(
802                    crate::provenance::ProvenanceLog::open(log, session_id.clone()).expect("log"),
803                ),
804                session_id,
805                cache_root: None,
806            };
807            (ctx, td)
808        }
809
810        fn clear_env() {
811            std::env::remove_var(GITHUB_API_BASE_ENV);
812            std::env::remove_var(GITHUB_RAW_BASE_ENV);
813        }
814
815        async fn mount_repo(server: &MockServer) {
816            Mock::given(method("GET"))
817                .and(path("/repos/srwhite59/HFDMRG.jl"))
818                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
819                    "name": "HFDMRG.jl", "owner": {"login": "srwhite59"},
820                    "html_url": "https://github.com/srwhite59/HFDMRG.jl"
821                })))
822                .mount(server)
823                .await;
824        }
825
826        #[tokio::test]
827        #[serial_test::serial]
828        async fn a_release_with_a_cff_cites_its_authors_version_and_date() {
829            let server = MockServer::start().await;
830            mount_repo(&server).await;
831            Mock::given(method("GET"))
832                .and(path("/repos/srwhite59/HFDMRG.jl/releases/tags/v0.1.0"))
833                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
834                    "tag_name": "v0.1.0", "published_at": "2023-05-02T10:00:00Z",
835                    "html_url": "https://github.com/srwhite59/HFDMRG.jl/releases/tag/v0.1.0"
836                })))
837                .mount(&server)
838                .await;
839            Mock::given(method("GET"))
840                .and(path("/srwhite59/HFDMRG.jl/v0.1.0/CITATION.cff"))
841                .respond_with(ResponseTemplate::new(200).set_body_string(
842                    "title: HFDMRG.jl\nauthors:\n  - family-names: White\n    given-names: Steven R.\n",
843                ))
844                .mount(&server)
845                .await;
846            let (ctx, _td) = ctx_for(&server).await;
847            let g = GithubRef::parse("https://github.com/srwhite59/HFDMRG.jl/releases/tag/v0.1.0")
848                .unwrap();
849            let cited = resolve_github(&g, &ctx).await.expect("cites");
850            clear_env();
851            let m = &cited.metadata;
852            assert!(cited.from_cff);
853            assert_eq!(m.authors, vec!["Steven R. White"]);
854            assert_eq!(m.year, Some(2023));
855            assert_eq!(m.type_.as_deref(), Some("software"));
856            assert_eq!(
857                m.other.get("version").and_then(toml::Value::as_str),
858                Some("v0.1.0")
859            );
860            let bib = crate::store::render::to_bibtex("HFDMRG_jl_v0_1_0", m);
861            assert!(bib.starts_with("@software{HFDMRG_jl_v0_1_0,"), "{bib}");
862            assert!(bib.contains("version    = {v0.1.0}"), "{bib}");
863            assert!(
864                bib.contains(
865                    "url        = {https://github.com/srwhite59/HFDMRG.jl/releases/tag/v0.1.0}"
866                ),
867                "{bib}"
868            );
869            let csl = crate::store::render::to_csl_array("k", m);
870            assert_eq!(csl[0]["type"], "software");
871            assert_eq!(csl[0]["version"], "v0.1.0");
872        }
873
874        #[tokio::test]
875        #[serial_test::serial]
876        async fn a_tag_without_a_release_or_cff_uses_the_commit_date_and_the_owner() {
877            let server = MockServer::start().await;
878            mount_repo(&server).await;
879            Mock::given(method("GET"))
880                .and(path("/repos/srwhite59/HFDMRG.jl/commits/v0.0.9"))
881                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
882                    "commit": {"committer": {"date": "2022-11-30T08:00:00Z"}}
883                })))
884                .mount(&server)
885                .await;
886            let (ctx, _td) = ctx_for(&server).await;
887            let g = GithubRef::parse("https://github.com/srwhite59/HFDMRG.jl/tree/v0.0.9").unwrap();
888            let cited = resolve_github(&g, &ctx).await.expect("cites");
889            clear_env();
890            assert!(!cited.from_cff);
891            assert_eq!(cited.metadata.authors, vec!["srwhite59"]);
892            assert_eq!(cited.metadata.year, Some(2022));
893            assert!(
894                cited.notes.iter().any(|n| n.contains("no GitHub release")),
895                "{:?}",
896                cited.notes
897            );
898            assert!(
899                cited.notes.iter().any(|n| n.contains("repository owner")),
900                "{:?}",
901                cited.notes
902            );
903        }
904
905        #[tokio::test]
906        #[serial_test::serial]
907        async fn a_bare_repository_url_cites_the_latest_release_or_no_version() {
908            let server = MockServer::start().await;
909            mount_repo(&server).await;
910            Mock::given(method("GET"))
911                .and(path("/repos/srwhite59/HFDMRG.jl/releases/latest"))
912                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
913                    "tag_name": "v0.2.0", "published_at": "2024-01-15T00:00:00Z"
914                })))
915                .mount(&server)
916                .await;
917            Mock::given(method("GET"))
918                .and(path("/repos/o/norel"))
919                .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
920                    "name": "norel", "owner": {"login": "o"}
921                })))
922                .mount(&server)
923                .await;
924            let (ctx, _td) = ctx_for(&server).await;
925            let latest = resolve_github(
926                &GithubRef::parse("https://github.com/srwhite59/HFDMRG.jl").unwrap(),
927                &ctx,
928            )
929            .await
930            .expect("cites");
931            let none = resolve_github(
932                &GithubRef::parse("https://github.com/o/norel").unwrap(),
933                &ctx,
934            )
935            .await
936            .expect("cites");
937            clear_env();
938            assert_eq!(
939                latest
940                    .metadata
941                    .other
942                    .get("version")
943                    .and_then(toml::Value::as_str),
944                Some("v0.2.0")
945            );
946            assert_eq!(latest.metadata.year, Some(2024));
947            assert!(
948                latest.notes.iter().any(|n| n.contains("latest release")),
949                "{:?}",
950                latest.notes
951            );
952            // No release and no tag: no version and no date are invented.
953            assert!(!none.metadata.other.contains_key("version"));
954            assert_eq!(none.metadata.year, None);
955        }
956
957        #[tokio::test]
958        #[serial_test::serial]
959        async fn a_missing_repository_is_not_found_and_the_hourly_limit_is_a_rate_limit() {
960            let server = MockServer::start().await;
961            Mock::given(method("GET"))
962                .and(path("/repos/o/gone"))
963                .respond_with(ResponseTemplate::new(404))
964                .mount(&server)
965                .await;
966            Mock::given(method("GET"))
967                .and(path("/repos/o/busy"))
968                .respond_with(ResponseTemplate::new(403))
969                .mount(&server)
970                .await;
971            let (ctx, _td) = ctx_for(&server).await;
972            let gone = resolve_github(
973                &GithubRef::parse("https://github.com/o/gone").unwrap(),
974                &ctx,
975            )
976            .await
977            .expect_err("404");
978            let busy = resolve_github(
979                &GithubRef::parse("https://github.com/o/busy").unwrap(),
980                &ctx,
981            )
982            .await
983            .expect_err("403");
984            let resolves = github_resolves(
985                &GithubRef::parse("https://github.com/o/gone").unwrap(),
986                &ctx,
987            )
988            .await
989            .expect("a clean answer");
990            clear_env();
991            assert_eq!(crate::ErrorCode::from(&gone), crate::ErrorCode::NotFound);
992            // A 403 stays a refusal (a private repository must not be
993            // retried every 30 s), and the explanation names both causes.
994            assert_eq!(
995                crate::ErrorCode::from(&busy),
996                crate::ErrorCode::CapabilityDenied
997            );
998            assert!(explain(&busy).is_some_and(|w| w.contains("60 requests an hour")));
999            assert!(!resolves);
1000            // Every request is on the provenance log, under its own source.
1001            let log = std::fs::read_to_string(ctx.log.path()).expect("log");
1002            assert!(log.contains("\"source\":\"github\""), "{log}");
1003        }
1004    }
1005}