vtcode_core/tools/ast_grep_installer/
mod.rs1mod archive;
2mod release;
3mod state;
4
5use std::path::PathBuf;
6
7use crate::tools::ast_grep_binary::{
8 AST_GREP_NO_INSTALL_ENV, managed_ast_grep_bin_dir, missing_ast_grep_message,
9 resolve_ast_grep_binary_from_env_and_fs,
10};
11use crate::tools::editing::patch::resolve_ast_grep_binary_path;
12use anyhow::{Context, Result, bail};
13
14use self::archive::{ast_grep_version, install_archive};
15use self::release::{
16 current_platform_asset_spec, download_release_asset, fetch_latest_release, select_release_asset,
17 verify_checksum_if_available,
18};
19use self::state::{InstallLockGuard, InstallPaths, InstallationCache};
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum AstGrepStatus {
23 Available {
24 version: String,
25 binary: PathBuf,
26 managed: bool,
27 },
28 NotFound,
29 Error {
30 reason: String,
31 },
32}
33
34#[derive(Debug, Clone)]
35pub struct AstGrepInstallOutcome {
36 pub version: String,
37 pub binary_path: PathBuf,
38 pub alias_path: Option<PathBuf>,
39 pub managed_bin_dir: PathBuf,
40 pub warning: Option<String>,
41}
42
43impl AstGrepStatus {
44 pub fn check() -> Self {
45 let Some(binary) = resolve_ast_grep_binary_from_env_and_fs() else {
46 return Self::NotFound;
47 };
48
49 match ast_grep_version(&binary) {
50 Ok(version) => Self::Available {
51 version,
52 managed: managed_ast_grep_bin_dir().is_ok_and(|directory| binary.starts_with(directory)),
53 binary,
54 },
55 Err(err) => Self::Error { reason: format!("{err:#}") },
56 }
57 }
58
59 pub async fn install() -> Result<AstGrepInstallOutcome> {
60 let paths = InstallPaths::discover()?;
61 let _lock = InstallLockGuard::acquire(&paths)?;
62
63 if !InstallationCache::is_stale(&paths)
64 && let Ok(cache) = InstallationCache::load(&paths)
65 && cache.status == "failed"
66 && !cache.failure_reason.as_deref().is_some_and(should_retry_without_cooldown)
67 {
68 let reason = cache.failure_reason.as_deref().unwrap_or("unknown reason");
69 bail!("Previous ast-grep installation attempt failed ({reason}). Not retrying for 24 hours.");
70 }
71
72 let install_result: Result<AstGrepInstallOutcome> = async {
73 let client = reqwest::Client::builder()
74 .user_agent("vtcode-ast-grep-installer")
75 .build()
76 .context("Failed to create HTTP client")?;
77
78 let release = fetch_latest_release(&client).await?;
79 let platform = current_platform_asset_spec()?;
80 let selected_asset = select_release_asset(&release, &platform)?;
81 let archive_bytes = download_release_asset(&client, &selected_asset.asset).await?;
82 let warning = verify_checksum_if_available(&client, &release, &selected_asset, &archive_bytes).await?;
83
84 install_archive(&paths, &selected_asset.asset.name, &archive_bytes)?;
85 let version = ast_grep_version(&paths.binary_path).context("Installed ast-grep failed version check")?;
86 InstallationCache::mark_success(&paths, &selected_asset.tag_name);
87
88 Ok(AstGrepInstallOutcome {
89 version,
90 binary_path: paths.binary_path.clone(),
91 alias_path: paths.alias_path.clone(),
92 managed_bin_dir: paths.bin_dir.clone(),
93 warning,
94 })
95 }
96 .await;
97
98 match install_result {
99 Ok(outcome) => Ok(outcome),
100 Err(err) => {
101 InstallationCache::mark_failure(&paths, &err.to_string());
102 Err(err)
103 }
104 }
105 }
106
107 pub async fn resolve_or_install() -> std::result::Result<PathBuf, String> {
121 if let Ok(path) = resolve_ast_grep_binary_path() {
125 return Ok(path);
126 }
127
128 if std::env::var_os(AST_GREP_NO_INSTALL_ENV).is_some() {
131 return Err(missing_ast_grep_message("Auto-install disabled via VTCODE_AST_GREP_NO_INSTALL."));
132 }
133
134 if crate::tools::ast_grep_binary::is_binary_override_missing()
139 || crate::tools::editing::patch::is_binary_override_missing()
140 {
141 return Err(missing_ast_grep_message("Auto-install skipped: test override forces missing binary."));
142 }
143
144 match Self::install().await {
145 Ok(_) => match resolve_ast_grep_binary_from_env_and_fs() {
146 Some(path) => Ok(path),
147 None => {
148 Err(missing_ast_grep_message("Auto-install reported success but the binary could not be resolved."))
149 }
150 },
151 Err(err) => Err(format!(
152 "ast-grep is not available; auto-install failed: {err}. Run `{}` manually.",
153 crate::tools::ast_grep_binary::AST_GREP_INSTALL_COMMAND
154 )),
155 }
156 }
157}
158
159fn should_retry_without_cooldown(failure_reason: &str) -> bool {
160 failure_reason.contains("No ast-grep release asset matched the current platform")
161 || failure_reason.contains("Unsupported platform for VT Code-managed ast-grep install")
162}
163
164#[cfg(test)]
165mod tests {
166 use super::{AstGrepStatus, should_retry_without_cooldown};
167 use crate::tools::ast_grep_binary::AST_GREP_INSTALL_COMMAND;
168 use crate::tools::ast_grep_binary::set_ast_grep_binary_override_for_tests;
169 use serial_test::serial;
170 use vtcode_commons::env_lock;
171
172 #[cfg(unix)]
173 #[test]
174 #[serial]
175 fn status_check_preserves_version_and_bounded_probe_causes() {
176 use std::os::unix::fs::PermissionsExt;
177 for (body, expected) in [
178 ("printf ' ast-grep 0.44.0\\n'", Ok("ast-grep 0.44.0")),
179 (":", Err("returned empty output")),
180 ("exit 7", Err("exited with status exit status: 7")),
181 ("head -c 65537 /dev/zero", Err("stdout exceeded 65536 bytes")),
182 ("exec /bin/sleep 30", Err("version probe timed out")),
183 ] {
184 let directory = tempfile::tempdir().unwrap();
185 let binary = directory.path().join("ast-grep");
186 let script = format!(
187 "#!/bin/sh\n[ \"$1\" = \"--version\" ] && [ \"$2\" = \"--color\" ] && [ \"$3\" = \"never\" ] && [ \"$NO_COLOR\" = 1 ] && [ \"$CLICOLOR\" = 0 ] || exit 9\n{body}\n"
188 );
189 std::fs::write(&binary, script).unwrap();
190 std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
191 let _override = set_ast_grep_binary_override_for_tests(Some(binary));
192 match (expected, AstGrepStatus::check()) {
193 (Ok(expected), AstGrepStatus::Available { version, .. }) => assert_eq!(version, expected),
194 (Err(expected), AstGrepStatus::Error { reason }) => assert!(reason.contains(expected), "{reason}"),
195 (expected, actual) => panic!("expected {expected:?}, got {actual:?}"),
196 }
197 }
198 let _override = set_ast_grep_binary_override_for_tests(None);
199 assert_eq!(AstGrepStatus::check(), AstGrepStatus::NotFound);
200 }
201
202 #[test]
203 fn platform_mismatch_failures_do_not_enter_cooldown() {
204 assert!(should_retry_without_cooldown(
205 "No ast-grep release asset matched the current platform (aarch64-apple-darwin)"
206 ));
207 assert!(should_retry_without_cooldown("Unsupported platform for VT Code-managed ast-grep install"));
208 }
209
210 #[test]
211 fn unrelated_failures_still_use_cooldown() {
212 assert!(!should_retry_without_cooldown("Failed to fetch ast-grep release metadata"));
213 }
214
215 #[tokio::test]
216 #[serial]
217 async fn resolve_or_install_skips_install_when_test_override_missing() {
218 let _guard = set_ast_grep_binary_override_for_tests(None);
219 let err = AstGrepStatus::resolve_or_install()
220 .await
221 .expect_err("should not install in test mode");
222 let text = err.to_string();
223 assert!(text.contains("ast-grep"), "{text}");
224 assert!(text.contains("not available"), "{text}");
225 assert!(text.contains(AST_GREP_INSTALL_COMMAND), "{text}");
226 }
227
228 #[tokio::test]
229 #[serial]
230 async fn resolve_or_install_mentions_env_optout_when_set() {
231 let _override = set_ast_grep_binary_override_for_tests(None);
236 let env = env_lock::lock();
237 env.set_var("VTCODE_AST_GREP_NO_INSTALL", "1");
238 let err = AstGrepStatus::resolve_or_install()
239 .await
240 .expect_err("should not install when env opt-out is set");
241 env.remove_var("VTCODE_AST_GREP_NO_INSTALL");
242 assert!(err.contains("VTCODE_AST_GREP_NO_INSTALL"), "error should mention the env var: {err}");
243 }
244}