Skip to main content

oxigeo_cli/util/
inspector.rs

1//! Minimal file inspector — reports format / size / structure for `oxigeo inspect`.
2//!
3//! This is a self-contained reimplementation of the structural-summary feature
4//! that previously lived in the (now disabled) `oxigeo_dev_tools` crate. It
5//! drives the per-format readers already used by the CLI (`GeoTiffReader`,
6//! `GeoJsonReader`) and reports only data that is actually reachable through
7//! their public APIs — any field the readers do not expose is left `None`.
8
9use anyhow::{Context, Result};
10use oxigeo_core::io::FileDataSource;
11use oxigeo_core::types::RasterDataType;
12use oxigeo_geojson::GeoJsonReader;
13use oxigeo_geotiff::GeoTiffReader;
14use serde::Serialize;
15use std::fs::File;
16use std::io::BufReader;
17use std::path::Path;
18
19/// Structured result of inspecting a single geospatial file.
20#[derive(Debug, Clone, Serialize)]
21pub struct InspectionReport {
22    /// The path (or URI) that was inspected.
23    pub path: String,
24    /// File size in bytes (0 for cloud URIs whose size is not locally known).
25    pub file_size: u64,
26    /// Detected format name (e.g. `GeoTIFF`, `GeoJSON`, or `Unknown`).
27    pub format: String,
28    /// Lower-cased file extension without the leading dot (empty if none).
29    pub extension: String,
30    /// True when the path looks like a cloud URI (`s3://`, `gs://`, `az://`).
31    pub is_cloud: bool,
32    /// Coordinate reference system description, if discoverable.
33    pub crs: Option<String>,
34    /// Raster structure summary, present only for raster formats.
35    pub raster: Option<RasterSummary>,
36    /// Vector structure summary, present only for vector formats.
37    pub vector: Option<VectorSummary>,
38}
39
40/// Raster-specific structural summary.
41#[derive(Debug, Clone, Serialize)]
42pub struct RasterSummary {
43    /// Raster width in pixels.
44    pub width: u32,
45    /// Raster height in pixels.
46    pub height: u32,
47    /// Number of bands.
48    pub band_count: u32,
49    /// Per-band data type names (one entry repeated per band).
50    pub data_types: Vec<String>,
51    /// Affine geo-transform `[origin_x, pixel_w, row_rot, origin_y, col_rot, pixel_h]`.
52    pub geo_transform: Option<[f64; 6]>,
53    /// NoData value, if defined.
54    pub nodata: Option<f64>,
55}
56
57/// Vector-specific structural summary.
58#[derive(Debug, Clone, Serialize)]
59pub struct VectorSummary {
60    /// Feature count, if the reader can determine it.
61    pub feature_count: Option<u64>,
62    /// Number of layers (GeoJSON FeatureCollections are always single-layer).
63    pub layer_count: u32,
64    /// Bounding box `[min_x, min_y, max_x, max_y]`, if present.
65    pub bounds: Option<[f64; 4]>,
66}
67
68/// Inspect a file and produce a structured report.
69///
70/// When `detailed` is `true` the report additionally fills `geo_transform`,
71/// `nodata`, full per-band `data_types`, and `layer_count`. When `false` only a
72/// lightweight summary is produced (raster `data_types` is left empty and
73/// `geo_transform`/`nodata` are `None`).
74///
75/// # Errors
76///
77/// Returns an error when the path does not exist (for local paths) or when an
78/// existing file cannot be parsed by the matching format reader. Files with an
79/// unrecognised extension do not error — they yield a report with format
80/// `Unknown` and neither a raster nor vector summary.
81pub fn inspect_file(path: &str, detailed: bool) -> Result<InspectionReport> {
82    let is_cloud = crate::util::cloud::is_cloud_uri(path);
83
84    // Resolve `file://` URIs to a plain filesystem path; cloud URIs stay as-is.
85    let resolved: &str = path.strip_prefix("file://").unwrap_or(path);
86    let resolved_path = Path::new(resolved);
87
88    // File size: real metadata for local files, 0 for cloud URIs.
89    let file_size = if is_cloud {
90        0
91    } else {
92        if !resolved_path.exists() {
93            anyhow::bail!("File not found: {}", resolved);
94        }
95        std::fs::metadata(resolved_path)
96            .with_context(|| format!("Failed to read file metadata: {}", resolved))?
97            .len()
98    };
99
100    // Extension: lower-cased, without the leading dot.
101    let extension = resolved_path
102        .extension()
103        .and_then(|ext| ext.to_str())
104        .map(|ext| ext.to_lowercase())
105        .unwrap_or_default();
106
107    // Format detection reuses the shared CLI helper.
108    let format = crate::util::detect_format(resolved_path)
109        .map(|f| f.to_string())
110        .unwrap_or_else(|| "Unknown".to_string());
111
112    let mut report = InspectionReport {
113        path: path.to_string(),
114        file_size,
115        format: format.clone(),
116        extension,
117        is_cloud,
118        crs: None,
119        raster: None,
120        vector: None,
121    };
122
123    // Cloud URIs and unknown formats stop here: opening the per-format readers
124    // requires a local file, and there is no honest structure data to report.
125    if is_cloud || format == "Unknown" {
126        return Ok(report);
127    }
128
129    match format.as_str() {
130        "GeoTIFF" => {
131            let (summary, crs) = inspect_geotiff(resolved_path, detailed)?;
132            report.crs = crs;
133            report.raster = Some(summary);
134        }
135        "GeoJSON" => {
136            let (summary, crs) = inspect_geojson(resolved_path, detailed)?;
137            report.crs = crs;
138            report.vector = Some(summary);
139        }
140        // Other detected formats (Shapefile, FlatGeobuf, GeoParquet, Zarr, ...)
141        // are recognised by extension but a structural reader is not wired here.
142        // The report still carries an accurate format / size / extension.
143        _ => {}
144    }
145
146    Ok(report)
147}
148
149/// Builds a [`RasterSummary`] for a GeoTIFF using `GeoTiffReader`.
150fn inspect_geotiff(path: &Path, detailed: bool) -> Result<(RasterSummary, Option<String>)> {
151    let source = FileDataSource::open(path)
152        .map_err(|e| anyhow::anyhow!("Failed to open file {}: {e}", path.display()))?;
153    let reader = GeoTiffReader::open(source)
154        .map_err(|e| anyhow::anyhow!("Failed to read GeoTIFF {}: {e}", path.display()))?;
155
156    let width = u32::try_from(reader.width()).unwrap_or(u32::MAX);
157    let height = u32::try_from(reader.height()).unwrap_or(u32::MAX);
158    let band_count = reader.band_count();
159
160    // data_types: one entry per band. Only populated in detailed mode.
161    let data_types = if detailed {
162        let type_name = reader
163            .data_type()
164            .map_or_else(|| "Unknown".to_string(), data_type_name);
165        vec![type_name; band_count as usize]
166    } else {
167        Vec::new()
168    };
169
170    // geo_transform / nodata only in detailed mode.
171    let geo_transform = if detailed {
172        reader.geo_transform().map(|gt| {
173            [
174                gt.origin_x,
175                gt.pixel_width,
176                gt.row_rotation,
177                gt.origin_y,
178                gt.col_rotation,
179                gt.pixel_height,
180            ]
181        })
182    } else {
183        None
184    };
185
186    let nodata = if detailed {
187        reader.nodata().as_f64()
188    } else {
189        None
190    };
191
192    // CRS is always reported when available — it is cheap and useful.
193    let crs = reader.epsg_code().map(|code| format!("EPSG:{code}"));
194
195    Ok((
196        RasterSummary {
197            width,
198            height,
199            band_count,
200            data_types,
201            geo_transform,
202            nodata,
203        },
204        crs,
205    ))
206}
207
208/// Builds a [`VectorSummary`] for a GeoJSON file using `GeoJsonReader`.
209fn inspect_geojson(path: &Path, detailed: bool) -> Result<(VectorSummary, Option<String>)> {
210    let file =
211        File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?;
212    let mut reader = GeoJsonReader::new(BufReader::new(file));
213    let collection = reader
214        .read_feature_collection()
215        .map_err(|e| anyhow::anyhow!("Failed to read GeoJSON {}: {e}", path.display()))?;
216
217    let feature_count = Some(collection.features.len() as u64);
218
219    // GeoJSON FeatureCollections are a single layer by definition. The
220    // `layer_count` field exists for parity with multi-layer formats; we only
221    // surface it explicitly in detailed mode but the value is the same (1).
222    let layer_count = 1;
223
224    // bbox is `Vec<f64>`; only a 4-element bbox maps to a 2D bounds array.
225    let bounds = collection.bbox.as_ref().and_then(|bbox| {
226        if bbox.len() >= 4 {
227            Some([bbox[0], bbox[1], bbox[2], bbox[3]])
228        } else {
229            None
230        }
231    });
232
233    // CRS: prefer the named CRS; in non-detailed mode still report it if cheap.
234    let _ = detailed;
235    let crs = collection.crs.as_ref().and_then(|c| c.name());
236
237    Ok((
238        VectorSummary {
239            feature_count,
240            layer_count,
241            bounds,
242        },
243        crs,
244    ))
245}
246
247/// Maps a [`RasterDataType`] to its display name.
248fn data_type_name(dt: RasterDataType) -> String {
249    match dt {
250        RasterDataType::UInt8 => "UInt8",
251        RasterDataType::UInt16 => "UInt16",
252        RasterDataType::UInt32 => "UInt32",
253        RasterDataType::UInt64 => "UInt64",
254        RasterDataType::Int8 => "Int8",
255        RasterDataType::Int16 => "Int16",
256        RasterDataType::Int32 => "Int32",
257        RasterDataType::Int64 => "Int64",
258        RasterDataType::Float32 => "Float32",
259        RasterDataType::Float64 => "Float64",
260        RasterDataType::CFloat32 => "CFloat32",
261        RasterDataType::CFloat64 => "CFloat64",
262    }
263    .to_string()
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_data_type_name() {
272        assert_eq!(data_type_name(RasterDataType::UInt8), "UInt8");
273        assert_eq!(data_type_name(RasterDataType::Float64), "Float64");
274        assert_eq!(data_type_name(RasterDataType::CFloat64), "CFloat64");
275    }
276
277    #[test]
278    fn test_inspect_unknown_extension_no_summary() -> Result<()> {
279        let dir = std::env::temp_dir();
280        let path = dir.join(format!(
281            "oxigeo_inspector_unit_{}_{}.xyz",
282            std::process::id(),
283            "unknown"
284        ));
285        std::fs::write(&path, b"not a geospatial file")?;
286
287        let report = inspect_file(path.to_string_lossy().as_ref(), false)?;
288        assert_eq!(report.format, "Unknown");
289        assert!(report.raster.is_none());
290        assert!(report.vector.is_none());
291        assert!(!report.is_cloud);
292
293        let _ = std::fs::remove_file(&path);
294        Ok(())
295    }
296}