Skip to main content

n3gb_rs/io/
csv.rs

1use crate::cell::HexCell;
2use crate::coord::{ConversionMethod, Crs};
3use crate::error::N3gbError;
4use crate::geom::parse_geometry;
5use std::collections::{HashMap, HashSet};
6use std::fs::File;
7use std::path::Path;
8
9enum SourceIndices {
10    Geometry(usize),
11    Coordinates { x_idx: usize, y_idx: usize },
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum GeometryFormat {
16    /// Well-Known Text format (e.g., "POLYGON((...))")
17    Wkt,
18    /// GeoJSON format
19    GeoJson,
20}
21
22#[derive(Debug, Clone)]
23pub enum CoordinateSource {
24    /// A single column containing WKT or GeoJSON geometry
25    GeometryColumn(String),
26    /// Separate X and Y coordinate columns (e.g., Easting/Northing or Lon/Lat)
27    CoordinateColumns { x_column: String, y_column: String },
28}
29
30/// Configuration controlling how a CSV file is converted into hex IDs.
31#[derive(Debug, Clone)]
32pub struct CsvHexConfig {
33    pub source: CoordinateSource,
34    pub exclude_columns: Vec<String>,
35    pub zoom_level: u8,
36    pub crs: Crs,
37    pub include_hex_geometry: Option<GeometryFormat>,
38    pub hex_density: bool,
39    pub conversion_method: ConversionMethod,
40}
41
42impl CsvHexConfig {
43    /// Create config for a CSV with a geometry column (WKT or GeoJSON).
44    ///
45    /// # Arguments
46    /// * `geometry_column` - Name of the CSV column containing WKT or GeoJSON geometry.
47    /// * `zoom_level` - Hex zoom level to encode cells at.
48    ///
49    /// # Returns
50    /// A new [`CsvHexConfig`] using the given geometry column as its coordinate source.
51    ///
52    /// # Example
53    /// ```
54    /// use n3gb_rs::CsvHexConfig;
55    ///
56    /// let config = CsvHexConfig::new("geometry", 12);
57    /// ```
58    pub fn new(geometry_column: impl Into<String>, zoom_level: u8) -> Self {
59        Self {
60            source: CoordinateSource::GeometryColumn(geometry_column.into()),
61            exclude_columns: Vec::new(),
62            zoom_level,
63            crs: Crs::default(),
64            include_hex_geometry: None,
65            hex_density: false,
66            conversion_method: ConversionMethod::default(),
67        }
68    }
69
70    /// Create config for a CSV with separate X/Y coordinate columns.
71    ///
72    /// # Arguments
73    /// * `x_column` - Name of the CSV column holding the X coordinate (Easting or Longitude).
74    /// * `y_column` - Name of the CSV column holding the Y coordinate (Northing or Latitude).
75    /// * `zoom_level` - Hex zoom level to encode cells at.
76    ///
77    /// # Returns
78    /// A new [`CsvHexConfig`] using the given coordinate columns as its source.
79    ///
80    /// # Example
81    /// ```
82    /// use n3gb_rs::{CsvHexConfig, Crs};
83    ///
84    /// // For BNG coordinates (Easting/Northing)
85    /// let config = CsvHexConfig::from_coords("Easting", "Northing", 12)
86    ///     .crs(Crs::Bng);
87    ///
88    /// // For WGS84 coordinates (Longitude/Latitude)
89    /// let config = CsvHexConfig::from_coords("Longitude", "Latitude", 12)
90    ///     .crs(Crs::Wgs84);
91    /// ```
92    pub fn from_coords(
93        x_column: impl Into<String>,
94        y_column: impl Into<String>,
95        zoom_level: u8,
96    ) -> Self {
97        Self {
98            source: CoordinateSource::CoordinateColumns {
99                x_column: x_column.into(),
100                y_column: y_column.into(),
101            },
102            exclude_columns: Vec::new(),
103            zoom_level,
104            crs: Crs::default(),
105            include_hex_geometry: None,
106            hex_density: false,
107            conversion_method: ConversionMethod::default(),
108        }
109    }
110
111    /// Set the columns to drop from the output.
112    ///
113    /// # Arguments
114    /// * `columns` - Names of input columns to exclude from the output rows.
115    ///
116    /// # Returns
117    /// The updated config for chaining.
118    pub fn exclude(mut self, columns: Vec<String>) -> Self {
119        self.exclude_columns = columns;
120        self
121    }
122
123    /// Set the coordinate reference system of the input data.
124    ///
125    /// # Arguments
126    /// * `crs` - The [`Crs`] of the input coordinates or geometry.
127    ///
128    /// # Returns
129    /// The updated config for chaining.
130    pub fn crs(mut self, crs: Crs) -> Self {
131        self.crs = crs;
132        self
133    }
134
135    /// Include hex polygon geometry in output.
136    ///
137    /// # Arguments
138    /// * `format` - The [`GeometryFormat`] (WKT or GeoJSON) for the emitted hex geometry.
139    ///
140    /// # Returns
141    /// The updated config for chaining.
142    pub fn with_hex_geometry(mut self, format: GeometryFormat) -> Self {
143        self.include_hex_geometry = Some(format);
144        self
145    }
146
147    /// Sets the WGS84→BNG conversion backend.
148    ///
149    /// Only relevant when `crs` is [`Crs::Wgs84`]. Defaults to [`ConversionMethod::Proj`].
150    ///
151    /// # Arguments
152    /// * `method` - The [`ConversionMethod`] backend used to convert WGS84 to BNG.
153    ///
154    /// # Returns
155    /// The updated config for chaining.
156    pub fn conversion_method(mut self, method: ConversionMethod) -> Self {
157        self.conversion_method = method;
158        self
159    }
160
161    /// Aggregate output to one row per hex cell with a count of input rows.
162    ///
163    /// Output columns: `hex_id`, `count` (and optionally `hex_geometry`).
164    /// Input attribute columns are dropped since rows are aggregated.
165    ///
166    /// # Returns
167    /// The updated config for chaining.
168    pub fn hex_density(mut self) -> Self {
169        self.hex_density = true;
170        self
171    }
172}
173
174/// Convert a single CSV record into the hex cells it covers.
175///
176/// # Arguments
177/// * `record` - The CSV record to read coordinate or geometry values from.
178/// * `source_indices` - Resolved column indices identifying the geometry or X/Y columns.
179/// * `config` - Conversion configuration (zoom level, CRS, conversion method).
180///
181/// # Returns
182/// The hex cells covered by the record, or an empty vector if the coordinates fall
183/// outside the projectable area.
184///
185/// # Errors
186/// Returns [`N3gbError::CsvError`] if a referenced column is missing or a coordinate
187/// fails to parse, [`N3gbError::GeometryParseError`] if a geometry value cannot be
188/// parsed, and [`N3gbError::InvalidZoomLevel`] if the configured zoom level is invalid.
189fn read_cells_from_record(
190    record: &csv::StringRecord,
191    source_indices: &SourceIndices,
192    config: &CsvHexConfig,
193) -> Result<Vec<HexCell>, N3gbError> {
194    match source_indices {
195        SourceIndices::Geometry(idx) => {
196            let geom_str = record.get(*idx).ok_or_else(|| {
197                N3gbError::CsvError(format!("Missing geometry column at index {}", idx))
198            })?;
199            let geom = parse_geometry(geom_str)?;
200            match HexCell::from_geometry(
201                geom,
202                config.zoom_level,
203                config.crs,
204                config.conversion_method,
205            ) {
206                Ok(cells) => Ok(cells),
207                Err(N3gbError::ProjectionError(_)) => Ok(vec![]),
208                Err(e) => Err(e),
209            }
210        }
211        SourceIndices::Coordinates { x_idx, y_idx } => {
212            let x_str = record
213                .get(*x_idx)
214                .ok_or_else(|| N3gbError::CsvError(format!("Missing X column at index {}", x_idx)))?
215                .trim();
216            let y_str = record
217                .get(*y_idx)
218                .ok_or_else(|| N3gbError::CsvError(format!("Missing Y column at index {}", y_idx)))?
219                .trim();
220
221            let x: f64 = x_str
222                .parse()
223                .map_err(|_| N3gbError::CsvError(format!("Invalid X coordinate: '{}'", x_str)))?;
224            let y: f64 = y_str
225                .parse()
226                .map_err(|_| N3gbError::CsvError(format!("Invalid Y coordinate: '{}'", y_str)))?;
227
228            use crate::coord::convert_to_bng;
229            let cell = match config.crs {
230                Crs::Wgs84 => match convert_to_bng(&(x, y), config.conversion_method) {
231                    Ok(bng) => HexCell::from_bng(&bng, config.zoom_level)?,
232                    Err(N3gbError::ProjectionError(_)) => return Ok(vec![]),
233                    Err(e) => return Err(e),
234                },
235                Crs::Bng => HexCell::from_bng(&(x, y), config.zoom_level)?,
236            };
237            Ok(vec![cell])
238        }
239    }
240}
241
242/// Aggregate records into one output row per hex cell with a count of input rows.
243///
244/// # Arguments
245/// * `reader` - The CSV reader positioned after the header row.
246/// * `source_indices` - Resolved column indices identifying the geometry or X/Y columns.
247/// * `output_path` - Path of the CSV file to write the aggregated counts to.
248/// * `config` - Conversion configuration controlling zoom, CRS, and optional hex geometry.
249///
250/// # Returns
251/// `()` on success, after the aggregated CSV has been written and flushed.
252///
253/// # Errors
254/// Returns [`N3gbError::CsvError`] if reading or writing records fails, or for a missing
255/// or invalid coordinate column; [`N3gbError::GeometryParseError`] if a geometry value
256/// cannot be parsed; [`N3gbError::InvalidZoomLevel`] if the configured zoom level is
257/// invalid; and [`N3gbError::IoError`] if the output file cannot be created.
258fn csv_to_hex_density(
259    mut reader: csv::Reader<File>,
260    source_indices: SourceIndices,
261    output_path: impl AsRef<Path>,
262    config: &CsvHexConfig,
263) -> Result<(), N3gbError> {
264    let mut counts: HashMap<String, usize> = HashMap::new();
265
266    for result in reader.records() {
267        let record = result?;
268        let cells = read_cells_from_record(&record, &source_indices, config)?;
269
270        for cell in cells {
271            *counts.entry(cell.id).or_insert(0) += 1;
272        }
273    }
274
275    let mut sorted: Vec<_> = counts.into_iter().collect();
276    sorted.sort_by(|a, b| b.1.cmp(&a.1));
277
278    let out_file = File::create(output_path)?;
279    let mut writer = csv::Writer::from_writer(out_file);
280
281    let mut header_row: Vec<&str> = vec!["hex_id", "count"];
282    if config.include_hex_geometry.is_some() {
283        header_row.push("hex_geometry");
284    }
285    writer.write_record(&header_row)?;
286
287    for (hex_id, count) in &sorted {
288        let mut row: Vec<String> = vec![hex_id.clone(), count.to_string()];
289
290        if let Some(format) = config.include_hex_geometry {
291            let cell = HexCell::from_hex_id(hex_id)?;
292            let polygon = cell.to_polygon();
293            let geom_str = match format {
294                GeometryFormat::Wkt => polygon_to_wkt(&polygon),
295                GeometryFormat::GeoJson => polygon_to_geojson(&polygon),
296            };
297            row.push(geom_str);
298        }
299
300        writer.write_record(&row)?;
301    }
302
303    writer.flush()?;
304
305    Ok(())
306}
307
308/// Render a polygon as a Well-Known Text (WKT) string.
309///
310/// # Arguments
311/// * `polygon` - The polygon to serialize.
312///
313/// # Returns
314/// The WKT representation of the polygon.
315fn polygon_to_wkt(polygon: &geo_types::Polygon<f64>) -> String {
316    use wkt::ToWkt;
317    polygon.wkt_string()
318}
319
320/// Render a polygon as a GeoJSON geometry string.
321///
322/// # Arguments
323/// * `polygon` - The polygon to serialize.
324///
325/// # Returns
326/// The GeoJSON representation of the polygon.
327fn polygon_to_geojson(polygon: &geo_types::Polygon<f64>) -> String {
328    let geom = geojson::Geometry::from(polygon);
329    geom.to_string()
330}
331
332/// Converts a CSV file with geometry or coordinate columns to a CSV file with hex IDs.
333///
334/// Streams output to minimize memory usage for large files.
335///
336/// # Example with geometry column (WKT or GeoJSON)
337///
338/// ```no_run
339/// use n3gb_rs::{csv_to_hex_csv, CsvHexConfig, Crs};
340///
341/// let config = CsvHexConfig::new("Geo Shape", 12)
342///     .exclude(vec!["Geo Point".into()])
343///     .crs(Crs::Wgs84);
344///
345/// csv_to_hex_csv("input.csv", "output.csv", &config).unwrap();
346/// ```
347///
348/// # Example with coordinate columns
349///
350/// ```no_run
351/// use n3gb_rs::{csv_to_hex_csv, CsvHexConfig, Crs};
352///
353/// // For BNG (Easting/Northing)
354/// let config = CsvHexConfig::from_coords("Easting", "Northing", 12)
355///     .crs(Crs::Bng);
356///
357/// csv_to_hex_csv("bus_stops.csv", "output.csv", &config).unwrap();
358/// ```
359///
360/// # Arguments
361/// * `csv_path` - Path of the input CSV file to read.
362/// * `output_path` - Path of the CSV file to write hex IDs (and optional geometry) to.
363/// * `config` - Conversion configuration describing the source columns, zoom, and CRS.
364///
365/// # Returns
366/// `()` on success, after the output CSV has been written and flushed.
367///
368/// # Errors
369/// Returns [`N3gbError::CsvError`] if the input cannot be read, a configured column name
370/// is empty or not found, or a record cannot be read or written;
371/// [`N3gbError::GeometryParseError`] if a geometry value cannot be parsed;
372/// [`N3gbError::InvalidZoomLevel`] if the configured zoom level is invalid; and
373/// [`N3gbError::IoError`] if the input file cannot be opened or the output file cannot
374/// be created.
375pub fn csv_to_hex_csv(
376    csv_path: impl AsRef<Path>,
377    output_path: impl AsRef<Path>,
378    config: &CsvHexConfig,
379) -> Result<(), N3gbError> {
380    let file = File::open(csv_path)?;
381    let mut reader = csv::Reader::from_reader(file);
382
383    let headers = reader.headers()?.clone();
384
385    // Determine which columns to exclude based on source type
386    // Best practice is to always exclude ANY geometry column
387    let (source_indices, mut exclude_indices) =
388        match &config.source {
389            CoordinateSource::GeometryColumn(col) => {
390                if col.is_empty() {
391                    return Err(N3gbError::CsvError(
392                        "Geometry column name cannot be empty".to_string(),
393                    ));
394                }
395                let idx = headers.iter().position(|h| h == col).ok_or_else(|| {
396                    N3gbError::CsvError(format!("Geometry column '{}' not found", col))
397                })?;
398                let mut exclude = HashSet::new();
399                exclude.insert(idx);
400                (SourceIndices::Geometry(idx), exclude)
401            }
402            CoordinateSource::CoordinateColumns { x_column, y_column } => {
403                if x_column.is_empty() {
404                    return Err(N3gbError::CsvError(
405                        "X column name cannot be empty".to_string(),
406                    ));
407                }
408                if y_column.is_empty() {
409                    return Err(N3gbError::CsvError(
410                        "Y column name cannot be empty".to_string(),
411                    ));
412                }
413                let x_idx = headers.iter().position(|h| h == x_column).ok_or_else(|| {
414                    N3gbError::CsvError(format!("X column '{}' not found", x_column))
415                })?;
416                let y_idx = headers.iter().position(|h| h == y_column).ok_or_else(|| {
417                    N3gbError::CsvError(format!("Y column '{}' not found", y_column))
418                })?;
419                let mut exclude = HashSet::new();
420                exclude.insert(x_idx);
421                exclude.insert(y_idx);
422                (SourceIndices::Coordinates { x_idx, y_idx }, exclude)
423            }
424        };
425
426    for col_name in &config.exclude_columns {
427        if let Some(idx) = headers.iter().position(|h| h == col_name) {
428            exclude_indices.insert(idx);
429        }
430    }
431
432    if config.hex_density {
433        return csv_to_hex_density(reader, source_indices, output_path, config);
434    }
435
436    let out_file = File::create(output_path)?;
437    let mut writer = csv::Writer::from_writer(out_file);
438
439    let mut header_row: Vec<&str> = vec!["hex_id"];
440    if config.include_hex_geometry.is_some() {
441        header_row.push("hex_geometry");
442    }
443    for (i, h) in headers.iter().enumerate() {
444        if !exclude_indices.contains(&i) {
445            header_row.push(h);
446        }
447    }
448    writer.write_record(&header_row)?;
449
450    for result in reader.records() {
451        let record = result?;
452
453        let cells = read_cells_from_record(&record, &source_indices, config)?;
454
455        for cell in cells {
456            let mut row: Vec<String> = vec![cell.id.clone()];
457
458            if let Some(format) = config.include_hex_geometry {
459                let polygon = cell.to_polygon();
460                let geom_str = match format {
461                    GeometryFormat::Wkt => polygon_to_wkt(&polygon),
462                    GeometryFormat::GeoJson => polygon_to_geojson(&polygon),
463                };
464                row.push(geom_str);
465            }
466
467            for (i, field) in record.iter().enumerate() {
468                if !exclude_indices.contains(&i) {
469                    row.push(field.to_string());
470                }
471            }
472            writer.write_record(&row)?;
473        }
474    }
475
476    writer.flush()?;
477
478    Ok(())
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use std::io::Write;
485    use tempfile::tempdir;
486
487    #[test]
488    fn test_csv_to_hex_csv_wgs84() -> Result<(), N3gbError> {
489        let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
490        let csv_path = dir.path().join("test.csv");
491        let output_path = dir.path().join("output.csv");
492
493        let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
494        writeln!(file, "ASSET_ID,TYPE,geometry").map_err(|e| N3gbError::IoError(e.to_string()))?;
495        writeln!(
496            file,
497            "CDT123,Pipe,\"{{\"\"type\"\":\"\"Point\"\",\"\"coordinates\"\":[-0.1,51.5]}}\""
498        )
499        .map_err(|e| N3gbError::IoError(e.to_string()))?;
500
501        let config = CsvHexConfig::new("geometry", 12).crs(Crs::Wgs84);
502        csv_to_hex_csv(&csv_path, &output_path, &config)?;
503
504        assert!(output_path.exists());
505        Ok(())
506    }
507
508    #[test]
509    fn test_csv_to_hex_csv_bng() -> Result<(), N3gbError> {
510        let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
511        let csv_path = dir.path().join("test.csv");
512        let output_path = dir.path().join("output.csv");
513
514        // BNG coordinates for somewhere in London
515        let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
516        writeln!(file, "ASSET_ID,TYPE,geometry").map_err(|e| N3gbError::IoError(e.to_string()))?;
517        writeln!(file, "CDT123,Pipe,\"POINT(530000 180000)\"")
518            .map_err(|e| N3gbError::IoError(e.to_string()))?;
519
520        let config = CsvHexConfig::new("geometry", 12).crs(Crs::Bng);
521        csv_to_hex_csv(&csv_path, &output_path, &config)?;
522
523        assert!(output_path.exists());
524        Ok(())
525    }
526
527    #[test]
528    fn test_csv_from_coords_bng() -> Result<(), N3gbError> {
529        let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
530        let csv_path = dir.path().join("test.csv");
531        let output_path = dir.path().join("output.csv");
532
533        let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
534        writeln!(file, "StopCode,Name,Easting,Northing")
535            .map_err(|e| N3gbError::IoError(e.to_string()))?;
536        writeln!(file, "ABC123,Temple Meads,359581,172304")
537            .map_err(|e| N3gbError::IoError(e.to_string()))?;
538        writeln!(file, "DEF456,Castle Park,358500,173100")
539            .map_err(|e| N3gbError::IoError(e.to_string()))?;
540
541        let config = CsvHexConfig::from_coords("Easting", "Northing", 12).crs(Crs::Bng);
542        csv_to_hex_csv(&csv_path, &output_path, &config)?;
543
544        assert!(output_path.exists());
545
546        let output =
547            std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
548        assert!(output.contains("hex_id"));
549        assert!(output.contains("StopCode"));
550        assert!(output.contains("Name"));
551        assert!(!output.contains(",Easting,"));
552        assert!(!output.contains(",Northing"));
553
554        Ok(())
555    }
556
557    #[test]
558    fn test_csv_hex_density() -> Result<(), N3gbError> {
559        let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
560        let csv_path = dir.path().join("test.csv");
561        let output_path = dir.path().join("output.csv");
562
563        let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
564        writeln!(file, "StopCode,Name,Easting,Northing")
565            .map_err(|e| N3gbError::IoError(e.to_string()))?;
566
567        writeln!(file, "ABC123,Stop A,359581,172304")
568            .map_err(|e| N3gbError::IoError(e.to_string()))?;
569        writeln!(file, "DEF456,Stop B,359582,172305")
570            .map_err(|e| N3gbError::IoError(e.to_string()))?;
571
572        writeln!(file, "GHI789,Stop C,350000,170000")
573            .map_err(|e| N3gbError::IoError(e.to_string()))?;
574
575        let config = CsvHexConfig::from_coords("Easting", "Northing", 12)
576            .crs(Crs::Bng)
577            .hex_density();
578        csv_to_hex_csv(&csv_path, &output_path, &config)?;
579
580        let output =
581            std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
582        let lines: Vec<&str> = output.lines().collect();
583
584        assert_eq!(lines[0], "hex_id,count");
585        assert_eq!(lines.len(), 3);
586
587        assert!(lines[1].ends_with(",2"));
588        assert!(lines[2].ends_with(",1"));
589
590        assert!(!output.contains("StopCode"));
591        assert!(!output.contains("Name"));
592
593        Ok(())
594    }
595
596    #[test]
597    fn test_csv_hex_density_with_geometry() -> Result<(), N3gbError> {
598        let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
599        let csv_path = dir.path().join("test.csv");
600        let output_path = dir.path().join("output.csv");
601
602        let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
603        writeln!(file, "ID,Easting,Northing").map_err(|e| N3gbError::IoError(e.to_string()))?;
604        writeln!(file, "1,359581,172304").map_err(|e| N3gbError::IoError(e.to_string()))?;
605        writeln!(file, "2,359582,172305").map_err(|e| N3gbError::IoError(e.to_string()))?;
606
607        let config = CsvHexConfig::from_coords("Easting", "Northing", 12)
608            .crs(Crs::Bng)
609            .hex_density()
610            .with_hex_geometry(GeometryFormat::Wkt);
611        csv_to_hex_csv(&csv_path, &output_path, &config)?;
612
613        let output =
614            std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
615        let lines: Vec<&str> = output.lines().collect();
616
617        assert_eq!(lines[0], "hex_id,count,hex_geometry");
618        assert!(lines[1].contains("POLYGON"));
619        Ok(())
620    }
621
622    #[test]
623    fn test_csv_hex_density_wgs84() -> Result<(), N3gbError> {
624        let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
625        let csv_path = dir.path().join("test.csv");
626        let output_path = dir.path().join("output.csv");
627
628        let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
629        writeln!(file, "ID,Lon,Lat").map_err(|e| N3gbError::IoError(e.to_string()))?;
630        // Three points near Bristol — two very close (same hex), one further away
631        writeln!(file, "1,-2.583,51.448").map_err(|e| N3gbError::IoError(e.to_string()))?;
632        writeln!(file, "2,-2.583,51.448").map_err(|e| N3gbError::IoError(e.to_string()))?;
633        writeln!(file, "3,-1.500,53.800").map_err(|e| N3gbError::IoError(e.to_string()))?;
634
635        let config = CsvHexConfig::from_coords("Lon", "Lat", 8)
636            .crs(Crs::Wgs84)
637            .hex_density();
638        csv_to_hex_csv(&csv_path, &output_path, &config)?;
639
640        let output =
641            std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
642        let lines: Vec<&str> = output.lines().collect();
643
644        // header + two distinct hex cells
645        assert_eq!(lines[0], "hex_id,count");
646        assert_eq!(lines.len(), 3);
647        // highest count first
648        assert!(lines[1].ends_with(",2"));
649        assert!(lines[2].ends_with(",1"));
650        Ok(())
651    }
652
653    #[test]
654    fn test_csv_hex_density_geometry_column() -> Result<(), N3gbError> {
655        let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
656        let csv_path = dir.path().join("test.csv");
657        let output_path = dir.path().join("output.csv");
658
659        let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
660        writeln!(file, "ID,geometry").map_err(|e| N3gbError::IoError(e.to_string()))?;
661        writeln!(file, "1,POINT(530000 180000)").map_err(|e| N3gbError::IoError(e.to_string()))?;
662        writeln!(file, "2,POINT(530001 180001)").map_err(|e| N3gbError::IoError(e.to_string()))?;
663        writeln!(file, "3,POINT(400000 300000)").map_err(|e| N3gbError::IoError(e.to_string()))?;
664
665        let config = CsvHexConfig::new("geometry", 10)
666            .crs(Crs::Bng)
667            .hex_density();
668        csv_to_hex_csv(&csv_path, &output_path, &config)?;
669
670        let output =
671            std::fs::read_to_string(&output_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
672        let lines: Vec<&str> = output.lines().collect();
673
674        assert_eq!(lines[0], "hex_id,count");
675        // first two points should be in the same hex at zoom 10, third in a different one
676        assert_eq!(lines.len(), 3);
677        assert!(lines[1].ends_with(",2"));
678        assert!(lines[2].ends_with(",1"));
679        Ok(())
680    }
681
682    #[test]
683    fn test_csv_from_coords_wgs84() -> Result<(), N3gbError> {
684        let dir = tempdir().map_err(|e| N3gbError::IoError(e.to_string()))?;
685        let csv_path = dir.path().join("test.csv");
686        let output_path = dir.path().join("output.csv");
687
688        let mut file = File::create(&csv_path).map_err(|e| N3gbError::IoError(e.to_string()))?;
689        writeln!(file, "ID,Longitude,Latitude,Description")
690            .map_err(|e| N3gbError::IoError(e.to_string()))?;
691        writeln!(file, "1,-2.58302,51.44827,Bristol Temple Meads")
692            .map_err(|e| N3gbError::IoError(e.to_string()))?;
693
694        let config = CsvHexConfig::from_coords("Longitude", "Latitude", 12).crs(Crs::Wgs84);
695        csv_to_hex_csv(&csv_path, &output_path, &config)?;
696
697        assert!(output_path.exists());
698        Ok(())
699    }
700}