Skip to main content

vtcode_core/tools/
file_tracker.rs

1use anyhow::{Context, Result};
2use serde::Serialize;
3use serde_json::Value;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7/// Tracks files generated by code execution
8#[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    /// Add a custom file pattern to track
32    pub fn add_pattern(&mut self, pattern: String) {
33        self.tracked_patterns.push(pattern);
34    }
35
36    /// Scan for newly created or modified files matching tracked patterns
37    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    /// Check if a specific file exists and return its info
88    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),  // Not a file
102            Err(_) => Ok(None), // Doesn't exist
103        }
104    }
105
106    /// Get the absolute path of a file
107    pub fn get_absolute_path(&self, filename: &str) -> PathBuf {
108        self.workspace_root.join(filename)
109    }
110
111    /// Find all files matching a glob pattern
112    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    /// Generate a verification code snippet
144    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    /// Generate a summary of tracked files
162    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/// Represents a tracked file with metadata
176#[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}