Skip to main content

howler_core/
import.rs

1use crate::models::{Sighting, Source};
2use anyhow::{Context, Result};
3use chrono::{DateTime, Utc};
4use csv::ReaderBuilder;
5use std::fs::File;
6use std::io::Read;
7
8/// Import sightings from CSV file
9pub fn import_csv(file_path: &str) -> Result<Vec<Sighting>> {
10    let file = File::open(file_path).context("Failed to open CSV file")?;
11    let mut rdr = ReaderBuilder::new().from_reader(file);
12
13    let mut sightings = Vec::new();
14
15    for result in rdr.records() {
16        let record = result.context("Failed to read CSV record")?;
17
18        let species = record.get(1).unwrap_or("Unknown").to_string();
19        let scientific_name = record.get(2).map(|s| s.to_string());
20        let latitude: f64 = record
21            .get(3)
22            .unwrap_or("0")
23            .parse()
24            .context("Failed to parse latitude")?;
25        let longitude: f64 = record
26            .get(4)
27            .unwrap_or("0")
28            .parse()
29            .context("Failed to parse longitude")?;
30        let observed_on_str = record.get(5).unwrap_or("");
31        let source_str = record.get(6).unwrap_or("GBIF");
32        let source_id = record.get(7).unwrap_or("").to_string();
33        let details = record.get(8).map(|s| s.to_string());
34
35        let source = match source_str {
36            "Movebank" => Source::Movebank,
37            "iNaturalist" => Source::INaturalist,
38            "IUCN" => Source::IUCN,
39            _ => Source::GBIF,
40        };
41
42        let observed_on = DateTime::parse_from_rfc3339(observed_on_str)
43            .map(|dt| dt.with_timezone(&Utc))
44            .unwrap_or_else(|_| Utc::now());
45
46        sightings.push(Sighting {
47            id: None,
48            species,
49            scientific_name,
50            latitude,
51            longitude,
52            observed_on,
53            source,
54            source_id,
55            details,
56        });
57    }
58
59    Ok(sightings)
60}
61
62/// Import sightings from GeoJSON file
63pub fn import_geojson(file_path: &str) -> Result<Vec<Sighting>> {
64    let mut file = File::open(file_path).context("Failed to open GeoJSON file")?;
65    let mut contents = String::new();
66    file.read_to_string(&mut contents)
67        .context("Failed to read GeoJSON file")?;
68
69    let geojson: geojson::GeoJson =
70        serde_json::from_str(&contents).context("Failed to parse GeoJSON")?;
71
72    let mut sightings = Vec::new();
73
74    if let geojson::GeoJson::FeatureCollection(collection) = geojson {
75        for feature in collection.features {
76            if let Some(geometry) = feature.geometry {
77                if let geojson::Value::Point(coords) = geometry.value {
78                    if coords.len() >= 2 {
79                        let longitude = coords[0];
80                        let latitude = coords[1];
81
82                        let properties = feature.properties.unwrap_or_default();
83                        let species = properties
84                            .get("species")
85                            .and_then(|v| v.as_str())
86                            .unwrap_or("Unknown")
87                            .to_string();
88                        let scientific_name = properties
89                            .get("scientific_name")
90                            .and_then(|v| v.as_str())
91                            .map(|s| s.to_string());
92                        let observed_on_str = properties
93                            .get("observed_on")
94                            .and_then(|v| v.as_str())
95                            .unwrap_or("");
96                        let source_str = properties
97                            .get("source")
98                            .and_then(|v| v.as_str())
99                            .unwrap_or("GBIF");
100                        let source_id = properties
101                            .get("source_id")
102                            .and_then(|v| v.as_str())
103                            .unwrap_or("")
104                            .to_string();
105                        let details = properties
106                            .get("details")
107                            .and_then(|v| v.as_str())
108                            .map(|s| s.to_string());
109
110                        let source = match source_str {
111                            "Movebank" => Source::Movebank,
112                            "iNaturalist" => Source::INaturalist,
113                            "IUCN" => Source::IUCN,
114                            _ => Source::GBIF,
115                        };
116
117                        let observed_on = DateTime::parse_from_rfc3339(observed_on_str)
118                            .map(|dt| dt.with_timezone(&Utc))
119                            .unwrap_or_else(|_| Utc::now());
120
121                        sightings.push(Sighting {
122                            id: None,
123                            species,
124                            scientific_name,
125                            latitude,
126                            longitude,
127                            observed_on,
128                            source,
129                            source_id,
130                            details,
131                        });
132                    }
133                }
134            }
135        }
136    }
137
138    Ok(sightings)
139}
140
141/// Validate sighting data
142pub fn validate_sighting(sighting: &Sighting) -> Result<()> {
143    if sighting.latitude < -90.0 || sighting.latitude > 90.0 {
144        anyhow::bail!("Invalid latitude: {}", sighting.latitude);
145    }
146
147    if sighting.longitude < -180.0 || sighting.longitude > 180.0 {
148        anyhow::bail!("Invalid longitude: {}", sighting.longitude);
149    }
150
151    if sighting.species.is_empty() {
152        anyhow::bail!("Species cannot be empty");
153    }
154
155    if sighting.source_id.is_empty() {
156        anyhow::bail!("Source ID cannot be empty");
157    }
158
159    Ok(())
160}
161
162/// Import and validate sightings
163pub fn import_and_validate(
164    file_path: &str,
165    format: super::export::ExportFormat,
166) -> Result<Vec<Sighting>> {
167    let sightings = match format {
168        super::export::ExportFormat::Csv => import_csv(file_path)?,
169        super::export::ExportFormat::GeoJson => import_geojson(file_path)?,
170        super::export::ExportFormat::Kml => {
171            anyhow::bail!("KML import not yet implemented");
172        }
173    };
174
175    // Validate all sightings
176    for sighting in &sightings {
177        validate_sighting(sighting)?;
178    }
179
180    Ok(sightings)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_validate_sighting_valid() {
189        let sighting = Sighting {
190            id: None,
191            species: "Canis lupus".to_string(),
192            scientific_name: Some("Canis lupus".to_string()),
193            latitude: 45.0,
194            longitude: -122.0,
195            observed_on: Utc::now(),
196            source: Source::GBIF,
197            source_id: "test_123".to_string(),
198            details: None,
199        };
200
201        assert!(validate_sighting(&sighting).is_ok());
202    }
203
204    #[test]
205    fn test_validate_sighting_invalid_lat() {
206        let sighting = Sighting {
207            id: None,
208            species: "Canis lupus".to_string(),
209            scientific_name: Some("Canis lupus".to_string()),
210            latitude: 95.0,
211            longitude: -122.0,
212            observed_on: Utc::now(),
213            source: Source::GBIF,
214            source_id: "test_123".to_string(),
215            details: None,
216        };
217
218        assert!(validate_sighting(&sighting).is_err());
219    }
220
221    #[test]
222    fn test_validate_sighting_empty_species() {
223        let sighting = Sighting {
224            id: None,
225            species: "".to_string(),
226            scientific_name: Some("Canis lupus".to_string()),
227            latitude: 45.0,
228            longitude: -122.0,
229            observed_on: Utc::now(),
230            source: Source::GBIF,
231            source_id: "test_123".to_string(),
232            details: None,
233        };
234
235        assert!(validate_sighting(&sighting).is_err());
236    }
237}