vtcode 0.99.1

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use anyhow::Result;
use vtcode_core::tools::ast_grep_binary::AST_GREP_INSTALL_COMMAND;
use vtcode_core::tools::ripgrep_binary::RIPGREP_INSTALL_COMMAND;
use vtcode_core::tools::{AstGrepStatus, RipgrepStatus};
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use vtcode_core::utils::dot_config::DotConfig;

const SEARCH_TOOLS_INSTALLER_FLAG: &str = "--with-search-tools";
const SEARCH_TOOLS_INSTALL_COMMAND: &str = "vtcode dependencies install search-tools";
const REASON_PREVIEW_LIMIT: usize = 120;

#[derive(Debug, Clone, PartialEq, Eq)]
enum DependencyIssue {
    Missing,
    Error(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OptionalSearchToolsNotice {
    ripgrep: Option<DependencyIssue>,
    ast_grep: Option<DependencyIssue>,
}

impl OptionalSearchToolsNotice {
    pub(crate) fn from_snapshot(
        config: &DotConfig,
        ripgrep_status: RipgrepStatus,
        ast_grep_status: AstGrepStatus,
    ) -> Option<Self> {
        let ripgrep = match ripgrep_status {
            RipgrepStatus::Available { .. } => None,
            RipgrepStatus::NotFound if config.dependency_notices.ripgrep_missing_notice_shown => {
                None
            }
            RipgrepStatus::NotFound => Some(DependencyIssue::Missing),
            RipgrepStatus::Error { reason } => Some(DependencyIssue::Error(reason)),
        };
        let ast_grep = match ast_grep_status {
            AstGrepStatus::Available { .. } => None,
            AstGrepStatus::NotFound if config.dependency_notices.ast_grep_missing_notice_shown => {
                None
            }
            AstGrepStatus::NotFound => Some(DependencyIssue::Missing),
            AstGrepStatus::Error { reason } => Some(DependencyIssue::Error(reason)),
        };

        if ripgrep.is_none() && ast_grep.is_none() {
            None
        } else {
            Some(Self { ripgrep, ast_grep })
        }
    }

    pub(crate) fn apply_to_config(&self, config: &mut DotConfig) {
        if matches!(self.ripgrep, Some(DependencyIssue::Missing)) {
            config.dependency_notices.ripgrep_missing_notice_shown = true;
        }
        if matches!(self.ast_grep, Some(DependencyIssue::Missing)) {
            config.dependency_notices.ast_grep_missing_notice_shown = true;
        }
    }

    pub(crate) fn render(&self, renderer: &mut AnsiRenderer) -> Result<()> {
        let headline = if self.ripgrep.is_some() && self.ast_grep.is_some() {
            "Search tools bundle is unavailable. VT Code will fall back where possible."
        } else {
            "Search tools bundle is partially unavailable. VT Code will fall back where possible."
        };
        renderer.line(MessageStyle::Status, headline)?;
        for line in self.lines() {
            renderer.line(MessageStyle::Info, &line)?;
        }
        renderer.line(MessageStyle::Info, "")?;
        Ok(())
    }

    fn lines(&self) -> Vec<String> {
        let mut lines = Vec::with_capacity(4);

        if let Some(issue) = &self.ripgrep {
            lines.push(match issue {
                DependencyIssue::Missing => format!(
                    "ripgrep (`rg`) missing. VT Code falls back to built-in text search -> `{RIPGREP_INSTALL_COMMAND}`"
                ),
                DependencyIssue::Error(reason) => format!(
                    "ripgrep (`rg`) failed to verify: {}. Text search may fall back -> `{RIPGREP_INSTALL_COMMAND}`",
                    compact_reason(reason)
                ),
            });
        }

        if let Some(issue) = &self.ast_grep {
            lines.push(match issue {
                DependencyIssue::Missing => format!(
                    "ast-grep missing. Structural search is unavailable -> `{AST_GREP_INSTALL_COMMAND}`"
                ),
                DependencyIssue::Error(reason) => format!(
                    "ast-grep failed to verify: {}. Structural search may be unavailable -> `{AST_GREP_INSTALL_COMMAND}`",
                    compact_reason(reason)
                ),
            });
        }

        lines.push(format!(
            "Install both with `{SEARCH_TOOLS_INSTALL_COMMAND}`."
        ));
        lines.push(format!(
            "Native installer bundle: use `{SEARCH_TOOLS_INSTALLER_FLAG}` during curl installs."
        ));

        lines
    }
}

fn compact_reason(reason: &str) -> String {
    let compact = reason.split_whitespace().collect::<Vec<_>>().join(" ");
    let mut chars = compact.chars();
    let preview: String = chars.by_ref().take(REASON_PREVIEW_LIMIT).collect();
    if chars.next().is_some() {
        format!("{preview}...")
    } else {
        compact
    }
}

#[cfg(test)]
mod tests {
    use super::{DependencyIssue, OptionalSearchToolsNotice, compact_reason};
    use vtcode_core::utils::dot_config::DotConfig;

    use vtcode_core::tools::{AstGrepStatus, RipgrepStatus};

    #[test]
    fn builds_notice_for_unseen_missing_dependencies() {
        let notice = OptionalSearchToolsNotice::from_snapshot(
            &DotConfig::default(),
            RipgrepStatus::NotFound,
            AstGrepStatus::NotFound,
        )
        .expect("missing dependencies should create a notice");

        assert_eq!(notice.ripgrep, Some(DependencyIssue::Missing));
        assert_eq!(notice.ast_grep, Some(DependencyIssue::Missing));
    }

    #[test]
    fn skips_dependencies_already_shown() {
        let mut config = DotConfig::default();
        config.dependency_notices.ripgrep_missing_notice_shown = true;

        let notice = OptionalSearchToolsNotice::from_snapshot(
            &config,
            RipgrepStatus::NotFound,
            AstGrepStatus::NotFound,
        )
        .expect("ast-grep notice should remain");

        assert_eq!(notice.ripgrep, None);
        assert_eq!(notice.ast_grep, Some(DependencyIssue::Missing));
    }

    #[test]
    fn applies_notice_to_config() {
        let notice = OptionalSearchToolsNotice {
            ripgrep: Some(DependencyIssue::Missing),
            ast_grep: Some(DependencyIssue::Error("broken".to_string())),
        };
        let mut config = DotConfig::default();

        notice.apply_to_config(&mut config);

        assert!(config.dependency_notices.ripgrep_missing_notice_shown);
        assert!(!config.dependency_notices.ast_grep_missing_notice_shown);
    }

    #[test]
    fn lines_include_bundle_install_guidance() {
        let notice = OptionalSearchToolsNotice {
            ripgrep: Some(DependencyIssue::Missing),
            ast_grep: None,
        };
        let lines = notice.lines();

        assert!(lines[0].contains("ripgrep"));
        assert!(lines[1].contains("search-tools"));
        assert!(lines[2].contains("--with-search-tools"));
    }

    #[test]
    fn captures_error_reasons_without_whitespace_noise() {
        let notice = OptionalSearchToolsNotice {
            ripgrep: Some(DependencyIssue::Error("bad\n  install".to_string())),
            ast_grep: None,
        };

        assert!(notice.lines()[0].contains("bad install"));
    }

    #[test]
    fn compact_reason_truncates_long_reasons() {
        let reason = "x".repeat(160);

        let compact = compact_reason(&reason);

        assert!(compact.ends_with("..."));
        assert!(compact.len() < reason.len());
    }
}