Skip to main content

flake_edit/forge/
api.rs

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/// Errors from talking to a forge over HTTP.
17#[derive(Error, Debug)]
18#[non_exhaustive]
19pub enum ApiError {
20    /// Request hit a configured timeout (connect, recv-response,
21    /// or recv-body).
22    #[error("request to {url} timed out")]
23    Timeout {
24        url: String,
25        #[source]
26        source: SourceError,
27    },
28
29    /// Could not establish a connection.
30    #[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    /// Failed to parse the JSON response body returned by the forge.
44    #[error("failed to parse JSON response from {url}")]
45    Json {
46        url: String,
47        #[source]
48        source: serde_json::Error,
49    },
50
51    /// HTTP error not classified above. Reached only if ureq grows
52    /// a new variant or a bespoke connector chain returns one of the
53    /// rarer existing variants.
54    #[error("unexpected HTTP error for {url}")]
55    Other {
56        url: String,
57        #[source]
58        source: SourceError,
59    },
60
61    /// The forge returned no tags for the repository.
62    #[error("no tags found for repository")]
63    NoTagsFound,
64
65    /// Branch listing exhausted retries (both schemes, all pages) without
66    /// returning usable data.
67    #[error("no branches found for repository")]
68    NoBranchesFound,
69}
70
71/// Classify a `ureq::Error` from establishing the request into the
72/// domain `ApiError`. Hand-written rather than `#[from]` so a new
73/// ureq variant must be handled explicitly and cannot silently
74/// become `Other`.
75fn 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
97/// Classify a `ureq::Error` from reading the response body off a
98/// connection that already succeeded. Distinct from `classify_ureq`
99/// because here an `Io(_)` is a mid-stream drop, not a connect
100/// failure; calling it `ConnectFailed` would tell the user we never
101/// reached the forge when in fact the peer hung up halfway through.
102fn 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/// Headers for HTTP requests
117#[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    /// Headers with optional Bearer token authentication for the given domain.
133    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
152/// Outcome of a conditional GET. Distinguishes "the cached body is
153/// still authoritative" from "here is a fresh body and its new ETag".
154pub(crate) enum ConditionalResponse {
155    /// The server returned 304: the caller's cached body for this URL
156    /// is still authoritative.
157    NotModified,
158    /// Fresh response. `etag` is `None` when the server did not send
159    /// one, in which case the response is uncacheable.
160    Body { body: String, etag: Option<String> },
161}
162
163/// HTTP layer: one shared `ureq::Agent` with explicit timeouts. The
164/// only direct user is [`super::cache::HttpCache`], which adds
165/// persistent ETag revalidation on top.
166pub(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    /// Probe a URL and report whether the resource exists.
211    ///
212    /// `Ok(true)` for any 2xx response, `Ok(false)` for HTTP 404,
213    /// `Err(_)` for anything else (timeout, connect failure, 5xx,
214    /// ...). The caller must not collapse these three into a bool:
215    /// "branch does not exist" and "could not reach the forge" are
216    /// different answers and the user needs to see the latter.
217    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    /// `ureq` turns only 4xx and 5xx into `Error::StatusCode`
228    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
287/// Hard cap on paginated listing requests.
288const MAX_PAGES: u32 = 20;
289/// GitHub's maximum page size for list endpoints.
290const PER_PAGE: usize = 100;
291/// Gitea pagination size. Smaller because self-hosted instances
292/// often cap server-side.
293const GITEA_PER_PAGE: usize = 50;
294
295/// Must stay in step with the [`IntermediaryTags`] to [`Tags`]
296/// conversion: [`ForgeClient::fetch_github_tags`] uses this
297/// predicate to decide that page 1 is trustworthy, then hands the
298/// same names to the conversion. If the conversion drops a name
299/// that the predicate accepted, the cheap path can return a
300/// [`Tags`] whose [`Tags::get_latest_tag`] is `None`.
301fn parses_as_semver(name: &str) -> bool {
302    let parsed = parse_ref(name, false);
303    Version::parse(&parsed.normalized_for_semver).is_ok()
304}
305
306/// Drive a paginated listing endpoint, accumulating items in order
307/// until exhaustion or `max_pages` bounds a misbehaving endpoint.
308///
309/// `fetch` receives a 1-based page number and returns the items on
310/// that page. Iteration stops when `fetch` yields fewer than
311/// `per_page` items (real APIs signal "no more" with a short page),
312/// or when `page` reaches `max_pages`, which exists so an endpoint
313/// that always returns full pages cannot loop indefinitely.
314fn 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
332/// Unified entry point for talking to forges (GitHub, Gitea/Forgejo).
333///
334/// One shared HTTP agent and per-run result caches for tags,
335/// branches, and branch-exists probes. Locking is fine-grained per
336/// cache: two threads racing on the same missing key may both
337/// fetch, at the cost of one duplicated round trip.
338pub 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    /// `false` when no github.com token is available; unauthenticated
345    /// runs skip the GraphQL batch because the endpoint rejects them
346    /// with HTTP 401, and fall back to anonymous REST.
347    github_graphql_enabled: bool,
348}
349
350/// One unit of work in a [`ForgeClient::batch_warm_github`] call.
351///
352/// The variants mirror the per-repo round trips the REST path would
353/// otherwise make: `Tags` replaces `list_tags`, `ChannelCandidates`
354/// replaces a fan of `branch_exists` probes for a known candidate set.
355/// Both prime the same caches the REST path consults.
356#[derive(Debug, Clone)]
357pub(crate) enum BatchLookup {
358    /// Prime the tags cache for `github.com/{owner}/{repo}`.
359    Tags { owner: String, repo: String },
360    /// Prime `branch_exists` for each `candidate` under
361    /// `refs/heads/{prefix}`. Candidates not present in the GraphQL
362    /// response cache as `false`; ones returned cache as `true`.
363    ChannelCandidates {
364        owner: String,
365        repo: String,
366        /// e.g. `"nixos-"`. Drives the `refPrefix` in the GraphQL query.
367        prefix: String,
368        /// Full branch names the caller will subsequently probe via
369        /// `branch_exists`. Pre-computed by the caller so api.rs stays
370        /// independent of the channel-version generator.
371        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    /// Build a client backed by the XDG-located persistent ETag cache.
392    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    /// Latest-known tags for `(owner, repo)` at `domain`. Cached on success.
408    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    /// All branch names for `(owner, repo)` at `domain`. Cached on success.
441    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    /// Single-branch existence probe.
474    ///
475    /// `Ok(true)` for an existing branch, `Ok(false)` for a forge that
476    /// returned 404, `Err(_)` for any transient failure (timeout,
477    /// DNS, 5xx, ...). Both `Ok(true)` and `Ok(false)` are cached;
478    /// errors are not.
479    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    /// `Ok(true)` for 2xx (after redirects), `Ok(false)` for 404,
513    /// `Err(_)` for anything transient.
514    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    /// Resolve many `github.com` lookups in one GraphQL POST and
532    /// prime the per-run caches with the results.
533    ///
534    /// `Tags` lookups populate `tags_cache`; `ChannelCandidates`
535    /// lookups populate `branch_exists_cache` for every candidate
536    /// (returned branches as `true`, missing ones as `false`).
537    /// Subsequent calls to [`ForgeClient::list_tags`] and
538    /// [`ForgeClient::branch_exists`] for the same `(owner, repo)`
539    /// then hit the cache instead of issuing per-repo REST round
540    /// trips.
541    ///
542    /// Per-input errors do not abort the batch. A repo that GitHub
543    /// returns as `null` (private, missing) leaves its cache slot
544    /// empty so the caller falls through to the REST path, which
545    /// surfaces the underlying error in context. The whole call
546    /// returns `Err` only when the POST itself fails.
547    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                // GraphQL returned null for this repo (private, missing,
573                // or partial-errors). Skip and let REST surface the
574                // error in context.
575                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    /// Fetch tags from `github.com/{owner}/{repo}`, trusting page 1
628    /// when it contains at least one parseable semver tag.
629    ///
630    /// GitHub's `/tags` orders by ref creation time, not by semver,
631    /// so the cheap path is right for monotone version progressions
632    /// and falls back to a paginated walk capped at [`MAX_PAGES`]
633    /// when page 1 is full but contains no semver-parseable names
634    /// (hash- or date-style tagging schemes). Repos that backport
635    /// onto an older branch can push the latest major off page 1;
636    /// users hitting that must pin manually.
637    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        // Try HTTPS, fall back to HTTP.
726        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, // Try next scheme
772                        }
773                    }
774                    Err(_) => break, // Try next scheme
775                }
776            }
777
778            if !all_branches.is_empty() {
779                return Ok(IntermediaryBranches(all_branches).into());
780            }
781            page = 1; // Reset for next scheme
782        }
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        // Try https, fall back to http. The first scheme to give a
796        // definitive answer (2xx or 404) wins; http is tried only
797        // when https errored out before reaching the application
798        // layer.
799        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
811/// Build a single GraphQL document that resolves every `lookup`.
812///
813/// Each lookup is wrapped in an aliased `repository(owner:, name:)`
814/// query (`r0`, `r1`, ...) so the response can be demultiplexed by
815/// alias even when two lookups target the same repo. GitHub GraphQL
816/// requires unique aliases when the same field appears more than
817/// once in a selection set; the index suffix guarantees uniqueness
818/// regardless of how the caller deduplicates upstream.
819fn 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                // `orderBy: TAG_COMMIT_DATE DESC` is what makes the
836                // first 100 refs comparable to REST's
837                // `/tags?per_page=100&page=1`. Without it, GitHub
838                // returns refs in lexicographic order, which for a
839                // repo with more than 100 tags can push the
840                // highest-semver tag off the window and silently
841                // cache a stale "latest". The REST cheap path
842                // defends with a paginated fallback; the GraphQL
843                // path has no equivalent, so the ordering hint is
844                // load-bearing rather than cosmetic.
845                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                // `first:100` is GitHub GraphQL's hard cap for `refs`.
852                // For the channel prefixes we care about (`nixos-`,
853                // `nixpkgs-`, `release-`, `nix-darwin-`) the universe
854                // of branches is far smaller, but the cap leaves
855                // headroom for forks that accumulate stale release
856                // branches without truncating real candidates into
857                // false-negatives.
858                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
873/// Escape `s` into a JSON-quoted string suitable for inlining as a
874/// GraphQL argument literal. `serde_json::to_string` is reused
875/// instead of hand-rolling escapes so backslash, quote, and control
876/// character handling stays correct.
877fn json_string(s: &str) -> String {
878    serde_json::Value::String(s.to_string()).to_string()
879}
880
881/// GraphQL response shape. Both lookup kinds collapse to the same
882/// `repository { refs { nodes { name } } }` projection on the wire,
883/// so one struct covers both.
884#[derive(Deserialize, Debug)]
885struct GraphQlResponse {
886    /// Absent when GitHub returned only an `errors` block (e.g. 401);
887    /// each entry can be `None` when a single repo failed inside an
888    /// otherwise-successful response.
889    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    /// Latest semver-ordered tag, or `None` for an empty / fully
930    /// unparseable set.
931    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
967/// Per-process cache of resolved forge tokens, keyed by domain.
968///
969/// Scope is per-process because the resolver inputs (`nix.conf`,
970/// `GITHUB_TOKEN` and friends) do not change during a `flake-edit`
971/// invocation; we never re-read them. `None` is cached the same
972/// as `Some(_)` so a domain with no configured token does not
973/// re-fork `nix config show --json` on every request.
974fn 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    // Resolve outside the lock so a slow `nix` fork does not
987    // block lookups for unrelated domains. A racing duplicate
988    // resolution is harmless: `or_insert_with` keeps whichever
989    // value was inserted first, and both racers compute the same
990    // answer for the same domain.
991    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
999/// Resolve `domain`'s forge token from scratch, with no caching.
1000///
1001/// Forks `nix config show --json` on every call. Callers must
1002/// route through [`get_forge_token`] so repeat lookups do not
1003/// re-fork.
1004fn 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    // Last-resort fallback: shell out to `gh auth token --hostname
1030    // <domain>`. A user who only ran `gh auth login` is anonymous to
1031    // every check above (the gh CLI stores its token in its own
1032    // credential file, not in nix.conf or the environment), so this
1033    // single fork is what lifts that population from the 60/hr
1034    // anonymous rate limit onto a real token. `gh` exits non-zero
1035    // when it has no token for the host, which we treat as "no
1036    // token" and fall through.
1037    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        // `v2.0.0-beta.1` beats `v1.5.0` because semver precedence
1238        // compares major before flagging prerelease status; a higher
1239        // major wins even when the higher tag is a prerelease.
1240        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        // Without timeouts a hung TCP connect blocks the whole CLI.
1288        // The exact values are tunable but they must not be unset.
1289        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        // Pins the agreement between the cheap-path early-stop and
1336        // the `IntermediaryTags -> Tags` conversion. See the doc on
1337        // `parses_as_semver` for why divergence is unsound.
1338        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")); // normalized to v1.0.0
1342        assert!(parses_as_semver("1.0.0+gitea")); // build metadata is valid semver
1343        assert!(parses_as_semver("release-1.5")); // 2-segment pads to 1.5.0
1344        assert!(!parses_as_semver("invalid-tag"));
1345        assert!(!parses_as_semver("abc"));
1346        assert!(!parses_as_semver(""));
1347        // Leading-zero rejection: `release-25.05` normalizes to
1348        // `25.05.0`, which the semver crate rejects because `05` has
1349        // a leading zero. Channel-style year.month refs must NOT be
1350        // accepted by the cheap path: a flake using `release-25.05`
1351        // (or `release-24.05`) on a github.com repo would otherwise
1352        // be mis-classified as a semver tag and short-circuit the
1353        // tag walk against a window that does not contain it.
1354        assert!(!parses_as_semver("release-25.05"));
1355        assert!(!parses_as_semver("release-24.05"));
1356        // Prereleases must satisfy the predicate so that a page-1
1357        // listing of prerelease tags still trips the cheap-path
1358        // early-stop. Selection downstream picks a stable release
1359        // on the same page when one exists.
1360        assert!(parses_as_semver("v1.2.3-rc1"));
1361    }
1362
1363    #[test]
1364    fn build_graphql_query_uses_distinct_aliases() {
1365        // GitHub GraphQL requires each `repository(...)` selection in
1366        // one document to use a unique alias. Two lookups that target
1367        // the same `(owner, repo)` must still get distinct aliases,
1368        // otherwise the POST is rejected before any response can be
1369        // demultiplexed.
1370        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}