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
8pub 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
62pub 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
141pub 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
162pub 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 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}