1use serde_json::Value;
25use url::Url;
26
27use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
28use crate::source::{FetchContext, FetchError};
29use crate::store::Metadata;
30
31pub const GITHUB_API: &str = "github";
33pub const GITHUB_RAW: &str = "github-raw";
35pub const GITHUB_API_BASE_ENV: &str = "DOIGET_GITHUB_API_BASE";
37pub 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
43fn 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#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct GithubRef {
65 owner: String,
66 repo: String,
67 tag: Option<String>,
68}
69
70impl GithubRef {
71 #[must_use]
73 pub fn owner(&self) -> &str {
74 &self.owner
75 }
76
77 #[must_use]
79 pub fn repo(&self) -> &str {
80 &self.repo
81 }
82
83 #[must_use]
85 pub fn tag(&self) -> Option<&str> {
86 self.tag.as_deref()
87 }
88
89 fn repo_path(&self) -> String {
91 format!("repos/{}/{}", self.owner, self.repo)
92 }
93
94 #[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 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct Cff {
166 pub title: Option<String>,
168 pub authors: Vec<String>,
170 pub version: Option<String>,
172 pub doi: Option<String>,
174 pub date_released: Option<String>,
176}
177
178#[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
266fn 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
282fn 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
289fn 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#[derive(Debug, Clone)]
306pub struct SoftwareCitation {
307 pub metadata: Metadata,
309 pub from_cff: bool,
311 pub notes: Vec<String>,
313}
314
315pub 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 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
462pub 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
508async 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
532async 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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum ZenodoDoi {
638 Version {
640 concept: String,
642 },
643 Concept,
645}
646
647#[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 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 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 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 let log = std::fs::read_to_string(ctx.log.path()).expect("log");
1002 assert!(log.contains("\"source\":\"github\""), "{log}");
1003 }
1004 }
1005}