1use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use super::refs::parse_registry_ref;
8use super::types::{
9 PublishSkillMarkdownResult, PublishStatus, RegistryAttestation, RegistryLinkResolution,
10 RegistryPublishHarnessReport, RegistryPublisher, RegistrySearchResult, RegistrySkill,
11 RegistrySkillDetail, RegistrySkillResolution, RegistrySkillVersion, RegistrySourceMetadata,
12 TrustTier,
13};
14
15#[derive(Clone, Debug)]
16pub struct FileRegistryStore {
17 root: PathBuf,
18}
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct PutVersionOptions {
22 pub upsert: bool,
23}
24
25#[derive(Clone, Debug, Default, PartialEq)]
26pub struct IngestSkillOptions {
27 pub owner: Option<String>,
28 pub version: Option<String>,
29 pub created_at: Option<String>,
30 pub profile_document: Option<String>,
31 pub package_files: Vec<super::types::RegistryPackageFile>,
32 pub publisher: Option<RegistryPublisher>,
33 pub trust_tier: Option<TrustTier>,
34 pub attestations: Vec<RegistryAttestation>,
35 pub source_metadata: Option<RegistrySourceMetadata>,
36 pub upsert: bool,
37}
38
39#[derive(Clone, Debug, PartialEq)]
40pub struct CreateRegistrySkillVersionResult {
41 pub record: RegistrySkillVersion,
42 pub created: bool,
43}
44
45#[derive(Clone, Debug)]
46pub struct LocalRegistryClient {
47 store: FileRegistryStore,
48}
49
50#[derive(Clone, Debug, Default, PartialEq)]
51pub struct PublishSkillMarkdownOptions {
52 pub ingest: IngestSkillOptions,
53 pub registry_url: Option<String>,
54 pub harness: RegistryPublishHarnessReport,
55}
56
57#[derive(Clone, Debug, Default, PartialEq, Eq)]
58pub struct RegistrySearchOptions {
59 pub limit: Option<usize>,
60 pub registry_url: Option<String>,
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64pub struct RegistryResolveOptions {
65 pub version: Option<String>,
66 pub registry_url: Option<String>,
67}
68
69#[derive(Debug, thiserror::Error)]
70pub enum LocalRegistryError {
71 #[error("{0}")]
72 Parse(#[from] runx_parser::ParseError),
73 #[error("{0}")]
74 Validation(#[from] runx_parser::ValidationError),
75 #[error("io error while {action} {path}: {source}")]
76 Io {
77 action: &'static str,
78 path: PathBuf,
79 source: io::Error,
80 },
81 #[error("invalid registry JSON at {path}: {source}")]
82 JsonRead {
83 path: PathBuf,
84 source: serde_json::Error,
85 },
86 #[error("failed to serialize registry JSON at {path}: {source}")]
87 JsonWrite {
88 path: PathBuf,
89 source: serde_json::Error,
90 },
91 #[error("invalid registry version payload at {field}: {message}")]
92 InvalidVersionPayload { field: String, message: String },
93 #[error("invalid registry skill manifest at {field}: {message}")]
94 InvalidSkillManifest { field: String, message: String },
95 #[error("invalid registry skill id '{0}'. Expected '<owner>/<name>'.")]
96 InvalidSkillId(String),
97 #[error("registry slugs cannot be empty")]
98 EmptySlug,
99 #[error("registry path component '{0}' is not allowed")]
100 UnsafePathComponent(String),
101 #[error("registry version {skill_id}@{version} already exists with a different digest")]
102 VersionConflict { skill_id: String, version: String },
103 #[error("Registry ref '{0}' is ambiguous. Use '<owner>/<name>' instead.")]
104 Ambiguous(String),
105}
106
107impl FileRegistryStore {
108 pub fn new(root: impl Into<PathBuf>) -> Self {
109 Self { root: root.into() }
110 }
111
112 #[must_use]
113 pub fn root(&self) -> &Path {
114 &self.root
115 }
116
117 pub fn put_version(
118 &self,
119 version: RegistrySkillVersion,
120 options: PutVersionOptions,
121 ) -> Result<RegistrySkillVersion, LocalRegistryError> {
122 let version_path = self.version_path(&version.skill_id, &version.version)?;
123 if let Some(parent) = version_path.parent() {
124 fs::create_dir_all(parent).map_err(|source| io_error("creating", parent, source))?;
125 }
126
127 if let Some(existing) = self.get_version(&version.skill_id, Some(&version.version))? {
128 if existing.digest != version.digest
129 || existing.profile_digest != version.profile_digest
130 || existing.package_digest != version.package_digest
131 {
132 if !options.upsert {
133 return Err(LocalRegistryError::VersionConflict {
134 skill_id: version.skill_id,
135 version: version.version,
136 });
137 }
138 let mut upserted = version;
139 upserted.updated_at = now_iso8601();
140 write_registry_json(&version_path, &upserted, false)?;
141 return Ok(upserted);
142 }
143
144 let mut refreshed = version;
145 refreshed.created_at = existing.created_at.clone();
146 refreshed.updated_at = now_iso8601();
147 if existing != refreshed {
148 write_registry_json(&version_path, &refreshed, false)?;
149 }
150 return Ok(refreshed);
151 }
152
153 write_registry_json(&version_path, &version, true)?;
154 Ok(version)
155 }
156
157 pub fn get_version(
158 &self,
159 skill_id: &str,
160 version: Option<&str>,
161 ) -> Result<Option<RegistrySkillVersion>, LocalRegistryError> {
162 let versions = self.list_versions(skill_id)?;
163 if versions.is_empty() {
164 return Ok(None);
165 }
166 let Some(version) = version else {
167 return Ok(versions.last().cloned());
168 };
169 Ok(versions
170 .into_iter()
171 .find(|candidate| candidate.version == version))
172 }
173
174 pub fn list_versions(
175 &self,
176 skill_id: &str,
177 ) -> Result<Vec<RegistrySkillVersion>, LocalRegistryError> {
178 let skill_dir = self.skill_dir(skill_id)?;
179 let mut files = safe_read_dir_names(&skill_dir)?;
180 files.sort();
181
182 let mut versions = Vec::new();
183 for file in files.into_iter().filter(|file| file.ends_with(".json")) {
184 let path = skill_dir.join(file);
185 let contents =
186 fs::read_to_string(&path).map_err(|source| io_error("reading", &path, source))?;
187 let payload = serde_json::from_str::<RegistrySkillVersionPayload>(&contents).map_err(
188 |source| LocalRegistryError::JsonRead {
189 path: path.clone(),
190 source,
191 },
192 )?;
193 versions.push(normalize_registry_skill_version(payload)?);
194 }
195 versions.sort_by(|left, right| {
196 left.created_at
197 .cmp(&right.created_at)
198 .then_with(|| left.version.cmp(&right.version))
199 });
200 Ok(versions)
201 }
202
203 pub fn list_skills(&self) -> Result<Vec<RegistrySkill>, LocalRegistryError> {
204 let owners = safe_read_dir_names(&self.root)?;
205 let mut skills = Vec::new();
206 for owner in owners {
207 let owner_dir = self.root.join(&owner);
208 for name in safe_read_dir_names(&owner_dir)? {
209 let skill_id = format!("{}/{}", decode_part(&owner)?, decode_part(&name)?);
210 let versions = self.list_versions(&skill_id)?;
211 let Some(latest) = versions.last() else {
212 continue;
213 };
214 skills.push(RegistrySkill {
215 skill_id,
216 owner: latest.owner.clone(),
217 name: latest.name.clone(),
218 description: latest.description.clone(),
219 category: latest.category.clone(),
220 source_category: latest.source_category.clone(),
221 latest_version: latest.version.clone(),
222 latest_digest: latest.digest.clone(),
223 versions,
224 });
225 }
226 }
227 skills.sort_by(|left, right| left.skill_id.cmp(&right.skill_id));
228 Ok(skills)
229 }
230
231 fn version_path(&self, skill_id: &str, version: &str) -> Result<PathBuf, LocalRegistryError> {
232 Ok(self
233 .skill_dir(skill_id)?
234 .join(format!("{}.json", encode_part(version))))
235 }
236
237 fn skill_dir(&self, skill_id: &str) -> Result<PathBuf, LocalRegistryError> {
238 let (owner, name) = split_skill_id(skill_id)?;
239 Ok(self.root.join(encode_part(owner)).join(encode_part(name)))
240 }
241}
242
243impl LocalRegistryClient {
244 pub fn new(store: FileRegistryStore) -> Self {
245 Self { store }
246 }
247
248 pub fn create_skill_version(
249 &self,
250 markdown: &str,
251 options: IngestSkillOptions,
252 ) -> Result<CreateRegistrySkillVersionResult, LocalRegistryError> {
253 create_registry_skill_version(&self.store, markdown, options)
254 }
255}
256
257pub fn create_file_registry_store(root: impl Into<PathBuf>) -> FileRegistryStore {
258 FileRegistryStore::new(root)
259}
260
261pub fn create_local_registry_client(store: FileRegistryStore) -> LocalRegistryClient {
262 LocalRegistryClient::new(store)
263}
264
265pub fn ingest_skill_markdown(
266 store: &FileRegistryStore,
267 markdown: &str,
268 options: IngestSkillOptions,
269) -> Result<RegistrySkillVersion, LocalRegistryError> {
270 Ok(create_registry_skill_version(store, markdown, options)?.record)
271}
272
273pub fn create_registry_skill_version(
274 store: &FileRegistryStore,
275 markdown: &str,
276 options: IngestSkillOptions,
277) -> Result<CreateRegistrySkillVersionResult, LocalRegistryError> {
278 let record = build_registry_skill_version(markdown, &options)?;
279 let existing = store.get_version(&record.skill_id, Some(&record.version))?;
280 if let Some(existing) = existing {
281 if existing.digest != record.digest
282 || existing.profile_digest != record.profile_digest
283 || existing.package_digest != record.package_digest
284 {
285 if !options.upsert {
286 return Err(LocalRegistryError::VersionConflict {
287 skill_id: record.skill_id,
288 version: record.version,
289 });
290 }
291 return Ok(CreateRegistrySkillVersionResult {
292 record: store.put_version(record, PutVersionOptions { upsert: true })?,
293 created: false,
294 });
295 }
296 let mut refreshed = record;
297 refreshed.created_at = existing.created_at;
298 return Ok(CreateRegistrySkillVersionResult {
299 record: store.put_version(refreshed, PutVersionOptions::default())?,
300 created: false,
301 });
302 }
303
304 Ok(CreateRegistrySkillVersionResult {
305 record: store.put_version(record, PutVersionOptions::default())?,
306 created: true,
307 })
308}
309
310mod build;
311mod trust;
312mod util;
313
314pub use build::{
315 RegistrySkillVersionPayload, build_registry_skill_version, normalize_registry_skill_version,
316};
317use trust::{
318 detail_for_version, normalize, resolve_by_name, search_result_for_version, searchable_text,
319};
320use util::{
321 decode_part, encode_part, encode_uri_component, io_error, is_unsafe_path_component,
322 now_iso8601, reject_unsafe_path_component, safe_read_dir_names, write_registry_json,
323};
324
325pub fn publish_skill_markdown(
326 client: &LocalRegistryClient,
327 markdown: &str,
328 options: PublishSkillMarkdownOptions,
329) -> Result<PublishSkillMarkdownResult, LocalRegistryError> {
330 let result = client.create_skill_version(markdown, options.ingest)?;
331 let link = runx_link_for_version(&result.record, options.registry_url.as_deref());
332 Ok(PublishSkillMarkdownResult {
333 status: if result.created {
334 PublishStatus::Published
335 } else {
336 PublishStatus::Unchanged
337 },
338 skill_id: result.record.skill_id.clone(),
339 name: result.record.name.clone(),
340 version: result.record.version.clone(),
341 digest: result.record.digest.clone(),
342 signed_manifest: result.record.signed_manifest.clone(),
343 profile_digest: result.record.profile_digest.clone(),
344 runner_names: result.record.runner_names.clone(),
345 source_type: result.record.source_type.clone(),
346 registry_url: options.registry_url,
347 harness: options.harness,
348 link,
349 record: result.record,
350 })
351}
352
353pub fn search_registry(
354 store: &FileRegistryStore,
355 query: &str,
356) -> Result<Vec<RegistrySearchResult>, LocalRegistryError> {
357 search_registry_with_options(store, query, RegistrySearchOptions::default())
358}
359
360pub fn search_registry_with_options(
361 store: &FileRegistryStore,
362 query: &str,
363 options: RegistrySearchOptions,
364) -> Result<Vec<RegistrySearchResult>, LocalRegistryError> {
365 let normalized_query = normalize(query);
366 let mut matches = store
367 .list_skills()?
368 .into_iter()
369 .filter_map(|skill| skill.versions.last().cloned())
370 .filter(registry_version_is_public)
371 .filter(|version| {
372 normalized_query.is_empty() || searchable_text(version).contains(&normalized_query)
373 })
374 .collect::<Vec<_>>();
375 matches.sort_by(|left, right| left.skill_id.cmp(&right.skill_id));
376 matches.truncate(options.limit.unwrap_or(20));
377 Ok(matches
378 .iter()
379 .map(|version| search_result_for_version(version, options.registry_url.as_deref()))
380 .collect())
381}
382
383fn registry_version_is_public(version: &RegistrySkillVersion) -> bool {
384 version.catalog_visibility.as_deref() != Some("internal")
385}
386
387pub fn resolve_registry_skill(
388 store: &FileRegistryStore,
389 registry_ref: &str,
390 options: RegistryResolveOptions,
391) -> Result<Option<RegistrySkillResolution>, LocalRegistryError> {
392 let parsed = parse_registry_ref(registry_ref);
393 let version = options.version.as_deref().or(parsed.version.as_deref());
394 let record = if parsed.skill_id.contains('/') {
395 store.get_version(&parsed.skill_id, version)?
396 } else {
397 resolve_by_name(store, &parsed.skill_id, version)?
398 };
399 Ok(record.map(|record| {
400 let link = runx_link_for_version(&record, options.registry_url.as_deref());
401 RegistrySkillResolution {
402 markdown: record.markdown,
403 profile_document: record.profile_document,
404 profile_digest: record.profile_digest,
405 package_files: record.package_files,
406 package_digest: record.package_digest,
407 runner_names: record.runner_names,
408 skill_id: record.skill_id,
409 name: record.name,
410 version: record.version,
411 digest: record.digest,
412 signed_manifest: record.signed_manifest,
413 source: "runx-registry".to_owned(),
414 source_label: "runx registry".to_owned(),
415 source_type: record.source_type,
416 trust_tier: record.trust_tier,
417 registry_url: options.registry_url,
418 install_command: link.install_command,
419 run_command: link.run_command,
420 }
421 }))
422}
423
424pub fn read_registry_skill(
425 store: &FileRegistryStore,
426 skill_id: &str,
427 version: Option<&str>,
428 registry_url: Option<&str>,
429) -> Result<Option<RegistrySkillDetail>, LocalRegistryError> {
430 Ok(store
431 .get_version(skill_id, version)?
432 .map(|record| detail_for_version(&record, registry_url)))
433}
434
435pub fn resolve_runx_link(
436 store: &FileRegistryStore,
437 skill_id: &str,
438 version: Option<&str>,
439 registry_url: Option<&str>,
440) -> Result<Option<RegistryLinkResolution>, LocalRegistryError> {
441 Ok(store
442 .get_version(skill_id, version)?
443 .map(|record| runx_link_for_version(&record, registry_url)))
444}
445
446pub fn runx_link_for_version(
447 record: &RegistrySkillVersion,
448 registry_url: Option<&str>,
449) -> RegistryLinkResolution {
450 let registry_ref = format!("{}@{}", record.skill_id, record.version);
451 let registry_flag = registry_url.map_or_else(String::new, |url| format!(" --registry {url}"));
452 RegistryLinkResolution {
453 link: format!(
454 "runx://skill/{}@{}",
455 encode_uri_component(&record.skill_id),
456 encode_uri_component(&record.version)
457 ),
458 skill_id: record.skill_id.clone(),
459 version: record.version.clone(),
460 digest: record.digest.clone(),
461 registry_url: registry_url.map(ToOwned::to_owned),
462 install_command: format!("runx add {registry_ref}{registry_flag}"),
463 run_command: format!("runx skill {registry_ref}{registry_flag}"),
464 }
465}
466
467pub fn build_skill_id(owner: &str, name: &str) -> Result<String, LocalRegistryError> {
468 Ok(format!("{}/{}", slugify(owner)?, slugify(name)?))
469}
470
471pub fn split_skill_id(skill_id: &str) -> Result<(&str, &str), LocalRegistryError> {
472 let mut parts = skill_id.split('/');
473 let Some(owner) = parts.next().filter(|part| !part.is_empty()) else {
474 return Err(LocalRegistryError::InvalidSkillId(skill_id.to_owned()));
475 };
476 let Some(name) = parts.next().filter(|part| !part.is_empty()) else {
477 return Err(LocalRegistryError::InvalidSkillId(skill_id.to_owned()));
478 };
479 if parts.next().is_some() {
480 return Err(LocalRegistryError::InvalidSkillId(skill_id.to_owned()));
481 }
482 reject_unsafe_path_component(owner)?;
483 reject_unsafe_path_component(name)?;
484 Ok((owner, name))
485}
486
487pub fn slugify(value: &str) -> Result<String, LocalRegistryError> {
488 let mut slug = String::new();
489 let mut last_dash = false;
490 for ch in value.trim().to_lowercase().chars() {
491 let keep = ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-');
492 if keep {
493 slug.push(ch);
494 last_dash = false;
495 } else if !last_dash {
496 slug.push('-');
497 last_dash = true;
498 }
499 }
500 let slug = slug.trim_matches('-').to_owned();
501 if slug.is_empty() {
502 Err(LocalRegistryError::EmptySlug)
503 } else if is_unsafe_path_component(&slug) {
504 Err(LocalRegistryError::UnsafePathComponent(slug))
505 } else {
506 Ok(slug)
507 }
508}