vtcode_core/tools/
file_tracker.rs1use anyhow::{Context, Result};
2use serde::Serialize;
3use serde_json::Value;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7#[derive(Debug, Clone)]
9pub struct FileTracker {
10 workspace_root: PathBuf,
11 tracked_patterns: Vec<String>,
12}
13
14impl FileTracker {
15 pub fn new(workspace_root: PathBuf) -> Self {
16 Self {
17 workspace_root,
18 tracked_patterns: vec![
19 "*.pdf".to_string(),
20 "*.xlsx".to_string(),
21 "*.csv".to_string(),
22 "*.docx".to_string(),
23 "*.png".to_string(),
24 "*.jpg".to_string(),
25 "*.json".to_string(),
26 "*.xml".to_string(),
27 ],
28 }
29 }
30
31 pub fn add_pattern(&mut self, pattern: String) {
33 self.tracked_patterns.push(pattern);
34 }
35
36 pub async fn detect_new_files(&self, since: SystemTime) -> Result<Vec<TrackedFile>> {
38 use tokio::task;
39 let workspace = self.workspace_root.clone();
40 let patterns = self.tracked_patterns.clone();
41
42 let entries = task::spawn_blocking(move || {
43 let mut files: Vec<PathBuf> = Vec::new();
44 let glob_patterns: Vec<_> = patterns
45 .iter()
46 .map(|p| glob::Pattern::new(p))
47 .collect::<Result<Vec<_>, _>>()
48 .unwrap_or_default();
49
50 let walker = vtcode_commons::walk::build_walker_single_threaded(&workspace).build();
51 for entry in walker.flatten() {
52 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
53 continue;
54 }
55 let path = entry.path();
56 let relative = path.strip_prefix(&workspace).unwrap_or(path);
57 let relative_str = relative.to_string_lossy();
58 for pattern in &glob_patterns {
59 if pattern.matches(&relative_str) {
60 files.push(path.to_path_buf());
61 break;
62 }
63 }
64 }
65 files
66 })
67 .await
68 .context("Failed to spawn file search task")?;
69
70 let mut new_files = Vec::new();
71 for file_path in entries {
72 if let Ok(metadata) = tokio::fs::metadata(&file_path).await
73 && let Ok(modified) = metadata.modified()
74 && modified >= since
75 {
76 new_files.push(TrackedFile {
77 absolute_path: file_path,
78 size: metadata.len(),
79 modified,
80 });
81 }
82 }
83
84 Ok(new_files)
85 }
86
87 pub async fn verify_file_exists(&self, filename: &str) -> Result<Option<TrackedFile>> {
89 let file_path = self.workspace_root.join(filename);
90
91 match tokio::fs::metadata(&file_path).await {
92 Ok(metadata) if metadata.is_file() => {
93 let modified = metadata.modified().unwrap_or_else(|_| SystemTime::now());
94
95 Ok(Some(TrackedFile {
96 absolute_path: file_path,
97 size: metadata.len(),
98 modified,
99 }))
100 }
101 Ok(_) => Ok(None), Err(_) => Ok(None), }
104 }
105
106 pub fn get_absolute_path(&self, filename: &str) -> PathBuf {
108 self.workspace_root.join(filename)
109 }
110
111 pub async fn find_files_matching_pattern(&self, pattern: &str) -> Result<Vec<PathBuf>> {
113 use tokio::task;
114 let workspace = self.workspace_root.clone();
115 let pattern = pattern.to_string();
116
117 task::spawn_blocking(move || {
118 let mut files = Vec::new();
119 let glob_pattern = match glob::Pattern::new(&pattern) {
120 Ok(p) => p,
121 Err(_) => return Ok(files),
122 };
123
124 let walker = vtcode_commons::walk::build_walker_single_threaded(&workspace).build();
125 for entry in walker.flatten() {
126 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
127 continue;
128 }
129 let path = entry.path();
130 let relative = path.strip_prefix(&workspace).unwrap_or(path);
131 let relative_str = relative.to_string_lossy();
132 if glob_pattern.matches(&relative_str) {
133 files.push(path.to_path_buf());
134 }
135 }
136
137 Ok(files)
138 })
139 .await
140 .context("Failed to spawn file search task")?
141 }
142
143 pub fn generate_verification_code(&self, filename: &str) -> String {
145 format!(
146 r#"
147import os
148import sys
149
150file_path = os.path.abspath('{filename}')
151exists = os.path.exists(file_path)
152is_file = os.path.isfile(file_path) if exists else False
153size = os.path.getsize(file_path) if exists else 0
154
155print(f"FILE_VERIFICATION: {{file_path}} {{exists}} {{is_file}} {{size}}")
156sys.exit(0 if exists and is_file else 1)
157"#
158 )
159 }
160
161 pub fn generate_file_summary(&self, files: &[TrackedFile]) -> String {
163 if files.is_empty() {
164 return "No generated files detected.".to_string();
165 }
166
167 let mut summary = String::from("Generated files:\n");
168 for file in files {
169 summary.push_str(&format!(" - {} ({} bytes)\n", file.absolute_path.display(), file.size));
170 }
171 summary
172 }
173}
174
175#[derive(Debug, Clone, Serialize)]
177pub struct TrackedFile {
178 pub absolute_path: PathBuf,
179 pub size: u64,
180 #[serde(with = "system_time_serde")]
181 pub modified: SystemTime,
182}
183
184mod system_time_serde {
185 use serde::Serializer;
186 use std::time::{SystemTime, UNIX_EPOCH};
187
188 pub fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
189 where
190 S: Serializer,
191 {
192 let duration = time
193 .duration_since(UNIX_EPOCH)
194 .unwrap_or_else(|_| std::time::Duration::from_secs(0));
195 serializer.serialize_u64(duration.as_secs())
196 }
197}
198
199impl TrackedFile {
200 pub fn to_json(&self) -> Value {
201 serde_json::json!({
202 "absolute_path": self.absolute_path.display().to_string(),
203 "size": self.size,
204 "modified": format!("{:?}", self.modified),
205 })
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use tokio;
213
214 #[tokio::test]
215 async fn test_file_tracker_basic() {
216 let tracker = FileTracker::new(PathBuf::from("/test"));
217 assert!(!tracker.tracked_patterns.is_empty());
218 }
219
220 #[tokio::test]
221 async fn test_verification_code_generation() {
222 let tracker = FileTracker::new(PathBuf::from("/workspace"));
223 let code = tracker.generate_verification_code("test.pdf");
224 assert!(code.contains("test.pdf"));
225 assert!(code.contains("FILE_VERIFICATION"));
226 }
227
228 #[test]
229 fn test_file_summary_generation() {
230 let tracker = FileTracker::new(PathBuf::from("/workspace"));
231 let files = vec![TrackedFile {
232 absolute_path: PathBuf::from("/workspace/test.pdf"),
233 size: 1024,
234 modified: SystemTime::now(),
235 }];
236
237 let summary = tracker.generate_file_summary(&files);
238 assert!(summary.contains("test.pdf"));
239 assert!(summary.contains("1024 bytes"));
240 }
241}