1use crate::tools::file_tracker::FileTracker;
7use anyhow::Result;
8use hashbrown::HashSet;
9use regex::Regex;
10use std::path::PathBuf;
11
12pub struct SkillFileTracker {
14 workspace_root: PathBuf,
15 file_tracker: FileTracker,
16 file_patterns: Vec<Regex>,
17}
18
19impl SkillFileTracker {
20 pub fn new(workspace_root: PathBuf) -> Self {
21 let file_tracker = FileTracker::new(workspace_root.clone());
22
23 let patterns = [
25 "['\"]?([\\w.-]+\\.(?:pdf|xlsx|csv|docx|png|jpg|json|xml|txt|md))['\"]?",
27 "['\"]?([\\w/\\\\.-]+\\.(?:pdf|xlsx|csv|docx|png|jpg|json|xml|txt|md))['\"]?",
29 "(?:[Gg]enerated|[Cc]reated):\\s*([\\w.-]+\\.(?:pdf|xlsx|csv|docx|png|jpg|json|xml|txt|md))",
31 "[Oo]utput (?:saved|written) to:?(?:\\s*)([\\w.-]+\\.(?:pdf|xlsx|csv|docx|png|jpg|json|xml|txt|md))",
33 ]
34 .into_iter()
35 .filter_map(|pattern| Regex::new(pattern).ok())
36 .collect();
37
38 Self {
39 workspace_root,
40 file_tracker,
41 file_patterns: patterns,
42 }
43 }
44
45 pub async fn scan_and_verify_skill_output(&self, output: &str) -> Result<SkillFileVerification> {
47 let mut detected_files = HashSet::new();
48
49 for pattern in &self.file_patterns {
51 for capture in pattern.captures_iter(output) {
52 if let Some(filename) = capture.get(1).map(|m| m.as_str()).filter(|f| !Self::is_false_positive(f)) {
53 detected_files.insert(filename.to_string());
54 }
55 }
56 }
57
58 let mut verified_files = Vec::new();
60 let mut missing_files = Vec::new();
61
62 for filename in detected_files {
63 match self.file_tracker.verify_file_exists(&filename).await? {
64 Some(file_info) => {
65 verified_files.push(VerifiedFile {
66 filename: filename.clone(),
67 absolute_path: file_info.absolute_path,
68 size: file_info.size,
69 status: FileStatus::Found,
70 });
71 }
72 None => {
73 let alt_path = self.find_alternative_location(&filename).await?;
75 if let Some(alt_file) = alt_path {
76 verified_files.push(VerifiedFile {
77 filename: filename.clone(),
78 absolute_path: alt_file.absolute_path,
79 size: alt_file.size,
80 status: FileStatus::FoundAlternative,
81 });
82 } else {
83 missing_files.push(MissingFile {
84 filename: filename.clone(),
85 attempted_locations: vec![self.workspace_root.join(&filename)],
86 suggestions: self.generate_suggestions(&filename),
87 });
88 }
89 }
90 }
91 }
92
93 let summary = self.generate_verification_summary(&verified_files, &missing_files);
94 let suggestion = self.generate_user_suggestion(&verified_files, &missing_files);
95
96 Ok(SkillFileVerification { verified_files, missing_files, summary, suggestion })
97 }
98
99 pub async fn enhance_skill_output(&self, original_output: String) -> Result<String> {
101 let verification = self.scan_and_verify_skill_output(&original_output).await?;
102
103 if verification.verified_files.is_empty() && verification.missing_files.is_empty() {
104 return Ok(original_output);
106 }
107
108 let enhanced_output = format!("{original_output}\n\n{}", verification.summary);
109
110 Ok(enhanced_output)
111 }
112
113 async fn find_alternative_location(&self, filename: &str) -> Result<Option<TrackedFile>> {
115 let subdirs = vec!["output", "results", "generated", "dist", "build", "tmp"];
117
118 for subdir in subdirs {
119 let alt_path = self.workspace_root.join(subdir).join(filename);
120 if let Some(file_info) = self.verify_file_at_path(&alt_path).await? {
121 return Ok(Some(file_info));
122 }
123 }
124
125 let pattern = format!("**/{filename}");
127 if let Some(path) = self
128 .file_tracker
129 .find_files_matching_pattern(&pattern)
130 .await
131 .ok()
132 .and_then(|mut files| files.pop())
133 && let Ok(Some(file_info)) = self.verify_file_at_path(&path).await
134 {
135 return Ok(Some(file_info));
136 }
137
138 Ok(None)
139 }
140
141 async fn verify_file_at_path(&self, path: &PathBuf) -> Result<Option<TrackedFile>> {
143 if let Some(metadata) = tokio::fs::metadata(path).await.ok().filter(|m| m.is_file()) {
144 return Ok(Some(TrackedFile {
145 absolute_path: path.clone(),
146 size: metadata.len(),
147 modified: metadata.modified().unwrap_or(std::time::SystemTime::now()),
148 }));
149 }
150 Ok(None)
151 }
152
153 fn generate_suggestions(&self, filename: &str) -> Vec<String> {
155 vec![
156 format!("Check if '{}' was created with a different name", filename),
157 "Verify the skill execution completed successfully".to_string(),
158 "Check subdirectories like 'output/', 'generated/', or 'dist/'".to_string(),
159 format!("Run 'find . -name \"{}\"' to search for the file", filename),
160 ]
161 }
162
163 fn is_false_positive(filename: &str) -> bool {
165 let false_positives = [
166 "example.pdf",
167 "template.xlsx",
168 "sample.csv", "Cargo.toml",
170 "package.json",
171 "go.mod", "README.md",
173 "LICENSE.txt",
174 ".gitignore", ];
176
177 false_positives.contains(&filename) || filename.starts_with('.')
178 }
179
180 fn generate_verification_summary(&self, verified: &[VerifiedFile], missing: &[MissingFile]) -> String {
182 let mut summary = String::new();
183
184 if !verified.is_empty() {
185 summary.push_str("v Generated Files:\n");
186 for file in verified {
187 match file.status {
188 FileStatus::Found => {
189 summary.push_str(&format!(
190 " ✓ {} → {} ({} bytes)\n",
191 file.filename,
192 file.absolute_path.display(),
193 file.size
194 ));
195 }
196 FileStatus::FoundAlternative => {
197 summary.push_str(&format!(
198 " ✓ {} → {} ({} bytes) [found in alternative location]\n",
199 file.filename,
200 file.absolute_path.display(),
201 file.size
202 ));
203 }
204 }
205 }
206 }
207
208 if !missing.is_empty() {
209 if !summary.is_empty() {
210 summary.push('\n');
211 }
212 summary.push_str("[!] Missing Files:\n");
213 for file in missing {
214 summary.push_str(&format!(" ✗ {}\n", file.filename));
215 for suggestion in &file.suggestions {
216 summary.push_str(&format!(" • {suggestion}\n"));
217 }
218 }
219 }
220
221 summary
222 }
223
224 fn generate_user_suggestion(&self, verified: &[VerifiedFile], missing: &[MissingFile]) -> String {
226 if missing.is_empty() && verified.len() == 1 {
227 format!("File generated at: {}", verified[0].absolute_path.display())
228 } else if missing.is_empty() && !verified.is_empty() {
229 format!("{} files generated successfully", verified.len())
230 } else if !missing.is_empty() && verified.is_empty() {
231 "Some files could not be found. Please check the output above.".to_string()
232 } else {
233 format!("Generated {} files, {} files missing. See summary above.", verified.len(), missing.len())
234 }
235 }
236}
237
238impl From<crate::tools::file_tracker::TrackedFile> for TrackedFile {
239 fn from(file: crate::tools::file_tracker::TrackedFile) -> Self {
240 Self {
241 absolute_path: file.absolute_path,
242 size: file.size,
243 modified: file.modified,
244 }
245 }
246}
247
248#[derive(Debug, Clone)]
250pub struct SkillFileVerification {
251 pub verified_files: Vec<VerifiedFile>,
252 pub missing_files: Vec<MissingFile>,
253 pub summary: String,
254 pub suggestion: String,
255}
256
257#[derive(Debug, Clone)]
259pub struct VerifiedFile {
260 pub filename: String,
261 pub absolute_path: PathBuf,
262 pub size: u64,
263 pub status: FileStatus,
264}
265
266#[derive(Debug, Clone, PartialEq)]
268pub enum FileStatus {
269 Found,
270 FoundAlternative,
271}
272
273#[derive(Debug, Clone)]
275pub struct MissingFile {
276 pub filename: String,
277 pub attempted_locations: Vec<PathBuf>,
278 pub suggestions: Vec<String>,
279}
280
281#[derive(Debug, Clone)]
283pub struct TrackedFile {
284 pub absolute_path: PathBuf,
285 pub size: u64,
286 pub modified: std::time::SystemTime,
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use tempfile::TempDir;
293
294 #[tokio::test]
295 async fn test_skill_file_scanning() {
296 let temp_dir = TempDir::new().unwrap();
297 let tracker = SkillFileTracker::new(temp_dir.path().to_path_buf());
298
299 let output = r#"
301Generated PDF report: quarterly_report.pdf
302Also created summary.csv with key metrics.
303Output saved to: chart.png
304"#;
305
306 let result = tracker.scan_and_verify_skill_output(output).await.unwrap();
307 assert_eq!(result.verified_files.len(), 0); assert_eq!(result.missing_files.len(), 3); let missing_names: Vec<String> = result.missing_files.iter().map(|m| m.filename.clone()).collect();
311
312 assert!(missing_names.contains(&"quarterly_report.pdf".to_string()));
313 assert!(missing_names.contains(&"summary.csv".to_string()));
314 assert!(missing_names.contains(&"chart.png".to_string()));
315 }
316
317 #[tokio::test]
318 async fn test_enhance_skill_output() {
319 let temp_dir = TempDir::new().unwrap();
320 let tracker = SkillFileTracker::new(temp_dir.path().to_path_buf());
321
322 let original = "Generated: report.pdf".to_string();
323 let enhanced = tracker.enhance_skill_output(original.clone()).await.unwrap();
324
325 assert!(enhanced.contains("Generated: report.pdf"));
326 assert!(enhanced.contains("Generated Files") || enhanced.contains("Missing Files"));
327 }
328
329 #[test]
330 fn test_false_positive_detection() {
331 assert!(SkillFileTracker::is_false_positive("Cargo.toml"));
332 assert!(SkillFileTracker::is_false_positive("README.md"));
333 assert!(!SkillFileTracker::is_false_positive("report.pdf"));
334 assert!(!SkillFileTracker::is_false_positive("my_chart.png"));
335 }
336}