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(
47 &self,
48 output: &str,
49 ) -> Result<SkillFileVerification> {
50 let mut detected_files = HashSet::new();
51
52 for pattern in &self.file_patterns {
54 for capture in pattern.captures_iter(output) {
55 if let Some(filename) = capture
56 .get(1)
57 .map(|m| m.as_str())
58 .filter(|f| !Self::is_false_positive(f))
59 {
60 detected_files.insert(filename.to_string());
61 }
62 }
63 }
64
65 let mut verified_files = Vec::new();
67 let mut missing_files = Vec::new();
68
69 for filename in detected_files {
70 match self.file_tracker.verify_file_exists(&filename).await? {
71 Some(file_info) => {
72 verified_files.push(VerifiedFile {
73 filename: filename.clone(),
74 absolute_path: file_info.absolute_path,
75 size: file_info.size,
76 status: FileStatus::Found,
77 });
78 }
79 None => {
80 let alt_path = self.find_alternative_location(&filename).await?;
82 if let Some(alt_file) = alt_path {
83 verified_files.push(VerifiedFile {
84 filename: filename.clone(),
85 absolute_path: alt_file.absolute_path,
86 size: alt_file.size,
87 status: FileStatus::FoundAlternative,
88 });
89 } else {
90 missing_files.push(MissingFile {
91 filename: filename.clone(),
92 attempted_locations: vec![self.workspace_root.join(&filename)],
93 suggestions: self.generate_suggestions(&filename),
94 });
95 }
96 }
97 }
98 }
99
100 let summary = self.generate_verification_summary(&verified_files, &missing_files);
101 let suggestion = self.generate_user_suggestion(&verified_files, &missing_files);
102
103 Ok(SkillFileVerification {
104 verified_files,
105 missing_files,
106 summary,
107 suggestion,
108 })
109 }
110
111 pub async fn enhance_skill_output(&self, original_output: String) -> Result<String> {
113 let verification = self.scan_and_verify_skill_output(&original_output).await?;
114
115 if verification.verified_files.is_empty() && verification.missing_files.is_empty() {
116 return Ok(original_output);
118 }
119
120 let enhanced_output = format!("{original_output}\n\n{}", verification.summary);
121
122 Ok(enhanced_output)
123 }
124
125 async fn find_alternative_location(&self, filename: &str) -> Result<Option<TrackedFile>> {
127 let subdirs = vec!["output", "results", "generated", "dist", "build", "tmp"];
129
130 for subdir in subdirs {
131 let alt_path = self.workspace_root.join(subdir).join(filename);
132 if let Some(file_info) = self.verify_file_at_path(&alt_path).await? {
133 return Ok(Some(file_info));
134 }
135 }
136
137 let pattern = format!("**/{}", filename);
139 if let Some(path) = self
140 .file_tracker
141 .find_files_matching_pattern(&pattern)
142 .await
143 .ok()
144 .and_then(|mut files| files.pop())
145 && let Ok(Some(file_info)) = self.verify_file_at_path(&path).await
146 {
147 return Ok(Some(file_info));
148 }
149
150 Ok(None)
151 }
152
153 async fn verify_file_at_path(&self, path: &PathBuf) -> Result<Option<TrackedFile>> {
155 if let Some(metadata) = tokio::fs::metadata(path).await.ok().filter(|m| m.is_file()) {
156 return Ok(Some(TrackedFile {
157 absolute_path: path.clone(),
158 size: metadata.len(),
159 modified: metadata.modified().unwrap_or(std::time::SystemTime::now()),
160 }));
161 }
162 Ok(None)
163 }
164
165 fn generate_suggestions(&self, filename: &str) -> Vec<String> {
167 vec![
168 format!("Check if '{}' was created with a different name", filename),
169 "Verify the skill execution completed successfully".to_string(),
170 "Check subdirectories like 'output/', 'generated/', or 'dist/'".to_string(),
171 format!("Run 'find . -name \"{}\"' to search for the file", filename),
172 ]
173 }
174
175 fn is_false_positive(filename: &str) -> bool {
177 let false_positives = vec![
178 "example.pdf",
179 "template.xlsx",
180 "sample.csv", "Cargo.toml",
182 "package.json",
183 "go.mod", "README.md",
185 "LICENSE.txt",
186 ".gitignore", ];
188
189 false_positives.contains(&filename) || filename.starts_with('.')
190 }
191
192 fn generate_verification_summary(
194 &self,
195 verified: &[VerifiedFile],
196 missing: &[MissingFile],
197 ) -> String {
198 let mut summary = String::new();
199
200 if !verified.is_empty() {
201 summary.push_str("v Generated Files:\n");
202 for file in verified {
203 match file.status {
204 FileStatus::Found => {
205 summary.push_str(&format!(
206 " ✓ {} → {} ({} bytes)\n",
207 file.filename,
208 file.absolute_path.display(),
209 file.size
210 ));
211 }
212 FileStatus::FoundAlternative => {
213 summary.push_str(&format!(
214 " ✓ {} → {} ({} bytes) [found in alternative location]\n",
215 file.filename,
216 file.absolute_path.display(),
217 file.size
218 ));
219 }
220 }
221 }
222 }
223
224 if !missing.is_empty() {
225 if !summary.is_empty() {
226 summary.push('\n');
227 }
228 summary.push_str("[!] Missing Files:\n");
229 for file in missing {
230 summary.push_str(&format!(" ✗ {}\n", file.filename));
231 for suggestion in &file.suggestions {
232 summary.push_str(&format!(" • {}\n", suggestion));
233 }
234 }
235 }
236
237 summary
238 }
239
240 fn generate_user_suggestion(
242 &self,
243 verified: &[VerifiedFile],
244 missing: &[MissingFile],
245 ) -> String {
246 if missing.is_empty() && verified.len() == 1 {
247 format!("File generated at: {}", verified[0].absolute_path.display())
248 } else if missing.is_empty() && !verified.is_empty() {
249 format!("{} files generated successfully", verified.len())
250 } else if !missing.is_empty() && verified.is_empty() {
251 "Some files could not be found. Please check the output above.".to_string()
252 } else {
253 format!(
254 "Generated {} files, {} files missing. See summary above.",
255 verified.len(),
256 missing.len()
257 )
258 }
259 }
260}
261
262impl From<crate::tools::file_tracker::TrackedFile> for TrackedFile {
263 fn from(file: crate::tools::file_tracker::TrackedFile) -> Self {
264 Self {
265 absolute_path: file.absolute_path,
266 size: file.size,
267 modified: file.modified,
268 }
269 }
270}
271
272#[derive(Debug, Clone)]
274pub struct SkillFileVerification {
275 pub verified_files: Vec<VerifiedFile>,
276 pub missing_files: Vec<MissingFile>,
277 pub summary: String,
278 pub suggestion: String,
279}
280
281#[derive(Debug, Clone)]
283pub struct VerifiedFile {
284 pub filename: String,
285 pub absolute_path: PathBuf,
286 pub size: u64,
287 pub status: FileStatus,
288}
289
290#[derive(Debug, Clone, PartialEq)]
292pub enum FileStatus {
293 Found,
294 FoundAlternative,
295}
296
297#[derive(Debug, Clone)]
299pub struct MissingFile {
300 pub filename: String,
301 pub attempted_locations: Vec<PathBuf>,
302 pub suggestions: Vec<String>,
303}
304
305#[derive(Debug, Clone)]
307pub struct TrackedFile {
308 pub absolute_path: PathBuf,
309 pub size: u64,
310 pub modified: std::time::SystemTime,
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use tempfile::TempDir;
317
318 #[tokio::test]
319 async fn test_skill_file_scanning() {
320 let temp_dir = TempDir::new().unwrap();
321 let tracker = SkillFileTracker::new(temp_dir.path().to_path_buf());
322
323 let output = r#"
325Generated PDF report: quarterly_report.pdf
326Also created summary.csv with key metrics.
327Output saved to: chart.png
328"#;
329
330 let result = tracker.scan_and_verify_skill_output(output).await.unwrap();
331 assert_eq!(result.verified_files.len(), 0); assert_eq!(result.missing_files.len(), 3); let missing_names: Vec<String> = result
335 .missing_files
336 .iter()
337 .map(|m| m.filename.clone())
338 .collect();
339
340 assert!(missing_names.contains(&"quarterly_report.pdf".to_string()));
341 assert!(missing_names.contains(&"summary.csv".to_string()));
342 assert!(missing_names.contains(&"chart.png".to_string()));
343 }
344
345 #[tokio::test]
346 async fn test_enhance_skill_output() {
347 let temp_dir = TempDir::new().unwrap();
348 let tracker = SkillFileTracker::new(temp_dir.path().to_path_buf());
349
350 let original = "Generated: report.pdf".to_string();
351 let enhanced = tracker
352 .enhance_skill_output(original.clone())
353 .await
354 .unwrap();
355
356 assert!(enhanced.contains("Generated: report.pdf"));
357 assert!(enhanced.contains("Generated Files") || enhanced.contains("Missing Files"));
358 }
359
360 #[test]
361 fn test_false_positive_detection() {
362 assert!(SkillFileTracker::is_false_positive("Cargo.toml"));
363 assert!(SkillFileTracker::is_false_positive("README.md"));
364 assert!(!SkillFileTracker::is_false_positive("report.pdf"));
365 assert!(!SkillFileTracker::is_false_positive("my_chart.png"));
366 }
367}