yapi_core/tools/
external.rs1use std::path::{Path, PathBuf};
7use std::process::Stdio;
8use std::time::Duration;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum ExternalTool {
13 Fd,
15 Rg,
17}
18
19impl ExternalTool {
20 fn binary(self) -> &'static str {
21 match self {
22 ExternalTool::Fd => "fd",
23 ExternalTool::Rg => "rg",
24 }
25 }
26
27 fn system_names(self) -> &'static [&'static str] {
28 match self {
29 ExternalTool::Fd => &["fd", "fdfind"],
30 ExternalTool::Rg => &["rg"],
31 }
32 }
33
34 fn repo(self) -> &'static str {
35 match self {
36 ExternalTool::Fd => "sharkdp/fd",
37 ExternalTool::Rg => "BurntSushi/ripgrep",
38 }
39 }
40
41 fn asset(self, version: &str) -> Option<String> {
42 let arch = match std::env::consts::ARCH {
43 "aarch64" => "aarch64",
44 "x86_64" => "x86_64",
45 _ => return None,
46 };
47 let target = match std::env::consts::OS {
48 "macos" => format!("{arch}-apple-darwin.tar.gz"),
49 "linux" => format!("{arch}-unknown-linux-musl.tar.gz"),
50 "windows" => format!("{arch}-pc-windows-msvc.zip"),
51 _ => return None,
52 };
53 Some(match self {
54 ExternalTool::Fd => format!("fd-v{version}-{target}"),
55 ExternalTool::Rg => format!("ripgrep-{version}-{target}"),
56 })
57 }
58
59 fn tag(self, version: &str) -> String {
60 match self {
61 ExternalTool::Fd => format!("v{version}"),
62 ExternalTool::Rg => version.to_owned(),
63 }
64 }
65}
66
67fn exe(name: &str) -> String {
68 if cfg!(windows) {
69 format!("{name}.exe")
70 } else {
71 name.to_owned()
72 }
73}
74
75pub fn offline() -> bool {
77 crate::config::env_flag("PI_OFFLINE")
78}
79
80async fn command_exists(name: &str) -> bool {
81 tokio::process::Command::new(name)
82 .arg("--version")
83 .stdin(Stdio::null())
84 .stdout(Stdio::null())
85 .stderr(Stdio::null())
86 .status()
87 .await
88 .is_ok()
89}
90
91pub async fn tool_path(tool: ExternalTool, bin_dir: &Path) -> Option<PathBuf> {
93 let local = bin_dir.join(exe(tool.binary()));
94 if local.exists() {
95 return Some(local);
96 }
97 for name in tool.system_names() {
98 if command_exists(name).await {
99 return Some(PathBuf::from(name));
100 }
101 }
102 None
103}
104
105pub async fn ensure_tool(tool: ExternalTool, bin_dir: &Path) -> Option<PathBuf> {
108 if let Some(path) = tool_path(tool, bin_dir).await {
109 return Some(path);
110 }
111 if offline() {
112 return None;
113 }
114 download(tool, bin_dir).await.ok()
116}
117
118pub async fn latest_version(releases: &str) -> Result<String, String> {
124 let repo = releases
126 .trim_start_matches("https://github.com/")
127 .trim_end_matches("/releases");
128 let client = reqwest::Client::builder()
129 .redirect(reqwest::redirect::Policy::none())
130 .timeout(Duration::from_secs(10))
131 .user_agent("yapi-coding-agent")
132 .build()
133 .map_err(|err| err.to_string())?;
134 let response = client
135 .get(format!("{releases}/latest"))
136 .send()
137 .await
138 .map_err(|err| err.to_string())?;
139 let status = response.status();
140 let location = response
141 .headers()
142 .get("location")
143 .and_then(|value| value.to_str().ok())
144 .filter(|_| status.is_redirection())
145 .ok_or_else(|| {
146 format!(
147 "Failed to resolve latest {repo} release: HTTP {} without redirect",
148 status.as_u16()
149 )
150 })?;
151 let unexpected =
152 || format!("Failed to resolve latest {repo} release: unexpected redirect to {location}");
153 let target = url::Url::parse(releases)
154 .and_then(|base| base.join(location))
155 .map_err(|_| unexpected())?;
156 let tag = target
157 .path_segments()
158 .and_then(|mut segments| segments.next_back())
159 .filter(|tag| !tag.is_empty() && location.contains("/releases/tag/"))
160 .ok_or_else(unexpected)?;
161 Ok(tag.strip_prefix('v').unwrap_or(tag).to_owned())
162}
163
164fn find_binary(root: &Path, name: &str) -> Option<PathBuf> {
165 let mut stack = vec![root.to_path_buf()];
166 while let Some(dir) = stack.pop() {
167 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
168 let path = entry.path();
169 let kind = entry.file_type().ok()?;
170 if kind.is_file() && entry.file_name() == name {
171 return Some(path);
172 }
173 if kind.is_dir() {
174 stack.push(path);
175 }
176 }
177 }
178 None
179}
180
181async fn run(command: &str, args: &[&std::ffi::OsStr]) -> Result<(), String> {
182 let output = tokio::process::Command::new(command)
183 .args(args)
184 .stdin(Stdio::null())
185 .output()
186 .await
187 .map_err(|err| format!("{command}: {err}"))?;
188 if output.status.success() {
189 return Ok(());
190 }
191 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
192 Err(format!(
193 "{command}: {}",
194 if stderr.is_empty() {
195 format!("exit status {}", output.status.code().unwrap_or(-1))
196 } else {
197 stderr
198 }
199 ))
200}
201
202pub(crate) async fn fetch(url: &str) -> Result<Vec<u8>, String> {
204 let response = yapi_ai::http::client()
205 .get(url)
206 .timeout(Duration::from_secs(120))
207 .send()
208 .await
209 .map_err(|err| err.to_string())?;
210 if !response.status().is_success() {
211 return Err(format!(
212 "Download failed with HTTP {}: {url}",
213 response.status().as_u16()
214 ));
215 }
216 let bytes = response.bytes().await.map_err(|err| err.to_string())?;
217 Ok(bytes.to_vec())
218}
219
220async fn download(tool: ExternalTool, bin_dir: &Path) -> Result<PathBuf, String> {
221 let version = latest_version(&format!("https://github.com/{}/releases", tool.repo())).await?;
222 let asset = tool.asset(&version).ok_or_else(|| {
223 format!(
224 "Unsupported platform: {}/{}",
225 std::env::consts::OS,
226 std::env::consts::ARCH
227 )
228 })?;
229 std::fs::create_dir_all(bin_dir).map_err(|err| err.to_string())?;
230 let url = format!(
231 "https://github.com/{}/releases/download/{}/{asset}",
232 tool.repo(),
233 tool.tag(&version)
234 );
235 let archive = bin_dir.join(&asset);
236 let binary = bin_dir.join(exe(tool.binary()));
237 let bytes = fetch(&url).await?;
238 std::fs::write(&archive, &bytes).map_err(|err| err.to_string())?;
239 let extract = bin_dir.join(format!(
240 "extract_tmp_{}_{}_{}",
241 tool.binary(),
242 std::process::id(),
243 crate::time::random_hex(4)
244 ));
245 let result = async {
246 std::fs::create_dir_all(&extract).map_err(|err| err.to_string())?;
247 if asset.ends_with(".tar.gz") {
248 run(
249 "tar",
250 &[
251 "xzf".as_ref(),
252 archive.as_os_str(),
253 "-C".as_ref(),
254 extract.as_os_str(),
255 ],
256 )
257 .await
258 .map_err(|err| format!("Failed to extract {asset}: {err}"))?;
259 } else if let Err(unzip) = run(
260 "unzip",
261 &[
262 "-q".as_ref(),
263 archive.as_os_str(),
264 "-d".as_ref(),
265 extract.as_os_str(),
266 ],
267 )
268 .await
269 {
270 run(
271 "tar",
272 &[
273 "xf".as_ref(),
274 archive.as_os_str(),
275 "-C".as_ref(),
276 extract.as_os_str(),
277 ],
278 )
279 .await
280 .map_err(|tar| format!("Failed to extract {asset}: {unzip}; {tar}"))?;
281 }
282 let name = exe(tool.binary());
283 let found = find_binary(&extract, &name).ok_or_else(|| {
284 format!(
285 "Binary not found in archive: expected {name} under {}",
286 extract.display()
287 )
288 })?;
289 std::fs::rename(found, &binary).map_err(|err| err.to_string())?;
290 #[cfg(unix)]
291 {
292 use std::os::unix::fs::PermissionsExt;
293 std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755))
294 .map_err(|err| err.to_string())?;
295 }
296 Ok(binary.clone())
297 }
298 .await;
299 let _ = std::fs::remove_file(&archive);
300 let _ = std::fs::remove_dir_all(&extract);
301 result
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn names_release_assets() {
310 if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
311 assert_eq!(
312 ExternalTool::Fd.asset("10.2.0").as_deref(),
313 Some("fd-v10.2.0-x86_64-unknown-linux-musl.tar.gz")
314 );
315 assert_eq!(
316 ExternalTool::Rg.asset("14.1.1").as_deref(),
317 Some("ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz")
318 );
319 }
320 assert_eq!(ExternalTool::Fd.tag("10.2.0"), "v10.2.0");
321 assert_eq!(ExternalTool::Rg.tag("14.1.1"), "14.1.1");
322 }
323}