Skip to main content

runx_runtime/registry/
refs.rs

1use std::path::{Path, PathBuf};
2
3use super::http::{RegistryClient, RegistryClientError};
4use super::types::ResolvedRegistryRef;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct ParsedRegistryRef {
8    pub skill_id: String,
9    pub version: Option<String>,
10}
11
12#[derive(Debug, thiserror::Error)]
13pub enum RegistryResolveError {
14    #[error("{0}")]
15    Client(#[from] RegistryClientError),
16    #[error("Registry ref '{0}' is ambiguous. Use '<owner>/<name>' instead.")]
17    Ambiguous(String),
18}
19
20pub fn parse_registry_ref(value: &str) -> ParsedRegistryRef {
21    let without_protocol = value
22        .strip_prefix("runx://skill/")
23        .and_then(|encoded| urlencoding_decode(encoded).ok())
24        .unwrap_or_else(|| value.to_owned());
25    let without_prefix = without_protocol
26        .strip_prefix("registry:")
27        .or_else(|| without_protocol.strip_prefix("runx-registry:"))
28        .unwrap_or(&without_protocol);
29    let Some(at_index) = without_prefix.rfind('@') else {
30        return ParsedRegistryRef {
31            skill_id: without_prefix.to_owned(),
32            version: None,
33        };
34    };
35    if at_index == 0 {
36        return ParsedRegistryRef {
37            skill_id: without_prefix.to_owned(),
38            version: None,
39        };
40    }
41    ParsedRegistryRef {
42        skill_id: without_prefix[..at_index].to_owned(),
43        version: non_empty(&without_prefix[at_index + 1..]),
44    }
45}
46
47pub fn resolve_remote_registry_ref<T: super::http::Transport>(
48    client: &RegistryClient<T>,
49    registry_ref: &str,
50    version_override: Option<&str>,
51) -> Result<Option<ResolvedRegistryRef>, RegistryResolveError> {
52    let parsed = parse_registry_ref(registry_ref);
53    if parsed.skill_id.contains('/') {
54        return Ok(Some(ResolvedRegistryRef {
55            skill_id: parsed.skill_id,
56            version: version_override.map(ToOwned::to_owned).or(parsed.version),
57        }));
58    }
59
60    let normalized = parsed.skill_id.trim().to_lowercase();
61    let matches = client
62        .search_with_limit(&parsed.skill_id, 100)?
63        .into_iter()
64        .filter(|candidate| candidate.name == normalized)
65        .collect::<Vec<_>>();
66    match matches.len() {
67        0 => Ok(None),
68        1 => {
69            let candidate = &matches[0];
70            Ok(Some(ResolvedRegistryRef {
71                skill_id: candidate.skill_id.clone(),
72                version: version_override
73                    .map(ToOwned::to_owned)
74                    .or(parsed.version)
75                    .or_else(|| candidate.version.clone()),
76            }))
77        }
78        _ => Err(RegistryResolveError::Ambiguous(parsed.skill_id)),
79    }
80}
81
82pub fn materialization_cache_path(
83    root: &Path,
84    owner: &str,
85    name: &str,
86    version: &str,
87    digest: &str,
88) -> PathBuf {
89    let marker = digest.strip_prefix("sha256:").unwrap_or(digest);
90    let short = marker.chars().take(16).collect::<String>();
91    root.join(safe_path_part(owner))
92        .join(safe_path_part(name))
93        .join(safe_path_part(version))
94        .join(safe_path_part(&short))
95}
96
97pub fn materialization_digest_marker(
98    digest: &str,
99    profile_digest: Option<&str>,
100    package_digest: Option<&str>,
101) -> String {
102    let profile_digest = profile_digest.unwrap_or("");
103    let package_digest = package_digest.unwrap_or("");
104    format!("digest={digest}\nprofile_digest={profile_digest}\npackage_digest={package_digest}\n")
105}
106
107pub fn safe_skill_package_parts(
108    registry_ref: &str,
109    skill_name: &str,
110    resolved_version: Option<&str>,
111) -> Vec<String> {
112    let normalized = normalize_install_ref(registry_ref);
113    let parsed = parse_registry_ref(registry_ref);
114    let version = parsed.version.as_deref().or(resolved_version);
115    let raw_parts = if normalized.contains('/') {
116        let mut parts = normalized.split('/').collect::<Vec<_>>();
117        if let Some(version) = version {
118            parts.push(version);
119        }
120        parts
121    } else {
122        let mut parts = vec![skill_name];
123        if let Some(version) = version {
124            parts.push(version);
125        }
126        parts
127    };
128    let parts = raw_parts
129        .into_iter()
130        .map(safe_path_part)
131        .filter(|part| !part.is_empty())
132        .collect::<Vec<_>>();
133    if parts.is_empty() {
134        vec![safe_path_part(skill_name)]
135    } else {
136        parts
137    }
138}
139
140fn normalize_install_ref(registry_ref: &str) -> String {
141    let parsed = parse_registry_ref(registry_ref);
142    parsed.skill_id
143}
144
145fn safe_path_part(value: &str) -> String {
146    let mut output = String::new();
147    let mut last_dash = false;
148    for ch in value.trim().to_lowercase().chars() {
149        let keep = ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-');
150        if keep {
151            output.push(ch);
152            last_dash = false;
153        } else if !last_dash {
154            output.push('-');
155            last_dash = true;
156        }
157    }
158    let trimmed = output.trim_matches('-').to_owned();
159    if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
160        "skill".to_owned()
161    } else {
162        trimmed
163    }
164}
165
166fn urlencoding_decode(value: &str) -> Result<String, std::str::Utf8Error> {
167    let mut decoded = Vec::with_capacity(value.len());
168    let bytes = value.as_bytes();
169    let mut index = 0;
170    while index < bytes.len() {
171        if bytes[index] == b'%' && index + 2 < bytes.len() {
172            let high = hex_value(bytes[index + 1]);
173            let low = hex_value(bytes[index + 2]);
174            if let (Some(high), Some(low)) = (high, low) {
175                decoded.push((high << 4) | low);
176                index += 3;
177                continue;
178            }
179        }
180        decoded.push(bytes[index]);
181        index += 1;
182    }
183    std::str::from_utf8(&decoded).map(ToOwned::to_owned)
184}
185
186fn hex_value(byte: u8) -> Option<u8> {
187    match byte {
188        b'0'..=b'9' => Some(byte - b'0'),
189        b'a'..=b'f' => Some(byte - b'a' + 10),
190        b'A'..=b'F' => Some(byte - b'A' + 10),
191        _ => None,
192    }
193}
194
195fn non_empty(value: &str) -> Option<String> {
196    if value.is_empty() {
197        None
198    } else {
199        Some(value.to_owned())
200    }
201}