Skip to main content

sui_eval/
fetcher.rs

1//! Content-addressed input fetcher for flake.lock resolved inputs.
2//!
3//! Fetches locked flake inputs (github tarballs, git repos, local paths,
4//! remote tarballs) and caches them by `narHash` so repeated evaluations
5//! hit the local filesystem instead of the network.
6
7use std::io::Read as _;
8use std::path::{Path, PathBuf};
9
10use sui_compat::flake::LockedInput;
11
12// ── Error type ────────────────────────────────────────────────
13
14/// Errors that can occur during input fetching.
15#[derive(Debug, thiserror::Error)]
16pub enum FetchError {
17    #[error("unsupported input type: {0}")]
18    UnsupportedType(String),
19    #[error("missing required field: {0}")]
20    MissingField(&'static str),
21    #[error("download failed: {0}")]
22    Download(String),
23    #[error("I/O error: {0}")]
24    Io(#[from] std::io::Error),
25    #[error("archive extraction failed: {0}")]
26    Extract(String),
27}
28
29// ── InputFetcher ──────────────────────────────────────────────
30
31/// A content-addressed input fetcher that downloads and caches flake inputs.
32///
33/// Inputs are cached under `~/.cache/sui/inputs/` (or a custom directory)
34/// keyed by their `narHash` from the lock file. Cache hits skip network
35/// access entirely.
36pub struct InputFetcher {
37    cache_dir: PathBuf,
38}
39
40impl Default for InputFetcher {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl InputFetcher {
47    /// Create a fetcher using the default cache directory (`~/.cache/sui/inputs/`).
48    #[must_use]
49    pub fn new() -> Self {
50        let cache_dir = dirs_cache_dir().join("sui/inputs");
51        Self { cache_dir }
52    }
53
54    /// Create a fetcher with a custom cache directory.
55    #[must_use]
56    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
57        Self { cache_dir }
58    }
59
60    /// Return the cache directory path.
61    #[must_use]
62    pub fn cache_dir(&self) -> &Path {
63        &self.cache_dir
64    }
65
66    /// Fetch a locked input and return the local filesystem path.
67    ///
68    /// Uses content-addressed caching by `narHash` — if the hash is present
69    /// and a cached directory exists, returns immediately without network access.
70    pub fn fetch(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
71        // Check cache first (keyed by narHash).
72        if let Some(ref nar_hash) = locked.nar_hash {
73            let cache_key = sanitize_hash(nar_hash);
74            let cached = self.cache_dir.join(&cache_key);
75            if cached.exists() {
76                let resolved = find_single_subdir_or_self(&cached);
77                // Validate the cache entry is non-empty.  A previous fetch may
78                // have created the directory but failed before extracting any
79                // content (e.g. network timeout).  Treat empty dirs as cache
80                // misses so the fetch is retried.
81                if is_non_empty_dir(&resolved) {
82                    return Ok(resolved);
83                }
84                // Cache entry is empty/invalid — remove it and re-fetch.
85                let _ = std::fs::remove_dir_all(&cached);
86            }
87        }
88
89        match locked.source_type.as_str() {
90            "github" => self.fetch_github(locked),
91            "gitlab" => self.fetch_gitlab(locked),
92            "sourcehut" => self.fetch_sourcehut(locked),
93            "path" => Self::fetch_path(locked),
94            "git" => self.fetch_git(locked),
95            "tarball" | "file" => self.fetch_tarball(locked),
96            other => Err(FetchError::UnsupportedType(other.to_string())),
97        }
98    }
99
100    /// Construct the GitHub archive URL for a locked input.
101    #[must_use]
102    pub fn github_archive_url(owner: &str, repo: &str, rev: &str) -> String {
103        format!("https://github.com/{owner}/{repo}/archive/{rev}.tar.gz")
104    }
105
106    /// GitLab archive URL.  Shape differs from GitHub — the file
107    /// name embeds the repo + rev and lives under `/-/archive/{rev}/`.
108    /// Honors `host` so self-hosted gitlab instances (e.g.
109    /// `gitlab.gnome.org`, `git.example.com`) work; defaults to
110    /// `gitlab.com` when host is None.
111    #[must_use]
112    pub fn gitlab_archive_url(host: Option<&str>, owner: &str, repo: &str, rev: &str) -> String {
113        let host = host.unwrap_or("gitlab.com");
114        format!(
115            "https://{host}/{owner}/{repo}/-/archive/{rev}/{repo}-{rev}.tar.gz"
116        )
117    }
118
119    /// Sourcehut archive URL. Owners carry the `~` prefix on the
120    /// platform; the flake-ref parser stores them without the prefix,
121    /// so we prepend here.
122    #[must_use]
123    pub fn sourcehut_archive_url(owner: &str, repo: &str, rev: &str) -> String {
124        let owner_prefix = if owner.starts_with('~') {
125            owner.to_string()
126        } else {
127            format!("~{owner}")
128        };
129        format!("https://git.sr.ht/{owner_prefix}/{repo}/archive/{rev}.tar.gz")
130    }
131
132    // ── Private fetch methods ─────────────────────────────
133
134    fn fetch_github(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
135        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
136        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
137        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
138
139        let url = Self::github_archive_url(owner, repo, rev);
140        let dest = self.dest_dir(locked, &format!("github-{owner}-{repo}-{rev}"));
141        std::fs::create_dir_all(&dest)?;
142
143        // Download and extract; on failure remove the (potentially empty) dest
144        // directory so the next attempt doesn't see a stale cache hit.
145        let bytes = match download_bytes(&url) {
146            Ok(b) => b,
147            Err(e) => {
148                let _ = std::fs::remove_dir_all(&dest);
149                return Err(e);
150            }
151        };
152        if let Err(e) = extract_tar_gz(&bytes, &dest) {
153            let _ = std::fs::remove_dir_all(&dest);
154            return Err(e);
155        }
156
157        Ok(find_single_subdir_or_self(&dest))
158    }
159
160    /// GitLab and Sourcehut share the same archive-fetch shape as
161    /// GitHub — download a tar.gz, extract, return the single top-
162    /// level directory. Only the URL construction differs.
163    fn fetch_archive(
164        &self,
165        locked: &LockedInput,
166        url: &str,
167        cache_key: &str,
168    ) -> Result<PathBuf, FetchError> {
169        let dest = self.dest_dir(locked, cache_key);
170        std::fs::create_dir_all(&dest)?;
171        let bytes = match download_bytes(url) {
172            Ok(b) => b,
173            Err(e) => {
174                let _ = std::fs::remove_dir_all(&dest);
175                return Err(e);
176            }
177        };
178        if let Err(e) = extract_tar_gz(&bytes, &dest) {
179            let _ = std::fs::remove_dir_all(&dest);
180            return Err(e);
181        }
182        Ok(find_single_subdir_or_self(&dest))
183    }
184
185    fn fetch_gitlab(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
186        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
187        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
188        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
189        let host = locked.host.as_deref();
190        let url = Self::gitlab_archive_url(host, owner, repo, rev);
191        let host_tag = host.unwrap_or("gitlab.com").replace('.', "_");
192        self.fetch_archive(locked, &url, &format!("gitlab-{host_tag}-{owner}-{repo}-{rev}"))
193    }
194
195    fn fetch_sourcehut(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
196        let owner = locked.owner.as_deref().ok_or(FetchError::MissingField("owner"))?;
197        let repo = locked.repo.as_deref().ok_or(FetchError::MissingField("repo"))?;
198        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
199        let url = Self::sourcehut_archive_url(owner, repo, rev);
200        let sanitized_owner = owner.trim_start_matches('~');
201        self.fetch_archive(
202            locked,
203            &url,
204            &format!("sourcehut-{sanitized_owner}-{repo}-{rev}"),
205        )
206    }
207
208    fn fetch_path(locked: &LockedInput) -> Result<PathBuf, FetchError> {
209        let path = locked
210            .path
211            .as_deref()
212            .ok_or(FetchError::MissingField("path"))?;
213        Ok(PathBuf::from(path))
214    }
215
216    fn fetch_git(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
217        let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
218        let rev = locked.rev.as_deref().ok_or(FetchError::MissingField("rev"))?;
219
220        let short_rev: String = rev.chars().take(12).collect();
221        let dest = self.dest_dir(locked, &format!("git-{short_rev}"));
222
223        if dest.exists() {
224            if is_non_empty_dir(&dest) {
225                return Ok(dest);
226            }
227            let _ = std::fs::remove_dir_all(&dest);
228        }
229
230        // Try GitHub tarball first (avoids git CLI dependency in containers).
231        // Most git-type inputs in flake.lock are GitHub repos that support
232        // archive downloads via /archive/{rev}.tar.gz.
233        if let Some(tarball_url) = github_tarball_from_git_url(url, rev) {
234            std::fs::create_dir_all(&dest)?;
235            match download_bytes(&tarball_url) {
236                Ok(bytes) => {
237                    if let Err(e) = extract_tar_gz(&bytes, &dest) {
238                        let _ = std::fs::remove_dir_all(&dest);
239                        return Err(e);
240                    }
241                    return Ok(find_single_subdir_or_self(&dest));
242                }
243                Err(e) => {
244                    // Tarball fallback failed — try git CLI below.
245                    let _ = std::fs::remove_dir_all(&dest);
246                    tracing::debug!(url = %tarball_url, error = %e, "Tarball fallback failed, trying git CLI");
247                }
248            }
249        }
250
251        // Fall back to git CLI for non-GitHub repos or when tarball fails.
252        let status = std::process::Command::new("git")
253            .args(["clone", "--depth", "1", url])
254            .arg(&dest)
255            .stdout(std::process::Stdio::null())
256            .stderr(std::process::Stdio::null())
257            .status()
258            .map_err(|e| FetchError::Download(format!(
259                "git clone failed (git not in PATH?): {e}"
260            )))?;
261        if !status.success() {
262            let _ = std::fs::remove_dir_all(&dest);
263            return Err(FetchError::Download(format!(
264                "git clone failed for {url} (exit code: {})",
265                status.code().unwrap_or(-1)
266            )));
267        }
268
269        // Checkout the exact revision.
270        crate::git::checkout_rev(&dest, rev)
271            .map_err(|e| FetchError::Download(format!("git checkout {rev}: {e}")))?;
272
273        Ok(dest)
274    }
275
276    fn fetch_tarball(&self, locked: &LockedInput) -> Result<PathBuf, FetchError> {
277        let url = locked.url.as_deref().ok_or(FetchError::MissingField("url"))?;
278
279        let hash_suffix = locked
280            .nar_hash
281            .as_deref()
282            .map_or_else(|| url_to_safe_name(url), sanitize_hash);
283        let dest = self.dest_dir(locked, &format!("tarball-{hash_suffix}"));
284
285        if dest.exists() {
286            let resolved = find_single_subdir_or_self(&dest);
287            if is_non_empty_dir(&resolved) {
288                return Ok(resolved);
289            }
290            let _ = std::fs::remove_dir_all(&dest);
291        }
292
293        std::fs::create_dir_all(&dest)?;
294        let bytes = match download_bytes(url) {
295            Ok(b) => b,
296            Err(e) => {
297                let _ = std::fs::remove_dir_all(&dest);
298                return Err(e);
299            }
300        };
301        if let Err(e) = extract_tar_gz(&bytes, &dest) {
302            let _ = std::fs::remove_dir_all(&dest);
303            return Err(e);
304        }
305
306        Ok(find_single_subdir_or_self(&dest))
307    }
308
309    /// Compute the destination directory, preferring narHash-based names.
310    fn dest_dir(&self, locked: &LockedInput, fallback: &str) -> PathBuf {
311        if let Some(ref nar_hash) = locked.nar_hash {
312            self.cache_dir.join(sanitize_hash(nar_hash))
313        } else {
314            self.cache_dir.join(fallback)
315        }
316    }
317}
318
319// ── Helpers ───────────────────────────────────────────────────
320
321/// Try to convert a git URL to a GitHub tarball URL.
322///
323/// `https://github.com/NixOS/nixpkgs.git` + rev → `https://github.com/NixOS/nixpkgs/archive/{rev}.tar.gz`
324/// Returns `None` for non-GitHub URLs.
325fn github_tarball_from_git_url(url: &str, rev: &str) -> Option<String> {
326    let stripped = url
327        .strip_prefix("https://github.com/")
328        .or_else(|| url.strip_prefix("git+https://github.com/"))
329        .or_else(|| url.strip_prefix("http://github.com/"))?;
330    let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
331    // Validate it looks like owner/repo (no extra path segments)
332    let parts: Vec<&str> = stripped.split('/').collect();
333    if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
334        Some(format!(
335            "https://github.com/{}/{}/archive/{rev}.tar.gz",
336            parts[0], parts[1]
337        ))
338    } else {
339        None
340    }
341}
342
343/// Turn a narHash like `sha256-AAAA...=` into a filesystem-safe name.
344fn sanitize_hash(hash: &str) -> String {
345    hash.replace(':', "-").replace('/', "_").replace('=', "")
346}
347
348/// Return `true` when `dir` exists and has at least one child entry.
349fn is_non_empty_dir(dir: &Path) -> bool {
350    std::fs::read_dir(dir)
351        .ok()
352        .is_some_and(|mut rd| rd.next().is_some())
353}
354
355/// If the directory contains exactly one child directory (common for GitHub
356/// tarballs which unpack as `repo-rev/`), return that child. Otherwise
357/// return the directory itself.
358fn find_single_subdir_or_self(dir: &Path) -> PathBuf {
359    let entries: Vec<_> = std::fs::read_dir(dir)
360        .ok()
361        .into_iter()
362        .flatten()
363        .filter_map(|e| e.ok())
364        .collect();
365    if entries.len() == 1 && entries[0].path().is_dir() {
366        entries[0].path()
367    } else {
368        dir.to_path_buf()
369    }
370}
371
372/// Download a URL and return the raw bytes.
373///
374/// Uses `ureq` (synchronous, no tokio runtime) so this function is safe to
375/// call from inside a running tokio context — no nested-runtime panic.
376///
377/// Body limit raised to 512 MiB to accommodate large inputs like nixpkgs tarballs.
378fn download_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
379    let mut req = ureq::get(url);
380
381    // Attach a host-appropriate auth token when one is available.
382    // CppNix consults `~/.config/nix/nix.conf` `access-tokens =
383    // github.com=<TOKEN>` etc.; we keep parity by reading the same
384    // sources plus the common `GITHUB_TOKEN` env (gh CLI, nix-darwin
385    // shell init).  Without this the operator's private flake
386    // inputs (e.g. `arnes`) 404 unauthenticated.
387    if let Some(token) = github_token_for_url(url) {
388        req = req.header("Authorization", &format!("token {token}"));
389    }
390
391    let mut response = req
392        .call()
393        .map_err(|e| FetchError::Download(format!("{url}: {e}")))?;
394
395    if !response.status().is_success() {
396        return Err(FetchError::Download(format!(
397            "{url}: HTTP {}",
398            response.status().as_u16()
399        )));
400    }
401
402    response
403        .body_mut()
404        .with_config()
405        .limit(512 * 1024 * 1024)
406        .read_to_vec()
407        .map_err(|e| FetchError::Download(format!("{url}: {e}")))
408}
409
410/// Resolve a host-appropriate auth token for outgoing requests.
411///
412/// Sources, in order:
413///   1. `GITHUB_TOKEN` env var (covers gh CLI exports + CI tokens).
414///   2. `NIX_CONFIG` env var, parsed for `access-tokens` line.
415///   3. `~/.config/nix/nix.conf` parsed for `access-tokens` line.
416///   4. `~/.config/gh/hosts.yml` (`oauth_token:` field for github.com).
417///
418/// Returns `Some(token)` only for github.com URLs in this iteration —
419/// gitlab / sr.ht / private git hosts can be added when needed.
420fn github_token_for_url(url: &str) -> Option<String> {
421    if !url.starts_with("https://github.com/")
422        && !url.starts_with("https://api.github.com/")
423    {
424        return None;
425    }
426    if let Ok(t) = std::env::var("GITHUB_TOKEN") {
427        if !t.is_empty() {
428            return Some(t);
429        }
430    }
431    if let Ok(cfg) = std::env::var("NIX_CONFIG") {
432        if let Some(t) = parse_access_tokens(&cfg, "github.com") {
433            return Some(t);
434        }
435    }
436    if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
437        let nix_conf = home.join(".config/nix/nix.conf");
438        if let Ok(cfg) = std::fs::read_to_string(&nix_conf) {
439            if let Some(t) = parse_access_tokens(&cfg, "github.com") {
440                return Some(t);
441            }
442        }
443        let gh_hosts = home.join(".config/gh/hosts.yml");
444        if let Ok(yml) = std::fs::read_to_string(&gh_hosts) {
445            if let Some(t) = parse_gh_hosts_token(&yml, "github.com") {
446                return Some(t);
447            }
448        }
449    }
450    None
451}
452
453/// Parse a `~/.config/nix/nix.conf`-style `access-tokens = host=TOKEN ...`
454/// line and return the token for `host` if present.
455fn parse_access_tokens(cfg: &str, host: &str) -> Option<String> {
456    for line in cfg.lines() {
457        let trimmed = line.trim();
458        if let Some(rest) = trimmed.strip_prefix("access-tokens") {
459            let rest = rest.trim_start().trim_start_matches('=').trim();
460            for pair in rest.split_whitespace() {
461                if let Some((h, t)) = pair.split_once('=') {
462                    if h == host {
463                        return Some(t.to_string());
464                    }
465                }
466            }
467        }
468    }
469    None
470}
471
472/// Parse `~/.config/gh/hosts.yml` and return the `oauth_token:` value
473/// nested under the given host key.  We do this without a full YAML
474/// parser to keep sui-eval's dep footprint small — the file is a
475/// stable 5-line shape gh maintains.
476fn parse_gh_hosts_token(yml: &str, host: &str) -> Option<String> {
477    let mut in_host = false;
478    for line in yml.lines() {
479        let raw = line;
480        let trimmed = raw.trim();
481        if trimmed.starts_with(host) && trimmed.ends_with(':') {
482            in_host = true;
483            continue;
484        }
485        if !raw.starts_with(' ') && !raw.starts_with('\t') && !trimmed.is_empty() {
486            in_host = false;
487        }
488        if in_host {
489            if let Some(rest) = trimmed.strip_prefix("oauth_token:") {
490                return Some(rest.trim().to_string());
491            }
492        }
493    }
494    None
495}
496
497/// Extract a `.tar.gz` archive into a destination directory.
498fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<(), FetchError> {
499    let gz = flate2::read::GzDecoder::new(bytes);
500
501    // Check if the gzip header is valid before attempting extraction.
502    // An empty or non-gzip payload would fail inside tar::Archive.
503    let mut buffered = std::io::BufReader::new(gz);
504    let mut peek = [0u8; 1];
505    // Try reading one byte to detect decompression errors early.
506    match buffered.read(&mut peek) {
507        Ok(0) => {
508            return Err(FetchError::Extract("empty archive".into()));
509        }
510        Err(e) => {
511            return Err(FetchError::Extract(format!("gzip decompression: {e}")));
512        }
513        Ok(_) => {
514            // Put the byte back by chaining it in front of the reader.
515            let cursor = std::io::Cursor::new(peek);
516            let chain = cursor.chain(buffered);
517            let mut archive = tar::Archive::new(chain);
518            archive
519                .unpack(dest)
520                .map_err(|e| FetchError::Extract(format!("tar unpack: {e}")))?;
521        }
522    }
523
524    Ok(())
525}
526
527/// Convert a URL into a filesystem-safe name (for fallback cache keys).
528fn url_to_safe_name(url: &str) -> String {
529    url.chars()
530        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
531        .collect()
532}
533
534/// Platform-aware cache directory discovery.
535fn dirs_cache_dir() -> PathBuf {
536    // Try XDG_CACHE_HOME first, then platform default, then /tmp.
537    if let Ok(xdg) = std::env::var("XDG_CACHE_HOME")
538        && !xdg.is_empty() {
539            return PathBuf::from(xdg);
540        }
541    if let Some(home) = std::env::var_os("HOME") {
542        let default = PathBuf::from(home).join(".cache");
543        if default.exists() || std::fs::create_dir_all(&default).is_ok() {
544            return default;
545        }
546    }
547    PathBuf::from("/tmp")
548}
549
550// ── Tests ─────────────────────────────────────────────────────
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use std::collections::BTreeMap;
556
557    /// Helper: build a `LockedInput` with the given fields.
558    fn make_locked(source_type: &str) -> LockedInput {
559        LockedInput {
560            source_type: source_type.to_string(),
561            owner: None,
562            repo: None,
563            rev: None,
564            nar_hash: None,
565            last_modified: None,
566            path: None,
567            url: None,
568            git_ref: None,
569            dir: None,
570            host: None,
571            extra: BTreeMap::new(),
572        }
573    }
574
575    // ── sanitize_hash ─────────────────────────────────────
576
577    #[test]
578    fn sanitize_hash_replaces_special_chars() {
579        assert_eq!(
580            sanitize_hash("sha256-AAAAAAAAAAAAAAAAAAAAAA="),
581            "sha256-AAAAAAAAAAAAAAAAAAAAAA"
582        );
583        assert_eq!(sanitize_hash("sha256:abc/def="), "sha256-abc_def");
584    }
585
586    // ── find_single_subdir_or_self ────────────────────────
587
588    #[test]
589    fn find_single_subdir_returns_child_when_one_dir() {
590        let tmp = tempfile::tempdir().unwrap();
591        let child = tmp.path().join("repo-abc123");
592        std::fs::create_dir(&child).unwrap();
593        std::fs::write(child.join("file.txt"), "hello").unwrap();
594
595        let result = find_single_subdir_or_self(tmp.path());
596        assert_eq!(result, child);
597    }
598
599    #[test]
600    fn find_single_subdir_returns_self_when_multiple() {
601        let tmp = tempfile::tempdir().unwrap();
602        std::fs::create_dir(tmp.path().join("a")).unwrap();
603        std::fs::create_dir(tmp.path().join("b")).unwrap();
604
605        let result = find_single_subdir_or_self(tmp.path());
606        assert_eq!(result, tmp.path());
607    }
608
609    #[test]
610    fn find_single_subdir_returns_self_when_empty() {
611        let tmp = tempfile::tempdir().unwrap();
612        let result = find_single_subdir_or_self(tmp.path());
613        assert_eq!(result, tmp.path());
614    }
615
616    #[test]
617    fn find_single_subdir_returns_self_when_child_is_file() {
618        let tmp = tempfile::tempdir().unwrap();
619        std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
620        let result = find_single_subdir_or_self(tmp.path());
621        assert_eq!(result, tmp.path());
622    }
623
624    // ── url_to_safe_name ──────────────────────────────────
625
626    #[test]
627    fn url_to_safe_name_replaces_slashes_and_colons() {
628        let name = url_to_safe_name("https://example.com/foo/bar.tar.gz");
629        assert!(!name.contains('/'));
630        assert!(!name.contains(':'));
631        assert!(name.contains("example"));
632    }
633
634    // ── InputFetcher construction ─────────────────────────
635
636    #[test]
637    fn fetcher_with_custom_cache_dir() {
638        let tmp = tempfile::tempdir().unwrap();
639        let fetcher = InputFetcher::with_cache_dir(tmp.path().to_path_buf());
640        assert_eq!(fetcher.cache_dir(), tmp.path());
641    }
642
643    #[test]
644    fn fetcher_default_cache_dir_exists() {
645        let fetcher = InputFetcher::new();
646        // The path should end with "sui/inputs".
647        let path_str = fetcher.cache_dir().to_string_lossy();
648        assert!(path_str.ends_with("sui/inputs"), "got: {path_str}");
649    }
650
651    // ── path-type fetch ───────────────────────────────────
652
653    #[test]
654    fn fetch_path_returns_filesystem_path() {
655        let tmp = tempfile::tempdir().unwrap();
656        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
657
658        let mut locked = make_locked("path");
659        locked.path = Some("/var/empty/dep".to_string());
660
661        let result = fetcher.fetch(&locked).unwrap();
662        assert_eq!(result, PathBuf::from("/var/empty/dep"));
663    }
664
665    #[test]
666    fn fetch_path_missing_field_errors() {
667        let tmp = tempfile::tempdir().unwrap();
668        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
669        let locked = make_locked("path");
670        let result = fetcher.fetch(&locked);
671        assert!(result.is_err());
672        assert!(result.unwrap_err().to_string().contains("path"));
673    }
674
675    // ── unsupported type ──────────────────────────────────
676
677    #[test]
678    fn fetch_unsupported_type_returns_error() {
679        // `mercurial` — parser doesn't produce this and fetcher
680        // doesn't handle it. Remains unsupported for now. If a
681        // future commit adds mercurial support, swap this to the
682        // next truly-unsupported source_type to keep the test
683        // meaningful.
684        let tmp = tempfile::tempdir().unwrap();
685        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
686        let locked = make_locked("mercurial");
687        let result = fetcher.fetch(&locked);
688        assert!(matches!(result, Err(FetchError::UnsupportedType(_))));
689    }
690
691    #[test]
692    fn gitlab_archive_url_is_well_formed() {
693        assert_eq!(
694            InputFetcher::gitlab_archive_url(None, "group", "proj", "abc123"),
695            "https://gitlab.com/group/proj/-/archive/abc123/proj-abc123.tar.gz"
696        );
697    }
698
699    #[test]
700    fn gitlab_archive_url_honors_custom_host() {
701        assert_eq!(
702            InputFetcher::gitlab_archive_url(Some("gitlab.gnome.org"), "GNOME", "gnome-shell", "abc"),
703            "https://gitlab.gnome.org/GNOME/gnome-shell/-/archive/abc/gnome-shell-abc.tar.gz"
704        );
705    }
706
707    #[test]
708    fn sourcehut_archive_url_prepends_tilde() {
709        // Sourcehut owner names on the platform carry a `~` prefix
710        // (`~emersion`) but the flake-ref parser drops it. Fetcher
711        // must reinstate so the URL is canonical.
712        assert_eq!(
713            InputFetcher::sourcehut_archive_url("emersion", "page", "HEAD"),
714            "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
715        );
716        // If the caller already included `~`, don't double it.
717        assert_eq!(
718            InputFetcher::sourcehut_archive_url("~emersion", "page", "HEAD"),
719            "https://git.sr.ht/~emersion/page/archive/HEAD.tar.gz"
720        );
721    }
722
723    // ── cache hit ─────────────────────────────────────────
724
725    #[test]
726    fn cache_hit_returns_cached_path() {
727        let tmp = tempfile::tempdir().unwrap();
728        let cache_dir = tmp.path().join("cache");
729        std::fs::create_dir_all(&cache_dir).unwrap();
730
731        // Pre-populate cache.
732        let hash = "sha256-TESTCACHEHIT";
733        let cached_dir = cache_dir.join(sanitize_hash(hash));
734        std::fs::create_dir_all(&cached_dir).unwrap();
735        std::fs::write(cached_dir.join("flake.nix"), "{}").unwrap();
736
737        let fetcher = InputFetcher::with_cache_dir(cache_dir);
738        let mut locked = make_locked("github");
739        locked.nar_hash = Some(hash.to_string());
740        // Intentionally leave owner/repo/rev empty — cache hit should skip fetch.
741
742        let result = fetcher.fetch(&locked).unwrap();
743        // The cached directory has one file (not a subdir), so it returns itself.
744        assert_eq!(result, cached_dir);
745    }
746
747    // ── github URL construction ───────────────────────────
748
749    #[test]
750    fn github_archive_url_format() {
751        let url = InputFetcher::github_archive_url("nixos", "nixpkgs", "abc123");
752        assert_eq!(
753            url,
754            "https://github.com/nixos/nixpkgs/archive/abc123.tar.gz"
755        );
756    }
757
758    // ── github fetch missing fields ───────────────────────
759
760    #[test]
761    fn fetch_github_missing_owner_errors() {
762        let tmp = tempfile::tempdir().unwrap();
763        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
764        let mut locked = make_locked("github");
765        locked.repo = Some("nixpkgs".into());
766        locked.rev = Some("abc123".into());
767        let result = fetcher.fetch(&locked);
768        assert!(result.is_err());
769        assert!(result.unwrap_err().to_string().contains("owner"));
770    }
771
772    #[test]
773    fn fetch_github_missing_rev_errors() {
774        let tmp = tempfile::tempdir().unwrap();
775        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
776        let mut locked = make_locked("github");
777        locked.owner = Some("nixos".into());
778        locked.repo = Some("nixpkgs".into());
779        let result = fetcher.fetch(&locked);
780        assert!(result.is_err());
781        assert!(result.unwrap_err().to_string().contains("rev"));
782    }
783
784    // ── git fetch missing fields ──────────────────────────
785
786    #[test]
787    fn fetch_git_missing_url_errors() {
788        let tmp = tempfile::tempdir().unwrap();
789        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
790        let mut locked = make_locked("git");
791        locked.rev = Some("abc123".into());
792        let result = fetcher.fetch(&locked);
793        assert!(result.is_err());
794        assert!(result.unwrap_err().to_string().contains("url"));
795    }
796
797    #[test]
798    fn fetch_git_missing_rev_errors() {
799        let tmp = tempfile::tempdir().unwrap();
800        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
801        let mut locked = make_locked("git");
802        locked.url = Some("https://example.com/repo.git".into());
803        let result = fetcher.fetch(&locked);
804        assert!(result.is_err());
805        assert!(result.unwrap_err().to_string().contains("rev"));
806    }
807
808    // ── tarball fetch missing URL ─────────────────────────
809
810    #[test]
811    fn fetch_tarball_missing_url_errors() {
812        let tmp = tempfile::tempdir().unwrap();
813        let fetcher = InputFetcher::with_cache_dir(tmp.path().join("cache"));
814        let locked = make_locked("tarball");
815        let result = fetcher.fetch(&locked);
816        assert!(result.is_err());
817        assert!(result.unwrap_err().to_string().contains("url"));
818    }
819
820    // ── extract_tar_gz ────────────────────────────────────
821
822    #[test]
823    fn extract_tar_gz_empty_archive_errors() {
824        let tmp = tempfile::tempdir().unwrap();
825        let result = extract_tar_gz(&[], tmp.path());
826        assert!(result.is_err());
827    }
828
829    #[test]
830    fn extract_tar_gz_invalid_data_errors() {
831        let tmp = tempfile::tempdir().unwrap();
832        let result = extract_tar_gz(b"not a gzip stream at all", tmp.path());
833        assert!(result.is_err());
834    }
835
836    // ── dest_dir logic ────────────────────────────────────
837
838    #[test]
839    fn dest_dir_uses_nar_hash_when_present() {
840        let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
841        let mut locked = make_locked("github");
842        locked.nar_hash = Some("sha256-ABC123=".to_string());
843        let dest = fetcher.dest_dir(&locked, "fallback");
844        assert!(dest.to_string_lossy().contains("sha256-ABC123"));
845        assert!(!dest.to_string_lossy().contains("fallback"));
846    }
847
848    #[test]
849    fn dest_dir_uses_fallback_when_no_hash() {
850        let fetcher = InputFetcher::with_cache_dir(PathBuf::from("/cache"));
851        let locked = make_locked("github");
852        let dest = fetcher.dest_dir(&locked, "fallback-name");
853        assert!(dest.to_string_lossy().contains("fallback-name"));
854    }
855
856    // ── is_non_empty_dir ─────────────────────────────────
857
858    #[test]
859    fn is_non_empty_dir_returns_true_for_non_empty() {
860        let tmp = tempfile::tempdir().unwrap();
861        std::fs::write(tmp.path().join("file.txt"), "data").unwrap();
862        assert!(is_non_empty_dir(tmp.path()));
863    }
864
865    #[test]
866    fn is_non_empty_dir_returns_false_for_empty() {
867        let tmp = tempfile::tempdir().unwrap();
868        assert!(!is_non_empty_dir(tmp.path()));
869    }
870
871    #[test]
872    fn is_non_empty_dir_returns_false_for_missing() {
873        assert!(!is_non_empty_dir(Path::new("/nonexistent/path/12345")));
874    }
875
876    // ── empty cache invalidation ─────────────────────────
877
878    #[test]
879    fn empty_cache_dir_is_treated_as_miss() {
880        let tmp = tempfile::tempdir().unwrap();
881        let cache_dir = tmp.path().join("cache");
882        std::fs::create_dir_all(&cache_dir).unwrap();
883
884        // Pre-create an *empty* cache directory (simulates a failed fetch).
885        let hash = "sha256-EMPTYTEST";
886        let cached_dir = cache_dir.join(sanitize_hash(hash));
887        std::fs::create_dir_all(&cached_dir).unwrap();
888        // Verify the directory is empty.
889        assert!(std::fs::read_dir(&cached_dir).unwrap().next().is_none());
890
891        let fetcher = InputFetcher::with_cache_dir(cache_dir);
892        let mut locked = make_locked("github");
893        locked.nar_hash = Some(hash.to_string());
894        // owner/repo/rev are missing, so the re-fetch will fail — but
895        // the important thing is that the cache miss was detected (the
896        // stale directory was removed) and the code attempted a fresh fetch.
897        let result = fetcher.fetch(&locked);
898        assert!(result.is_err(), "should not return stale empty cache");
899        // The empty directory should have been cleaned up.
900        assert!(!cached_dir.exists(), "stale cache dir should be removed");
901    }
902
903    // ── github_tarball_from_git_url ──────────────────────
904
905    #[test]
906    fn tarball_from_https_github() {
907        let url = github_tarball_from_git_url(
908            "https://github.com/NixOS/nixpkgs.git",
909            "abc123",
910        );
911        assert_eq!(
912            url.as_deref(),
913            Some("https://github.com/NixOS/nixpkgs/archive/abc123.tar.gz")
914        );
915    }
916
917    #[test]
918    fn tarball_from_git_plus_https() {
919        let url = github_tarball_from_git_url(
920            "git+https://github.com/NixOS/nixpkgs",
921            "def456",
922        );
923        assert_eq!(
924            url.as_deref(),
925            Some("https://github.com/NixOS/nixpkgs/archive/def456.tar.gz")
926        );
927    }
928
929    #[test]
930    fn tarball_from_non_github_returns_none() {
931        assert!(github_tarball_from_git_url("https://gitlab.com/foo/bar.git", "abc").is_none());
932        assert!(github_tarball_from_git_url("ssh://git@github.com/foo/bar", "abc").is_none());
933    }
934
935    #[test]
936    fn tarball_from_malformed_path_returns_none() {
937        assert!(github_tarball_from_git_url("https://github.com/", "abc").is_none());
938        assert!(github_tarball_from_git_url("https://github.com/only-owner", "abc").is_none());
939    }
940}