1use std::collections::BTreeMap;
7use std::process::ExitCode;
8
9use runx_runtime::registry::{
10 GithubRepoRef, IndexGithubRepoOptions, IndexResponse, IndexWarning, IndexedListing,
11 IndexedRepo, TrustTier, index_github_repo, parse_github_repo_ref,
12};
13use serde::Serialize;
14
15use crate::router::AddUrlPlan;
16
17pub fn run_native_add(plan: AddUrlPlan) -> ExitCode {
18 let env = crate::history::env_map();
19 let base_url = resolve_public_api_base_url(&plan, &env);
20
21 let repo_ref = match parse_github_repo_ref(&plan.repo) {
22 Ok(parsed) => parsed,
23 Err(error) => return fail(&error.to_string()),
24 };
25
26 let transport = match crate::public_api::transport(false) {
27 Ok(transport) => transport,
28 Err(error) => return fail(&format!("failed to initialize HTTP transport: {error}")),
29 };
30
31 let options = IndexGithubRepoOptions {
32 base_url: &base_url,
33 repo_url: &repo_ref.canonical_url,
34 repo_ref: plan.repo_ref.as_deref(),
35 };
36
37 match index_github_repo(&transport, &options) {
38 Ok(response) => render_result(plan.json, &repo_ref, &response),
39 Err(error) => fail(&error.to_string()),
40 }
41}
42
43fn resolve_public_api_base_url(plan: &AddUrlPlan, env: &BTreeMap<String, String>) -> String {
44 crate::public_api::resolve_base_url(plan.api_base_url.as_deref(), env)
45}
46
47fn render_result(json: bool, repo_ref: &GithubRepoRef, response: &IndexResponse) -> ExitCode {
48 if json {
49 let envelope = AddUrlJsonResult {
50 status: "success",
51 requested: AddUrlRequestedRef {
52 canonical_url: &repo_ref.canonical_url,
53 owner: &repo_ref.owner,
54 repo: &repo_ref.repo,
55 },
56 repo: &response.repo,
57 listings: &response.listings,
58 warnings: &response.warnings,
59 };
60 match serde_json::to_string_pretty(&envelope) {
61 Ok(serialized) => crate::cli_io::write_stdout_code(&format!("{serialized}\n"), 0),
62 Err(error) => fail(&format!("failed to serialize add result: {error}")),
63 }
64 } else {
65 crate::cli_io::write_stdout_code(&render_text(response), 0)
66 }
67}
68
69fn render_text(response: &IndexResponse) -> String {
70 let mut out = String::new();
71 let sha_short: String = response.repo.sha.chars().take(12).collect();
72 let count = response.listings.len();
73 out.push_str(&format!(
74 "indexed {count} skill{plural} from {owner}/{repo}@{sha}\n\n",
75 plural = if count == 1 { "" } else { "s" },
76 owner = response.repo.owner,
77 repo = response.repo.repo,
78 sha = sha_short,
79 ));
80 for listing in &response.listings {
81 let tag = if listing.digest_unchanged {
82 "(unchanged)"
83 } else {
84 "(new)"
85 };
86 out.push_str(&format!(
87 " {}@{} · {} {}\n",
88 listing.skill_id,
89 listing.version,
90 trust_tier_label(&listing.trust_tier),
91 tag,
92 ));
93 out.push_str(&format!(" → {}\n", listing.permalink));
94 out.push_str(&format!(
95 " install: runx add {}@{}\n",
96 listing.skill_id, listing.version,
97 ));
98 out.push_str(&format!(" run: runx {}\n\n", listing.name));
99 }
100 if !response.warnings.is_empty() {
101 out.push_str("warnings:\n");
102 for warning in &response.warnings {
103 let where_ = warning
104 .skill_path
105 .as_deref()
106 .map(|path| format!(" ({path})"))
107 .unwrap_or_default();
108 out.push_str(&format!(
109 " - {}{}: {}\n",
110 warning.code, where_, warning.detail,
111 ));
112 }
113 out.push('\n');
114 }
115 out
116}
117
118fn trust_tier_label(tier: &TrustTier) -> &'static str {
119 match tier {
120 TrustTier::FirstParty => "first_party",
121 TrustTier::Verified => "verified",
122 TrustTier::Community => "community",
123 }
124}
125
126fn fail(message: &str) -> ExitCode {
127 let _ignored = crate::cli_io::write_stderr(&format!("runx: {message}\n"));
128 ExitCode::from(1)
129}
130
131#[derive(Serialize)]
132struct AddUrlJsonResult<'a> {
133 status: &'a str,
134 requested: AddUrlRequestedRef<'a>,
135 repo: &'a IndexedRepo,
136 listings: &'a [IndexedListing],
137 warnings: &'a [IndexWarning],
138}
139
140#[derive(Serialize)]
141struct AddUrlRequestedRef<'a> {
142 canonical_url: &'a str,
143 owner: &'a str,
144 repo: &'a str,
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use runx_runtime::registry::{IndexedRepo, TrustTier};
151
152 fn sample_listing(skill_id: &str, version: &str, unchanged: bool) -> IndexedListing {
153 IndexedListing {
154 owner: skill_id.split('/').next().unwrap_or("runxhq").to_owned(),
155 name: skill_id.split('/').nth(1).unwrap_or("demo").to_owned(),
156 skill_id: skill_id.to_owned(),
157 version: version.to_owned(),
158 permalink: format!("https://runx.ai/{skill_id}/{version}"),
159 trust_tier: TrustTier::Community,
160 skill_path: format!("skills/{skill_id}/SKILL.md"),
161 digest_unchanged: unchanged,
162 }
163 }
164
165 fn sample_response(
166 listings: Vec<IndexedListing>,
167 warnings: Vec<IndexWarning>,
168 ) -> IndexResponse {
169 IndexResponse {
170 repo: IndexedRepo {
171 owner: "runxhq".to_owned(),
172 repo: "runx".to_owned(),
173 git_ref: "main".to_owned(),
174 sha: "abcdef0123456789".to_owned(),
175 },
176 listings,
177 warnings,
178 }
179 }
180
181 #[test]
182 fn renders_singular_for_one_listing() {
183 let response = sample_response(vec![sample_listing("runxhq/demo", "sha-1", false)], vec![]);
184 let text = render_text(&response);
185 assert!(text.starts_with("indexed 1 skill from runxhq/runx@abcdef012345\n"));
186 assert!(text.contains("(new)"));
187 assert!(text.contains("install: runx add runxhq/demo@sha-1"));
188 }
189
190 #[test]
191 fn renders_plural_and_unchanged_tag() {
192 let response = sample_response(
193 vec![
194 sample_listing("runxhq/a", "sha-1", true),
195 sample_listing("runxhq/b", "sha-2", false),
196 ],
197 vec![],
198 );
199 let text = render_text(&response);
200 assert!(text.starts_with("indexed 2 skills from runxhq/runx@abcdef012345\n"));
201 assert!(text.contains("(unchanged)"));
202 assert!(text.contains("(new)"));
203 }
204
205 #[test]
206 fn renders_warnings_block_only_when_present() {
207 let bare = sample_response(vec![], vec![]);
208 assert!(!render_text(&bare).contains("warnings:"));
209
210 let warned = sample_response(
211 vec![],
212 vec![IndexWarning {
213 skill_path: Some("skills/foo/SKILL.md".to_owned()),
214 code: "missing_runner".to_owned(),
215 detail: "runner manifest absent".to_owned(),
216 }],
217 );
218 let text = render_text(&warned);
219 assert!(text.contains("warnings:"));
220 assert!(text.contains("missing_runner (skills/foo/SKILL.md): runner manifest absent"));
221 }
222
223 #[test]
224 fn resolves_base_url_in_precedence_order() {
225 let plan_with_override = AddUrlPlan {
226 repo: "https://github.com/runxhq/runx".to_owned(),
227 repo_ref: None,
228 api_base_url: Some("https://override.example/".to_owned()),
229 json: false,
230 };
231 let mut env: BTreeMap<String, String> = BTreeMap::new();
232 env.insert(
233 "RUNX_PUBLIC_API_BASE_URL".to_owned(),
234 "https://from-env.example/".to_owned(),
235 );
236
237 assert_eq!(
239 resolve_public_api_base_url(&plan_with_override, &env),
240 "https://override.example",
241 );
242
243 let plan_no_override = AddUrlPlan {
245 repo: "https://github.com/runxhq/runx".to_owned(),
246 repo_ref: None,
247 api_base_url: None,
248 json: false,
249 };
250 assert_eq!(
251 resolve_public_api_base_url(&plan_no_override, &env),
252 "https://from-env.example",
253 );
254
255 let empty_env: BTreeMap<String, String> = BTreeMap::new();
257 assert_eq!(
258 resolve_public_api_base_url(&plan_no_override, &empty_env),
259 "https://api.runx.ai",
260 );
261 }
262
263 #[test]
264 fn trust_tier_labels_match_snake_case_wire_form() {
265 assert_eq!(trust_tier_label(&TrustTier::FirstParty), "first_party");
266 assert_eq!(trust_tier_label(&TrustTier::Verified), "verified");
267 assert_eq!(trust_tier_label(&TrustTier::Community), "community");
268 }
269}