debtmap/builders/
validated_analysis.rs

1//! Multi-file analysis with error accumulation.
2//!
3//! This module provides validation-aware analysis that accumulates ALL errors
4//! instead of failing at the first one. This enables users to see all file
5//! issues in a single run.
6//!
7//! # Design Philosophy
8//!
9//! - **Error Accumulation**: Collect ALL file read/parse errors before failing
10//! - **Pure Functions**: File analysis is performed using pure transformations
11//! - **Context Preservation**: Each error includes the file path and details
12//! - **Backwards Compatible**: Wrappers convert to `anyhow::Result` for existing code
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use debtmap::builders::validated_analysis::{
18//!     analyze_files_validated, analyze_files_result
19//! };
20//!
21//! // Get validation with ALL errors accumulated
22//! let validation = analyze_files_validated(&files, &config);
23//!
24//! // Or use backwards-compatible Result API
25//! let result = analyze_files_result(&files, &config);
26//! ```
27
28use std::path::{Path, PathBuf};
29
30use crate::core::Language;
31use crate::effects::{
32    combine_validations, run_validation, validation_failure, validation_success, AnalysisValidation,
33};
34use crate::errors::AnalysisError;
35
36/// Result of reading a file with its content and path.
37#[derive(Debug, Clone)]
38pub struct FileContent {
39    /// The file path
40    pub path: PathBuf,
41    /// The file content
42    pub content: String,
43    /// The detected language
44    pub language: Language,
45}
46
47/// Validate that files can be read, accumulating ALL read errors.
48///
49/// Returns a validation containing either all successfully read files
50/// or ALL errors encountered during reading.
51///
52/// # Example
53///
54/// ```rust,ignore
55/// let files = vec![
56///     PathBuf::from("src/good.rs"),
57///     PathBuf::from("/nonexistent/path"),
58///     PathBuf::from("src/also_good.rs"),
59/// ];
60///
61/// let result = validate_files_readable(&files);
62/// // If /nonexistent/path doesn't exist, failure contains that error
63/// // but also includes any other missing files
64/// ```
65pub fn validate_files_readable(files: &[PathBuf]) -> AnalysisValidation<Vec<FileContent>> {
66    let validations: Vec<AnalysisValidation<FileContent>> = files
67        .iter()
68        .map(|path| validate_single_file_readable(path))
69        .collect();
70
71    combine_validations(validations)
72}
73
74/// Validate that a single file can be read.
75fn validate_single_file_readable(path: &Path) -> AnalysisValidation<FileContent> {
76    // Check if file exists
77    if !path.exists() {
78        return validation_failure(AnalysisError::io_with_path(
79            format!("File not found: {}", path.display()),
80            path,
81        ));
82    }
83
84    // Check if it's a file (not directory)
85    if !path.is_file() {
86        return validation_failure(AnalysisError::io_with_path(
87            format!("Path is not a file: {}", path.display()),
88            path,
89        ));
90    }
91
92    // Try to read the file
93    match std::fs::read_to_string(path) {
94        Ok(content) => {
95            let language = Language::from_path(path);
96            validation_success(FileContent {
97                path: path.to_path_buf(),
98                content,
99                language,
100            })
101        }
102        Err(e) => validation_failure(AnalysisError::io_with_path(
103            format!("Cannot read file: {}", e),
104            path,
105        )),
106    }
107}
108
109/// Validate files readable with backwards-compatible Result API.
110pub fn validate_files_readable_result(files: &[PathBuf]) -> anyhow::Result<Vec<FileContent>> {
111    run_validation(validate_files_readable(files))
112}
113
114/// Summary of file read operations for user feedback.
115#[derive(Debug, Clone)]
116pub struct FileReadSummary {
117    /// Number of files successfully read
118    pub successful: usize,
119    /// Number of files that failed to read
120    pub failed: usize,
121    /// Total files attempted
122    pub total: usize,
123    /// Error messages for failed files
124    pub errors: Vec<AnalysisError>,
125    /// Successfully read files
126    pub files: Vec<FileContent>,
127}
128
129impl FileReadSummary {
130    /// Check if all files were read successfully.
131    pub fn all_successful(&self) -> bool {
132        self.failed == 0
133    }
134
135    /// Format a summary message for display.
136    pub fn format_summary(&self) -> String {
137        if self.all_successful() {
138            format!("Successfully read {} files", self.successful)
139        } else {
140            format!(
141                "Read {} of {} files ({} failed)",
142                self.successful, self.total, self.failed
143            )
144        }
145    }
146}
147
148/// Read files with partial success support.
149///
150/// Unlike `validate_files_readable`, this function returns a summary
151/// that includes both successes and failures, allowing analysis to
152/// continue with successfully read files while reporting failures.
153///
154/// # Use Cases
155///
156/// - When you want to analyze as many files as possible
157/// - When some files may be temporarily locked or inaccessible
158/// - When you want to show progress even with some failures
159pub fn read_files_with_summary(files: &[PathBuf]) -> FileReadSummary {
160    let mut successful_files = Vec::new();
161    let mut errors = Vec::new();
162
163    for path in files {
164        match validate_single_file_readable(path) {
165            stillwater::Validation::Success(file_content) => {
166                successful_files.push(file_content);
167            }
168            stillwater::Validation::Failure(errs) => {
169                for err in errs {
170                    errors.push(err);
171                }
172            }
173        }
174    }
175
176    let successful = successful_files.len();
177    let failed = errors.len();
178    let total = files.len();
179
180    FileReadSummary {
181        successful,
182        failed,
183        total,
184        errors,
185        files: successful_files,
186    }
187}
188
189/// Validate source content can be parsed, accumulating ALL parse errors.
190///
191/// This function attempts to parse each file content and accumulates
192/// all parse errors instead of failing at the first one.
193///
194/// # Note
195///
196/// This is a validation-level function. Actual parsing uses the
197/// language-specific analyzers from the `analyzers` module.
198pub fn validate_sources_parseable(files: &[FileContent]) -> AnalysisValidation<Vec<FileContent>> {
199    let validations: Vec<AnalysisValidation<FileContent>> =
200        files.iter().map(validate_single_source_parseable).collect();
201
202    combine_validations(validations)
203}
204
205/// Validate a single source file can be parsed.
206fn validate_single_source_parseable(file: &FileContent) -> AnalysisValidation<FileContent> {
207    match file.language {
208        Language::Rust => validate_rust_parseable(file),
209        Language::Python => validate_python_parseable(file),
210        Language::Unknown => {
211            // Unknown languages pass through - we can't validate them
212            validation_success(file.clone())
213        }
214    }
215}
216
217/// Validate Rust source is parseable.
218fn validate_rust_parseable(file: &FileContent) -> AnalysisValidation<FileContent> {
219    // Use syn to check if the file parses
220    match syn::parse_file(&file.content) {
221        Ok(_) => validation_success(file.clone()),
222        Err(e) => {
223            let line = e.span().start().line;
224            validation_failure(AnalysisError::parse_with_context(
225                format!("Rust parse error: {}", e),
226                &file.path,
227                line,
228            ))
229        }
230    }
231}
232
233/// Validate Python source is parseable (basic validation).
234fn validate_python_parseable(file: &FileContent) -> AnalysisValidation<FileContent> {
235    // Basic Python syntax validation:
236    // Check for common syntax issues without a full parser
237
238    for (line_num, line) in file.content.lines().enumerate() {
239        // Skip empty lines and comments
240        let trimmed = line.trim();
241        if trimmed.is_empty() || trimmed.starts_with('#') {
242            continue;
243        }
244
245        // Check for tabs mixed with spaces (common Python error)
246        if line.starts_with(' ') && line.contains('\t') {
247            return validation_failure(AnalysisError::parse_with_context(
248                "Mixed tabs and spaces in indentation".to_string(),
249                &file.path,
250                line_num + 1,
251            ));
252        }
253    }
254
255    // For now, Python files pass through without deep validation
256    // A full Python parser would be needed for proper validation
257    validation_success(file.clone())
258}
259
260/// Validate sources parseable with backwards-compatible Result API.
261pub fn validate_sources_parseable_result(
262    files: &[FileContent],
263) -> anyhow::Result<Vec<FileContent>> {
264    run_validation(validate_sources_parseable(files))
265}
266
267/// Full file validation pipeline: read + parse, accumulating ALL errors.
268///
269/// This combines `validate_files_readable` and `validate_sources_parseable`
270/// to validate that files both exist and can be parsed.
271pub fn validate_files_full(files: &[PathBuf]) -> AnalysisValidation<Vec<FileContent>> {
272    // First validate all files are readable
273    let readable = validate_files_readable(files);
274
275    // Then validate all readable files are parseable
276    match readable {
277        stillwater::Validation::Success(contents) => validate_sources_parseable(&contents),
278        stillwater::Validation::Failure(errors) => stillwater::Validation::Failure(errors),
279    }
280}
281
282/// Full file validation with backwards-compatible Result API.
283pub fn validate_files_full_result(files: &[PathBuf]) -> anyhow::Result<Vec<FileContent>> {
284    run_validation(validate_files_full(files))
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::fs;
291    use stillwater::Validation;
292    use tempfile::TempDir;
293
294    #[test]
295    fn test_validate_files_readable_all_exist() {
296        let temp_dir = TempDir::new().unwrap();
297        let file1 = temp_dir.path().join("file1.rs");
298        let file2 = temp_dir.path().join("file2.rs");
299
300        fs::write(&file1, "fn main() {}").unwrap();
301        fs::write(&file2, "fn test() {}").unwrap();
302
303        let files = vec![file1, file2];
304        let result = validate_files_readable(&files);
305
306        assert!(result.is_success());
307        if let Validation::Success(contents) = result {
308            assert_eq!(contents.len(), 2);
309        }
310    }
311
312    #[test]
313    fn test_validate_files_readable_accumulates_errors() {
314        let files = vec![
315            PathBuf::from("/nonexistent/path1.rs"),
316            PathBuf::from("/nonexistent/path2.rs"),
317            PathBuf::from("/nonexistent/path3.rs"),
318        ];
319
320        let result = validate_files_readable(&files);
321
322        match result {
323            Validation::Failure(errors) => {
324                assert_eq!(errors.len(), 3, "Expected 3 file read errors");
325            }
326            Validation::Success(_) => panic!("Expected failure for nonexistent files"),
327        }
328    }
329
330    #[test]
331    fn test_read_files_with_summary_partial_success() {
332        let temp_dir = TempDir::new().unwrap();
333        let good_file = temp_dir.path().join("good.rs");
334        fs::write(&good_file, "fn main() {}").unwrap();
335
336        let files = vec![good_file, PathBuf::from("/nonexistent/path.rs")];
337
338        let summary = read_files_with_summary(&files);
339
340        assert_eq!(summary.successful, 1);
341        assert_eq!(summary.failed, 1);
342        assert_eq!(summary.total, 2);
343        assert!(!summary.all_successful());
344        assert_eq!(summary.files.len(), 1);
345        assert_eq!(summary.errors.len(), 1);
346    }
347
348    #[test]
349    fn test_read_files_with_summary_format() {
350        let summary = FileReadSummary {
351            successful: 5,
352            failed: 2,
353            total: 7,
354            errors: vec![],
355            files: vec![],
356        };
357
358        let message = summary.format_summary();
359        assert!(message.contains("5"));
360        assert!(message.contains("7"));
361        assert!(message.contains("2"));
362    }
363
364    #[test]
365    fn test_validate_rust_parseable_success() {
366        let file = FileContent {
367            path: PathBuf::from("test.rs"),
368            content: "fn main() { println!(\"Hello\"); }".to_string(),
369            language: Language::Rust,
370        };
371
372        let result = validate_rust_parseable(&file);
373        assert!(result.is_success());
374    }
375
376    #[test]
377    fn test_validate_rust_parseable_failure() {
378        let file = FileContent {
379            path: PathBuf::from("test.rs"),
380            content: "fn main() { incomplete".to_string(),
381            language: Language::Rust,
382        };
383
384        let result = validate_rust_parseable(&file);
385        assert!(result.is_failure());
386    }
387
388    #[test]
389    fn test_validate_sources_parseable_accumulates_errors() {
390        let files = vec![
391            FileContent {
392                path: PathBuf::from("good.rs"),
393                content: "fn main() {}".to_string(),
394                language: Language::Rust,
395            },
396            FileContent {
397                path: PathBuf::from("bad1.rs"),
398                content: "fn main() {".to_string(), // Missing closing brace
399                language: Language::Rust,
400            },
401            FileContent {
402                path: PathBuf::from("bad2.rs"),
403                content: "fn incomplete(".to_string(), // Incomplete
404                language: Language::Rust,
405            },
406        ];
407
408        let result = validate_sources_parseable(&files);
409
410        match result {
411            Validation::Failure(errors) => {
412                assert_eq!(errors.len(), 2, "Expected 2 parse errors");
413            }
414            Validation::Success(_) => panic!("Expected failure for invalid Rust"),
415        }
416    }
417
418    #[test]
419    fn test_validate_files_full_integration() {
420        let temp_dir = TempDir::new().unwrap();
421        let good_file = temp_dir.path().join("good.rs");
422        fs::write(&good_file, "fn main() {}").unwrap();
423
424        let files = vec![good_file];
425        let result = validate_files_full(&files);
426
427        assert!(result.is_success());
428    }
429
430    #[test]
431    fn test_file_content_language_detection() {
432        let temp_dir = TempDir::new().unwrap();
433
434        // Create files with different extensions
435        let rust_file1 = temp_dir.path().join("test1.rs");
436        let rust_file2 = temp_dir.path().join("test2.rs");
437
438        fs::write(&rust_file1, "fn main() {}").unwrap();
439        fs::write(&rust_file2, "fn another() { let x = 5; }").unwrap();
440
441        let files = vec![rust_file1, rust_file2];
442        let result = validate_files_readable(&files);
443
444        if let Validation::Success(contents) = result {
445            assert_eq!(contents[0].language, Language::Rust);
446            assert_eq!(contents[1].language, Language::Rust);
447        } else {
448            panic!("Expected success");
449        }
450    }
451}