Skip to main content

open_geocode/
batch.rs

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
106/// Rows read, geocoded and written per chunk. Chunks keep memory flat for
107/// inputs of any size.
108const BATCH_CHUNK_ROWS: usize = 50_000;
109
110/// Geocode a CSV in streaming chunks. Within a chunk, rows are looked up in
111/// parallel and grouped by postcode and place, so neighbouring lookups touch
112/// the same index and record pages; output rows keep the input order.
113pub 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
258/// Group key: rows in the same postcode and place are geocoded together.
259fn 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
404/// A CSV written beside its destination and moved into place only once it is
405/// complete, so a failed run never leaves a truncated file behind.
406struct 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
443/// Refuse output paths that name the input or each other, however they are
444/// spelled.
445fn 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        // The same file spelled differently must be caught too.
917        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}