1use std::{
9 collections::{BTreeSet, HashMap},
10 fmt, fs, io, mem,
11 path::{Path, PathBuf},
12 sync::Arc,
13};
14
15use anyhow::{Context, Result, bail};
16use tantivy::{
17 Index, IndexWriter, TantivyDocument,
18 directory::{
19 Directory, FileHandle, RamDirectory, WatchCallback, WatchHandle, WritePtr,
20 error::{DeleteError, OpenReadError, OpenWriteError},
21 },
22 indexer::UserOperation,
23 merge_policy::NoMergePolicy,
24 schema::{FAST, Field, IndexRecordOption, STRING, Schema, TextFieldIndexing, TextOptions},
25};
26
27use crate::{
28 container::{Bytes, Container, ContainerWriter},
29 extsort::Scratch,
30 memory::{MemoryBudget, Reservation},
31 pack::RecordId,
32 record::{
33 AddressComponents, AddressRecord, InterpolationAddressComponents, InterpolationRecord,
34 PlaceRecord, PostcodeRecord, Record, StreetRecord,
35 },
36 util::text::collapse_whitespace,
37};
38
39pub const TEXT_SECTION_PREFIX: &str = "text/";
40pub const TEXT_INDEX_SCHEMA_VERSION: u32 = 5;
41
42const MIN_INDEX_MEMORY_BYTES: usize = 16 << 20;
45const MAX_INDEX_MEMORY_BYTES: usize = 1_600_000_000;
46const TEXT_INDEX_BATCH_SIZE: usize = 10_000;
47const AUTOCOMPLETE_SUBJECT_FIELD: &str = "autocomplete_subject_text";
48
49pub struct TextIndexWriter {
50 writer: Option<(IndexWriter, Reservation)>,
53 budget: Arc<MemoryBudget>,
54 memory_bytes: usize,
55 index: Index,
56 path: PathBuf,
57 fields: TextIndexFields,
58 pending_documents: Vec<TantivyDocument>,
59 document_count: u64,
60}
61
62#[derive(Debug, Clone, Copy)]
63pub struct TextIndexFields {
64 pub record_id: Field,
65 pub layer: Field,
66 pub content_text: Field,
67 pub label_text: Field,
68 pub name_text: Field,
69 pub address_number: Field,
70 pub postcode_exact: Field,
71 pub autocomplete_subject_text: Field,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct TextIndexCommit {
76 pub document_count: u64,
77 pub bytes: u64,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct TextIndexDocument {
82 pub record_id: RecordId,
83 pub layer: String,
84 pub content_text: String,
85 pub label: Option<String>,
86 pub name: Option<String>,
87 pub address_number: Option<String>,
88 pub postcode: Option<String>,
89 pub autocomplete_subject_text: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93struct TextIndexProjection {
94 document: TextIndexDocument,
95}
96
97impl fmt::Debug for TextIndexWriter {
98 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99 formatter
100 .debug_struct("TextIndexWriter")
101 .field("fields", &self.fields)
102 .field("pending_document_count", &self.pending_documents.len())
103 .field("document_count", &self.document_count)
104 .finish_non_exhaustive()
105 }
106}
107
108impl TextIndexWriter {
109 pub fn create(
110 scratch: &Arc<Scratch>,
111 budget: &Arc<MemoryBudget>,
112 memory_bytes: usize,
113 ) -> Result<Self> {
114 let (schema, fields) = build_schema();
115 let path = scratch.path().join("text");
116 fs::create_dir_all(&path)
117 .with_context(|| format!("failed to create {}", path.display()))?;
118 let index = Index::create_in_dir(&path, schema)
119 .with_context(|| format!("failed to create Tantivy index {}", path.display()))?;
120 Ok(Self {
121 writer: None,
122 budget: Arc::clone(budget),
123 memory_bytes: memory_bytes.clamp(MIN_INDEX_MEMORY_BYTES, MAX_INDEX_MEMORY_BYTES),
124 index,
125 path,
126 fields,
127 pending_documents: Vec::with_capacity(TEXT_INDEX_BATCH_SIZE),
128 document_count: 0,
129 })
130 }
131
132 pub fn fields(&self) -> TextIndexFields {
133 self.fields
134 }
135
136 fn writer(&mut self) -> Result<&mut IndexWriter> {
137 if self.writer.is_none() {
138 let mut memory = Reservation::empty(&self.budget);
141 if !memory.try_grow(self.memory_bytes) {
142 memory.force_grow(self.memory_bytes);
143 }
144 let writer = self
145 .index
146 .writer(self.memory_bytes)
147 .context("failed to create Tantivy index writer")?;
148 writer.set_merge_policy(Box::new(NoMergePolicy));
151 self.writer = Some((writer, memory));
152 }
153 Ok(&mut self.writer.as_mut().expect("writer created").0)
154 }
155
156 pub fn add(&mut self, document: TantivyDocument) -> Result<()> {
157 self.pending_documents.push(document);
158 self.document_count += 1;
159 if self.pending_documents.len() >= TEXT_INDEX_BATCH_SIZE {
160 self.flush()?;
161 }
162 Ok(())
163 }
164
165 fn flush(&mut self) -> Result<()> {
166 if self.pending_documents.is_empty() {
167 return Ok(());
168 }
169 let documents = mem::replace(
170 &mut self.pending_documents,
171 Vec::with_capacity(TEXT_INDEX_BATCH_SIZE),
172 );
173 self.writer()?
174 .run(documents.into_iter().map(UserOperation::Add))
175 .context("failed to batch index records")?;
176 Ok(())
177 }
178
179 pub fn finish(mut self, pack: &mut ContainerWriter) -> Result<TextIndexCommit> {
181 self.flush()?;
182 self.writer()?;
183 let (mut writer, memory) = self.writer.take().expect("writer created");
184 writer
185 .commit()
186 .context("failed to commit Tantivy text index")?;
187 let segment_ids = self
188 .index
189 .searchable_segment_ids()
190 .context("failed to list text index segments")?;
191 if segment_ids.len() > 1 {
192 writer
193 .merge(&segment_ids)
194 .wait()
195 .context("failed to merge text index segments")?;
196 writer
197 .commit()
198 .context("failed to commit merged text index")?;
199 }
200 writer
201 .garbage_collect_files()
202 .wait()
203 .context("failed to garbage collect text index files")?;
204 writer
205 .wait_merging_threads()
206 .context("failed to stop Tantivy indexing threads")?;
207 drop(memory);
208
209 let mut files = fs::read_dir(&self.path)?
210 .map(|entry| entry.map(|entry| entry.path()))
211 .collect::<io::Result<Vec<_>>>()?;
212 files.sort();
213 let mut bytes = 0;
214 for file in files {
215 let name = file
216 .file_name()
217 .and_then(|name| name.to_str())
218 .context("text index file name is not UTF-8")?;
219 if name.ends_with(".lock") {
220 continue;
221 }
222 bytes += fs::metadata(&file)?.len();
223 pack.add_file(
224 &format!("{TEXT_SECTION_PREFIX}{name}"),
225 TEXT_INDEX_SCHEMA_VERSION,
226 &file,
227 )?;
228 }
229 Ok(TextIndexCommit {
230 document_count: self.document_count,
231 bytes,
232 })
233 }
234}
235
236impl TextIndexFields {
237 pub fn from_schema(schema: &Schema) -> Result<Self> {
238 Ok(Self {
239 record_id: schema.get_field("record_id")?,
240 layer: schema.get_field("layer")?,
241 content_text: schema.get_field("content_text")?,
242 label_text: schema.get_field("label_text")?,
243 name_text: schema.get_field("name_text")?,
244 address_number: schema.get_field("address_number")?,
245 postcode_exact: schema.get_field("postcode_exact")?,
246 autocomplete_subject_text: schema.get_field(AUTOCOMPLETE_SUBJECT_FIELD)?,
247 })
248 }
249
250 pub fn document(self, record_id: RecordId, record: &Record) -> TantivyDocument {
253 self.to_tantivy_document(&TextIndexDocument::from_record(record_id, record))
254 }
255
256 fn to_tantivy_document(self, projected: &TextIndexDocument) -> TantivyDocument {
257 let mut document = TantivyDocument::default();
258 document.add_u64(self.record_id, projected.record_id);
259 document.add_text(self.layer, &projected.layer);
260 if !projected.content_text.is_empty() {
261 document.add_text(self.content_text, &projected.content_text);
262 }
263 add_text_if_present(&mut document, self.label_text, projected.label.as_deref());
264 add_text_if_present(&mut document, self.name_text, projected.name.as_deref());
265 add_normalized_text_if_present(
266 &mut document,
267 self.address_number,
268 projected.address_number.as_deref(),
269 );
270 add_normalized_text_if_present(
271 &mut document,
272 self.postcode_exact,
273 projected.postcode.as_deref(),
274 );
275 if !projected.autocomplete_subject_text.is_empty() {
276 document.add_text(
277 self.autocomplete_subject_text,
278 &projected.autocomplete_subject_text,
279 );
280 }
281 document
282 }
283}
284
285impl TextIndexDocument {
286 pub fn from_record(record_id: RecordId, record: &Record) -> Self {
287 match record {
288 Record::Address(record) => Self::project_address(record_id, record),
289 Record::Interpolation(record) => Self::project_interpolation(record_id, record),
290 Record::Street(record) => Self::project_street(record_id, record),
291 Record::Postcode(record) => Self::project_postcode(record_id, record),
292 Record::Place(layer, record) => Self::project_place(record_id, layer.as_str(), record),
293 }
294 .document
295 }
296
297 fn project_address(record_id: RecordId, address: &AddressRecord) -> TextIndexProjection {
298 let mut builder = ProjectionBuilder::new(record_id, "address");
299 builder.label_for_search(&address.label());
300 builder.name_for_search(&address.name());
301 builder.address(&address.address);
302 builder.build()
303 }
304
305 fn project_interpolation(
306 record_id: RecordId,
307 interpolation: &InterpolationRecord,
308 ) -> TextIndexProjection {
309 let mut builder = ProjectionBuilder::new(record_id, "interpolation");
310 builder.label_for_search(&interpolation.label());
311 builder.name_for_search(&interpolation.name());
312 builder.interpolation_address(&interpolation.address);
313 builder.build()
314 }
315
316 fn project_street(record_id: RecordId, street: &StreetRecord) -> TextIndexProjection {
317 let mut builder = ProjectionBuilder::new(record_id, "street");
318 builder.label(&street.label());
319 builder.name(&street.name);
320 builder.build()
321 }
322
323 fn project_postcode(record_id: RecordId, postcode: &PostcodeRecord) -> TextIndexProjection {
324 let mut builder = ProjectionBuilder::new(record_id, "postcode");
325 builder.label_for_search(&postcode.label());
326 builder.name_for_search(&postcode.name());
327 builder.postcode(&postcode.postcode);
328 builder.build()
329 }
330
331 fn project_place(record_id: RecordId, layer: &str, place: &PlaceRecord) -> TextIndexProjection {
332 let mut builder = ProjectionBuilder::new(record_id, layer);
333 builder.label(&place.label());
334 builder.name(&place.name);
335 builder.build()
336 }
337}
338
339pub fn open_text_index(container: &Container) -> Result<Index> {
340 Index::open(PackDirectory::new(container)?).context("failed to open the Pack text index")
341}
342
343#[derive(Clone)]
346struct PackDirectory {
347 files: Arc<HashMap<PathBuf, Bytes>>,
348 scratch: RamDirectory,
349}
350
351impl fmt::Debug for PackDirectory {
352 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
353 formatter
354 .debug_struct("PackDirectory")
355 .field("files", &self.files.len())
356 .finish()
357 }
358}
359
360impl PackDirectory {
361 fn new(container: &Container) -> Result<Self> {
362 let mut files = HashMap::new();
363 for (name, info) in container.sections() {
364 let Some(file) = name.strip_prefix(TEXT_SECTION_PREFIX) else {
365 continue;
366 };
367 if info.version != TEXT_INDEX_SCHEMA_VERSION {
368 bail!(
369 "text index version {} is unsupported; rebuild the Pack for version {TEXT_INDEX_SCHEMA_VERSION}",
370 info.version
371 );
372 }
373 files.insert(PathBuf::from(file), container.raw_section(name)?);
374 }
375 if files.is_empty() {
376 bail!("Pack has no text index");
377 }
378 Ok(Self {
379 files: Arc::new(files),
380 scratch: RamDirectory::create(),
381 })
382 }
383}
384
385impl Directory for PackDirectory {
386 fn get_file_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>, OpenReadError> {
387 match self.files.get(path) {
388 Some(bytes) => Ok(Arc::new(bytes.clone())),
389 None => self.scratch.get_file_handle(path),
390 }
391 }
392
393 fn delete(&self, path: &Path) -> Result<(), DeleteError> {
394 self.scratch.delete(path)
395 }
396
397 fn exists(&self, path: &Path) -> Result<bool, OpenReadError> {
398 Ok(self.files.contains_key(path) || self.scratch.exists(path)?)
399 }
400
401 fn open_write(&self, path: &Path) -> Result<WritePtr, OpenWriteError> {
402 self.scratch.open_write(path)
403 }
404
405 fn atomic_read(&self, path: &Path) -> Result<Vec<u8>, OpenReadError> {
406 match self.files.get(path) {
407 Some(bytes) => Ok(bytes.as_slice().to_vec()),
408 None => self.scratch.atomic_read(path),
409 }
410 }
411
412 fn atomic_write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
413 self.scratch.atomic_write(path, data)
414 }
415
416 fn sync_directory(&self) -> io::Result<()> {
417 Ok(())
418 }
419
420 fn watch(&self, _watch_callback: WatchCallback) -> tantivy::Result<WatchHandle> {
421 Ok(WatchHandle::empty())
422 }
423}
424
425fn build_schema() -> (Schema, TextIndexFields) {
426 let mut builder = Schema::builder();
427 let record_id = builder.add_u64_field("record_id", FAST);
428 let exact_unstored = exact_string_options();
429 let layer = builder.add_text_field("layer", exact_unstored.clone());
430 let content_text = builder.add_text_field("content_text", searchable_text_options());
431 let label_text = builder.add_text_field("label_text", searchable_text_options());
432 let name_text = builder.add_text_field("name_text", searchable_text_options());
433 let address_number = builder.add_text_field("address_number", exact_unstored.clone());
434 let postcode_exact = builder.add_text_field("postcode_exact", exact_unstored);
435 let autocomplete_subject_text = builder.add_text_field(
436 AUTOCOMPLETE_SUBJECT_FIELD,
437 autocomplete_subject_text_options(),
438 );
439 let schema = builder.build();
440 let fields = TextIndexFields {
441 record_id,
442 layer,
443 content_text,
444 label_text,
445 name_text,
446 address_number,
447 postcode_exact,
448 autocomplete_subject_text,
449 };
450 (schema, fields)
451}
452
453fn searchable_text_options() -> TextOptions {
454 TextOptions::default().set_indexing_options(
455 TextFieldIndexing::default()
456 .set_tokenizer("default")
457 .set_index_option(IndexRecordOption::WithFreqs)
458 .set_fieldnorms(false),
459 )
460}
461
462fn autocomplete_subject_text_options() -> TextOptions {
463 TextOptions::default().set_indexing_options(
464 TextFieldIndexing::default()
465 .set_tokenizer("default")
466 .set_index_option(IndexRecordOption::WithFreqsAndPositions)
467 .set_fieldnorms(false),
468 )
469}
470
471fn exact_string_options() -> TextOptions {
472 STRING.set_indexing_options(
473 TextFieldIndexing::default()
474 .set_tokenizer("raw")
475 .set_index_option(IndexRecordOption::Basic)
476 .set_fieldnorms(false),
477 )
478}
479
480#[derive(Debug)]
481struct ProjectionBuilder {
482 projected: TextIndexDocument,
483 content_parts: Vec<String>,
484 autocomplete_subject_parts: Vec<String>,
485}
486
487impl ProjectionBuilder {
488 fn new(record_id: RecordId, layer: &str) -> Self {
489 Self {
490 projected: TextIndexDocument {
491 record_id,
492 layer: layer.to_string(),
493 content_text: String::new(),
494 label: None,
495 name: None,
496 address_number: None,
497 postcode: None,
498 autocomplete_subject_text: String::new(),
499 },
500 content_parts: Vec::new(),
501 autocomplete_subject_parts: Vec::new(),
502 }
503 }
504
505 fn label(&mut self, value: &str) {
506 self.projected.label = collapse_whitespace(value);
507 self.add_content_text(value);
508 self.add_autocomplete_subject_text(value);
509 }
510
511 fn name(&mut self, value: &str) {
512 self.projected.name = collapse_whitespace(value);
513 self.add_content_text(value);
514 self.add_autocomplete_subject_text(value);
515 }
516
517 fn label_for_search(&mut self, value: &str) {
518 self.projected.label = collapse_whitespace(value);
519 self.add_content_text(value);
520 }
521
522 fn name_for_search(&mut self, value: &str) {
523 self.projected.name = collapse_whitespace(value);
524 self.add_content_text(value);
525 }
526
527 fn address(&mut self, address: &AddressComponents) {
528 self.projected.address_number = collapse_whitespace(&address.number);
529 self.add_content_text(&address.number);
530 self.add_optional_content_text(address.street.as_deref());
531 self.add_optional_content_text(address.place.as_deref());
532 self.add_optional_content_text(address.unit.as_deref());
533 self.add_optional_content_text(address.locality.as_deref());
534 self.add_optional_content_text(address.region.as_deref());
535 let address_subject = address.street.as_deref().or(address.place.as_deref());
536 self.add_optional_autocomplete_text(address_subject);
537 self.projected.postcode = self.add_optional_postcode_text(address.postcode.as_deref());
538 self.add_optional_content_text(address.country.as_deref());
539 }
540
541 fn interpolation_address(&mut self, address: &InterpolationAddressComponents) {
542 self.add_optional_content_text(address.street.as_deref());
543 self.add_optional_content_text(address.place.as_deref());
544 self.add_optional_content_text(address.locality.as_deref());
545 self.add_optional_content_text(address.region.as_deref());
546 self.projected.postcode =
547 self.add_optional_postcode_for_search(address.postcode.as_deref());
548 self.add_optional_content_text(address.country.as_deref());
549 }
550
551 fn postcode(&mut self, value: &str) {
552 self.projected.postcode = collapse_whitespace(value);
553 self.add_content_text(value);
554 self.add_postcode_subject_text(value);
555 }
556
557 fn add_optional_autocomplete_text(&mut self, value: Option<&str>) {
558 let Some(cleaned) = value.and_then(collapse_whitespace) else {
559 return;
560 };
561 self.add_autocomplete_subject_text(&cleaned);
562 }
563
564 fn add_optional_content_text(&mut self, value: Option<&str>) {
565 if let Some(cleaned) = value.and_then(collapse_whitespace) {
566 self.add_content_text(&cleaned);
567 }
568 }
569
570 fn add_optional_postcode_text(&mut self, value: Option<&str>) -> Option<String> {
571 let cleaned = value.and_then(collapse_whitespace)?;
572 self.add_content_text(&cleaned);
573 self.add_postcode_subject_text(&cleaned);
574 Some(cleaned)
575 }
576
577 fn add_optional_postcode_for_search(&mut self, value: Option<&str>) -> Option<String> {
578 let cleaned = value.and_then(collapse_whitespace)?;
579 self.add_content_text(&cleaned);
580 Some(cleaned)
581 }
582
583 fn add_content_text(&mut self, value: &str) {
584 if let Some(normalized) = normalize_index_text(value) {
585 self.content_parts.push(normalized);
586 }
587 }
588
589 fn add_autocomplete_subject_text(&mut self, value: &str) {
590 if let Some(normalized) = normalize_index_text(value) {
591 self.autocomplete_subject_parts.push(normalized);
592 }
593 }
594
595 fn add_postcode_subject_text(&mut self, value: &str) {
596 if let Some(normalized) = normalize_index_text(value) {
597 self.content_parts.push(normalized.clone());
598 self.autocomplete_subject_parts.push(normalized.clone());
599 let compact = normalized.split_whitespace().collect::<String>();
600 if compact != normalized {
601 self.content_parts.push(compact.clone());
602 self.autocomplete_subject_parts.push(compact);
603 }
604 }
605 }
606
607 fn build(mut self) -> TextIndexProjection {
608 let content_parts = unique_parts(self.content_parts);
609 self.projected.content_text = content_parts.join(" ");
610 let autocomplete_subject_parts = unique_parts(self.autocomplete_subject_parts);
611 self.projected.autocomplete_subject_text = autocomplete_subject_parts.join(" ");
612
613 TextIndexProjection {
614 document: self.projected,
615 }
616 }
617}
618
619fn add_text_if_present(document: &mut TantivyDocument, field: Field, value: Option<&str>) {
620 if let Some(value) = value.and_then(collapse_whitespace) {
621 document.add_text(field, value);
622 }
623}
624
625fn add_normalized_text_if_present(
626 document: &mut TantivyDocument,
627 field: Field,
628 value: Option<&str>,
629) {
630 if let Some(value) = value.and_then(normalize_index_text) {
631 document.add_text(field, value);
632 }
633}
634
635pub(crate) fn normalize_index_text(value: &str) -> Option<String> {
636 let mut normalized = String::with_capacity(value.len());
637 let mut previous_was_space = true;
638 for character in value.chars() {
639 if character.is_alphanumeric() {
640 for folded in character.to_lowercase() {
641 normalized.push(folded);
642 }
643 previous_was_space = false;
644 } else if !previous_was_space {
645 normalized.push(' ');
646 previous_was_space = true;
647 }
648 }
649 let normalized = normalized.trim().to_string();
650 if normalized.is_empty() {
651 None
652 } else {
653 Some(normalized)
654 }
655}
656
657fn unique_parts(parts: Vec<String>) -> Vec<String> {
658 let mut seen = BTreeSet::new();
659 parts
660 .into_iter()
661 .filter(|part| seen.insert(part.clone()))
662 .collect()
663}
664
665#[cfg(test)]
666mod tests {
667 use std::collections::BTreeMap;
668
669 use crate::record::{
670 AddressRecord, DerivedSourceProvenance, InterpolationRange, LocationPrecision,
671 OsmObjectType, PlaceLayer, PostcodeRecord, SourceProvenance, point_geometry,
672 };
673
674 use super::*;
675
676 #[test]
677 fn projects_address_fields_for_search() {
678 let record = AddressRecord {
679 address: AddressComponents {
680 number: "221B".to_string(),
681 street: Some("Baker Street".to_string()),
682 place: None,
683 unit: None,
684 locality: Some("London".to_string()),
685 region: None,
686 postcode: Some("NW1".to_string()),
687 country: Some("GB".to_string()),
688 },
689 geometry: point_geometry(-0.1586, 51.5237),
690 location_precision: LocationPrecision::Point,
691 source: SourceProvenance::osm(OsmObjectType::Node, 123),
692 };
693
694 let projected = TextIndexDocument::from_record(42, &record.into());
695
696 assert_eq!(projected.record_id, 42);
697 assert_eq!(projected.layer, "address");
698 assert_eq!(projected.address_number.as_deref(), Some("221B"));
699 assert!(projected.content_text.contains("221b baker street"));
700 assert_eq!(projected.autocomplete_subject_text, "baker street nw1");
701 assert!(!projected.autocomplete_subject_text.contains("london"));
702 }
703
704 #[test]
705 fn projects_interpolation_without_indexing_range_fields() {
706 let record = crate::record::InterpolationRecord {
707 address: InterpolationAddressComponents {
708 street: Some("Baker Street".to_string()),
709 place: None,
710 locality: Some("London".to_string()),
711 region: None,
712 postcode: Some("NW1".to_string()),
713 country: Some("GB".to_string()),
714 },
715 interpolation: InterpolationRange {
716 kind: "odd".to_string(),
717 start: 1,
718 end: 99,
719 step: 2,
720 },
721 anchor_node_ids: [1, 2],
722 geometry: point_geometry(-0.1586, 51.5237),
723 representative_point: [-0.1586, 51.5237],
724 source: SourceProvenance::osm(OsmObjectType::Way, 9),
725 };
726
727 let projected = TextIndexDocument::from_record(7, &record.into());
728
729 assert_eq!(projected.address_number, None);
730 assert_eq!(projected.postcode.as_deref(), Some("NW1"));
731 assert!(projected.content_text.contains("baker street"));
732 assert!(projected.autocomplete_subject_text.is_empty());
733 }
734
735 #[test]
736 fn projects_postcodes_and_place_layers() {
737 let postcode = PostcodeRecord {
738 postcode: "M5V".to_string(),
739 geometry: point_geometry(-79.4, 43.6),
740 source: DerivedSourceProvenance::osm_address_records(2),
741 };
742 let place = PlaceRecord {
743 name: "Toronto".to_string(),
744 place_type: "city".to_string(),
745 geometry: point_geometry(-79.4, 43.6),
746 source: SourceProvenance {
747 dataset: "osm".to_string(),
748 object_type: OsmObjectType::Node,
749 object_id: 1,
750 tags: Some(BTreeMap::new()),
751 },
752 };
753
754 let postcode = TextIndexDocument::from_record(1, &postcode.into());
755 let place = TextIndexDocument::from_record(2, &Record::Place(PlaceLayer::Locality, place));
756
757 assert_eq!(postcode.postcode.as_deref(), Some("M5V"));
758 assert_eq!(place.layer, "locality");
759 assert!(place.content_text.contains("toronto"));
760 assert_eq!(postcode.autocomplete_subject_text, "m5v");
761 assert_eq!(place.autocomplete_subject_text, "toronto");
762 }
763}