vtcode_core/utils/
utils.rs

1//! Utility functions for the VTCode agent
2//!
3//! This module contains common utility functions that are used across different parts
4//! of the VTCode agent, helping to reduce code duplication and improve maintainability.
5
6use anyhow::Result;
7use console::style;
8use regex::Regex;
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13/// Render PTY output in a terminal-like interface
14pub fn render_pty_output_fn(output: &str, title: &str, command: Option<&str>) -> Result<()> {
15    // Print top border
16    println!("{}", style("=".repeat(80)).dim());
17
18    // Print title
19    println!(
20        "{} {}",
21        style("==").blue().bold(),
22        style(title).blue().bold()
23    );
24
25    // Print command if available
26    if let Some(cmd) = command {
27        println!("{}", style(format!("> {}", cmd)).dim());
28    }
29
30    // Print separator
31    println!("{}", style("-".repeat(80)).dim());
32
33    // Print the output
34    print!("{}", output);
35    std::io::stdout().flush()?;
36
37    // Print bottom border
38    println!("{}", style("-".repeat(80)).dim());
39    println!("{}", style("==").blue().bold());
40    println!("{}", style("=".repeat(80)).dim());
41
42    Ok(())
43}
44
45/// Lightweight project overview extracted from workspace files
46pub struct ProjectOverview {
47    pub name: Option<String>,
48    pub version: Option<String>,
49    pub description: Option<String>,
50    pub readme_excerpt: Option<String>,
51    pub root: PathBuf,
52}
53
54impl ProjectOverview {
55    pub fn short_for_display(&self) -> String {
56        let mut out = String::new();
57        if let Some(name) = &self.name {
58            out.push_str(&format!("Project: {}", name));
59        }
60        if let Some(ver) = &self.version {
61            if !out.is_empty() {
62                out.push(' ');
63            }
64            out.push_str(&format!("v{}", ver));
65        }
66        if !out.is_empty() {
67            out.push('\n');
68        }
69        if let Some(desc) = &self.description {
70            out.push_str(desc);
71            out.push('\n');
72        }
73        out.push_str(&format!("Root: {}", self.root.display()));
74        out
75    }
76
77    pub fn as_prompt_block(&self) -> String {
78        let mut s = String::new();
79        if let Some(name) = &self.name {
80            s.push_str(&format!("- Name: {}\n", name));
81        }
82        if let Some(ver) = &self.version {
83            s.push_str(&format!("- Version: {}\n", ver));
84        }
85        if let Some(desc) = &self.description {
86            s.push_str(&format!("- Description: {}\n", desc));
87        }
88        s.push_str(&format!("- Workspace Root: {}\n", self.root.display()));
89        if let Some(excerpt) = &self.readme_excerpt {
90            s.push_str("- README Excerpt: \n");
91            s.push_str(excerpt);
92            if !excerpt.ends_with('\n') {
93                s.push('\n');
94            }
95        }
96        s
97    }
98}
99
100/// Build a minimal project overview from Cargo.toml and README.md
101pub fn build_project_overview(root: &Path) -> Option<ProjectOverview> {
102    let mut overview = ProjectOverview {
103        name: None,
104        version: None,
105        description: None,
106        readme_excerpt: None,
107        root: root.to_path_buf(),
108    };
109
110    // Parse Cargo.toml (best-effort, no extra deps)
111    let cargo_toml_path = root.join("Cargo.toml");
112    if let Ok(cargo_toml) = fs::read_to_string(&cargo_toml_path) {
113        overview.name = extract_toml_str(&cargo_toml, "name");
114        overview.version = extract_toml_str(&cargo_toml, "version");
115        overview.description = extract_toml_str(&cargo_toml, "description");
116    }
117
118    // Read README.md excerpt
119    let readme_path = root.join("README.md");
120    if let Ok(readme) = fs::read_to_string(&readme_path) {
121        overview.readme_excerpt = Some(extract_readme_excerpt(&readme, 1200));
122    } else {
123        // Fallback to QUICKSTART.md or user-context.md if present
124        for alt in [
125            "QUICKSTART.md",
126            "user-context.md",
127            "docs/project/ROADMAP.md",
128        ] {
129            let path = root.join(alt);
130            if let Ok(txt) = fs::read_to_string(&path) {
131                overview.readme_excerpt = Some(extract_readme_excerpt(&txt, 800));
132                break;
133            }
134        }
135    }
136
137    // If nothing found, return None
138    if overview.name.is_none() && overview.readme_excerpt.is_none() {
139        return None;
140    }
141    Some(overview)
142}
143
144/// Extract a string value from a simple TOML key assignment within [package]
145pub fn extract_toml_str(content: &str, key: &str) -> Option<String> {
146    // Only consider the [package] section to avoid matching other tables
147    let pkg_section = if let Some(start) = content.find("[package]") {
148        let rest = &content[start + "[package]".len()..];
149        // Stop at next section header or end
150        if let Some(_next) = rest.find('\n') {
151            &content[start..]
152        } else {
153            &content[start..]
154        }
155    } else {
156        content
157    };
158
159    // Example target: name = "vtcode"
160    let pattern = format!(r#"(?m)^\s*{}\s*=\s*"([^"]+)"\s*$"#, regex::escape(key));
161    let re = Regex::new(&pattern).ok()?;
162    re.captures(pkg_section)
163        .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
164}
165
166/// Get the first meaningful section of the README/markdown as an excerpt
167pub fn extract_readme_excerpt(md: &str, max_len: usize) -> String {
168    // Take from start until we pass the first major sections or hit max_len
169    let mut excerpt = String::new();
170    for line in md.lines() {
171        // Stop if we reach a deep section far into the doc
172        if excerpt.len() > max_len {
173            break;
174        }
175        excerpt.push_str(line);
176        excerpt.push('\n');
177        // Prefer stopping after an initial overview section
178        if line.trim().starts_with("## ") && excerpt.len() > (max_len / 2) {
179            break;
180        }
181    }
182    if excerpt.len() > max_len {
183        excerpt.truncate(max_len);
184        excerpt.push_str("...\n");
185    }
186    excerpt
187}
188
189/// Summarize workspace languages
190pub fn summarize_workspace_languages(root: &std::path::Path) -> Option<String> {
191    use indexmap::IndexMap;
192    let analyzer = match crate::tools::tree_sitter::analyzer::TreeSitterAnalyzer::new() {
193        Ok(a) => a,
194        Err(_) => return None,
195    };
196    let mut counts: IndexMap<String, usize> = IndexMap::new();
197    let mut total = 0usize;
198    for entry in walkdir::WalkDir::new(root)
199        .max_depth(4)
200        .into_iter()
201        .filter_map(|e| e.ok())
202    {
203        let path = entry.path();
204        if path.is_file()
205            && let Ok(lang) = analyzer.detect_language_from_path(path)
206        {
207            *counts.entry(format!("{:?}", lang)).or_insert(0) += 1;
208            total += 1;
209        }
210        if total > 5000 {
211            break;
212        }
213    }
214    if counts.is_empty() {
215        None
216    } else {
217        let mut parts: Vec<String> = counts
218            .into_iter()
219            .map(|(k, v)| format!("{}:{}", k, v))
220            .collect();
221        parts.sort();
222        Some(parts.join(", "))
223    }
224}
225
226/// Safe text replacement with validation
227pub fn safe_replace_text(
228    content: &str,
229    old_str: &str,
230    new_str: &str,
231) -> Result<String, anyhow::Error> {
232    if old_str.is_empty() {
233        return Err(anyhow::anyhow!("old_string cannot be empty"));
234    }
235
236    if !content.contains(old_str) {
237        return Err(anyhow::anyhow!("Text '{}' not found in file", old_str));
238    }
239
240    Ok(content.replace(old_str, new_str))
241}