1use std::collections::HashMap;
2use std::process::Command;
3use std::sync::Mutex;
4use std::sync::OnceLock;
5use std::time::Duration;
6
7use semver::Version;
8use serde::Deserialize;
9use thiserror::Error;
10use ureq::Agent;
11
12use super::version::parse_ref;
13
14type SourceError = Box<dyn std::error::Error + Send + Sync + 'static>;
15
16#[derive(Error, Debug)]
18#[non_exhaustive]
19pub enum ApiError {
20 #[error("request to {url} timed out")]
23 Timeout {
24 url: String,
25 #[source]
26 source: SourceError,
27 },
28
29 #[error("could not reach {url}")]
31 ConnectFailed {
32 url: String,
33 #[source]
34 source: SourceError,
35 },
36
37 #[error("{url} not found (HTTP 404)")]
38 NotFound { url: String },
39
40 #[error("{url} returned HTTP {status}")]
41 HttpStatus { url: String, status: u16 },
42
43 #[error("failed to parse JSON response from {url}")]
45 Json {
46 url: String,
47 #[source]
48 source: serde_json::Error,
49 },
50
51 #[error("unexpected HTTP error for {url}")]
55 Other {
56 url: String,
57 #[source]
58 source: SourceError,
59 },
60
61 #[error("no tags found for repository")]
63 NoTagsFound,
64
65 #[error("no branches found for repository")]
68 NoBranchesFound,
69}
70
71fn classify_ureq(err: ureq::Error, url: &str) -> ApiError {
76 let url = url.to_string();
77 match err {
78 ureq::Error::StatusCode(404) => ApiError::NotFound { url },
79 ureq::Error::StatusCode(status) => ApiError::HttpStatus { url, status },
80 ureq::Error::Timeout(_) => ApiError::Timeout {
81 url,
82 source: Box::new(err),
83 },
84 ureq::Error::HostNotFound | ureq::Error::ConnectionFailed | ureq::Error::Io(_) => {
85 ApiError::ConnectFailed {
86 url,
87 source: Box::new(err),
88 }
89 }
90 _ => ApiError::Other {
91 url,
92 source: Box::new(err),
93 },
94 }
95}
96
97fn classify_body_read(err: ureq::Error, url: &str) -> ApiError {
103 let url = url.to_string();
104 match err {
105 ureq::Error::Timeout(_) => ApiError::Timeout {
106 url,
107 source: Box::new(err),
108 },
109 _ => ApiError::Other {
110 url,
111 source: Box::new(err),
112 },
113 }
114}
115
116#[derive(Clone, Default)]
118pub(crate) struct Headers {
119 pub(crate) user_agent: Option<String>,
120 pub(crate) authorization: Option<String>,
121}
122
123const USER_AGENT: &str = concat!(
124 "flake-edit/",
125 env!("CARGO_PKG_VERSION"),
126 " (+",
127 env!("CARGO_PKG_REPOSITORY"),
128 ")"
129);
130
131impl Headers {
132 fn for_domain(domain: &str) -> Self {
134 let authorization = get_forge_token(domain).map(|token| {
135 tracing::debug!("Found token for {}", domain);
136 format!("Bearer {token}")
137 });
138 Self {
139 user_agent: Some(USER_AGENT.to_string()),
140 authorization,
141 }
142 }
143
144 fn anonymous() -> Self {
145 Self {
146 user_agent: Some(USER_AGENT.to_string()),
147 authorization: None,
148 }
149 }
150}
151
152pub(crate) enum ConditionalResponse {
155 NotModified,
158 Body { body: String, etag: Option<String> },
161}
162
163pub(crate) struct HttpClient {
167 agent: Agent,
168}
169
170impl Default for HttpClient {
171 fn default() -> Self {
172 let config = ureq::Agent::config_builder()
173 .timeout_connect(Some(Duration::from_secs(10)))
174 .timeout_recv_response(Some(Duration::from_secs(30)))
175 .timeout_recv_body(Some(Duration::from_secs(30)))
176 .build();
177 Self {
178 agent: Agent::new_with_config(config),
179 }
180 }
181}
182
183impl HttpClient {
184 fn build(
185 &self,
186 url: &str,
187 headers: &Headers,
188 ) -> ureq::RequestBuilder<ureq::typestate::WithoutBody> {
189 let mut request = self.agent.get(url);
190 if let Some(ref ua) = headers.user_agent {
191 request = request.header("User-Agent", ua);
192 }
193 if let Some(ref auth) = headers.authorization {
194 request = request.header("Authorization", auth);
195 }
196 request
197 }
198
199 pub(crate) fn get(&self, url: &str, headers: &Headers) -> Result<String, ApiError> {
200 let body = self
201 .build(url, headers)
202 .call()
203 .map_err(|e| classify_ureq(e, url))?
204 .body_mut()
205 .read_to_string()
206 .map_err(|e| classify_body_read(e, url))?;
207 Ok(body)
208 }
209
210 pub(crate) fn head_status(&self, url: &str, headers: &Headers) -> Result<bool, ApiError> {
218 match self.build(url, headers).call() {
219 Ok(_) => Ok(true),
220 Err(e) => match classify_ureq(e, url) {
221 ApiError::NotFound { .. } => Ok(false),
222 other => Err(other),
223 },
224 }
225 }
226
227 pub(crate) fn get_conditional(
229 &self,
230 url: &str,
231 headers: &Headers,
232 etag: Option<&str>,
233 ) -> Result<ConditionalResponse, ApiError> {
234 let mut request = self.build(url, headers);
235 if let Some(etag) = etag {
236 request = request.header("If-None-Match", etag);
237 }
238 match request.call() {
239 Ok(mut response) => {
240 if response.status() == ureq::http::StatusCode::NOT_MODIFIED {
241 return Ok(ConditionalResponse::NotModified);
242 }
243 let new_etag = response
244 .headers()
245 .get("etag")
246 .and_then(|v| v.to_str().ok())
247 .map(String::from);
248 let body = response
249 .body_mut()
250 .read_to_string()
251 .map_err(|e| classify_body_read(e, url))?;
252 Ok(ConditionalResponse::Body {
253 body,
254 etag: new_etag,
255 })
256 }
257 Err(e) => Err(classify_ureq(e, url)),
258 }
259 }
260
261 pub(crate) fn post_json(
262 &self,
263 url: &str,
264 headers: &Headers,
265 body: &str,
266 ) -> Result<String, ApiError> {
267 let mut request = self
268 .agent
269 .post(url)
270 .header("Content-Type", "application/json");
271 if let Some(ref ua) = headers.user_agent {
272 request = request.header("User-Agent", ua);
273 }
274 if let Some(ref auth) = headers.authorization {
275 request = request.header("Authorization", auth);
276 }
277 let response_body = request
278 .send(body)
279 .map_err(|e| classify_ureq(e, url))?
280 .body_mut()
281 .read_to_string()
282 .map_err(|e| classify_body_read(e, url))?;
283 Ok(response_body)
284 }
285}
286
287const MAX_PAGES: u32 = 20;
289const PER_PAGE: usize = 100;
291const GITEA_PER_PAGE: usize = 50;
294
295fn parses_as_semver(name: &str) -> bool {
302 let parsed = parse_ref(name, false);
303 Version::parse(&parsed.normalized_for_semver).is_ok()
304}
305
306fn paginated<T, F>(per_page: usize, max_pages: u32, mut fetch: F) -> Result<Vec<T>, ApiError>
315where
316 F: FnMut(u32) -> Result<Vec<T>, ApiError>,
317{
318 let mut all = Vec::new();
319 let mut page: u32 = 1;
320 loop {
321 let items = fetch(page)?;
322 let count = items.len();
323 all.extend(items);
324 if count < per_page || page >= max_pages {
325 break;
326 }
327 page += 1;
328 }
329 Ok(all)
330}
331
332pub struct ForgeClient {
339 http: super::cache::HttpCache,
340 tags_cache: Mutex<HashMap<RepoKey, Tags>>,
341 branches_cache: Mutex<HashMap<RepoKey, Branches>>,
342 branch_exists_cache: Mutex<HashMap<BranchKey, bool>>,
343 url_exists_cache: Mutex<HashMap<String, bool>>,
344 github_graphql_enabled: bool,
348}
349
350#[derive(Debug, Clone)]
357pub(crate) enum BatchLookup {
358 Tags { owner: String, repo: String },
360 ChannelCandidates {
364 owner: String,
365 repo: String,
366 prefix: String,
368 candidates: Vec<String>,
372 },
373}
374
375type RepoKey = (String, String, String);
376type BranchKey = (String, String, String, String);
377
378impl Default for ForgeClient {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384impl std::fmt::Debug for ForgeClient {
385 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 f.debug_struct("ForgeClient").finish_non_exhaustive()
387 }
388}
389
390impl ForgeClient {
391 pub fn new() -> Self {
393 Self {
394 http: super::cache::HttpCache::new(),
395 tags_cache: Mutex::new(HashMap::new()),
396 branches_cache: Mutex::new(HashMap::new()),
397 branch_exists_cache: Mutex::new(HashMap::new()),
398 url_exists_cache: Mutex::new(HashMap::new()),
399 github_graphql_enabled: get_forge_token("github.com").is_some(),
400 }
401 }
402
403 fn canonical_domain(domain: Option<&str>) -> String {
404 domain.unwrap_or("github.com").to_string()
405 }
406
407 pub fn list_tags(
409 &self,
410 owner: &str,
411 repo: &str,
412 domain: Option<&str>,
413 ) -> Result<Tags, ApiError> {
414 let key = (
415 Self::canonical_domain(domain),
416 owner.to_string(),
417 repo.to_string(),
418 );
419 if let Some(hit) = self
420 .tags_cache
421 .lock()
422 .expect("forge tags cache poisoned")
423 .get(&key)
424 .cloned()
425 {
426 return Ok(hit);
427 }
428 let fresh = if key.0 == "github.com" {
429 self.fetch_github_tags(owner, repo)?
430 } else {
431 self.fetch_gitea_tags(&key.0, owner, repo)?
432 };
433 self.tags_cache
434 .lock()
435 .expect("forge tags cache poisoned")
436 .insert(key, fresh.clone());
437 Ok(fresh)
438 }
439
440 pub fn list_branches(
442 &self,
443 owner: &str,
444 repo: &str,
445 domain: Option<&str>,
446 ) -> Result<Branches, ApiError> {
447 let key = (
448 Self::canonical_domain(domain),
449 owner.to_string(),
450 repo.to_string(),
451 );
452 if let Some(hit) = self
453 .branches_cache
454 .lock()
455 .expect("forge branches cache poisoned")
456 .get(&key)
457 .cloned()
458 {
459 return Ok(hit);
460 }
461 let fresh = if key.0 == "github.com" {
462 self.fetch_github_branches(owner, repo)?
463 } else {
464 self.fetch_gitea_branches(&key.0, owner, repo)?
465 };
466 self.branches_cache
467 .lock()
468 .expect("forge branches cache poisoned")
469 .insert(key, fresh.clone());
470 Ok(fresh)
471 }
472
473 pub fn branch_exists(
480 &self,
481 owner: &str,
482 repo: &str,
483 branch: &str,
484 domain: Option<&str>,
485 ) -> Result<bool, ApiError> {
486 let key = (
487 Self::canonical_domain(domain),
488 owner.to_string(),
489 repo.to_string(),
490 branch.to_string(),
491 );
492 if let Some(&hit) = self
493 .branch_exists_cache
494 .lock()
495 .expect("forge branch_exists cache poisoned")
496 .get(&key)
497 {
498 return Ok(hit);
499 }
500 let fresh = if key.0 == "github.com" {
501 self.fetch_github_branch_exists(owner, repo, branch)?
502 } else {
503 self.fetch_gitea_branch_exists(&key.0, owner, repo, branch)?
504 };
505 self.branch_exists_cache
506 .lock()
507 .expect("forge branch_exists cache poisoned")
508 .insert(key, fresh);
509 Ok(fresh)
510 }
511
512 pub(crate) fn url_exists(&self, url: &str) -> Result<bool, ApiError> {
515 if let Some(&hit) = self
516 .url_exists_cache
517 .lock()
518 .expect("forge url_exists cache poisoned")
519 .get(url)
520 {
521 return Ok(hit);
522 }
523 let fresh = self.http.head_status(url, &Headers::anonymous())?;
524 self.url_exists_cache
525 .lock()
526 .expect("forge url_exists cache poisoned")
527 .insert(url.to_string(), fresh);
528 Ok(fresh)
529 }
530
531 pub(crate) fn batch_warm_github(&self, lookups: &[BatchLookup]) -> Result<usize, ApiError> {
548 if !self.github_graphql_enabled || lookups.is_empty() {
549 return Ok(0);
550 }
551 let headers = Headers::for_domain("github.com");
552 let (query, aliases) = build_graphql_query(lookups);
553 let payload = serde_json::json!({ "query": query }).to_string();
554 let url = "https://api.github.com/graphql";
555 tracing::debug!(
556 "Batching {} github.com lookup(s) into one GraphQL POST",
557 aliases.len()
558 );
559 let body = self.http.post_json(url, &headers, &payload)?;
560 let parsed: GraphQlResponse =
561 serde_json::from_str(&body).map_err(|source| ApiError::Json {
562 url: url.to_string(),
563 source,
564 })?;
565
566 let mut primed = 0usize;
567 for (alias, lookup) in &aliases {
568 let Some(node) = parsed.data.as_ref().and_then(|d| d.get(alias)) else {
569 continue;
570 };
571 let Some(repo) = node.as_ref() else {
572 tracing::debug!("GraphQL returned null for alias {}", alias);
576 continue;
577 };
578 let names: Vec<String> = repo
579 .refs
580 .as_ref()
581 .map(|r| r.nodes.iter().map(|n| n.name.clone()).collect())
582 .unwrap_or_default();
583 match lookup {
584 BatchLookup::Tags { owner, repo: r } => {
585 let inter = IntermediaryTags(
586 names
587 .into_iter()
588 .map(|name| IntermediaryTag { name })
589 .collect(),
590 );
591 let tags: Tags = inter.into();
592 let key = ("github.com".to_string(), owner.clone(), r.clone());
593 self.tags_cache
594 .lock()
595 .expect("forge tags cache poisoned")
596 .insert(key, tags);
597 primed += 1;
598 }
599 BatchLookup::ChannelCandidates {
600 owner,
601 repo: r,
602 candidates,
603 ..
604 } => {
605 let returned: std::collections::HashSet<&str> =
606 names.iter().map(|n| n.as_str()).collect();
607 let mut cache = self
608 .branch_exists_cache
609 .lock()
610 .expect("forge branch_exists cache poisoned");
611 for candidate in candidates {
612 let key = (
613 "github.com".to_string(),
614 owner.clone(),
615 r.clone(),
616 candidate.clone(),
617 );
618 cache.insert(key, returned.contains(candidate.as_str()));
619 }
620 primed += 1;
621 }
622 }
623 }
624 Ok(primed)
625 }
626
627 fn fetch_github_tags(&self, owner: &str, repo: &str) -> Result<Tags, ApiError> {
638 let headers = Headers::for_domain("github.com");
639 let url = |page: u32| {
640 format!(
641 "https://api.github.com/repos/{owner}/{repo}/tags?per_page={PER_PAGE}&page={page}"
642 )
643 };
644
645 let first_url = url(1);
646 tracing::debug!("Fetching tags page 1: {}", first_url);
647 let body = self.http.get(&first_url, &headers)?;
648 let first: IntermediaryTags =
649 serde_json::from_str(&body).map_err(|source| ApiError::Json {
650 url: first_url.clone(),
651 source,
652 })?;
653 let mut all = first.0;
654 let first_was_full = all.len() >= PER_PAGE;
655 let first_has_semver = all.iter().any(|t| parses_as_semver(&t.name));
656
657 if !first_was_full || first_has_semver {
658 tracing::debug!(
659 "Cheap path returned {} tag(s) from page 1 (full={}, has_semver={})",
660 all.len(),
661 first_was_full,
662 first_has_semver
663 );
664 return Ok(IntermediaryTags(all).into());
665 }
666
667 tracing::debug!(
668 "Page 1 had no parseable semver in a full page; falling back to paginated walk"
669 );
670 for page in 2..=MAX_PAGES {
671 let page_url = url(page);
672 tracing::debug!("Fetching tags page {}: {}", page, page_url);
673 let body = self.http.get(&page_url, &headers)?;
674 let next: IntermediaryTags =
675 serde_json::from_str(&body).map_err(|source| ApiError::Json {
676 url: page_url.clone(),
677 source,
678 })?;
679 let count = next.0.len();
680 all.extend(next.0);
681 if count < PER_PAGE {
682 break;
683 }
684 }
685 tracing::debug!("Total tags fetched: {}", all.len());
686 Ok(IntermediaryTags(all).into())
687 }
688
689 fn fetch_github_branches(&self, owner: &str, repo: &str) -> Result<Branches, ApiError> {
690 let headers = Headers::for_domain("github.com");
691 let branches = paginated(PER_PAGE, MAX_PAGES, |page| {
692 let url = format!(
693 "https://api.github.com/repos/{owner}/{repo}/branches?per_page={PER_PAGE}&page={page}"
694 );
695 tracing::debug!("Fetching branches page {}: {}", page, url);
696 let body = self.http.get(&url, &headers)?;
697 let page_branches =
698 serde_json::from_str::<IntermediaryBranches>(&body).map_err(|source| {
699 ApiError::Json {
700 url: url.clone(),
701 source,
702 }
703 })?;
704 tracing::debug!("Got {} branches on page {}", page_branches.0.len(), page);
705 Ok(page_branches.0)
706 })?;
707 tracing::debug!("Total branches fetched: {}", branches.len());
708 Ok(IntermediaryBranches(branches).into())
709 }
710
711 fn fetch_github_branch_exists(
712 &self,
713 owner: &str,
714 repo: &str,
715 branch: &str,
716 ) -> Result<bool, ApiError> {
717 let headers = Headers::for_domain("github.com");
718 let url = format!("https://api.github.com/repos/{owner}/{repo}/branches/{branch}");
719 self.http.head_status(&url, &headers)
720 }
721
722 fn fetch_gitea_tags(&self, domain: &str, owner: &str, repo: &str) -> Result<Tags, ApiError> {
723 let headers = Headers::for_domain(domain);
724
725 for scheme in ["https", "http"] {
727 let url = format!("{scheme}://{domain}/api/v1/repos/{owner}/{repo}/tags");
728 tracing::debug!("Trying Gitea tags endpoint: {}", url);
729
730 if let Ok(body) = self.http.get(&url, &headers) {
731 tracing::debug!("Body from Gitea API: {body}");
732 if let Ok(tags) = serde_json::from_str::<IntermediaryTags>(&body) {
733 return Ok(tags.into());
734 }
735 }
736 }
737
738 Err(ApiError::NoTagsFound)
739 }
740
741 fn fetch_gitea_branches(
742 &self,
743 domain: &str,
744 owner: &str,
745 repo: &str,
746 ) -> Result<Branches, ApiError> {
747 let headers = Headers::for_domain(domain);
748 let mut all_branches = Vec::new();
749 let mut page = 1;
750
751 for scheme in ["https", "http"] {
752 loop {
753 let url = format!(
754 "{scheme}://{domain}/api/v1/repos/{owner}/{repo}/branches?limit={GITEA_PER_PAGE}&page={page}"
755 );
756 tracing::debug!("Trying Gitea branches endpoint: {}", url);
757
758 match self.http.get(&url, &headers) {
759 Ok(body) => {
760 tracing::debug!("Body from Gitea API: {body}");
761 match serde_json::from_str::<IntermediaryBranches>(&body) {
762 Ok(page_branches) => {
763 let count = page_branches.0.len();
764 all_branches.extend(page_branches.0);
765
766 if count < GITEA_PER_PAGE || page >= MAX_PAGES {
767 return Ok(IntermediaryBranches(all_branches).into());
768 }
769 page += 1;
770 }
771 Err(_) => break, }
773 }
774 Err(_) => break, }
776 }
777
778 if !all_branches.is_empty() {
779 return Ok(IntermediaryBranches(all_branches).into());
780 }
781 page = 1; }
783
784 Err(ApiError::NoBranchesFound)
785 }
786
787 fn fetch_gitea_branch_exists(
788 &self,
789 domain: &str,
790 owner: &str,
791 repo: &str,
792 branch: &str,
793 ) -> Result<bool, ApiError> {
794 let headers = Headers::for_domain(domain);
795 let mut last_err: Option<ApiError> = None;
800 for scheme in ["https", "http"] {
801 let url = format!("{scheme}://{domain}/api/v1/repos/{owner}/{repo}/branches/{branch}");
802 match self.http.head_status(&url, &headers) {
803 Ok(answer) => return Ok(answer),
804 Err(e) => last_err = Some(e),
805 }
806 }
807 Err(last_err.expect("at least one scheme was attempted"))
808 }
809}
810
811fn build_graphql_query(lookups: &[BatchLookup]) -> (String, Vec<(String, BatchLookup)>) {
820 let mut query = String::from("query {\n");
821 let mut aliases = Vec::with_capacity(lookups.len());
822 for (i, lookup) in lookups.iter().enumerate() {
823 let alias = format!("r{i}");
824 let (owner, repo) = match lookup {
825 BatchLookup::Tags { owner, repo } => (owner, repo),
826 BatchLookup::ChannelCandidates { owner, repo, .. } => (owner, repo),
827 };
828 query.push_str(&format!(
829 " {alias}: repository(owner:{owner}, name:{repo}) {{\n",
830 owner = json_string(owner),
831 repo = json_string(repo),
832 ));
833 match lookup {
834 BatchLookup::Tags { .. } => {
835 query.push_str(
846 " refs(refPrefix:\"refs/tags/\", first:100, \
847 orderBy:{field: TAG_COMMIT_DATE, direction: DESC}) {\n",
848 );
849 }
850 BatchLookup::ChannelCandidates { prefix, .. } => {
851 query.push_str(&format!(
859 " refs(refPrefix:{p}, first:100) {{\n",
860 p = json_string(&format!("refs/heads/{prefix}")),
861 ));
862 }
863 }
864 query.push_str(" nodes { name }\n");
865 query.push_str(" }\n");
866 query.push_str(" }\n");
867 aliases.push((alias, lookup.clone()));
868 }
869 query.push_str("}\n");
870 (query, aliases)
871}
872
873fn json_string(s: &str) -> String {
878 serde_json::Value::String(s.to_string()).to_string()
879}
880
881#[derive(Deserialize, Debug)]
885struct GraphQlResponse {
886 data: Option<HashMap<String, Option<GraphQlRepo>>>,
890}
891
892#[derive(Deserialize, Debug)]
893struct GraphQlRepo {
894 refs: Option<GraphQlRefs>,
895}
896
897#[derive(Deserialize, Debug)]
898struct GraphQlRefs {
899 nodes: Vec<GraphQlRefName>,
900}
901
902#[derive(Deserialize, Debug)]
903struct GraphQlRefName {
904 name: String,
905}
906
907#[derive(Deserialize, Debug)]
908struct IntermediaryTags(Vec<IntermediaryTag>);
909
910#[derive(Deserialize, Debug)]
911struct IntermediaryBranches(Vec<IntermediaryBranch>);
912
913#[derive(Deserialize, Debug)]
914struct IntermediaryBranch {
915 name: String,
916}
917
918#[derive(Debug, Default, Clone)]
919pub struct Branches {
920 pub names: Vec<String>,
921}
922
923#[derive(Debug, Clone)]
924pub struct Tags {
925 versions: Vec<TagVersion>,
926}
927
928impl Tags {
929 pub fn get_latest_tag(&self) -> Option<String> {
932 self.versions
933 .iter()
934 .max_by(|a, b| a.version.cmp_precedence(&b.version))
935 .map(|tag| tag.original.clone())
936 }
937}
938
939#[derive(Deserialize, Debug)]
940struct IntermediaryTag {
941 name: String,
942}
943
944#[derive(Debug, Clone)]
945struct TagVersion {
946 version: Version,
947 original: String,
948}
949
950#[derive(Deserialize, Debug, Clone)]
951struct NixConfig {
952 #[serde(rename = "access-tokens")]
953 access_tokens: Option<AccessTokens>,
954}
955
956impl NixConfig {
957 fn forge_token(&self, domain: &str) -> Option<String> {
958 self.access_tokens.as_ref()?.value.get(domain).cloned()
959 }
960}
961
962#[derive(Deserialize, Debug, Clone)]
963struct AccessTokens {
964 value: HashMap<String, String>,
965}
966
967fn token_cache() -> &'static Mutex<HashMap<String, Option<String>>> {
975 static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
976 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
977}
978
979fn get_forge_token(domain: &str) -> Option<String> {
980 {
981 let cache = token_cache().lock().expect("forge token cache poisoned");
982 if let Some(cached) = cache.get(domain) {
983 return cached.clone();
984 }
985 }
986 let resolved = resolve_forge_token(domain);
992 let mut cache = token_cache().lock().expect("forge token cache poisoned");
993 cache
994 .entry(domain.to_string())
995 .or_insert_with(|| resolved.clone())
996 .clone()
997}
998
999fn resolve_forge_token(domain: &str) -> Option<String> {
1005 if let Ok(output) = Command::new("nix")
1006 .arg("config")
1007 .arg("show")
1008 .arg("--json")
1009 .output()
1010 && let Ok(stdout) = String::from_utf8(output.stdout)
1011 && let Ok(config) = serde_json::from_str::<NixConfig>(&stdout)
1012 && let Some(token) = config.forge_token(domain)
1013 {
1014 return Some(token);
1015 }
1016
1017 if let Ok(token) = std::env::var("GITEA_TOKEN") {
1018 return Some(token);
1019 }
1020 if let Ok(token) = std::env::var("FORGEJO_TOKEN") {
1021 return Some(token);
1022 }
1023 if domain == "github.com"
1024 && let Ok(token) = std::env::var("GITHUB_TOKEN")
1025 {
1026 return Some(token);
1027 }
1028
1029 if let Ok(output) = Command::new("gh")
1038 .args(["auth", "token", "--hostname", domain])
1039 .output()
1040 && output.status.success()
1041 && let Ok(stdout) = String::from_utf8(output.stdout)
1042 {
1043 let token = stdout.trim();
1044 if !token.is_empty() {
1045 return Some(token.to_string());
1046 }
1047 }
1048
1049 None
1050}
1051
1052impl From<IntermediaryTags> for Tags {
1053 fn from(value: IntermediaryTags) -> Self {
1054 let mut versions = vec![];
1055 for itag in value.0 {
1056 let parsed = parse_ref(&itag.name, false);
1057 let normalized = parsed.normalized_for_semver;
1058 match Version::parse(&normalized) {
1059 Ok(semver) => {
1060 versions.push(TagVersion {
1061 version: semver,
1062 original: parsed.original_ref,
1063 });
1064 }
1065 Err(e) => {
1066 tracing::error!("Could not parse version {:?}", e);
1067 }
1068 }
1069 }
1070 Tags { versions }
1071 }
1072}
1073
1074impl From<IntermediaryBranches> for Branches {
1075 fn from(value: IntermediaryBranches) -> Self {
1076 Branches {
1077 names: value.0.into_iter().map(|b| b.name).collect(),
1078 }
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 const URL: &str = "https://api.github.com/repos/foo/bar/branches/baz";
1087
1088 #[test]
1089 fn paginated_accumulates_until_short_page() {
1090 let pages: Vec<Vec<u32>> = vec![(0..100).collect(), (100..105).collect()];
1091 let mut calls = 0u32;
1092 let result = paginated::<u32, _>(100, MAX_PAGES, |page| {
1093 calls += 1;
1094 Ok(pages[(page - 1) as usize].clone())
1095 })
1096 .unwrap();
1097 assert_eq!(calls, 2, "stops after the short page, no third request");
1098 assert_eq!(result.len(), 105);
1099 assert_eq!(result.first().copied(), Some(0));
1100 assert_eq!(result.last().copied(), Some(104));
1101 }
1102
1103 #[test]
1104 fn paginated_caps_at_max_pages() {
1105 let mut calls = 0u32;
1106 let result = paginated::<u32, _>(2, 3, |_| {
1107 calls += 1;
1108 Ok(vec![1, 2])
1109 })
1110 .unwrap();
1111 assert_eq!(calls, 3, "safety cap halts the loop on always-full pages");
1112 assert_eq!(result.len(), 6);
1113 }
1114
1115 #[test]
1116 fn classify_404_is_not_found() {
1117 let err = classify_ureq(ureq::Error::StatusCode(404), URL);
1118 match err {
1119 ApiError::NotFound { url } => assert_eq!(url, URL),
1120 other => panic!("expected NotFound, got {other:?}"),
1121 }
1122 }
1123
1124 #[test]
1125 fn classify_500_is_http_status() {
1126 let err = classify_ureq(ureq::Error::StatusCode(503), URL);
1127 match err {
1128 ApiError::HttpStatus { url, status } => {
1129 assert_eq!(url, URL);
1130 assert_eq!(status, 503);
1131 }
1132 other => panic!("expected HttpStatus, got {other:?}"),
1133 }
1134 }
1135
1136 #[test]
1137 fn body_read_io_is_not_connect_failed() {
1138 let io = std::io::Error::other("peer closed");
1139 let err = classify_body_read(ureq::Error::Io(io), URL);
1140 match err {
1141 ApiError::Other { url, .. } => assert_eq!(url, URL),
1142 other => panic!("expected Other, got {other:?}"),
1143 }
1144 }
1145
1146 #[test]
1147 fn tags_parsing_with_refs_tags_prefix() {
1148 let json = r#"[
1149 {"name": "refs/tags/v1.0.0"},
1150 {"name": "refs/tags/v2.0.0"},
1151 {"name": "refs/tags/v1.5.0"}
1152 ]"#;
1153
1154 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1155 let tags: Tags = intermediary.into();
1156
1157 assert_eq!(tags.get_latest_tag(), Some("refs/tags/v2.0.0".to_string()));
1158 }
1159
1160 #[test]
1161 fn tags_parsing_with_short_versions() {
1162 let json = r#"[
1163 {"name": "v1"},
1164 {"name": "v1.1"}
1165 ]"#;
1166
1167 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1168 let tags: Tags = intermediary.into();
1169
1170 assert_eq!(tags.get_latest_tag(), Some("v1.1".to_string()));
1171 }
1172
1173 #[test]
1174 fn tags_parsing_without_prefix() {
1175 let json = r#"[
1176 {"name": "1.0.0"},
1177 {"name": "2.0.0"},
1178 {"name": "1.5.0"}
1179 ]"#;
1180
1181 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1182 let tags: Tags = intermediary.into();
1183
1184 assert_eq!(tags.get_latest_tag(), Some("2.0.0".to_string()));
1185 }
1186
1187 #[test]
1188 fn tags_parsing_with_dash_prefix() {
1189 let json = r#"[
1190 {"name": "release-1.0.0"},
1191 {"name": "release-2.0.0"},
1192 {"name": "release-1.5.0"}
1193 ]"#;
1194
1195 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1196 let tags: Tags = intermediary.into();
1197
1198 assert_eq!(tags.get_latest_tag(), Some("release-2.0.0".to_string()));
1199 }
1200
1201 #[test]
1202 fn tags_parsing_mixed_valid_invalid() {
1203 let json = r#"[
1204 {"name": "v1.0.0"},
1205 {"name": "v2.0.0"},
1206 {"name": "invalid-tag"},
1207 {"name": "v1.5.0"}
1208 ]"#;
1209
1210 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1211 let tags: Tags = intermediary.into();
1212
1213 assert_eq!(tags.get_latest_tag(), Some("v2.0.0".to_string()));
1214 }
1215
1216 #[test]
1217 fn tags_parsing_empty() {
1218 let json = r#"[]"#;
1219
1220 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1221 let tags: Tags = intermediary.into();
1222
1223 assert_eq!(tags.get_latest_tag(), None);
1224 }
1225
1226 #[test]
1227 fn tags_parsing_orders_prereleases_by_semver_precedence() {
1228 let json = r#"[
1229 {"name": "v1.0.0"},
1230 {"name": "v2.0.0-beta.1"},
1231 {"name": "v1.5.0"}
1232 ]"#;
1233
1234 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1235 let tags: Tags = intermediary.into();
1236
1237 assert_eq!(tags.get_latest_tag(), Some("v2.0.0-beta.1".to_string()));
1241 }
1242
1243 #[test]
1244 fn tags_parsing_handles_hl_prefixed_scheme_without_downgrade() {
1245 let json = r#"[
1246 {"name": "hl0.21.0-1"},
1247 {"name": "hl0.33.0-1"},
1248 {"name": "hl0.46.0-1"},
1249 {"name": "hl0.47.0-1"}
1250 ]"#;
1251
1252 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1253 let tags: Tags = intermediary.into();
1254
1255 assert_eq!(tags.get_latest_tag(), Some("hl0.47.0-1".to_string()));
1256 }
1257
1258 #[test]
1259 fn tags_parsing_combined_prefixes() {
1260 let json = r#"[
1261 {"name": "refs/tags/v1.0.0"},
1262 {"name": "refs/tags/v2.0.0"}
1263 ]"#;
1264
1265 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1266 let tags: Tags = intermediary.into();
1267
1268 assert_eq!(tags.get_latest_tag(), Some("refs/tags/v2.0.0".to_string()));
1269 }
1270
1271 #[test]
1272 fn tags_sort_by_semver_not_lex() {
1273 let json = r#"[
1274 {"name": "v10.0.0"},
1275 {"name": "v2.0.0"},
1276 {"name": "v1.0.0"}
1277 ]"#;
1278
1279 let intermediary: IntermediaryTags = serde_json::from_str(json).unwrap();
1280 let tags: Tags = intermediary.into();
1281
1282 assert_eq!(tags.get_latest_tag(), Some("v10.0.0".to_string()));
1283 }
1284
1285 #[test]
1286 fn http_client_has_explicit_timeouts() {
1287 let client = HttpClient::default();
1290 let timeouts = client.agent.config().timeouts();
1291 assert!(
1292 timeouts.connect.is_some(),
1293 "connect timeout must be set on the HTTP agent"
1294 );
1295 assert!(
1296 timeouts.recv_response.is_some(),
1297 "recv_response timeout must be set on the HTTP agent"
1298 );
1299 }
1300
1301 #[test]
1302 fn conditional_get_reports_304_as_not_modified() {
1303 use crate::forge::testing::{Reply, StubServer};
1304
1305 let server = StubServer::start(vec![Reply::NotModified { etag: "\"abc\"" }]);
1306 let client = HttpClient::default();
1307 let url = server.url("/repos/foo/bar/tags");
1308
1309 let response = client
1310 .get_conditional(&url, &Headers::anonymous(), Some("\"abc\""))
1311 .expect("conditional GET");
1312
1313 assert!(
1314 matches!(response, ConditionalResponse::NotModified),
1315 "304 must be reported as NotModified"
1316 );
1317 assert_eq!(
1318 server
1319 .requests()
1320 .first()
1321 .and_then(|r| r.if_none_match.clone()),
1322 Some("\"abc\"".to_string()),
1323 "ETag MUST be If-None-Match"
1324 );
1325 }
1326
1327 #[test]
1328 fn forge_client_is_send_and_sync() {
1329 fn assert_send_sync<T: Send + Sync>() {}
1330 assert_send_sync::<ForgeClient>();
1331 }
1332
1333 #[test]
1334 fn parses_as_semver_recognizes_normalized_shapes() {
1335 assert!(parses_as_semver("v1.2.3"));
1339 assert!(parses_as_semver("refs/tags/v1.2.3"));
1340 assert!(parses_as_semver("release-1.2.3"));
1341 assert!(parses_as_semver("v1")); assert!(parses_as_semver("1.0.0+gitea")); assert!(parses_as_semver("release-1.5")); assert!(!parses_as_semver("invalid-tag"));
1345 assert!(!parses_as_semver("abc"));
1346 assert!(!parses_as_semver(""));
1347 assert!(!parses_as_semver("release-25.05"));
1355 assert!(!parses_as_semver("release-24.05"));
1356 assert!(parses_as_semver("v1.2.3-rc1"));
1361 }
1362
1363 #[test]
1364 fn build_graphql_query_uses_distinct_aliases() {
1365 let lookups = vec![
1371 BatchLookup::Tags {
1372 owner: "same".into(),
1373 repo: "same".into(),
1374 },
1375 BatchLookup::Tags {
1376 owner: "same".into(),
1377 repo: "same".into(),
1378 },
1379 ];
1380 let (query, aliases) = build_graphql_query(&lookups);
1381 assert_eq!(aliases.len(), 2);
1382 assert_eq!(aliases[0].0, "r0");
1383 assert_eq!(aliases[1].0, "r1");
1384 assert!(
1385 query.contains("r0:"),
1386 "first lookup must use the r0 alias; query was:\n{query}"
1387 );
1388 assert!(
1389 query.contains("r1:"),
1390 "second lookup must use a distinct r1 alias; query was:\n{query}"
1391 );
1392 }
1393}