1use std::fmt::{Debug, Display};
2use std::path::Path;
3use std::process::Stdio;
4use std::str::FromStr;
5
6use anyhow::{anyhow, bail, Result};
7use tokio::process::Command;
8
9use anyhow::Context;
10use tracing::error;
11use wasmcloud_control_interface::HostInventory;
12
13use crate::id::{ModuleId, ServerId, ServiceId};
14
15const DEFAULT_GIT_PATH: &str = "git";
17
18const CLAIMS_CALL_ALIAS: &str = "call_alias";
19pub(crate) const CLAIMS_NAME: &str = "name";
20pub(crate) const CLAIMS_SUBJECT: &str = "sub";
21
22pub(crate) fn boxed_err_to_anyhow(e: Box<dyn ::std::error::Error + Send + Sync>) -> anyhow::Error {
24 anyhow::anyhow!(e)
25}
26
27#[derive(Debug, thiserror::Error)]
28pub enum FindIdError {
29 #[error("No matches found with the provided search term")]
31 NoMatches,
32 #[error("Multiple matches found with the provided search term: {0:?}")]
35 MultipleMatches(Vec<Match>),
36 #[error(transparent)]
37 Error(#[from] anyhow::Error),
38}
39
40#[derive(Clone)]
42pub struct Match {
43 pub id: String,
44 pub friendly_name: Option<String>,
45}
46
47impl Display for Match {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 if let Some(friendly_name) = &self.friendly_name {
50 write!(f, "{} ({friendly_name})", self.id)
51 } else {
52 write!(f, "{}", self.id)
53 }
54 }
55}
56
57impl Debug for Match {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 Display::fmt(&self, f)
60 }
61}
62
63#[derive(Default, Debug, Clone, PartialEq, Eq)]
65pub enum CommandGroupUsage {
66 #[default]
68 UseParent,
69 CreateNew,
72}
73
74pub async fn find_component_id(
86 value: &str,
87 ctl_client: &wasmcloud_control_interface::Client,
88) -> Result<(ModuleId, Option<String>), FindIdError> {
89 find_id_matches(value, ctl_client).await
90}
91
92pub async fn find_provider_id(
103 value: &str,
104 ctl_client: &wasmcloud_control_interface::Client,
105) -> Result<(ServiceId, Option<String>), FindIdError> {
106 find_id_matches(value, ctl_client).await
107}
108
109async fn find_id_matches<T: FromStr + ToString + Display>(
110 value: &str,
111 ctl_client: &wasmcloud_control_interface::Client,
112) -> Result<(T, Option<String>), FindIdError> {
113 if let Ok(id) = T::from_str(value) {
114 return Ok((id, None));
115 }
116 let value = value.to_lowercase();
118 let ctl_response = ctl_client
120 .get_claims()
121 .await
122 .map_err(boxed_err_to_anyhow)
123 .context("unable to get claims for lookup")?;
124 let Some(claims) = ctl_response.into_data() else {
125 error!("received claims response from control interface but no claims were present in the response");
126 return Err(FindIdError::NoMatches);
127 };
128
129 let all_matches = claims
130 .iter()
131 .filter_map(|v| {
132 let id_str = v
133 .get(CLAIMS_SUBJECT)
134 .map(String::as_str)
135 .unwrap_or_default();
136 let id = match T::from_str(id_str) {
138 Ok(id) => id,
139 Err(_) => return None,
140 };
141 (id_str.to_lowercase().starts_with(&value)
142 || v.get(CLAIMS_CALL_ALIAS)
143 .map(|s| s.to_lowercase())
144 .unwrap_or_default()
145 .contains(&value)
146 || v.get(CLAIMS_NAME)
147 .map(|s| s.to_ascii_lowercase())
148 .unwrap_or_default()
149 .contains(&value))
150 .then(|| (id, v.get(CLAIMS_NAME).map(ToString::to_string)))
151 })
152 .collect::<Vec<_>>();
153
154 if all_matches.is_empty() {
155 Err(FindIdError::NoMatches)
156 } else if all_matches.len() > 1 {
157 Err(FindIdError::MultipleMatches(
158 all_matches
159 .into_iter()
160 .map(|(id, friendly_name)| Match {
161 id: id.to_string(),
162 friendly_name,
163 })
164 .collect(),
165 ))
166 } else {
167 Ok(all_matches.into_iter().next().unwrap())
169 }
170}
171
172pub async fn find_host_id(
182 value: &str,
183 ctl_client: &wasmcloud_control_interface::Client,
184) -> Result<(ServerId, String), FindIdError> {
185 if let Ok(id) = ServerId::from_str(value) {
186 return Ok((id, String::new()));
187 }
188
189 let value = value.to_lowercase();
191
192 let hosts = ctl_client
193 .get_hosts()
194 .await
195 .map_err(boxed_err_to_anyhow)
196 .context("unable to fetch hosts for lookup")?;
197
198 let all_matches = hosts
199 .into_iter()
200 .filter_map(|h| h.into_data())
201 .filter_map(|h| {
202 if h.id().to_lowercase().starts_with(&value)
203 || h.friendly_name().to_lowercase().contains(&value)
204 {
205 ServerId::from_str(h.id())
206 .ok()
207 .map(|id| (id, h.friendly_name().to_string()))
208 } else {
209 None
210 }
211 })
212 .collect::<Vec<_>>();
213
214 if all_matches.is_empty() {
215 Err(FindIdError::NoMatches)
216 } else if all_matches.len() > 1 {
217 Err(FindIdError::MultipleMatches(
218 all_matches
219 .into_iter()
220 .map(|(id, friendly_name)| Match {
221 id: id.to_string(),
222 friendly_name: Some(friendly_name.to_string()),
223 })
224 .collect(),
225 ))
226 } else {
227 Ok(all_matches.into_iter().next().unwrap())
229 }
230}
231
232pub async fn get_all_inventories(
233 client: &wasmcloud_control_interface::Client,
234) -> anyhow::Result<Vec<HostInventory>> {
235 let hosts = client.get_hosts().await.map_err(boxed_err_to_anyhow)?;
236 let host_ids = match hosts.len() {
237 0 => return Ok(Vec::with_capacity(0)),
238 _ => hosts
239 .into_iter()
240 .filter_map(|h| h.into_data().map(|h| h.id().to_string())),
241 };
242
243 let futs =
244 host_ids
245 .map(|host_id| (client.clone(), host_id))
246 .map(|(client, host_id)| async move {
247 client
248 .get_host_inventory(&host_id)
249 .await
250 .map(|inventory| inventory.into_data())
251 .map_err(boxed_err_to_anyhow)
252 });
253 futures::future::join_all(futs)
254 .await
255 .into_iter()
256 .filter_map(Result::transpose)
257 .collect::<anyhow::Result<Vec<HostInventory>>>()
258}
259
260#[derive(Debug, Eq, PartialEq)]
262pub enum RepoRef {
263 Unknown(String),
265 Branch(String),
267 Tag(String),
269 Sha(String),
271}
272
273impl RepoRef {
274 #[must_use]
276 pub fn git_ref(&self) -> &str {
277 match self {
278 RepoRef::Unknown(s) => s,
279 RepoRef::Branch(s) => s,
280 RepoRef::Tag(s) => s,
281 RepoRef::Sha(s) if s.starts_with("sha:") => &s[4..],
282 RepoRef::Sha(s) => s,
283 }
284 }
285}
286
287impl FromStr for RepoRef {
288 type Err = anyhow::Error;
289
290 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
291 Ok(match s.strip_prefix("sha:") {
292 Some(s) => Self::Sha(s.into()),
293 None => Self::Unknown(s.into()),
294 })
295 }
296}
297
298pub async fn clone_git_repo(
300 git_cmd: Option<String>,
301 tmp_dir: impl AsRef<Path>,
302 repo_url: String,
303 sub_folder: Option<String>,
304 repo_ref: Option<RepoRef>,
305) -> Result<()> {
306 let git_cmd = git_cmd.unwrap_or_else(|| DEFAULT_GIT_PATH.into());
307 let tmp_dir = tmp_dir.as_ref();
308 let cwd =
309 std::env::current_dir().map_err(|e| anyhow!("could not get current directory: {}", e))?;
310 std::env::set_current_dir(tmp_dir)
311 .map_err(|e| anyhow!("could not cd to tmp dir {}: {}", tmp_dir.display(), e))?;
312
313 let repo_url = {
315 if repo_url.starts_with("http://") || repo_url.starts_with("https://") {
316 repo_url
317 } else if repo_url.starts_with("git+https://")
318 || repo_url.starts_with("git+http://")
319 || repo_url.starts_with("git+ssh")
320 {
321 repo_url.replace("git+", "")
322 } else if repo_url.starts_with("github.com/") {
323 format!("https://{}", &repo_url)
324 } else {
325 format!("https://github.com/{}", repo_url.trim_start_matches('/'))
326 }
327 };
328
329 let repo_url = {
331 let mut url = reqwest::Url::parse(&repo_url)?;
332 url.query_pairs_mut().clear();
333 format!(
334 "{}://{}{}",
335 match url.scheme() {
336 "ssh" => "ssh",
337 _ => "https",
338 },
339 url.authority(),
340 url.path()
341 )
342 };
343
344 let mut args = vec!["clone", &repo_url, "--no-checkout", "."];
346 if let Some(RepoRef::Branch(_) | RepoRef::Tag(_)) = repo_ref {
349 args.push("--depth");
350 args.push("1");
351 }
352
353 if let Some(RepoRef::Branch(ref branch)) = repo_ref {
355 args.push("--branch");
356 args.push(branch);
357 }
358
359 let clone_cmd_out = Command::new(&git_cmd)
360 .args(args)
361 .stdin(Stdio::piped())
362 .stdout(Stdio::piped())
363 .stderr(Stdio::piped())
364 .spawn()?
365 .wait_with_output()
366 .await?;
367 if !clone_cmd_out.status.success() {
368 bail!(
369 "git clone error: {}",
370 String::from_utf8_lossy(&clone_cmd_out.stderr)
371 );
372 }
373
374 if let Some(repo_ref) = repo_ref {
377 let checkout_cmd_out = Command::new(&git_cmd)
378 .args(["checkout", repo_ref.git_ref()])
379 .stdin(Stdio::piped())
380 .stdout(Stdio::piped())
381 .stderr(Stdio::piped())
382 .spawn()?
383 .wait_with_output()
384 .await?;
385 if !checkout_cmd_out.status.success() {
386 bail!(
387 "git checkout error: {}",
388 String::from_utf8_lossy(&checkout_cmd_out.stderr)
389 );
390 }
391 }
392
393 if let Some(sub_folder) = sub_folder {
395 let checkout_cmd_out = Command::new(&git_cmd)
396 .args(["sparse-checkout", "set", &sub_folder])
397 .stdin(Stdio::piped())
398 .stdout(Stdio::piped())
399 .stderr(Stdio::piped())
400 .spawn()?
401 .wait_with_output()
402 .await?;
403 if !checkout_cmd_out.status.success() {
404 bail!(
405 "git sparse-checkout set error: {}",
406 String::from_utf8_lossy(&checkout_cmd_out.stderr)
407 );
408 }
409 }
410
411 std::env::set_current_dir(cwd)?;
412 Ok(())
413}