1use 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#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct GithubRepoRef {
25 pub canonical_url: String,
27 pub owner: String,
28 pub repo: String,
29}
30
31#[derive(Clone, Debug)]
33pub struct IndexGithubRepoOptions<'a> {
34 pub base_url: &'a str,
36 pub repo_url: &'a str,
38 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#[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#[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
108pub 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
146pub 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}