use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use super::refs::parse_registry_ref;
use super::types::{
PublishSkillMarkdownResult, PublishStatus, RegistryAttestation, RegistryLinkResolution,
RegistryPublishHarnessReport, RegistryPublisher, RegistrySearchResult, RegistrySkill,
RegistrySkillDetail, RegistrySkillResolution, RegistrySkillVersion, RegistrySourceMetadata,
TrustTier,
};
#[derive(Clone, Debug)]
pub struct FileRegistryStore {
root: PathBuf,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PutVersionOptions {
pub upsert: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct IngestSkillOptions {
pub owner: Option<String>,
pub version: Option<String>,
pub created_at: Option<String>,
pub profile_document: Option<String>,
pub package_files: Vec<super::types::RegistryPackageFile>,
pub publisher: Option<RegistryPublisher>,
pub trust_tier: Option<TrustTier>,
pub attestations: Vec<RegistryAttestation>,
pub source_metadata: Option<RegistrySourceMetadata>,
pub upsert: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateRegistrySkillVersionResult {
pub record: RegistrySkillVersion,
pub created: bool,
}
#[derive(Clone, Debug)]
pub struct LocalRegistryClient {
store: FileRegistryStore,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PublishSkillMarkdownOptions {
pub ingest: IngestSkillOptions,
pub registry_url: Option<String>,
pub harness: RegistryPublishHarnessReport,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RegistrySearchOptions {
pub limit: Option<usize>,
pub registry_url: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RegistryResolveOptions {
pub version: Option<String>,
pub registry_url: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum LocalRegistryError {
#[error("{0}")]
Parse(#[from] runx_parser::ParseError),
#[error("{0}")]
Validation(#[from] runx_parser::ValidationError),
#[error("io error while {action} {path}: {source}")]
Io {
action: &'static str,
path: PathBuf,
source: io::Error,
},
#[error("invalid registry JSON at {path}: {source}")]
JsonRead {
path: PathBuf,
source: serde_json::Error,
},
#[error("failed to serialize registry JSON at {path}: {source}")]
JsonWrite {
path: PathBuf,
source: serde_json::Error,
},
#[error("invalid registry version payload at {field}: {message}")]
InvalidVersionPayload { field: String, message: String },
#[error("invalid registry skill manifest at {field}: {message}")]
InvalidSkillManifest { field: String, message: String },
#[error("invalid registry skill id '{0}'. Expected '<owner>/<name>'.")]
InvalidSkillId(String),
#[error("registry slugs cannot be empty")]
EmptySlug,
#[error("registry path component '{0}' is not allowed")]
UnsafePathComponent(String),
#[error("registry version {skill_id}@{version} already exists with a different digest")]
VersionConflict { skill_id: String, version: String },
#[error("Registry ref '{0}' is ambiguous. Use '<owner>/<name>' instead.")]
Ambiguous(String),
}
impl FileRegistryStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn put_version(
&self,
version: RegistrySkillVersion,
options: PutVersionOptions,
) -> Result<RegistrySkillVersion, LocalRegistryError> {
let version_path = self.version_path(&version.skill_id, &version.version)?;
if let Some(parent) = version_path.parent() {
fs::create_dir_all(parent).map_err(|source| io_error("creating", parent, source))?;
}
if let Some(existing) = self.get_version(&version.skill_id, Some(&version.version))? {
if existing.digest != version.digest
|| existing.profile_digest != version.profile_digest
|| existing.package_digest != version.package_digest
{
if !options.upsert {
return Err(LocalRegistryError::VersionConflict {
skill_id: version.skill_id,
version: version.version,
});
}
let mut upserted = version;
upserted.updated_at = now_iso8601();
write_registry_json(&version_path, &upserted, false)?;
return Ok(upserted);
}
let mut refreshed = version;
refreshed.created_at = existing.created_at.clone();
refreshed.updated_at = now_iso8601();
if existing != refreshed {
write_registry_json(&version_path, &refreshed, false)?;
}
return Ok(refreshed);
}
write_registry_json(&version_path, &version, true)?;
Ok(version)
}
pub fn get_version(
&self,
skill_id: &str,
version: Option<&str>,
) -> Result<Option<RegistrySkillVersion>, LocalRegistryError> {
let versions = self.list_versions(skill_id)?;
if versions.is_empty() {
return Ok(None);
}
let Some(version) = version else {
return Ok(versions.last().cloned());
};
Ok(versions
.into_iter()
.find(|candidate| candidate.version == version))
}
pub fn list_versions(
&self,
skill_id: &str,
) -> Result<Vec<RegistrySkillVersion>, LocalRegistryError> {
let skill_dir = self.skill_dir(skill_id)?;
let mut files = safe_read_dir_names(&skill_dir)?;
files.sort();
let mut versions = Vec::new();
for file in files.into_iter().filter(|file| file.ends_with(".json")) {
let path = skill_dir.join(file);
let contents =
fs::read_to_string(&path).map_err(|source| io_error("reading", &path, source))?;
let payload = serde_json::from_str::<RegistrySkillVersionPayload>(&contents).map_err(
|source| LocalRegistryError::JsonRead {
path: path.clone(),
source,
},
)?;
versions.push(normalize_registry_skill_version(payload)?);
}
versions.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.version.cmp(&right.version))
});
Ok(versions)
}
pub fn list_skills(&self) -> Result<Vec<RegistrySkill>, LocalRegistryError> {
let owners = safe_read_dir_names(&self.root)?;
let mut skills = Vec::new();
for owner in owners {
let owner_dir = self.root.join(&owner);
for name in safe_read_dir_names(&owner_dir)? {
let skill_id = format!("{}/{}", decode_part(&owner)?, decode_part(&name)?);
let versions = self.list_versions(&skill_id)?;
let Some(latest) = versions.last() else {
continue;
};
skills.push(RegistrySkill {
skill_id,
owner: latest.owner.clone(),
name: latest.name.clone(),
description: latest.description.clone(),
category: latest.category.clone(),
source_category: latest.source_category.clone(),
latest_version: latest.version.clone(),
latest_digest: latest.digest.clone(),
versions,
});
}
}
skills.sort_by(|left, right| left.skill_id.cmp(&right.skill_id));
Ok(skills)
}
fn version_path(&self, skill_id: &str, version: &str) -> Result<PathBuf, LocalRegistryError> {
Ok(self
.skill_dir(skill_id)?
.join(format!("{}.json", encode_part(version))))
}
fn skill_dir(&self, skill_id: &str) -> Result<PathBuf, LocalRegistryError> {
let (owner, name) = split_skill_id(skill_id)?;
Ok(self.root.join(encode_part(owner)).join(encode_part(name)))
}
}
impl LocalRegistryClient {
pub fn new(store: FileRegistryStore) -> Self {
Self { store }
}
pub fn create_skill_version(
&self,
markdown: &str,
options: IngestSkillOptions,
) -> Result<CreateRegistrySkillVersionResult, LocalRegistryError> {
create_registry_skill_version(&self.store, markdown, options)
}
}
pub fn create_file_registry_store(root: impl Into<PathBuf>) -> FileRegistryStore {
FileRegistryStore::new(root)
}
pub fn create_local_registry_client(store: FileRegistryStore) -> LocalRegistryClient {
LocalRegistryClient::new(store)
}
pub fn ingest_skill_markdown(
store: &FileRegistryStore,
markdown: &str,
options: IngestSkillOptions,
) -> Result<RegistrySkillVersion, LocalRegistryError> {
Ok(create_registry_skill_version(store, markdown, options)?.record)
}
pub fn create_registry_skill_version(
store: &FileRegistryStore,
markdown: &str,
options: IngestSkillOptions,
) -> Result<CreateRegistrySkillVersionResult, LocalRegistryError> {
let record = build_registry_skill_version(markdown, &options)?;
let existing = store.get_version(&record.skill_id, Some(&record.version))?;
if let Some(existing) = existing {
if existing.digest != record.digest
|| existing.profile_digest != record.profile_digest
|| existing.package_digest != record.package_digest
{
if !options.upsert {
return Err(LocalRegistryError::VersionConflict {
skill_id: record.skill_id,
version: record.version,
});
}
return Ok(CreateRegistrySkillVersionResult {
record: store.put_version(record, PutVersionOptions { upsert: true })?,
created: false,
});
}
let mut refreshed = record;
refreshed.created_at = existing.created_at;
return Ok(CreateRegistrySkillVersionResult {
record: store.put_version(refreshed, PutVersionOptions::default())?,
created: false,
});
}
Ok(CreateRegistrySkillVersionResult {
record: store.put_version(record, PutVersionOptions::default())?,
created: true,
})
}
mod build;
mod trust;
mod util;
pub use build::{
RegistrySkillVersionPayload, build_registry_skill_version, normalize_registry_skill_version,
};
use trust::{
detail_for_version, normalize, resolve_by_name, search_result_for_version, searchable_text,
};
use util::{
decode_part, encode_part, encode_uri_component, io_error, is_unsafe_path_component,
now_iso8601, reject_unsafe_path_component, safe_read_dir_names, write_registry_json,
};
pub fn publish_skill_markdown(
client: &LocalRegistryClient,
markdown: &str,
options: PublishSkillMarkdownOptions,
) -> Result<PublishSkillMarkdownResult, LocalRegistryError> {
let result = client.create_skill_version(markdown, options.ingest)?;
let link = runx_link_for_version(&result.record, options.registry_url.as_deref());
Ok(PublishSkillMarkdownResult {
status: if result.created {
PublishStatus::Published
} else {
PublishStatus::Unchanged
},
skill_id: result.record.skill_id.clone(),
name: result.record.name.clone(),
version: result.record.version.clone(),
digest: result.record.digest.clone(),
signed_manifest: result.record.signed_manifest.clone(),
profile_digest: result.record.profile_digest.clone(),
runner_names: result.record.runner_names.clone(),
source_type: result.record.source_type.clone(),
registry_url: options.registry_url,
harness: options.harness,
link,
record: result.record,
})
}
pub fn search_registry(
store: &FileRegistryStore,
query: &str,
) -> Result<Vec<RegistrySearchResult>, LocalRegistryError> {
search_registry_with_options(store, query, RegistrySearchOptions::default())
}
pub fn search_registry_with_options(
store: &FileRegistryStore,
query: &str,
options: RegistrySearchOptions,
) -> Result<Vec<RegistrySearchResult>, LocalRegistryError> {
let normalized_query = normalize(query);
let mut matches = store
.list_skills()?
.into_iter()
.filter_map(|skill| skill.versions.last().cloned())
.filter(registry_version_is_public)
.filter(|version| {
normalized_query.is_empty() || searchable_text(version).contains(&normalized_query)
})
.collect::<Vec<_>>();
matches.sort_by(|left, right| left.skill_id.cmp(&right.skill_id));
matches.truncate(options.limit.unwrap_or(20));
Ok(matches
.iter()
.map(|version| search_result_for_version(version, options.registry_url.as_deref()))
.collect())
}
fn registry_version_is_public(version: &RegistrySkillVersion) -> bool {
version.catalog_visibility.as_deref() != Some("internal")
}
pub fn resolve_registry_skill(
store: &FileRegistryStore,
registry_ref: &str,
options: RegistryResolveOptions,
) -> Result<Option<RegistrySkillResolution>, LocalRegistryError> {
let parsed = parse_registry_ref(registry_ref);
let version = options.version.as_deref().or(parsed.version.as_deref());
let record = if parsed.skill_id.contains('/') {
store.get_version(&parsed.skill_id, version)?
} else {
resolve_by_name(store, &parsed.skill_id, version)?
};
Ok(record.map(|record| {
let link = runx_link_for_version(&record, options.registry_url.as_deref());
RegistrySkillResolution {
markdown: record.markdown,
profile_document: record.profile_document,
profile_digest: record.profile_digest,
package_files: record.package_files,
package_digest: record.package_digest,
runner_names: record.runner_names,
skill_id: record.skill_id,
name: record.name,
version: record.version,
digest: record.digest,
signed_manifest: record.signed_manifest,
source: "runx-registry".to_owned(),
source_label: "runx registry".to_owned(),
source_type: record.source_type,
trust_tier: record.trust_tier,
registry_url: options.registry_url,
install_command: link.install_command,
run_command: link.run_command,
}
}))
}
pub fn read_registry_skill(
store: &FileRegistryStore,
skill_id: &str,
version: Option<&str>,
registry_url: Option<&str>,
) -> Result<Option<RegistrySkillDetail>, LocalRegistryError> {
Ok(store
.get_version(skill_id, version)?
.map(|record| detail_for_version(&record, registry_url)))
}
pub fn resolve_runx_link(
store: &FileRegistryStore,
skill_id: &str,
version: Option<&str>,
registry_url: Option<&str>,
) -> Result<Option<RegistryLinkResolution>, LocalRegistryError> {
Ok(store
.get_version(skill_id, version)?
.map(|record| runx_link_for_version(&record, registry_url)))
}
pub fn runx_link_for_version(
record: &RegistrySkillVersion,
registry_url: Option<&str>,
) -> RegistryLinkResolution {
let registry_ref = format!("{}@{}", record.skill_id, record.version);
let registry_flag = registry_url.map_or_else(String::new, |url| format!(" --registry {url}"));
RegistryLinkResolution {
link: format!(
"runx://skill/{}@{}",
encode_uri_component(&record.skill_id),
encode_uri_component(&record.version)
),
skill_id: record.skill_id.clone(),
version: record.version.clone(),
digest: record.digest.clone(),
registry_url: registry_url.map(ToOwned::to_owned),
install_command: format!("runx add {registry_ref}{registry_flag}"),
run_command: format!("runx skill {registry_ref}{registry_flag}"),
}
}
pub fn build_skill_id(owner: &str, name: &str) -> Result<String, LocalRegistryError> {
Ok(format!("{}/{}", slugify(owner)?, slugify(name)?))
}
pub fn split_skill_id(skill_id: &str) -> Result<(&str, &str), LocalRegistryError> {
let mut parts = skill_id.split('/');
let Some(owner) = parts.next().filter(|part| !part.is_empty()) else {
return Err(LocalRegistryError::InvalidSkillId(skill_id.to_owned()));
};
let Some(name) = parts.next().filter(|part| !part.is_empty()) else {
return Err(LocalRegistryError::InvalidSkillId(skill_id.to_owned()));
};
if parts.next().is_some() {
return Err(LocalRegistryError::InvalidSkillId(skill_id.to_owned()));
}
reject_unsafe_path_component(owner)?;
reject_unsafe_path_component(name)?;
Ok((owner, name))
}
pub fn slugify(value: &str) -> Result<String, LocalRegistryError> {
let mut slug = String::new();
let mut last_dash = false;
for ch in value.trim().to_lowercase().chars() {
let keep = ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-');
if keep {
slug.push(ch);
last_dash = false;
} else if !last_dash {
slug.push('-');
last_dash = true;
}
}
let slug = slug.trim_matches('-').to_owned();
if slug.is_empty() {
Err(LocalRegistryError::EmptySlug)
} else if is_unsafe_path_component(&slug) {
Err(LocalRegistryError::UnsafePathComponent(slug))
} else {
Ok(slug)
}
}