Skip to main content

yapi_core/tools/
external.rs

1//! `rg` and `fd` for the `grep` and `find` tools: the agent's copy, else the
2//! system's, else a download of the latest release.
3//!
4//! Port of `packages/coding-agent/src/utils/tools-manager.ts` in pi `v1.0.0`.
5
6use std::path::{Path, PathBuf};
7use std::process::Stdio;
8use std::time::Duration;
9
10/// A helper binary the tools run.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum ExternalTool {
13    /// `fd`, for `find`.
14    Fd,
15    /// `rg`, for `grep`.
16    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
75/// Whether `PI_OFFLINE` asks to skip network operations.
76pub 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
91/// The installed tool: the agent's copy, else a system command name.
92pub 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
105/// The tool, downloading the latest release into `bin_dir` when it is missing and
106/// network use is allowed. `None` when it cannot be had.
107pub 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    // pi reports download failures only to an interactive status line.
115    download(tool, bin_dir).await.ok()
116}
117
118/// The version of the latest release on the GitHub releases page
119/// `releases`, such as `https://github.com/sharkdp/fd/releases`, without a
120/// leading `v`: pi's `getLatestVersion`, which reads the redirect of
121/// `<releases>/latest` because the API endpoint's anonymous quota is often
122/// exhausted behind shared egress.
123pub async fn latest_version(releases: &str) -> Result<String, String> {
124    // pi's messages name the repository.
125    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
202/// The body of `url`, a release download.
203pub(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}