Skip to main content

bock_pkg/
network.rs

1//! Network-backed package registry client.
2//!
3//! Implements the registry protocol defined in spec §19.5:
4//!
5//! ```text
6//! GET /packages/{name}                   → { versions, latest }
7//! GET /packages/{name}/{version}         → { manifest, checksum, download_url }
8//! GET /packages/{name}/{version}/download → tarball bytes
9//! ```
10//!
11//! Tarballs are cached under `cache_dir` after SHA-256 verification.
12//! A [`PackageRegistry`] can be passed as a fallback for offline use
13//! or private overrides.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use serde::Deserialize;
20use sha2::{Digest, Sha256};
21
22use crate::error::PkgError;
23use crate::resolver::{PackageRegistry, PackageVersionMeta};
24
25/// Response body for `GET /packages/{name}`.
26#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
27pub struct VersionsResponse {
28    /// All versions known for this package (semver strings).
29    pub versions: Vec<String>,
30    /// The latest (highest) stable version, hinted by the registry.
31    pub latest: String,
32}
33
34/// Response body for `GET /packages/{name}/{version}`.
35#[derive(Debug, Clone, Deserialize)]
36pub struct VersionMetaResponse {
37    /// Subset of the package manifest relevant for resolution.
38    pub manifest: ManifestData,
39    /// SHA-256 digest of the tarball, optionally prefixed with `"sha256:"`.
40    pub checksum: String,
41    /// URL from which the tarball can be fetched. If empty, the default
42    /// `/packages/{name}/{version}/download` endpoint is used.
43    #[serde(default)]
44    pub download_url: String,
45}
46
47/// Manifest fragment served by the registry for a specific version.
48#[derive(Debug, Clone, Deserialize, Default)]
49pub struct ManifestData {
50    /// Direct dependencies: name → version requirement string.
51    #[serde(default)]
52    pub dependencies: BTreeMap<String, String>,
53    /// Targets this version supports. `None` = all targets.
54    #[serde(default)]
55    pub supported_targets: Option<Vec<String>>,
56    /// Features declared by this version.
57    #[serde(default)]
58    pub available_features: BTreeMap<String, Vec<String>>,
59    /// Features requested from each dependency.
60    #[serde(default)]
61    pub dep_features: BTreeMap<String, Vec<String>>,
62}
63
64/// Environment variable that supplies a Bearer auth token for private registries.
65pub const AUTH_TOKEN_ENV: &str = "BOCK_REGISTRY_TOKEN";
66
67/// A network-backed registry that fetches metadata and tarballs over HTTPS.
68///
69/// Hydrate into a [`PackageRegistry`] via [`Self::hydrate`] before running
70/// resolution, since resolution is driven by the in-memory provider.
71pub struct NetworkRegistry {
72    base_url: String,
73    client: reqwest::blocking::Client,
74    cache_dir: PathBuf,
75    fallback: Option<PackageRegistry>,
76    auth_token: Option<String>,
77}
78
79impl NetworkRegistry {
80    /// Build a client pointed at `base_url` with tarballs cached under `cache_dir`.
81    ///
82    /// The cache directory is created if it does not exist.
83    pub fn new(
84        base_url: impl Into<String>,
85        cache_dir: impl Into<PathBuf>,
86    ) -> Result<Self, PkgError> {
87        let client = reqwest::blocking::Client::builder()
88            .timeout(Duration::from_secs(30))
89            .user_agent(concat!("bock-pkg/", env!("CARGO_PKG_VERSION")))
90            .build()
91            .map_err(|e| PkgError::Network(e.to_string()))?;
92        let cache_dir = cache_dir.into();
93        std::fs::create_dir_all(&cache_dir).map_err(|e| PkgError::Io(e.to_string()))?;
94        let base_url = base_url.into().trim_end_matches('/').to_string();
95        Ok(Self {
96            base_url,
97            client,
98            cache_dir,
99            fallback: None,
100            auth_token: std::env::var(AUTH_TOKEN_ENV).ok().filter(|s| !s.is_empty()),
101        })
102    }
103
104    /// Attach an in-memory [`PackageRegistry`] to serve entries the network
105    /// does not know about (or cannot be reached for).
106    #[must_use]
107    pub fn with_fallback(mut self, fallback: PackageRegistry) -> Self {
108        self.fallback = Some(fallback);
109        self
110    }
111
112    /// Override the Bearer auth token used for registry requests.
113    ///
114    /// Pass `None` to clear the token (useful for tests that want to skip the
115    /// environment-provided value). By default, the value of the
116    /// [`AUTH_TOKEN_ENV`] environment variable is used.
117    #[must_use]
118    pub fn with_auth_token(mut self, token: Option<String>) -> Self {
119        self.auth_token = token.filter(|s| !s.is_empty());
120        self
121    }
122
123    /// The Bearer auth token currently in effect, if any.
124    #[must_use]
125    pub fn auth_token(&self) -> Option<&str> {
126        self.auth_token.as_deref()
127    }
128
129    fn authed_get(&self, url: &str) -> reqwest::blocking::RequestBuilder {
130        let mut req = self.client.get(url);
131        if let Some(token) = &self.auth_token {
132            req = req.bearer_auth(token);
133        }
134        req
135    }
136
137    /// Base URL of the registry (with any trailing slash stripped).
138    #[must_use]
139    pub fn base_url(&self) -> &str {
140        &self.base_url
141    }
142
143    /// Directory where downloaded tarballs are cached.
144    #[must_use]
145    pub fn cache_dir(&self) -> &Path {
146        &self.cache_dir
147    }
148
149    /// Fetch the list of versions available for `name`.
150    pub fn fetch_versions(&self, name: &str) -> Result<VersionsResponse, PkgError> {
151        let url = format!("{}/packages/{}", self.base_url, name);
152        let response = self
153            .authed_get(&url)
154            .send()
155            .map_err(|e| PkgError::Network(format!("GET {url}: {e}")))?;
156        if response.status() == reqwest::StatusCode::NOT_FOUND {
157            return Err(PkgError::PackageNotFound(name.to_string()));
158        }
159        if !response.status().is_success() {
160            return Err(PkgError::Network(format!(
161                "GET {url}: status {}",
162                response.status()
163            )));
164        }
165        response
166            .json()
167            .map_err(|e| PkgError::Network(format!("decoding {url}: {e}")))
168    }
169
170    /// Fetch the metadata for a specific `version` of `name`.
171    pub fn fetch_version_meta(
172        &self,
173        name: &str,
174        version: &str,
175    ) -> Result<VersionMetaResponse, PkgError> {
176        let url = format!("{}/packages/{}/{}", self.base_url, name, version);
177        let response = self
178            .authed_get(&url)
179            .send()
180            .map_err(|e| PkgError::Network(format!("GET {url}: {e}")))?;
181        if response.status() == reqwest::StatusCode::NOT_FOUND {
182            return Err(PkgError::PackageNotFound(format!("{name}@{version}")));
183        }
184        if !response.status().is_success() {
185            return Err(PkgError::Network(format!(
186                "GET {url}: status {}",
187                response.status()
188            )));
189        }
190        response
191            .json()
192            .map_err(|e| PkgError::Network(format!("decoding {url}: {e}")))
193    }
194
195    /// Download a package tarball, verifying its checksum, and cache it.
196    ///
197    /// Returns the path of the cached tarball. If the file already exists in
198    /// the cache, it is returned without re-fetching or re-verifying — the
199    /// tarball name embeds the version, so the cache key is version-scoped.
200    pub fn download_package(&self, name: &str, version: &str) -> Result<PathBuf, PkgError> {
201        let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
202        if cache_path.exists() {
203            return Ok(cache_path);
204        }
205
206        let meta = self.fetch_version_meta(name, version)?;
207        let tarball_url = if meta.download_url.is_empty() {
208            format!("{}/packages/{}/{}/download", self.base_url, name, version)
209        } else {
210            meta.download_url.clone()
211        };
212
213        let response = self
214            .authed_get(&tarball_url)
215            .send()
216            .map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
217        if !response.status().is_success() {
218            return Err(PkgError::Network(format!(
219                "GET {tarball_url}: status {}",
220                response.status()
221            )));
222        }
223        let bytes = response
224            .bytes()
225            .map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
226
227        verify_checksum(&bytes, &meta.checksum)?;
228
229        std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
230        Ok(cache_path)
231    }
232
233    /// Fetch a package: resolve metadata, download (or reuse cached) tarball,
234    /// and return the cached path together with the metadata response.
235    ///
236    /// When the tarball is already cached, the checksum in the returned meta
237    /// is still fetched from the registry to keep lockfile entries accurate.
238    pub fn fetch_package(
239        &self,
240        name: &str,
241        version: &str,
242    ) -> Result<FetchedPackage, PkgError> {
243        let meta = self.fetch_version_meta(name, version)?;
244        let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
245
246        if !cache_path.exists() {
247            let tarball_url = if meta.download_url.is_empty() {
248                format!("{}/packages/{}/{}/download", self.base_url, name, version)
249            } else {
250                meta.download_url.clone()
251            };
252
253            let response = self
254                .authed_get(&tarball_url)
255                .send()
256                .map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
257            if !response.status().is_success() {
258                return Err(PkgError::Network(format!(
259                    "GET {tarball_url}: status {}",
260                    response.status()
261                )));
262            }
263            let bytes = response
264                .bytes()
265                .map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
266
267            verify_checksum(&bytes, &meta.checksum)?;
268
269            std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
270        } else {
271            // Cache hit — sanity-check the cached bytes against the registry checksum.
272            let bytes = std::fs::read(&cache_path).map_err(|e| PkgError::Io(e.to_string()))?;
273            verify_checksum(&bytes, &meta.checksum)?;
274        }
275
276        Ok(FetchedPackage {
277            tarball_path: cache_path,
278            checksum: normalize_checksum(&meta.checksum),
279            meta,
280        })
281    }
282
283    /// Hydrate an in-memory [`PackageRegistry`] by fetching metadata for each
284    /// of the named packages.
285    ///
286    /// If a `fallback` was attached, it seeds the returned registry and absorbs
287    /// any packages the network layer could not resolve (offline or not found).
288    /// Per-package network errors are swallowed when a fallback is present so
289    /// offline operation can continue; otherwise they propagate.
290    pub fn hydrate(&self, names: &[&str]) -> Result<PackageRegistry, PkgError> {
291        let mut registry = self.fallback.clone().unwrap_or_default();
292        for name in names {
293            match self.fetch_versions(name) {
294                Ok(versions) => {
295                    for version in &versions.versions {
296                        match self.fetch_version_meta(name, version) {
297                            Ok(meta) => {
298                                let pkg_meta = PackageVersionMeta {
299                                    deps: meta.manifest.dependencies,
300                                    dep_features: meta.manifest.dep_features,
301                                    supported_targets: meta.manifest.supported_targets,
302                                    available_features: meta.manifest.available_features,
303                                };
304                                registry.register_with_meta(name, version, pkg_meta)?;
305                            }
306                            Err(PkgError::Network(_)) if self.fallback.is_some() => {}
307                            Err(e) => return Err(e),
308                        }
309                    }
310                }
311                Err(PkgError::Network(_)) if self.fallback.is_some() => {}
312                Err(PkgError::PackageNotFound(_)) if registry.has_package(name) => {
313                    // Fallback already provides it — continue.
314                }
315                Err(e) => return Err(e),
316            }
317        }
318        Ok(registry)
319    }
320}
321
322/// Result of [`NetworkRegistry::fetch_package`].
323#[derive(Debug, Clone)]
324pub struct FetchedPackage {
325    /// Path to the verified tarball in the cache directory.
326    pub tarball_path: PathBuf,
327    /// Canonicalized checksum (bare hex, lowercased) recorded for the lockfile.
328    pub checksum: String,
329    /// Full metadata response from the registry.
330    pub meta: VersionMetaResponse,
331}
332
333/// Strip any `sha256:` prefix and lowercase the remaining hex for storage.
334#[must_use]
335pub fn normalize_checksum(checksum: &str) -> String {
336    checksum
337        .strip_prefix("sha256:")
338        .unwrap_or(checksum)
339        .to_ascii_lowercase()
340}
341
342/// Verify a byte buffer against a SHA-256 checksum.
343///
344/// Accepts both bare hex (`"a1b2..."`) and the `"sha256:"`-prefixed form used
345/// by the registry. Returns [`PkgError::ChecksumMismatch`] on disagreement.
346pub fn verify_checksum(data: &[u8], expected: &str) -> Result<(), PkgError> {
347    let expected_hex = expected.strip_prefix("sha256:").unwrap_or(expected);
348    let actual = sha256_hex(data);
349    if !actual.eq_ignore_ascii_case(expected_hex) {
350        return Err(PkgError::ChecksumMismatch {
351            expected: expected_hex.to_string(),
352            actual,
353        });
354    }
355    Ok(())
356}
357
358/// Compute the hex-encoded SHA-256 digest of `data`.
359#[must_use]
360pub fn sha256_hex(data: &[u8]) -> String {
361    let digest = Sha256::digest(data);
362    let mut out = String::with_capacity(digest.len() * 2);
363    for byte in digest {
364        use std::fmt::Write;
365        let _ = write!(out, "{byte:02x}");
366    }
367    out
368}
369
370/// The `[registries]` section of an `bock.project` file.
371#[derive(Debug, Clone, Deserialize, Default)]
372pub struct RegistriesSection {
373    /// URL of the default registry. Falls back to the built-in public
374    /// registry if unset.
375    pub default: Option<String>,
376    /// Named private registries (e.g. `internal = "https://..."`).
377    #[serde(flatten)]
378    pub named: BTreeMap<String, String>,
379}
380
381/// Parse just the `[registries]` section out of an `bock.project` TOML string.
382///
383/// Other sections are ignored, so this is safe to call on any project file.
384pub fn parse_registries(project_toml: &str) -> Result<RegistriesSection, PkgError> {
385    #[derive(Deserialize)]
386    struct Wrapper {
387        #[serde(default)]
388        registries: RegistriesSection,
389    }
390    let wrapper: Wrapper = toml::from_str(project_toml)
391        .map_err(|e| PkgError::ManifestParse(format!("bock.project: {e}")))?;
392    Ok(wrapper.registries)
393}
394
395/// Resolve the effective default registry URL for a project.
396///
397/// Reads `bock.project` in `project_dir`. If the file is missing or lacks a
398/// `[registries]` section, returns `None` — callers should fall back to the
399/// in-memory registry in that case.
400pub fn default_registry_url(project_dir: &Path) -> Option<String> {
401    let path = project_dir.join("bock.project");
402    let content = std::fs::read_to_string(&path).ok()?;
403    parse_registries(&content).ok()?.default
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use mockito::Server;
410
411    fn tmp_cache() -> (tempfile::TempDir, PathBuf) {
412        let dir = tempfile::tempdir().unwrap();
413        let path = dir.path().join("cache");
414        (dir, path)
415    }
416
417    #[test]
418    fn sha256_hex_matches_known_vector() {
419        // SHA-256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
420        assert_eq!(
421            sha256_hex(b"abc"),
422            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
423        );
424    }
425
426    #[test]
427    fn verify_checksum_accepts_matching_hex() {
428        let bytes = b"hello world";
429        let hex = sha256_hex(bytes);
430        verify_checksum(bytes, &hex).unwrap();
431    }
432
433    #[test]
434    fn verify_checksum_accepts_sha256_prefix() {
435        let bytes = b"hello world";
436        let hex = sha256_hex(bytes);
437        verify_checksum(bytes, &format!("sha256:{hex}")).unwrap();
438    }
439
440    #[test]
441    fn verify_checksum_rejects_mismatch() {
442        let result = verify_checksum(b"hello", "sha256:deadbeef");
443        assert!(matches!(result, Err(PkgError::ChecksumMismatch { .. })));
444    }
445
446    #[test]
447    fn fetch_versions_parses_response() {
448        let mut server = Server::new();
449        let mock = server
450            .mock("GET", "/packages/foo")
451            .with_status(200)
452            .with_header("content-type", "application/json")
453            .with_body(r#"{"versions":["1.0.0","1.1.0"],"latest":"1.1.0"}"#)
454            .create();
455
456        let (_tmp, cache) = tmp_cache();
457        let reg = NetworkRegistry::new(server.url(), cache).unwrap();
458        let resp = reg.fetch_versions("foo").unwrap();
459
460        assert_eq!(resp.versions, vec!["1.0.0", "1.1.0"]);
461        assert_eq!(resp.latest, "1.1.0");
462        mock.assert();
463    }
464
465    #[test]
466    fn fetch_versions_maps_404_to_package_not_found() {
467        let mut server = Server::new();
468        let _mock = server
469            .mock("GET", "/packages/missing")
470            .with_status(404)
471            .create();
472
473        let (_tmp, cache) = tmp_cache();
474        let reg = NetworkRegistry::new(server.url(), cache).unwrap();
475        let err = reg.fetch_versions("missing").unwrap_err();
476        assert!(matches!(err, PkgError::PackageNotFound(_)));
477    }
478
479    #[test]
480    fn fetch_version_meta_parses_manifest() {
481        let mut server = Server::new();
482        let body = r#"{
483            "manifest": {
484                "dependencies": {"bar": "^1.0"},
485                "supported_targets": ["js", "rust"],
486                "available_features": {"json": []}
487            },
488            "checksum": "sha256:abc",
489            "download_url": ""
490        }"#;
491        let mock = server
492            .mock("GET", "/packages/foo/1.0.0")
493            .with_status(200)
494            .with_header("content-type", "application/json")
495            .with_body(body)
496            .create();
497
498        let (_tmp, cache) = tmp_cache();
499        let reg = NetworkRegistry::new(server.url(), cache).unwrap();
500        let meta = reg.fetch_version_meta("foo", "1.0.0").unwrap();
501
502        assert_eq!(meta.checksum, "sha256:abc");
503        assert_eq!(meta.manifest.dependencies["bar"], "^1.0");
504        assert_eq!(
505            meta.manifest.supported_targets,
506            Some(vec!["js".into(), "rust".into()])
507        );
508        mock.assert();
509    }
510
511    #[test]
512    fn download_package_verifies_and_caches() {
513        let mut server = Server::new();
514        let tarball = b"fake tarball contents";
515        let checksum = sha256_hex(tarball);
516        let body = format!(
517            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
518        );
519        let _meta = server
520            .mock("GET", "/packages/foo/1.0.0")
521            .with_status(200)
522            .with_header("content-type", "application/json")
523            .with_body(body)
524            .create();
525        let _download = server
526            .mock("GET", "/packages/foo/1.0.0/download")
527            .with_status(200)
528            .with_body(tarball)
529            .create();
530
531        let (_tmp, cache) = tmp_cache();
532        let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
533        let path = reg.download_package("foo", "1.0.0").unwrap();
534
535        assert!(path.exists());
536        assert_eq!(std::fs::read(&path).unwrap(), tarball);
537
538        // Second call should be served from cache — no new mock expectations.
539        let again = reg.download_package("foo", "1.0.0").unwrap();
540        assert_eq!(again, path);
541    }
542
543    #[test]
544    fn download_package_rejects_bad_checksum() {
545        let mut server = Server::new();
546        let tarball = b"bytes that do not match";
547        let body = r#"{"manifest":{"dependencies":{}},"checksum":"sha256:deadbeef","download_url":""}"#;
548        let _meta = server
549            .mock("GET", "/packages/foo/1.0.0")
550            .with_status(200)
551            .with_header("content-type", "application/json")
552            .with_body(body)
553            .create();
554        let _download = server
555            .mock("GET", "/packages/foo/1.0.0/download")
556            .with_status(200)
557            .with_body(tarball)
558            .create();
559
560        let (_tmp, cache) = tmp_cache();
561        let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
562        let err = reg.download_package("foo", "1.0.0").unwrap_err();
563        assert!(matches!(err, PkgError::ChecksumMismatch { .. }));
564        // Nothing written to cache on failure.
565        assert!(!cache.join("foo-1.0.0.tar.gz").exists());
566    }
567
568    #[test]
569    fn download_package_honors_custom_download_url() {
570        let mut server = Server::new();
571        let tarball = b"custom url payload";
572        let checksum = sha256_hex(tarball);
573        let custom_url = format!("{}/mirror/foo-1.0.0.tgz", server.url());
574        let body = format!(
575            r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":"{custom_url}"}}"#
576        );
577        let _meta = server
578            .mock("GET", "/packages/foo/1.0.0")
579            .with_status(200)
580            .with_header("content-type", "application/json")
581            .with_body(body)
582            .create();
583        let _download = server
584            .mock("GET", "/mirror/foo-1.0.0.tgz")
585            .with_status(200)
586            .with_body(tarball)
587            .create();
588
589        let (_tmp, cache) = tmp_cache();
590        let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
591        let path = reg.download_package("foo", "1.0.0").unwrap();
592        assert_eq!(std::fs::read(&path).unwrap(), tarball);
593    }
594
595    #[test]
596    fn hydrate_populates_registry_from_network() {
597        let mut server = Server::new();
598        let _v = server
599            .mock("GET", "/packages/foo")
600            .with_status(200)
601            .with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
602            .create();
603        let _m = server
604            .mock("GET", "/packages/foo/1.0.0")
605            .with_status(200)
606            .with_body(
607                r#"{"manifest":{"dependencies":{"bar":"^1.0"}},"checksum":"sha256:x","download_url":""}"#,
608            )
609            .create();
610
611        let (_tmp, cache) = tmp_cache();
612        let reg = NetworkRegistry::new(server.url(), cache).unwrap();
613        let registry = reg.hydrate(&["foo"]).unwrap();
614
615        assert!(registry.has_package("foo"));
616        assert_eq!(registry.available_versions("foo").len(), 1);
617    }
618
619    #[test]
620    fn hydrate_falls_back_when_network_unreachable() {
621        // Point at a URL with no server listening; with a fallback, hydration
622        // should swallow the transport error and return the fallback entries.
623        let mut fallback = PackageRegistry::new();
624        fallback
625            .register("foo", "1.0.0", BTreeMap::new())
626            .unwrap();
627
628        let (_tmp, cache) = tmp_cache();
629        let reg = NetworkRegistry::new("http://127.0.0.1:1/", cache)
630            .unwrap()
631            .with_fallback(fallback);
632
633        let registry = reg.hydrate(&["foo"]).unwrap();
634        assert!(registry.has_package("foo"));
635    }
636
637    #[test]
638    fn parse_registries_reads_default_and_named() {
639        let project = r#"
640[project]
641name = "test"
642version = "0.1.0"
643
644[registries]
645default = "https://registry.bock-lang.dev/api/v1"
646internal = "https://bock.company.internal"
647"#;
648        let regs = parse_registries(project).unwrap();
649        assert_eq!(
650            regs.default.as_deref(),
651            Some("https://registry.bock-lang.dev/api/v1")
652        );
653        assert_eq!(
654            regs.named.get("internal").map(String::as_str),
655            Some("https://bock.company.internal"),
656        );
657    }
658
659    #[test]
660    fn parse_registries_missing_section_is_empty() {
661        let project = r#"
662[project]
663name = "test"
664version = "0.1.0"
665"#;
666        let regs = parse_registries(project).unwrap();
667        assert!(regs.default.is_none());
668        assert!(regs.named.is_empty());
669    }
670
671    #[test]
672    fn default_registry_url_reads_from_file() {
673        let dir = tempfile::tempdir().unwrap();
674        std::fs::write(
675            dir.path().join("bock.project"),
676            "[project]\nname = \"t\"\nversion = \"0.1.0\"\n\n[registries]\ndefault = \"https://example.com/api/v1\"\n",
677        )
678        .unwrap();
679        assert_eq!(
680            default_registry_url(dir.path()).as_deref(),
681            Some("https://example.com/api/v1")
682        );
683    }
684
685    #[test]
686    fn default_registry_url_missing_file_is_none() {
687        let dir = tempfile::tempdir().unwrap();
688        assert!(default_registry_url(dir.path()).is_none());
689    }
690}