Skip to main content

oxigdal_dev_tools/
inspector.rs

1//! File inspection utilities
2//!
3//! This module provides tools for inspecting and analyzing geospatial file formats.
4
5use crate::{DevToolsError, Result};
6use colored::Colorize;
7use comfy_table::{Cell, Row, Table};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11/// File inspector
12#[derive(Debug, Clone)]
13pub struct FileInspector {
14    /// File path
15    path: PathBuf,
16    /// File information
17    info: FileInfo,
18}
19
20/// File information
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FileInfo {
23    /// File path
24    pub path: String,
25    /// File size in bytes
26    pub size: u64,
27    /// File extension
28    pub extension: Option<String>,
29    /// Detected format
30    pub format: Option<FileFormat>,
31    /// Is readable
32    pub readable: bool,
33    /// Is writable
34    pub writable: bool,
35}
36
37/// File format
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum FileFormat {
40    /// GeoTIFF
41    GeoTiff,
42    /// GeoJSON
43    GeoJson,
44    /// Shapefile
45    Shapefile,
46    /// Zarr
47    Zarr,
48    /// NetCDF
49    NetCdf,
50    /// HDF5
51    Hdf5,
52    /// GeoParquet
53    GeoParquet,
54    /// FlatGeobuf
55    FlatGeobuf,
56    /// Unknown format
57    Unknown,
58}
59
60impl FileFormat {
61    /// Get format description
62    pub fn description(&self) -> &str {
63        match self {
64            Self::GeoTiff => "GeoTIFF raster format",
65            Self::GeoJson => "GeoJSON vector format",
66            Self::Shapefile => "ESRI Shapefile",
67            Self::Zarr => "Zarr array storage",
68            Self::NetCdf => "NetCDF scientific data",
69            Self::Hdf5 => "HDF5 hierarchical data",
70            Self::GeoParquet => "GeoParquet columnar format",
71            Self::FlatGeobuf => "FlatGeobuf binary format",
72            Self::Unknown => "Unknown format",
73        }
74    }
75}
76
77impl FileInspector {
78    /// Create a new file inspector
79    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
80        let path = path.as_ref().to_path_buf();
81
82        if !path.exists() {
83            return Err(DevToolsError::Inspector(format!(
84                "File does not exist: {}",
85                path.display()
86            )));
87        }
88
89        let metadata = std::fs::metadata(&path)?;
90        let size = metadata.len();
91        let extension = path
92            .extension()
93            .and_then(|s| s.to_str())
94            .map(|s| s.to_string());
95
96        let format = Self::detect_format(&path, extension.as_deref())?;
97
98        // `Permissions::readonly()` reports whether the write bit is unset; it says
99        // nothing about read permission. Determine actual readability by attempting
100        // to open the file for reading rather than deriving it from `readonly()`.
101        let readable = std::fs::File::open(&path).is_ok();
102
103        let info = FileInfo {
104            path: path.display().to_string(),
105            size,
106            extension,
107            format: Some(format),
108            readable,
109            writable: !metadata.permissions().readonly(),
110        };
111
112        Ok(Self { path, info })
113    }
114
115    /// Detect file format
116    fn detect_format(path: &Path, extension: Option<&str>) -> Result<FileFormat> {
117        // First try by extension
118        if let Some(ext) = extension {
119            match ext.to_lowercase().as_str() {
120                "tif" | "tiff" | "gtiff" => return Ok(FileFormat::GeoTiff),
121                "json" | "geojson" => return Ok(FileFormat::GeoJson),
122                "shp" => return Ok(FileFormat::Shapefile),
123                "zarr" => return Ok(FileFormat::Zarr),
124                "nc" | "nc4" => return Ok(FileFormat::NetCdf),
125                "h5" | "hdf5" => return Ok(FileFormat::Hdf5),
126                "parquet" | "geoparquet" => return Ok(FileFormat::GeoParquet),
127                "fgb" => return Ok(FileFormat::FlatGeobuf),
128                _ => {}
129            }
130        }
131
132        // Try by magic bytes
133        if let Ok(mut file) = std::fs::File::open(path) {
134            use std::io::Read;
135            let mut magic = [0u8; 8];
136            if file.read_exact(&mut magic).is_ok() {
137                // GeoTIFF: II or MM (little/big endian TIFF)
138                if magic[0..2] == [0x49, 0x49] || magic[0..2] == [0x4D, 0x4D] {
139                    return Ok(FileFormat::GeoTiff);
140                }
141                // GeoJSON: starts with '{'
142                if magic[0] == b'{' {
143                    return Ok(FileFormat::GeoJson);
144                }
145                // HDF5: magic number
146                if magic[0..4] == [0x89, 0x48, 0x44, 0x46] {
147                    return Ok(FileFormat::Hdf5);
148                }
149            }
150        }
151
152        Ok(FileFormat::Unknown)
153    }
154
155    /// Get file info
156    pub fn info(&self) -> &FileInfo {
157        &self.info
158    }
159
160    /// Get file path
161    pub fn path(&self) -> &Path {
162        &self.path
163    }
164
165    /// Generate summary report
166    pub fn summary(&self) -> String {
167        let mut report = String::new();
168        report.push_str(&format!("\n{}\n", "File Inspection".bold()));
169        report.push_str(&format!("{}\n\n", "=".repeat(60)));
170
171        let mut table = Table::new();
172        table.add_row(Row::from(vec![
173            Cell::new("Path"),
174            Cell::new(&self.info.path),
175        ]));
176        table.add_row(Row::from(vec![
177            Cell::new("Size"),
178            Cell::new(format_size(self.info.size)),
179        ]));
180        if let Some(ref ext) = self.info.extension {
181            table.add_row(Row::from(vec![Cell::new("Extension"), Cell::new(ext)]));
182        }
183        if let Some(format) = self.info.format {
184            table.add_row(Row::from(vec![
185                Cell::new("Format"),
186                Cell::new(format!("{:?}", format)),
187            ]));
188            table.add_row(Row::from(vec![
189                Cell::new("Description"),
190                Cell::new(format.description()),
191            ]));
192        }
193        table.add_row(Row::from(vec![
194            Cell::new("Readable"),
195            Cell::new(if self.info.readable { "Yes" } else { "No" }),
196        ]));
197        table.add_row(Row::from(vec![
198            Cell::new("Writable"),
199            Cell::new(if self.info.writable { "Yes" } else { "No" }),
200        ]));
201
202        report.push_str(&table.to_string());
203        report.push('\n');
204
205        report
206    }
207
208    /// Export info as JSON
209    pub fn export_json(&self) -> Result<String> {
210        Ok(serde_json::to_string_pretty(&self.info)?)
211    }
212}
213
214/// Format file size
215fn format_size(bytes: u64) -> String {
216    if bytes < 1024 {
217        format!("{} B", bytes)
218    } else if bytes < 1024 * 1024 {
219        format!("{:.2} KB", bytes as f64 / 1024.0)
220    } else if bytes < 1024 * 1024 * 1024 {
221        format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
222    } else {
223        format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use std::io::Write;
231    use tempfile::NamedTempFile;
232
233    #[test]
234    fn test_format_size() {
235        assert_eq!(format_size(512), "512 B");
236        assert_eq!(format_size(2048), "2.00 KB");
237        assert_eq!(format_size(2 * 1024 * 1024), "2.00 MB");
238    }
239
240    #[test]
241    fn test_file_inspector_creation() -> Result<()> {
242        let mut temp_file = NamedTempFile::new()?;
243        temp_file.write_all(b"test data")?;
244
245        let inspector = FileInspector::new(temp_file.path())?;
246        assert_eq!(inspector.info().size, 9);
247
248        Ok(())
249    }
250
251    #[test]
252    fn test_file_inspector_nonexistent() {
253        let result = FileInspector::new("/nonexistent/file.tif");
254        assert!(result.is_err());
255    }
256
257    #[test]
258    fn test_format_detection_by_extension() -> Result<()> {
259        let mut temp_file = NamedTempFile::with_suffix(".tif")?;
260        temp_file.write_all(b"II\x2a\x00")?; // TIFF magic bytes
261
262        let inspector = FileInspector::new(temp_file.path())?;
263        assert_eq!(inspector.info().format, Some(FileFormat::GeoTiff));
264
265        Ok(())
266    }
267
268    #[test]
269    fn test_readable_reports_true_for_writable_file() -> Result<()> {
270        // A freshly created temp file has default (writable) permissions and is
271        // openable for reading, so `readable` must be true and `writable` true.
272        // Regression test: previously `readable` was derived from
273        // `Permissions::readonly()`, which inverted the meaning and reported a
274        // normal writable file as unreadable.
275        let mut temp_file = NamedTempFile::new()?;
276        temp_file.write_all(b"test data")?;
277
278        let inspector = FileInspector::new(temp_file.path())?;
279        assert!(
280            inspector.info().readable,
281            "a readable temp file must report readable: true"
282        );
283        assert!(
284            inspector.info().writable,
285            "a writable temp file must report writable: true"
286        );
287
288        Ok(())
289    }
290
291    #[test]
292    fn test_file_info_export() -> Result<()> {
293        let mut temp_file = NamedTempFile::new()?;
294        temp_file.write_all(b"test")?;
295
296        let inspector = FileInspector::new(temp_file.path())?;
297        let json = inspector.export_json()?;
298        assert!(json.contains("path"));
299        assert!(json.contains("size"));
300
301        Ok(())
302    }
303}