runx_runtime/registry/
refs.rs1use 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}