Skip to main content

vtcode_core/tools/ast_grep_installer/
mod.rs

1mod 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: err.to_string() },
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    /// Resolve the ast-grep binary path, auto-installing on first use when the
108    /// binary is missing. Used by `structural` and `outline` search actions so
109    /// the model does not need to run `vtcode dependencies install ast-grep`
110    /// manually before the first structural/outline call.
111    ///
112    /// Skips auto-install (returns the standard "not available" error) when:
113    /// - the test override forces `Missing` (unit tests),
114    /// - `VTCODE_AST_GREP_NO_INSTALL` is set (user opt-out).
115    ///
116    /// On install failure, returns `"ast-grep is not available; auto-install
117    /// failed: {error}"` so the structural grep-fallback substring check
118    /// (`msg.contains("ast-grep") && msg.contains("not available")`) still
119    /// triggers for query/count workflows.
120    pub async fn resolve_or_install() -> std::result::Result<PathBuf, String> {
121        // Use an already-resolved binary regardless of the opt-out env var:
122        // `VTCODE_AST_GREP_NO_INSTALL` only disables auto-install, not usage of
123        // an already-present binary.
124        if let Ok(path) = resolve_ast_grep_binary_path() {
125            return Ok(path);
126        }
127
128        // No binary available. If the user opted out of auto-install, surface
129        // the missing-binary error immediately without attempting a download.
130        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        // Skip auto-install in test mode when either override registry forces
135        // the binary to be missing. Both are checked because
136        // `resolve_ast_grep_binary_path` consults the patch (semantic) registry
137        // first and the `ast_grep_binary` registry second.
138        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    #[test]
173    fn platform_mismatch_failures_do_not_enter_cooldown() {
174        assert!(should_retry_without_cooldown(
175            "No ast-grep release asset matched the current platform (aarch64-apple-darwin)"
176        ));
177        assert!(should_retry_without_cooldown("Unsupported platform for VT Code-managed ast-grep install"));
178    }
179
180    #[test]
181    fn unrelated_failures_still_use_cooldown() {
182        assert!(!should_retry_without_cooldown("Failed to fetch ast-grep release metadata"));
183    }
184
185    #[tokio::test]
186    #[serial]
187    async fn resolve_or_install_skips_install_when_test_override_missing() {
188        let _guard = set_ast_grep_binary_override_for_tests(None);
189        let err = AstGrepStatus::resolve_or_install()
190            .await
191            .expect_err("should not install in test mode");
192        let text = err.to_string();
193        assert!(text.contains("ast-grep"), "{text}");
194        assert!(text.contains("not available"), "{text}");
195        assert!(text.contains(AST_GREP_INSTALL_COMMAND), "{text}");
196    }
197
198    #[tokio::test]
199    #[serial]
200    async fn resolve_or_install_mentions_env_optout_when_set() {
201        // Force the binary to be missing so `resolve_ast_grep_binary_path`
202        // fails and execution reaches the env-var check. The test override is
203        // checked AFTER the env var, so the env-var error wins. This keeps the
204        // test portable across machines that happen to have ast-grep installed.
205        let _override = set_ast_grep_binary_override_for_tests(None);
206        let env = env_lock::lock();
207        env.set_var("VTCODE_AST_GREP_NO_INSTALL", "1");
208        let err = AstGrepStatus::resolve_or_install()
209            .await
210            .expect_err("should not install when env opt-out is set");
211        env.remove_var("VTCODE_AST_GREP_NO_INSTALL");
212        assert!(err.contains("VTCODE_AST_GREP_NO_INSTALL"), "error should mention the env var: {err}");
213    }
214}