1use std::{
2 collections::HashMap,
3 fs::File,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, bail};
8use csv::{ReaderBuilder, WriterBuilder};
9use rayon::prelude::*;
10
11use crate::{
12 pack::{RecordPointPrecision, RecordSource},
13 record::OsmObjectType,
14 search::{AddressGeocodeOptions, PackTextSearcher},
15};
16
17#[derive(Debug, Clone)]
18pub struct BatchGeocodeOptions {
19 pub pack: Option<PathBuf>,
20 pub input: PathBuf,
21 pub output: PathBuf,
22 pub audit: PathBuf,
23 pub address_field_groups: Vec<Vec<String>>,
24 pub locality_field: Option<String>,
25 pub region_field: Option<String>,
26 pub postcode_field: Option<String>,
27 pub layer: Option<String>,
28 pub limit: usize,
29 pub lat_column: String,
30 pub lon_column: String,
31 pub join: Option<CoordinateJoinOptions>,
32}
33
34#[derive(Debug, Clone)]
35pub struct CoordinateJoinOptions {
36 pub path: PathBuf,
37 pub key_column: String,
38 pub lat_column: String,
39 pub lon_column: String,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct BatchGeocodeReport {
44 pub rows: usize,
45 pub resolved: usize,
46}
47
48#[derive(Debug, Clone, Default)]
49struct AuditFields {
50 status: String,
51 query: String,
52 score: String,
53 record_id: String,
54 id: String,
55 layer: String,
56 label: String,
57 lat: String,
58 lon: String,
59 precision: String,
60 source_dataset: String,
61 source_object_type: String,
62 source_object_id: String,
63 source_derived_from: String,
64 source_record_count: String,
65 reason: String,
66}
67
68#[derive(Debug, Clone)]
69struct CsvTable {
70 headers: Vec<String>,
71 rows: Vec<Vec<String>>,
72}
73
74#[derive(Debug, Clone, Copy)]
75struct CoordinateColumns {
76 lat_index: usize,
77 lon_index: usize,
78}
79
80#[derive(Debug, Clone)]
81struct JoinedCoordinate {
82 lat: String,
83 lon: String,
84 audit_fields: HashMap<String, String>,
85}
86
87const AUDIT_HEADERS: [&str; 16] = [
88 "geocode_status",
89 "geocode_query",
90 "geocode_score",
91 "geocode_record_id",
92 "geocode_id",
93 "geocode_layer",
94 "geocode_label",
95 "geocode_lat",
96 "geocode_lon",
97 "geocode_precision",
98 "geocode_source_dataset",
99 "geocode_source_object_type",
100 "geocode_source_object_id",
101 "geocode_source_derived_from",
102 "geocode_source_record_count",
103 "geocode_reason",
104];
105
106const BATCH_CHUNK_ROWS: usize = 50_000;
109
110pub fn run_batch_geocode(options: BatchGeocodeOptions) -> Result<BatchGeocodeReport> {
114 if options.address_field_groups.is_empty() == options.join.is_none() {
115 bail!("provide either --address-fields or --join-coordinates-from, but not both");
116 }
117 ensure_distinct_paths(&options.input, &options.output, &options.audit)?;
118
119 let mut reader = ReaderBuilder::new()
120 .flexible(true)
121 .from_path(&options.input)
122 .with_context(|| format!("failed to open {}", options.input.display()))?;
123 let original_headers = reader
124 .headers()
125 .with_context(|| {
126 format!(
127 "failed to read CSV headers from {}",
128 options.input.display()
129 )
130 })?
131 .iter()
132 .map(str::to_string)
133 .collect::<Vec<_>>();
134 let lookup = header_lookup(&original_headers);
135 let mut output_headers = original_headers.clone();
136 let coordinate_columns = ensure_coordinate_columns(
137 &mut output_headers,
138 &options.lat_column,
139 &options.lon_column,
140 )?;
141 let mut audit_headers = original_headers.clone();
142 audit_headers.extend(AUDIT_HEADERS.iter().map(|header| (*header).to_string()));
143
144 let resolver = match options.join.as_ref() {
145 Some(join) => Resolver::Join {
146 lookup: read_join_lookup(join)?,
147 key_index: find_header(&lookup, &join.key_column)
148 .with_context(|| format!("missing join key column {:?}", join.key_column))?,
149 },
150 None => {
151 let pack = options
152 .pack
153 .as_ref()
154 .context("--pack is required when geocoding address fields")?;
155 Resolver::Pack(Box::new(
156 PackTextSearcher::open(pack)
157 .with_context(|| format!("failed to open Pack {}", pack.display()))?,
158 ))
159 }
160 };
161
162 let mut output = create_csv(&options.output, &output_headers)?;
163 let mut audit = create_csv(&options.audit, &audit_headers)?;
164 let mut report = BatchGeocodeReport {
165 rows: 0,
166 resolved: 0,
167 };
168 let mut records = reader.records();
169 loop {
170 let chunk = records
171 .by_ref()
172 .take(BATCH_CHUNK_ROWS)
173 .map(|record| {
174 Ok(record
175 .with_context(|| {
176 format!("failed to read CSV record from {}", options.input.display())
177 })?
178 .iter()
179 .map(str::to_string)
180 .collect::<Vec<_>>())
181 })
182 .collect::<Result<Vec<_>>>()?;
183 if chunk.is_empty() {
184 break;
185 }
186 let audits = resolver.resolve(&options, &lookup, &chunk)?;
187 for (row, row_audit) in chunk.iter().zip(audits) {
188 let mut output_row = padded_row(row, output_headers.len());
189 if !row_audit.lat.trim().is_empty() && !row_audit.lon.trim().is_empty() {
190 set_coordinate_values(
191 &mut output_row,
192 coordinate_columns,
193 &row_audit.lat,
194 &row_audit.lon,
195 );
196 report.resolved += 1;
197 }
198 output.write_record(&output_row)?;
199 audit.write_record(audit_record(&original_headers, row, &row_audit))?;
200 report.rows += 1;
201 }
202 }
203 output.finish()?;
204 audit.finish()?;
205 Ok(report)
206}
207
208enum Resolver {
209 Pack(Box<PackTextSearcher>),
210 Join {
211 lookup: HashMap<String, JoinedCoordinate>,
212 key_index: usize,
213 },
214}
215
216impl Resolver {
217 fn resolve(
218 &self,
219 options: &BatchGeocodeOptions,
220 headers: &HashMap<String, usize>,
221 rows: &[Vec<String>],
222 ) -> Result<Vec<AuditFields>> {
223 match self {
224 Resolver::Join { lookup, key_index } => Ok(rows
225 .iter()
226 .map(|row| {
227 let key = row.get(*key_index).map(String::as_str).unwrap_or_default();
228 match lookup.get(key).filter(|joined| {
229 !joined.lat.trim().is_empty() && !joined.lon.trim().is_empty()
230 }) {
231 Some(joined) => audit_from_joined(joined),
232 None => AuditFields {
233 status: "unresolved".to_string(),
234 reason: "join key not found or joined coordinates are blank"
235 .to_string(),
236 ..AuditFields::default()
237 },
238 }
239 })
240 .collect()),
241 Resolver::Pack(searcher) => {
242 let mut order = (0..rows.len()).collect::<Vec<_>>();
243 order.sort_by_cached_key(|&index| locality_key(&rows[index], options, headers));
244 let mut results = order
245 .into_par_iter()
246 .map(|index| {
247 geocode_row(searcher, options, headers, &rows[index])
248 .map(|audit| (index, audit))
249 })
250 .collect::<Result<Vec<_>>>()?;
251 results.sort_unstable_by_key(|(index, _)| *index);
252 Ok(results.into_iter().map(|(_, audit)| audit).collect())
253 }
254 }
255 }
256}
257
258fn locality_key(
260 row: &[String],
261 options: &BatchGeocodeOptions,
262 headers: &HashMap<String, usize>,
263) -> [String; 4] {
264 let field = |name: Option<&str>| {
265 field_value(row, headers, name)
266 .map(|value| value.to_lowercase())
267 .unwrap_or_default()
268 };
269 [
270 field(options.postcode_field.as_deref()).replace(' ', ""),
271 field(options.region_field.as_deref()),
272 field(options.locality_field.as_deref()),
273 options
274 .address_field_groups
275 .first()
276 .and_then(|group| compose_query(row, headers, group))
277 .unwrap_or_default()
278 .to_lowercase(),
279 ]
280}
281
282pub fn parse_field_groups(values: &[String]) -> Result<Vec<Vec<String>>> {
283 values
284 .iter()
285 .map(|value| {
286 let fields = value
287 .split(',')
288 .map(str::trim)
289 .filter(|field| !field.is_empty())
290 .map(str::to_string)
291 .collect::<Vec<_>>();
292 if fields.is_empty() {
293 bail!("address field group must include at least one column")
294 }
295 Ok(fields)
296 })
297 .collect()
298}
299
300fn geocode_row(
301 searcher: &PackTextSearcher,
302 options: &BatchGeocodeOptions,
303 headers: &HashMap<String, usize>,
304 row: &[String],
305) -> Result<AuditFields> {
306 let locality = field_value(row, headers, options.locality_field.as_deref());
307 let region = field_value(row, headers, options.region_field.as_deref());
308 let postcode = field_value(row, headers, options.postcode_field.as_deref());
309
310 let mut tried_queries = Vec::new();
311 for group in &options.address_field_groups {
312 let Some(address) = compose_query(row, headers, group) else {
313 continue;
314 };
315 let response = searcher.geocode_address(AddressGeocodeOptions {
316 address: address.clone(),
317 locality: locality.clone(),
318 region: region.clone(),
319 postcode: postcode.clone(),
320 limit: options.limit,
321 layer: options.layer.clone(),
322 })?;
323 tried_queries.push(address);
324 if let Some(response) = response {
325 let point = response
326 .hit
327 .record
328 .point
329 .expect("geocode_address only returns hits with points");
330 return Ok(AuditFields {
331 status: "resolved".to_string(),
332 query: response.query,
333 score: response.hit.score.to_string(),
334 record_id: response.hit.record_id.to_string(),
335 id: response.hit.record.id,
336 layer: response.hit.record.layer,
337 label: response.hit.record.label,
338 lat: format_coordinate(point.lat),
339 lon: format_coordinate(point.lon),
340 precision: precision_name(point.precision).to_string(),
341 ..source_audit_fields(response.hit.record.source)
342 });
343 }
344 }
345
346 Ok(AuditFields {
347 status: "unresolved".to_string(),
348 query: tried_queries.join(" | "),
349 reason: "no engine result with matching coordinates and context".to_string(),
350 ..AuditFields::default()
351 })
352}
353
354fn read_csv(path: &Path) -> Result<CsvTable> {
355 let mut reader = ReaderBuilder::new()
356 .flexible(true)
357 .from_path(path)
358 .with_context(|| format!("failed to open {}", path.display()))?;
359 let headers = reader
360 .headers()
361 .with_context(|| format!("failed to read CSV headers from {}", path.display()))?
362 .iter()
363 .map(str::to_string)
364 .collect::<Vec<_>>();
365 let mut rows = Vec::new();
366 for record in reader.records() {
367 rows.push(
368 record
369 .with_context(|| format!("failed to read CSV record from {}", path.display()))?
370 .iter()
371 .map(str::to_string)
372 .collect::<Vec<_>>(),
373 );
374 }
375 Ok(CsvTable { headers, rows })
376}
377
378fn create_csv(path: &Path, headers: &[String]) -> Result<PendingCsv> {
379 if let Some(parent) = path.parent()
380 && !parent.as_os_str().is_empty()
381 {
382 std::fs::create_dir_all(parent)
383 .with_context(|| format!("failed to create {}", parent.display()))?;
384 }
385 let file_name = path
386 .file_name()
387 .with_context(|| format!("{} is not a file path", path.display()))?
388 .to_string_lossy();
389 let temporary = path.with_file_name(format!(".{file_name}.{}.tmp", uuid::Uuid::new_v4()));
390 let file = File::create(&temporary)
391 .with_context(|| format!("failed to create {}", temporary.display()))?;
392 let mut writer = WriterBuilder::new().from_writer(file);
393 writer
394 .write_record(headers)
395 .with_context(|| format!("failed to write headers to {}", path.display()))?;
396 Ok(PendingCsv {
397 writer,
398 temporary,
399 path: path.to_path_buf(),
400 finished: false,
401 })
402}
403
404struct PendingCsv {
407 writer: csv::Writer<File>,
408 temporary: PathBuf,
409 path: PathBuf,
410 finished: bool,
411}
412
413impl PendingCsv {
414 fn write_record<I, T>(&mut self, record: I) -> Result<()>
415 where
416 I: IntoIterator<Item = T>,
417 T: AsRef<[u8]>,
418 {
419 self.writer
420 .write_record(record)
421 .with_context(|| format!("failed to write a row to {}", self.path.display()))
422 }
423
424 fn finish(mut self) -> Result<()> {
425 self.writer
426 .flush()
427 .with_context(|| format!("failed to flush {}", self.path.display()))?;
428 std::fs::rename(&self.temporary, &self.path)
429 .with_context(|| format!("failed to write {}", self.path.display()))?;
430 self.finished = true;
431 Ok(())
432 }
433}
434
435impl Drop for PendingCsv {
436 fn drop(&mut self) {
437 if !self.finished {
438 let _ = std::fs::remove_file(&self.temporary);
439 }
440 }
441}
442
443fn ensure_distinct_paths(input: &Path, output: &Path, audit: &Path) -> Result<()> {
446 let resolve = |path: &Path| -> Result<PathBuf> {
447 if path.exists() {
448 return path
449 .canonicalize()
450 .with_context(|| format!("failed to resolve {}", path.display()));
451 }
452 let parent = match path.parent() {
453 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
454 _ => PathBuf::from("."),
455 };
456 let parent = parent.canonicalize().unwrap_or(parent);
457 Ok(parent.join(path.file_name().unwrap_or_default()))
458 };
459 let (input, output_path, audit_path) = (resolve(input)?, resolve(output)?, resolve(audit)?);
460 for (first, second, first_name, second_name) in [
461 (&output_path, &input, "--output", "--input"),
462 (&audit_path, &input, "--audit", "--input"),
463 (&output_path, &audit_path, "--output", "--audit"),
464 ] {
465 if first == second {
466 bail!(
467 "{first_name} and {second_name} name the same file ({}); choose a different path",
468 first.display()
469 );
470 }
471 }
472 Ok(())
473}
474
475fn read_join_lookup(join: &CoordinateJoinOptions) -> Result<HashMap<String, JoinedCoordinate>> {
476 let table = read_csv(&join.path)?;
477 let lookup = header_lookup(&table.headers);
478 let key_index = find_header(&lookup, &join.key_column)
479 .with_context(|| format!("missing join key column {:?}", join.key_column))?;
480 let lat_index = find_header(&lookup, &join.lat_column)
481 .with_context(|| format!("missing join latitude column {:?}", join.lat_column))?;
482 let lon_index = find_header(&lookup, &join.lon_column)
483 .with_context(|| format!("missing join longitude column {:?}", join.lon_column))?;
484 let mut coordinates = HashMap::new();
485 for row in table.rows {
486 let key = row.get(key_index).cloned().unwrap_or_default();
487 if key.trim().is_empty() {
488 continue;
489 }
490 let lat = row.get(lat_index).cloned().unwrap_or_default();
491 let lon = row.get(lon_index).cloned().unwrap_or_default();
492 let audit_fields = table
493 .headers
494 .iter()
495 .enumerate()
496 .filter_map(|(index, header)| {
497 row.get(index).map(|value| (header.clone(), value.clone()))
498 })
499 .collect::<HashMap<_, _>>();
500 let candidate = JoinedCoordinate {
501 lat,
502 lon,
503 audit_fields,
504 };
505 coordinates
506 .entry(key)
507 .and_modify(|existing: &mut JoinedCoordinate| {
508 if existing.lat.trim().is_empty() || existing.lon.trim().is_empty() {
509 *existing = candidate.clone();
510 }
511 })
512 .or_insert(candidate);
513 }
514 Ok(coordinates)
515}
516
517fn header_lookup(headers: &[String]) -> HashMap<String, usize> {
518 headers
519 .iter()
520 .enumerate()
521 .map(|(index, header)| (header.to_ascii_lowercase(), index))
522 .collect()
523}
524
525fn find_header(headers: &HashMap<String, usize>, name: &str) -> Option<usize> {
526 headers.get(&name.to_ascii_lowercase()).copied()
527}
528
529fn ensure_coordinate_columns(
530 headers: &mut Vec<String>,
531 lat_column: &str,
532 lon_column: &str,
533) -> Result<CoordinateColumns> {
534 if lat_column.eq_ignore_ascii_case(lon_column) {
535 bail!("latitude and longitude columns must be different");
536 }
537 let mut lookup = header_lookup(headers);
538 let lat_index = find_header(&lookup, lat_column).unwrap_or_else(|| {
539 headers.push(lat_column.to_string());
540 headers.len() - 1
541 });
542 lookup = header_lookup(headers);
543 let lon_index = find_header(&lookup, lon_column).unwrap_or_else(|| {
544 headers.push(lon_column.to_string());
545 headers.len() - 1
546 });
547 Ok(CoordinateColumns {
548 lat_index,
549 lon_index,
550 })
551}
552
553fn padded_row(row: &[String], len: usize) -> Vec<String> {
554 let mut padded = row.to_vec();
555 padded.resize(len, String::new());
556 padded
557}
558
559fn set_coordinate_values(row: &mut [String], columns: CoordinateColumns, lat: &str, lon: &str) {
560 row[columns.lat_index] = lat.to_string();
561 row[columns.lon_index] = lon.to_string();
562}
563
564fn field_value(
565 row: &[String],
566 headers: &HashMap<String, usize>,
567 field: Option<&str>,
568) -> Option<String> {
569 let index = find_header(headers, field?)?;
570 let value = row.get(index)?.trim();
571 (!value.is_empty()).then(|| value.to_string())
572}
573
574fn compose_query(
575 row: &[String],
576 headers: &HashMap<String, usize>,
577 fields: &[String],
578) -> Option<String> {
579 let parts = fields
580 .iter()
581 .filter_map(|field| {
582 let index = find_header(headers, field)?;
583 let value = row
584 .get(index)?
585 .split_whitespace()
586 .collect::<Vec<_>>()
587 .join(" ");
588 (!value.is_empty()).then_some(value)
589 })
590 .collect::<Vec<_>>();
591 (!parts.is_empty()).then(|| parts.join(", "))
592}
593
594fn audit_record(headers: &[String], row: &[String], audit: &AuditFields) -> Vec<String> {
595 let mut record = padded_row(row, headers.len());
596 record.extend([
597 audit.status.clone(),
598 audit.query.clone(),
599 audit.score.clone(),
600 audit.record_id.clone(),
601 audit.id.clone(),
602 audit.layer.clone(),
603 audit.label.clone(),
604 audit.lat.clone(),
605 audit.lon.clone(),
606 audit.precision.clone(),
607 audit.source_dataset.clone(),
608 audit.source_object_type.clone(),
609 audit.source_object_id.clone(),
610 audit.source_derived_from.clone(),
611 audit.source_record_count.clone(),
612 audit.reason.clone(),
613 ]);
614 record
615}
616
617fn audit_from_joined(joined: &JoinedCoordinate) -> AuditFields {
618 AuditFields {
619 status: joined
620 .audit_fields
621 .get("geocode_status")
622 .cloned()
623 .filter(|value| !value.trim().is_empty())
624 .unwrap_or_else(|| "joined".to_string()),
625 query: joined
626 .audit_fields
627 .get("geocode_query")
628 .cloned()
629 .unwrap_or_default(),
630 score: joined
631 .audit_fields
632 .get("geocode_score")
633 .cloned()
634 .unwrap_or_default(),
635 record_id: joined
636 .audit_fields
637 .get("geocode_record_id")
638 .cloned()
639 .unwrap_or_default(),
640 id: joined
641 .audit_fields
642 .get("geocode_id")
643 .cloned()
644 .unwrap_or_default(),
645 layer: joined
646 .audit_fields
647 .get("geocode_layer")
648 .cloned()
649 .unwrap_or_default(),
650 label: joined
651 .audit_fields
652 .get("geocode_label")
653 .cloned()
654 .unwrap_or_default(),
655 lat: joined.lat.clone(),
656 lon: joined.lon.clone(),
657 precision: joined
658 .audit_fields
659 .get("geocode_precision")
660 .cloned()
661 .unwrap_or_default(),
662 source_dataset: joined
663 .audit_fields
664 .get("geocode_source_dataset")
665 .cloned()
666 .unwrap_or_default(),
667 source_object_type: joined
668 .audit_fields
669 .get("geocode_source_object_type")
670 .cloned()
671 .unwrap_or_default(),
672 source_object_id: joined
673 .audit_fields
674 .get("geocode_source_object_id")
675 .cloned()
676 .unwrap_or_default(),
677 source_derived_from: joined
678 .audit_fields
679 .get("geocode_source_derived_from")
680 .cloned()
681 .unwrap_or_default(),
682 source_record_count: joined
683 .audit_fields
684 .get("geocode_source_record_count")
685 .cloned()
686 .unwrap_or_default(),
687 reason: joined
688 .audit_fields
689 .get("geocode_reason")
690 .cloned()
691 .unwrap_or_default(),
692 }
693}
694
695fn source_audit_fields(source: RecordSource) -> AuditFields {
696 AuditFields {
697 source_dataset: source.dataset,
698 source_object_type: source.object_type.map(object_type_name).unwrap_or_default(),
699 source_object_id: source
700 .object_id
701 .map(|object_id| object_id.to_string())
702 .unwrap_or_default(),
703 source_derived_from: source.derived_from.unwrap_or_default(),
704 source_record_count: source
705 .record_count
706 .map(|count| count.to_string())
707 .unwrap_or_default(),
708 ..AuditFields::default()
709 }
710}
711
712fn object_type_name(object_type: OsmObjectType) -> String {
713 match object_type {
714 OsmObjectType::Node => "node",
715 OsmObjectType::Way => "way",
716 OsmObjectType::Relation => "relation",
717 }
718 .to_string()
719}
720
721fn precision_name(precision: RecordPointPrecision) -> &'static str {
722 match precision {
723 RecordPointPrecision::Point => "point",
724 RecordPointPrecision::Centroid => "centroid",
725 RecordPointPrecision::RepresentativePoint => "representative_point",
726 }
727}
728
729fn format_coordinate(value: f64) -> String {
730 format!("{value:.7}")
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 #[test]
738 fn parses_repeated_field_groups() {
739 let groups = parse_field_groups(&[
740 "street1, city".to_string(),
741 "street2,postalcode".to_string(),
742 ])
743 .expect("parse groups");
744 assert_eq!(
745 groups,
746 vec![
747 vec!["street1".to_string(), "city".to_string()],
748 vec!["street2".to_string(), "postalcode".to_string()]
749 ]
750 );
751 }
752
753 #[test]
754 fn adds_missing_coordinate_columns() {
755 let mut headers = vec!["id".to_string(), "address".to_string()];
756 let columns = ensure_coordinate_columns(&mut headers, "lat", "lon").expect("columns");
757 assert_eq!(headers, vec!["id", "address", "lat", "lon"]);
758 assert_eq!(columns.lat_index, 2);
759 assert_eq!(columns.lon_index, 3);
760 }
761
762 #[test]
763 fn reuses_existing_coordinate_columns_case_insensitively() {
764 let mut headers = vec!["id".to_string(), "LAT".to_string(), "Lon".to_string()];
765 let columns = ensure_coordinate_columns(&mut headers, "lat", "lon").expect("columns");
766 assert_eq!(headers, vec!["id", "LAT", "Lon"]);
767 assert_eq!(columns.lat_index, 1);
768 assert_eq!(columns.lon_index, 2);
769 }
770
771 #[test]
772 fn composes_query_from_configured_fields() {
773 let headers = vec![
774 "street".to_string(),
775 "city".to_string(),
776 "postalcode".to_string(),
777 ];
778 let lookup = header_lookup(&headers);
779 let row = vec![
780 " 1333 Sheppard Ave E ".to_string(),
781 "Toronto".to_string(),
782 "M3C 1J4".to_string(),
783 ];
784 let query = compose_query(
785 &row,
786 &lookup,
787 &["street".to_string(), "postalcode".to_string()],
788 );
789 assert_eq!(query.as_deref(), Some("1333 Sheppard Ave E, M3C 1J4"));
790 }
791
792 #[test]
793 fn geocodes_streamed_rows_in_input_order() {
794 use crate::{
795 pack::PackWriter,
796 record::{
797 AddressComponents, AddressRecord, LocationPrecision, SourceProvenance,
798 point_geometry,
799 },
800 };
801
802 let root =
803 std::env::temp_dir().join(format!("open-geocode-batch-{}", uuid::Uuid::new_v4()));
804 let mut writer = PackWriter::create(root.join("pack")).expect("writer");
805 for (object_id, number, street, lon) in [
806 (1, "10", "King Street", -79.40),
807 (2, "20", "Queen Street", -79.41),
808 ] {
809 writer
810 .write(
811 &AddressRecord {
812 address: AddressComponents {
813 number: number.into(),
814 street: Some(street.into()),
815 place: None,
816 unit: None,
817 locality: None,
818 region: None,
819 postcode: None,
820 country: None,
821 },
822 geometry: point_geometry(lon, 43.6),
823 location_precision: LocationPrecision::Point,
824 source: SourceProvenance::osm(OsmObjectType::Node, object_id),
825 }
826 .into(),
827 None,
828 )
829 .expect("write");
830 }
831 writer.finish().expect("finish");
832
833 let input = root.join("in.csv");
834 std::fs::write(
835 &input,
836 "id,street\na,20 Queen St\nb,nowhere at all\nc,10 King Street\n",
837 )
838 .expect("input");
839 let report = run_batch_geocode(BatchGeocodeOptions {
840 pack: Some(root.join("pack")),
841 input,
842 output: root.join("out.csv"),
843 audit: root.join("audit.csv"),
844 address_field_groups: vec![vec!["street".into()]],
845 locality_field: None,
846 region_field: None,
847 postcode_field: None,
848 layer: None,
849 limit: 5,
850 lat_column: "lat".into(),
851 lon_column: "lon".into(),
852 join: None,
853 })
854 .expect("batch");
855 assert_eq!(
856 report,
857 BatchGeocodeReport {
858 rows: 3,
859 resolved: 2
860 }
861 );
862 let output = read_csv(&root.join("out.csv")).expect("output");
863 assert_eq!(output.headers, vec!["id", "street", "lat", "lon"]);
864 assert_eq!(
865 output.rows[0],
866 vec!["a", "20 Queen St", "43.6000000", "-79.4100000"]
867 );
868 assert_eq!(output.rows[1], vec!["b", "nowhere at all", "", ""]);
869 assert_eq!(
870 output.rows[2],
871 vec!["c", "10 King Street", "43.6000000", "-79.4000000"]
872 );
873 let audit = read_csv(&root.join("audit.csv")).expect("audit");
874 assert_eq!(audit.rows[1][2], "unresolved");
875 assert_eq!(audit.rows[2][6], "osm:node:1");
876 }
877
878 #[test]
879 fn refuses_to_overwrite_the_input() {
880 let root =
881 std::env::temp_dir().join(format!("open-geocode-batch-same-{}", uuid::Uuid::new_v4()));
882 std::fs::create_dir_all(&root).expect("dir");
883 let input = root.join("in.csv");
884 let contents = "id,street
885a,10 King Street
886";
887 std::fs::write(&input, contents).expect("input");
888 let coordinates = root.join("coordinates.csv");
889 std::fs::write(
890 &coordinates,
891 "id,lat,lon
892a,43.6,-79.4
893",
894 )
895 .expect("coordinates");
896 let options = |output: PathBuf, audit: PathBuf| BatchGeocodeOptions {
897 pack: None,
898 input: input.clone(),
899 output,
900 audit,
901 address_field_groups: Vec::new(),
902 locality_field: None,
903 region_field: None,
904 postcode_field: None,
905 layer: None,
906 limit: 5,
907 lat_column: "lat".into(),
908 lon_column: "lon".into(),
909 join: Some(CoordinateJoinOptions {
910 path: coordinates.clone(),
911 key_column: "id".into(),
912 lat_column: "lat".into(),
913 lon_column: "lon".into(),
914 }),
915 };
916 let same = root.join(".").join("in.csv");
918 for (output, audit) in [
919 (same.clone(), root.join("audit.csv")),
920 (root.join("out.csv"), same),
921 (root.join("out.csv"), root.join("out.csv")),
922 ] {
923 let error = run_batch_geocode(options(output, audit)).expect_err("same file");
924 assert!(format!("{error:#}").contains("same file"), "{error:#}");
925 assert_eq!(std::fs::read_to_string(&input).expect("input"), contents);
926 }
927 }
928
929 #[test]
930 fn pads_rows_before_writing_coordinates() {
931 let mut row = padded_row(&["1".to_string()], 3);
932 set_coordinate_values(
933 &mut row,
934 CoordinateColumns {
935 lat_index: 1,
936 lon_index: 2,
937 },
938 "43.1",
939 "-79.2",
940 );
941 assert_eq!(row, vec!["1", "43.1", "-79.2"]);
942 }
943}