Skip to main content

runx_runtime/registry/
local.rs

1// rust-style-allow: large-file because this untracked registry file is under
2// active parallel work; keep the module stable while extracting blockers here.
3use 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}