memscope_rs/cli/commands/html_from_json/
json_file_discovery.rs

1//! JSON file discovery module for MemScope HTML generation
2//!
3//! This module provides functionality to discover and validate JSON files
4//! in the MemoryAnalysis directory structure for HTML report generation.
5
6use std::error::Error;
7use std::fmt;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11/// Configuration for a specific type of JSON file
12#[derive(Debug, Clone)]
13pub struct JsonFileConfig {
14    /// File suffix used to identify the file type (e.g., "memory_analysis")
15    pub suffix: &'static str,
16    /// Human-readable description of the file type
17    pub description: &'static str,
18    /// Whether this file type is required for HTML generation
19    pub required: bool,
20    /// Maximum allowed file size in megabytes
21    pub max_size_mb: Option<usize>,
22}
23
24impl JsonFileConfig {
25    /// Create a new JSON file configuration
26    pub fn new(suffix: &'static str, description: &'static str) -> Self {
27        Self {
28            suffix,
29            description,
30            required: false,
31            max_size_mb: Some(100), // Default 100MB limit
32        }
33    }
34
35    /// Mark this file type as required
36    pub fn required(mut self) -> Self {
37        self.required = true;
38        self
39    }
40
41    /// Set maximum file size limit in megabytes
42    pub fn max_size_mb(mut self, size: usize) -> Self {
43        self.max_size_mb = Some(size);
44        self
45    }
46}
47
48/// Information about a discovered JSON file
49#[derive(Debug, Clone)]
50pub struct JsonFileInfo {
51    /// Full path to the JSON file
52    pub path: PathBuf,
53    /// File type configuration
54    pub config: JsonFileConfig,
55    /// File size in bytes
56    pub size_bytes: u64,
57    /// Whether the file is readable
58    pub is_readable: bool,
59}
60
61/// Result of JSON file discovery process
62#[derive(Debug)]
63pub struct JsonDiscoveryResult {
64    /// Successfully discovered files
65    pub found_files: Vec<JsonFileInfo>,
66    /// Missing required files
67    pub missing_required: Vec<JsonFileConfig>,
68    /// Files that exceed size limits
69    pub oversized_files: Vec<JsonFileInfo>,
70    /// Files that are not readable
71    pub unreadable_files: Vec<JsonFileInfo>,
72    /// Total size of all discovered files in bytes
73    pub total_size_bytes: u64,
74}
75
76/// Errors that can occur during JSON file discovery
77#[derive(Debug)]
78pub enum JsonDiscoveryError {
79    /// Directory does not exist or is not accessible
80    DirectoryNotFound(String),
81    /// Required JSON files are missing
82    MissingRequiredFiles(Vec<String>),
83    /// Files exceed maximum size limits
84    FilesTooLarge(Vec<String>),
85    /// IO error during file discovery
86    IoError(std::io::Error),
87}
88
89impl fmt::Display for JsonDiscoveryError {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            JsonDiscoveryError::DirectoryNotFound(dir) => {
93                write!(f, "Directory not found or not accessible: {dir}")
94            }
95            JsonDiscoveryError::MissingRequiredFiles(files) => {
96                write!(f, "Missing required JSON files: {files:?}")
97            }
98            JsonDiscoveryError::FilesTooLarge(files) => {
99                write!(f, "Files exceed size limit: {files:?}")
100            }
101            JsonDiscoveryError::IoError(err) => {
102                write!(f, "IO error during file discovery: {err}")
103            }
104        }
105    }
106}
107
108impl Error for JsonDiscoveryError {}
109
110/// JSON file discovery service
111pub struct JsonFileDiscovery {
112    /// Base directory to search for JSON files
113    input_dir: String,
114    /// Base name pattern for JSON files
115    base_name: String,
116}
117
118impl JsonFileDiscovery {
119    /// Create a new JSON file discovery instance
120    pub fn new(input_dir: String, base_name: String) -> Self {
121        Self {
122            input_dir,
123            base_name,
124        }
125    }
126
127    /// Get the default file configurations for MemScope analysis
128    pub fn get_default_file_configs() -> Vec<JsonFileConfig> {
129        vec![
130            JsonFileConfig::new("memory_analysis", "Memory Analysis").required(),
131            JsonFileConfig::new("lifetime", "Lifecycle Analysis"),
132            JsonFileConfig::new("unsafe_ffi", "Unsafe/FFI Analysis"),
133            JsonFileConfig::new("performance", "Performance Metrics"),
134            JsonFileConfig::new("complex_types", "Complex Types Analysis"),
135            JsonFileConfig::new("security_violations", "Security Violations"),
136            JsonFileConfig::new("variable_relationships", "Variable Relationships"),
137        ]
138    }
139
140    /// Discover JSON files in the input directory
141    pub fn discover_files(&self) -> Result<JsonDiscoveryResult, JsonDiscoveryError> {
142        // Check if input directory exists
143        let input_path = Path::new(&self.input_dir);
144        if !input_path.exists() || !input_path.is_dir() {
145            return Err(JsonDiscoveryError::DirectoryNotFound(
146                self.input_dir.clone(),
147            ));
148        }
149
150        let file_configs = Self::get_default_file_configs();
151        let mut found_files = Vec::new();
152        let mut missing_required = Vec::new();
153        let mut oversized_files = Vec::new();
154        let mut unreadable_files = Vec::new();
155        let mut total_size_bytes = 0u64;
156
157        tracing::info!("🔍 Discovering JSON files in directory: {}", self.input_dir);
158        tracing::info!("🏷️  Using base name pattern: {}", self.base_name);
159
160        for config in file_configs {
161            match self.find_file_for_config(&config) {
162                Ok(Some(file_info)) => {
163                    // Check file size limits
164                    if let Some(max_size_mb) = config.max_size_mb {
165                        let max_bytes = (max_size_mb * 1024 * 1024) as u64;
166                        if file_info.size_bytes > max_bytes {
167                            tracing::info!(
168                                "⚠️  File {} ({:.1} MB) exceeds size limit ({} MB)",
169                                file_info.path.display(),
170                                file_info.size_bytes as f64 / 1024.0 / 1024.0,
171                                max_size_mb
172                            );
173                            oversized_files.push(file_info);
174                            continue;
175                        }
176                    }
177
178                    // Check readability
179                    if !file_info.is_readable {
180                        tracing::info!("⚠️  File {} is not readable", file_info.path.display());
181                        unreadable_files.push(file_info);
182                        continue;
183                    }
184
185                    total_size_bytes += file_info.size_bytes;
186                    tracing::info!(
187                        "✅ Found {}: {} ({:.1} KB)",
188                        config.description,
189                        file_info.path.display(),
190                        file_info.size_bytes as f64 / 1024.0
191                    );
192                    found_files.push(file_info);
193                }
194                Ok(None) => {
195                    if config.required {
196                        tracing::info!(
197                            "❌ Required file not found: {}_{}*.json",
198                            self.base_name,
199                            config.suffix
200                        );
201                        missing_required.push(config);
202                    } else {
203                        tracing::info!(
204                            "⚠️  Optional file not found: {}_{}*.json (skipping)",
205                            self.base_name,
206                            config.suffix
207                        );
208                    }
209                }
210                Err(e) => {
211                    tracing::info!("❌ Error searching for {}: {}", config.description, e);
212                    if config.required {
213                        missing_required.push(config);
214                    }
215                }
216            }
217        }
218
219        // Print discovery summary
220        tracing::info!("📊 Discovery Summary:");
221        tracing::info!("   Files found: {}", found_files.len());
222        tracing::info!(
223            "   Total size: {:.1} MB",
224            total_size_bytes as f64 / 1024.0 / 1024.0
225        );
226        tracing::info!("   Missing required: {}", missing_required.len());
227        tracing::info!("   Oversized files: {}", oversized_files.len());
228        tracing::info!("   Unreadable files: {}", unreadable_files.len());
229
230        // Check for critical errors
231        if !missing_required.is_empty() {
232            let missing_names: Vec<String> = missing_required
233                .iter()
234                .map(|config| format!("{}_{}", self.base_name, config.suffix))
235                .collect();
236            return Err(JsonDiscoveryError::MissingRequiredFiles(missing_names));
237        }
238
239        if !oversized_files.is_empty() {
240            let oversized_names: Vec<String> = oversized_files
241                .iter()
242                .map(|file| file.path.to_string_lossy().to_string())
243                .collect();
244            return Err(JsonDiscoveryError::FilesTooLarge(oversized_names));
245        }
246
247        Ok(JsonDiscoveryResult {
248            found_files,
249            missing_required,
250            oversized_files,
251            unreadable_files,
252            total_size_bytes,
253        })
254    }
255
256    /// Find a JSON file for a specific configuration
257    fn find_file_for_config(
258        &self,
259        config: &JsonFileConfig,
260    ) -> Result<Option<JsonFileInfo>, std::io::Error> {
261        // Try exact match first
262        let exact_path = format!(
263            "{}/{}_{}.json",
264            self.input_dir, self.base_name, config.suffix
265        );
266        if let Ok(file_info) = self.create_file_info(&exact_path, config) {
267            return Ok(Some(file_info));
268        }
269
270        // If exact match fails, search for files containing the suffix
271        let entries = fs::read_dir(&self.input_dir)?;
272        for entry in entries {
273            let entry = entry?;
274            let path = entry.path();
275
276            if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
277                if file_name.contains(config.suffix) && file_name.ends_with(".json") {
278                    if let Ok(file_info) = self.create_file_info(&path.to_string_lossy(), config) {
279                        return Ok(Some(file_info));
280                    }
281                }
282            }
283        }
284
285        Ok(None)
286    }
287
288    /// Create file info for a discovered file
289    fn create_file_info(
290        &self,
291        file_path: &str,
292        config: &JsonFileConfig,
293    ) -> Result<JsonFileInfo, std::io::Error> {
294        let path = PathBuf::from(file_path);
295        let metadata = fs::metadata(&path)?;
296        let size_bytes = metadata.len();
297
298        // Check if file is readable by attempting to open it
299        let is_readable = fs::File::open(&path).is_ok();
300
301        Ok(JsonFileInfo {
302            path,
303            config: config.clone(),
304            size_bytes,
305            is_readable,
306        })
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::fs;
314    use tempfile::TempDir;
315
316    #[test]
317    fn test_json_file_config_creation() {
318        let config = JsonFileConfig::new("test", "Test File");
319        assert_eq!(config.suffix, "test");
320        assert_eq!(config.description, "Test File");
321        assert!(!config.required);
322        assert_eq!(config.max_size_mb, Some(100));
323    }
324
325    #[test]
326    fn test_json_file_config_required() {
327        let config = JsonFileConfig::new("test", "Test File").required();
328        assert!(config.required);
329    }
330
331    #[test]
332    fn test_json_file_config_max_size() {
333        let config = JsonFileConfig::new("test", "Test File").max_size_mb(50);
334        assert_eq!(config.max_size_mb, Some(50));
335    }
336
337    #[test]
338    fn test_directory_not_found() {
339        let discovery = JsonFileDiscovery::new("/nonexistent/path".to_string(), "test".to_string());
340        let result = discovery.discover_files();
341        assert!(matches!(
342            result,
343            Err(JsonDiscoveryError::DirectoryNotFound(_))
344        ));
345    }
346
347    #[test]
348    fn test_discover_files_with_temp_dir() {
349        let temp_dir = TempDir::new().expect("Failed to get test value");
350        let temp_path = temp_dir
351            .path()
352            .to_str()
353            .expect("Failed to convert path to string");
354
355        // Create a test JSON file
356        let test_file_path = format!("{temp_path}/test_memory_analysis.json");
357        fs::write(&test_file_path, r#"{"test": "data"}"#).expect("Failed to write test file");
358
359        let discovery = JsonFileDiscovery::new(temp_path.to_string(), "test".to_string());
360        let result = discovery
361            .discover_files()
362            .expect("Failed to discover files");
363
364        assert!(!result.found_files.is_empty());
365        assert_eq!(result.found_files[0].config.suffix, "memory_analysis");
366    }
367}