Skip to main content

runx_runtime/registry/
index.rs

1//! Cloud registry index endpoint (`POST /v1/index`).
2//!
3//! This module is the canonical client for indexing a GitHub repository into
4//! the hosted runx registry. It is consumed by the `runx add <github-url>` CLI
5//! path and by any future flow that needs to publish a remote repo through the
6//! hosted index. Single responsibility: parse a GitHub ref, POST it, return a
7//! typed envelope; presentation and arg parsing live in the CLI.
8
9use serde::{Deserialize, Serialize};
10use url::Url;
11
12use super::types::TrustTier;
13use crate::http::{
14    HttpMethod, RuntimeHttpError, RuntimeHttpHeader, RuntimeHttpRequest as HttpRequest,
15    RuntimeHttpTransport as Transport,
16};
17
18/// Structured GitHub repository reference parsed from a user-provided URL.
19///
20/// Returning a structured value (rather than a `bool` predicate over the raw
21/// string) lets callers show a friendly progress message and validates that the
22/// URL has both owner and repo segments before the network call.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct GithubRepoRef {
25    /// Canonical `https://github.com/<owner>/<repo>` form of the input.
26    pub canonical_url: String,
27    pub owner: String,
28    pub repo: String,
29}
30
31/// Inputs to [`index_github_repo`]. Borrowed so callers don't have to clone.
32#[derive(Clone, Debug)]
33pub struct IndexGithubRepoOptions<'a> {
34    /// Base URL of the hosted registry (no trailing slash required).
35    pub base_url: &'a str,
36    /// The repo URL to send to the cloud (canonical form preferred).
37    pub repo_url: &'a str,
38    /// Optional branch/tag forwarded as `ref` in the request body.
39    pub repo_ref: Option<&'a str>,
40}
41
42#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
43pub struct IndexedRepo {
44    pub owner: String,
45    pub repo: String,
46    #[serde(rename = "ref")]
47    pub git_ref: String,
48    pub sha: String,
49}
50
51#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
52pub struct IndexedListing {
53    pub owner: String,
54    pub name: String,
55    pub skill_id: String,
56    pub version: String,
57    pub permalink: String,
58    pub trust_tier: TrustTier,
59    pub skill_path: String,
60    pub digest_unchanged: bool,
61}
62
63#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
64pub struct IndexWarning {
65    #[serde(default)]
66    pub skill_path: Option<String>,
67    pub code: String,
68    pub detail: String,
69}
70
71/// Successful `POST /v1/index` envelope returned by the cloud.
72#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
73pub struct IndexResponse {
74    pub repo: IndexedRepo,
75    pub listings: Vec<IndexedListing>,
76    #[serde(default)]
77    pub warnings: Vec<IndexWarning>,
78}
79
80/// Errors returned by [`parse_github_repo_ref`] and [`index_github_repo`].
81///
82/// Distinct from [`crate::registry::RegistryClientError`] because the `/v1/index`
83/// endpoint has its own error envelope shape (`{ status: "error", error: { code,
84/// detail, hint?, retry_after_seconds? } }`) that callers want to match on. We
85/// surface those structured fields directly so the CLI can render hints and
86/// retry guidance without re-parsing strings.
87#[derive(Debug, thiserror::Error)]
88pub enum IndexError {
89    #[error(
90        "'{0}' is not a recognized GitHub repository URL. Expected https://github.com/<owner>/<repo>."
91    )]
92    NotAGithubRepoUrl(String),
93    #[error(transparent)]
94    RuntimeHttp(#[from] RuntimeHttpError),
95    #[error("runx-api index returned HTTP {status}: {body}")]
96    HttpStatus { status: u16, body: String },
97    #[error("runx-api index returned invalid JSON: {0}")]
98    InvalidJson(String),
99    #[error("runx-api index returned error envelope [{code}]: {detail}")]
100    RunxApi {
101        code: String,
102        detail: String,
103        hint: Option<String>,
104        retry_after_seconds: Option<u32>,
105    },
106}
107
108/// Parse a user-supplied GitHub repo URL into a structured reference.
109///
110/// Accepts `https://github.com/<owner>/<repo>[/...]`, the same with `http://`,
111/// or bare `github.com/<owner>/<repo>[/...]`. Anything else is rejected.
112pub fn parse_github_repo_ref(input: &str) -> Result<GithubRepoRef, IndexError> {
113    let trimmed = input.trim();
114    if trimmed.is_empty() {
115        return Err(IndexError::NotAGithubRepoUrl(input.to_owned()));
116    }
117    let normalized: String = if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
118        trimmed.to_owned()
119    } else if let Some(rest) = trimmed.strip_prefix("github.com/") {
120        format!("https://github.com/{rest}")
121    } else {
122        return Err(IndexError::NotAGithubRepoUrl(input.to_owned()));
123    };
124    let parsed =
125        Url::parse(&normalized).map_err(|_| IndexError::NotAGithubRepoUrl(input.to_owned()))?;
126    if parsed.host_str() != Some("github.com") {
127        return Err(IndexError::NotAGithubRepoUrl(input.to_owned()));
128    }
129    let mut segments = parsed
130        .path_segments()
131        .map(|iter| iter.filter(|segment| !segment.is_empty()))
132        .ok_or_else(|| IndexError::NotAGithubRepoUrl(input.to_owned()))?;
133    let owner = segments
134        .next()
135        .ok_or_else(|| IndexError::NotAGithubRepoUrl(input.to_owned()))?;
136    let repo = segments
137        .next()
138        .ok_or_else(|| IndexError::NotAGithubRepoUrl(input.to_owned()))?;
139    Ok(GithubRepoRef {
140        canonical_url: format!("https://github.com/{owner}/{repo}"),
141        owner: owner.to_owned(),
142        repo: repo.to_owned(),
143    })
144}
145
146/// POST the repo URL to the hosted registry's `/v1/index` endpoint.
147///
148/// The transport handles timeouts/retries/TLS per the runtime's standard HTTP
149/// discipline. Generic over `T: Transport` so tests can inject a stub without
150/// touching the network.
151pub fn index_github_repo<T: Transport>(
152    transport: &T,
153    options: &IndexGithubRepoOptions<'_>,
154) -> Result<IndexResponse, IndexError> {
155    let base = options.base_url.trim_end_matches('/');
156    let url = format!("{base}/v1/index");
157    let body = serde_json::json!({
158        "repo_url": options.repo_url,
159        "ref": options.repo_ref,
160    })
161    .to_string();
162    let request = HttpRequest {
163        method: HttpMethod::Post,
164        url,
165        headers: vec![RuntimeHttpHeader {
166            name: "content-type".to_owned(),
167            value: "application/json".to_owned(),
168        }],
169        body: Some(body),
170    };
171    let response = transport.send(request)?;
172    if !(200..=299).contains(&response.status) {
173        if let Ok(envelope) = serde_json::from_str::<ErrorEnvelope>(&response.body) {
174            return Err(IndexError::RunxApi {
175                code: envelope.error.code,
176                detail: envelope.error.detail,
177                hint: envelope.error.hint,
178                retry_after_seconds: envelope.error.retry_after_seconds,
179            });
180        }
181        return Err(IndexError::HttpStatus {
182            status: response.status,
183            body: response.body,
184        });
185    }
186    let envelope: SuccessEnvelope = serde_json::from_str(&response.body)
187        .map_err(|error| IndexError::InvalidJson(error.to_string()))?;
188    if envelope.status != "success" {
189        return Err(IndexError::InvalidJson(format!(
190            "expected status \"success\", received \"{}\"",
191            envelope.status
192        )));
193    }
194    Ok(IndexResponse {
195        repo: envelope.repo,
196        listings: envelope.listings,
197        warnings: envelope.warnings,
198    })
199}
200
201#[derive(Deserialize)]
202struct SuccessEnvelope {
203    status: String,
204    repo: IndexedRepo,
205    listings: Vec<IndexedListing>,
206    #[serde(default)]
207    warnings: Vec<IndexWarning>,
208}
209
210#[derive(Deserialize)]
211struct ErrorEnvelope {
212    error: ErrorPayload,
213}
214
215#[derive(Deserialize)]
216struct ErrorPayload {
217    code: String,
218    detail: String,
219    #[serde(default)]
220    hint: Option<String>,
221    #[serde(default)]
222    retry_after_seconds: Option<u32>,
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn parses_https_github_url() -> Result<(), IndexError> {
231        let parsed = parse_github_repo_ref("https://github.com/runxhq/runx")?;
232        assert_eq!(parsed.canonical_url, "https://github.com/runxhq/runx");
233        assert_eq!(parsed.owner, "runxhq");
234        assert_eq!(parsed.repo, "runx");
235        Ok(())
236    }
237
238    #[test]
239    fn parses_http_url_normalizes_to_https_canonical_form() -> Result<(), IndexError> {
240        let parsed = parse_github_repo_ref("http://github.com/runxhq/runx")?;
241        assert_eq!(parsed.canonical_url, "https://github.com/runxhq/runx");
242        Ok(())
243    }
244
245    #[test]
246    fn parses_bare_github_form_with_canonical_https_url() -> Result<(), IndexError> {
247        let parsed = parse_github_repo_ref("github.com/runxhq/runx")?;
248        assert_eq!(parsed.canonical_url, "https://github.com/runxhq/runx");
249        assert_eq!(parsed.owner, "runxhq");
250        Ok(())
251    }
252
253    #[test]
254    fn parses_url_with_trailing_path_taking_first_two_segments_only() -> Result<(), IndexError> {
255        let parsed = parse_github_repo_ref("https://github.com/runxhq/runx/tree/main/skills")?;
256        assert_eq!(parsed.canonical_url, "https://github.com/runxhq/runx");
257        assert_eq!(parsed.owner, "runxhq");
258        assert_eq!(parsed.repo, "runx");
259        Ok(())
260    }
261
262    #[test]
263    fn trims_whitespace_from_input() -> Result<(), IndexError> {
264        let parsed = parse_github_repo_ref("  https://github.com/runxhq/runx  ")?;
265        assert_eq!(parsed.canonical_url, "https://github.com/runxhq/runx");
266        Ok(())
267    }
268
269    #[test]
270    fn rejects_non_github_host() {
271        let result = parse_github_repo_ref("https://gitlab.com/foo/bar");
272        assert!(matches!(result, Err(IndexError::NotAGithubRepoUrl(_))));
273    }
274
275    #[test]
276    fn rejects_missing_repo_segment() {
277        let result = parse_github_repo_ref("https://github.com/runxhq");
278        assert!(matches!(result, Err(IndexError::NotAGithubRepoUrl(_))));
279    }
280
281    #[test]
282    fn rejects_empty_input() {
283        assert!(matches!(
284            parse_github_repo_ref(""),
285            Err(IndexError::NotAGithubRepoUrl(_))
286        ));
287        assert!(matches!(
288            parse_github_repo_ref("   "),
289            Err(IndexError::NotAGithubRepoUrl(_))
290        ));
291    }
292
293    #[test]
294    fn rejects_unsupported_scheme() {
295        let result = parse_github_repo_ref("ftp://github.com/runxhq/runx");
296        assert!(matches!(result, Err(IndexError::NotAGithubRepoUrl(_))));
297    }
298}