Skip to main content

ryra_core/registry/
resolve.rs

1use std::path::{Path, PathBuf};
2
3use crate::config::schema::Config;
4use crate::error::{Error, Result};
5use crate::paths::{DEFAULT_REGISTRY_URL, REGISTRY_DIR_ENV};
6use crate::registry;
7
8/// A reference to a service in a registry.
9///
10/// - `Default("forgejo")` — refers to a service in the project-managed
11///   default registry (cloned from [`DEFAULT_REGISTRY_URL`]).
12/// - `Custom { registry: "acme", service: "forgejo" }` — refers to a
13///   service in a user-added custom registry.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum ServiceRef {
16    /// A service from the default registry. E.g., `forgejo`.
17    Default(String),
18    /// A service from a named custom registry. E.g., `acme/forgejo`.
19    Custom { registry: String, service: String },
20}
21
22impl ServiceRef {
23    /// Parse a service reference from a string.
24    ///
25    /// - `"forgejo"` → `Default("forgejo")`
26    /// - `"acme/forgejo"` → `Custom { registry: "acme", service: "forgejo" }`
27    /// - `""`, `"/forgejo"`, `"acme/"`, `"acme/sub/forgejo"` → error
28    pub fn parse(input: &str) -> Result<Self> {
29        let parts: Vec<&str> = input.split('/').collect();
30        match parts.as_slice() {
31            [""] => Err(Error::InvalidServiceRef(
32                "service reference cannot be empty".to_string(),
33            )),
34            [name] => {
35                if name.is_empty() {
36                    Err(Error::InvalidServiceRef(
37                        "service reference cannot be empty".to_string(),
38                    ))
39                } else {
40                    Ok(ServiceRef::Default((*name).to_string()))
41                }
42            }
43            [registry, service] => {
44                if registry.is_empty() {
45                    return Err(Error::InvalidServiceRef(format!(
46                        "registry name cannot be empty in reference '{input}'"
47                    )));
48                }
49                if service.is_empty() {
50                    return Err(Error::InvalidServiceRef(format!(
51                        "service name cannot be empty in reference '{input}'"
52                    )));
53                }
54                Ok(ServiceRef::Custom {
55                    registry: (*registry).to_string(),
56                    service: (*service).to_string(),
57                })
58            }
59            _ => Err(Error::InvalidServiceRef(format!(
60                "invalid service reference '{input}': expected 'service' or 'registry/service'"
61            ))),
62        }
63    }
64
65    /// Returns the service name part of this reference.
66    pub fn service_name(&self) -> &str {
67        match self {
68            ServiceRef::Default(name) => name,
69            ServiceRef::Custom { service, .. } => service,
70        }
71    }
72
73    /// Returns the registry name for this reference.
74    ///
75    /// Returns `"default"` for default-registry services.
76    pub fn registry_name(&self) -> &str {
77        match self {
78            ServiceRef::Default(_) => crate::paths::REGISTRY_DEFAULT,
79            ServiceRef::Custom { registry, .. } => registry,
80        }
81    }
82}
83
84/// Resolve the on-disk directory of the default registry.
85///
86/// If `RYRA_REGISTRY_DIR` is set to an existing directory, that path is
87/// returned as-is (no clone, no pull) — the escape hatch tests use to inject
88/// `/opt/ryra-test-registry` inside the VM without network access. Otherwise
89/// the registry is cloned (or pulled) from [`DEFAULT_REGISTRY_URL`] into
90/// `<cache_dir>/default/`.
91pub async fn resolve_default_registry_dir(cache_dir: &Path) -> Result<PathBuf> {
92    if let Ok(override_path) = std::env::var(REGISTRY_DIR_ENV) {
93        let path = PathBuf::from(override_path);
94        if path.is_dir() {
95            return Ok(path);
96        }
97    }
98
99    let dest = cache_dir.join("default");
100    registry::fetch::clone_or_pull(DEFAULT_REGISTRY_URL, &dest).await?;
101    Ok(dest)
102}
103
104/// Resolve the registry directory for a service reference.
105///
106/// - For `Default`: see [`resolve_default_registry_dir`].
107/// - For `Custom`: looks up the registry name in `config.registries` and clones/pulls it.
108pub async fn resolve_registry_dir(
109    service_ref: &ServiceRef,
110    config: &Config,
111    cache_dir: &Path,
112) -> Result<PathBuf> {
113    match service_ref {
114        ServiceRef::Default(_) => resolve_default_registry_dir(cache_dir).await,
115        ServiceRef::Custom { registry, .. } => {
116            let entry = config
117                .registries
118                .iter()
119                .find(|r| r.name == *registry)
120                .ok_or_else(|| Error::RegistryNotFound(registry.clone()))?;
121
122            let dest = cache_dir.join("registries").join(registry);
123            registry::fetch::clone_or_pull(&entry.url, &dest).await?;
124            Ok(dest)
125        }
126    }
127}
128
129/// Resolve a service from a registry, returning its definition and directory.
130///
131/// - For `Default`: finds the service in the default registry.
132/// - For `Custom`: finds the service in the named custom registry.
133pub async fn resolve_service(
134    service_ref: &ServiceRef,
135    config: &Config,
136    cache_dir: &Path,
137) -> Result<registry::RegistryService> {
138    let repo_dir = resolve_registry_dir(service_ref, config, cache_dir).await?;
139    registry::find_service(&repo_dir, service_ref.service_name())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn parse_default_service() {
148        let r = ServiceRef::parse("forgejo").expect("should parse");
149        assert_eq!(r, ServiceRef::Default("forgejo".to_string()));
150        assert_eq!(r.service_name(), "forgejo");
151        assert_eq!(r.registry_name(), "default");
152    }
153
154    #[test]
155    fn parse_custom_service() {
156        let r = ServiceRef::parse("acme/forgejo").expect("should parse");
157        assert_eq!(
158            r,
159            ServiceRef::Custom {
160                registry: "acme".to_string(),
161                service: "forgejo".to_string(),
162            }
163        );
164        assert_eq!(r.service_name(), "forgejo");
165        assert_eq!(r.registry_name(), "acme");
166    }
167
168    #[test]
169    fn parse_empty_fails() {
170        let err = ServiceRef::parse("").expect_err("empty input should fail");
171        let msg = err.to_string();
172        assert!(
173            msg.contains("empty"),
174            "expected 'empty' in error message, got: {msg}"
175        );
176    }
177
178    #[test]
179    fn parse_empty_parts_fails() {
180        let err = ServiceRef::parse("/forgejo").expect_err("leading slash should fail");
181        let msg = err.to_string();
182        assert!(
183            msg.contains("empty"),
184            "expected 'empty' in error for '/forgejo', got: {msg}"
185        );
186
187        let err = ServiceRef::parse("acme/").expect_err("trailing slash should fail");
188        let msg = err.to_string();
189        assert!(
190            msg.contains("empty"),
191            "expected 'empty' in error for 'acme/', got: {msg}"
192        );
193    }
194
195    #[test]
196    fn parse_too_many_slashes_fails() {
197        let err = ServiceRef::parse("acme/sub/forgejo").expect_err("too many slashes should fail");
198        let msg = err.to_string();
199        assert!(
200            msg.contains("invalid"),
201            "expected 'invalid' in error message, got: {msg}"
202        );
203    }
204
205    #[test]
206    fn env_override_returns_path_directly() {
207        // SAFETY: this test sets a process-global env var; running multiple
208        // env-mutating tests in parallel within the same process can race.
209        // Cargo runs tests in threads, so we scope to a tempdir that
210        // exists for the duration of the call.
211        let tmp = tempfile::TempDir::new().expect("tempdir");
212        let cache = tempfile::TempDir::new().expect("cache tempdir");
213        // SAFETY: tests in this module are single-threaded by Cargo's
214        // default scheduler; setting env here doesn't escape the call.
215        unsafe { std::env::set_var(REGISTRY_DIR_ENV, tmp.path()) };
216
217        let rt = tokio::runtime::Runtime::new().expect("runtime");
218        let resolved = rt
219            .block_on(resolve_default_registry_dir(cache.path()))
220            .expect("resolve");
221        assert_eq!(resolved, tmp.path());
222
223        unsafe { std::env::remove_var(REGISTRY_DIR_ENV) };
224    }
225}