1use std::collections::HashMap;
10
11use serde_json::{Map, Value};
12
13use crate::error::{Result, ShapefileError};
14use crate::input::Feature;
15
16const MAX_NAME_BYTES: usize = 11;
18pub const MAX_CHARACTER_WIDTH: usize = 254;
20const MAX_DECIMALS: usize = 15;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum FieldKind {
25 Character { width: u8 },
26 Numeric { width: u8, decimals: u8 },
27 Logical,
28}
29
30impl FieldKind {
31 pub fn label(&self) -> &'static str {
32 match self {
33 FieldKind::Character { .. } => "character",
34 FieldKind::Numeric { .. } => "numeric",
35 FieldKind::Logical => "logical",
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
43pub struct Field {
44 pub source: String,
46 pub name: String,
48 pub kind: FieldKind,
49}
50
51#[derive(Default)]
53struct Observation {
54 saw_string: bool,
55 saw_number: bool,
56 saw_bool: bool,
57 saw_nested: bool,
58 max_text_bytes: usize,
60 max_decimals: usize,
62 numbers: Vec<f64>,
63}
64
65impl Observation {
66 fn record(&mut self, value: &Value) {
67 match value {
68 Value::Null => {}
69 Value::String(text) => {
70 self.saw_string = true;
71 self.max_text_bytes = self.max_text_bytes.max(text.len());
72 }
73 Value::Bool(_) => {
74 self.saw_bool = true;
75 self.max_text_bytes = self.max_text_bytes.max(5); }
77 Value::Number(number) => {
78 self.saw_number = true;
79 if let Some(as_f64) = number.as_f64() {
80 self.max_decimals = self.max_decimals.max(fraction_digits(as_f64));
81 self.numbers.push(as_f64);
82 self.max_text_bytes = self.max_text_bytes.max(shortest_text(as_f64).len());
83 } else {
84 self.saw_nested = true;
86 self.max_text_bytes = self.max_text_bytes.max(number.to_string().len());
87 }
88 }
89 other => {
90 self.saw_nested = true;
91 self.max_text_bytes = self.max_text_bytes.max(other.to_string().len());
92 }
93 }
94 }
95
96 fn resolve(&self, max_character_width: usize) -> FieldKind {
97 let distinct_scalars = usize::from(self.saw_string)
98 + usize::from(self.saw_number)
99 + usize::from(self.saw_bool);
100
101 if self.saw_nested || distinct_scalars > 1 {
102 return character(self.max_text_bytes, max_character_width);
103 }
104
105 if self.saw_bool {
106 return FieldKind::Logical;
107 }
108
109 if self.saw_number {
110 let mut decimals = self.max_decimals.min(MAX_DECIMALS);
114 loop {
115 let width = self
116 .numbers
117 .iter()
118 .map(|value| format!("{value:.decimals$}").len())
119 .max()
120 .unwrap_or(1);
121 if width <= MAX_CHARACTER_WIDTH {
122 return FieldKind::Numeric {
123 width: width.max(1) as u8,
124 decimals: decimals as u8,
125 };
126 }
127 if decimals == 0 {
128 return character(self.max_text_bytes, max_character_width);
129 }
130 decimals -= 1;
131 }
132 }
133
134 character(self.max_text_bytes.max(1), max_character_width)
136 }
137}
138
139fn character(observed: usize, cap: usize) -> FieldKind {
140 let width = observed.clamp(1, cap.clamp(1, MAX_CHARACTER_WIDTH));
141 FieldKind::Character { width: width as u8 }
142}
143
144fn shortest_text(value: f64) -> String {
147 if value.is_finite() {
148 format!("{value}")
149 } else {
150 String::new()
151 }
152}
153
154fn fraction_digits(value: f64) -> usize {
155 let text = shortest_text(value);
156 match text.split_once('.') {
157 Some((_, fraction)) => fraction.len(),
158 None => 0,
159 }
160}
161
162fn sanitize_name(source: &str, taken: &mut HashMap<String, usize>) -> Result<String> {
165 let mut cleaned: String = source
166 .chars()
167 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
168 .collect();
169
170 if !cleaned
172 .chars()
173 .next()
174 .is_some_and(|c| c.is_ascii_alphabetic())
175 {
176 cleaned.insert(0, 'F');
177 }
178
179 cleaned.truncate(MAX_NAME_BYTES);
180 let cleaned = cleaned.trim_end_matches('_').to_string();
181 let base = if cleaned.is_empty() {
182 "FIELD".to_string()
183 } else {
184 cleaned
185 };
186
187 let next = taken.entry(base.clone()).or_insert(0);
188 *next += 1;
189 if *next == 1 {
190 return Ok(base);
191 }
192
193 for attempt in *next..(*next + 1000) {
195 let suffix = attempt.to_string();
196 let keep = MAX_NAME_BYTES.saturating_sub(suffix.len() + 1);
197 let mut candidate = base.clone();
198 candidate.truncate(keep);
199 let candidate = format!("{candidate}_{suffix}");
200 if !taken.contains_key(&candidate) {
201 taken.insert(candidate.clone(), 1);
202 return Ok(candidate);
203 }
204 }
205
206 Err(ShapefileError::FieldName(source.to_string()))
207}
208
209const SYNTHETIC_FID: &str = "\u{0}fid";
211
212pub struct Schema {
214 pub fields: Vec<Field>,
215 pub synthetic: bool,
217}
218
219impl Schema {
220 pub fn infer(features: &[Feature], max_character_width: usize) -> Result<Self> {
221 let mut order: Vec<String> = Vec::new();
222 let mut observations: HashMap<String, Observation> = HashMap::new();
223
224 for feature in features {
225 for (key, value) in &feature.properties {
226 let entry = observations.entry(key.clone()).or_insert_with(|| {
227 order.push(key.clone());
228 Observation::default()
229 });
230 entry.record(value);
231 }
232 }
233
234 let mut taken = HashMap::new();
235 let mut fields = Vec::with_capacity(order.len());
236 for source in order {
237 let kind = observations[&source].resolve(max_character_width);
238 let name = sanitize_name(&source, &mut taken)?;
239 fields.push(Field { source, name, kind });
240 }
241
242 if fields.is_empty() {
245 fields.push(Field {
246 source: SYNTHETIC_FID.to_string(),
247 name: "FID".to_string(),
248 kind: FieldKind::Numeric {
249 width: 11,
250 decimals: 0,
251 },
252 });
253 return Ok(Self {
254 fields,
255 synthetic: true,
256 });
257 }
258
259 Ok(Self {
260 fields,
261 synthetic: false,
262 })
263 }
264
265 pub fn builder(&self) -> Result<dbase::TableWriterBuilder> {
266 let mut builder = dbase::TableWriterBuilder::with_encoding(dbase::UnicodeLossy);
269
270 for field in &self.fields {
271 let name = dbase::FieldName::try_from(field.name.as_str())
272 .map_err(|_| ShapefileError::FieldName(field.source.clone()))?;
273 builder = match field.kind {
274 FieldKind::Character { width } => builder.add_character_field(name, width),
275 FieldKind::Numeric { width, decimals } => {
276 builder.add_numeric_field(name, width, decimals)
277 }
278 FieldKind::Logical => builder.add_logical_field(name),
279 };
280 }
281
282 Ok(builder)
283 }
284
285 pub fn record(&self, properties: &Map<String, Value>, index: usize) -> dbase::Record {
288 let mut record = dbase::Record::default();
289
290 for field in &self.fields {
291 if field.source == SYNTHETIC_FID {
292 record.insert(
293 field.name.clone(),
294 dbase::FieldValue::Numeric(Some(index as f64)),
295 );
296 continue;
297 }
298 let value = properties.get(&field.source).unwrap_or(&Value::Null);
299 record.insert(field.name.clone(), to_field_value(value, field.kind));
300 }
301
302 record
303 }
304}
305
306fn to_field_value(value: &Value, kind: FieldKind) -> dbase::FieldValue {
307 match kind {
308 FieldKind::Logical => dbase::FieldValue::Logical(value.as_bool()),
309 FieldKind::Numeric { .. } => dbase::FieldValue::Numeric(match value {
310 Value::Number(number) => number.as_f64().filter(|n| n.is_finite()),
311 _ => None,
312 }),
313 FieldKind::Character { width } => {
314 let text = match value {
315 Value::Null => return dbase::FieldValue::Character(None),
316 Value::String(text) => text.clone(),
317 Value::Number(number) => number
318 .as_f64()
319 .map(shortest_text)
320 .unwrap_or_else(|| number.to_string()),
321 Value::Bool(flag) => flag.to_string(),
322 other => other.to_string(),
323 };
324 dbase::FieldValue::Character(Some(truncate_utf8(text, width as usize)))
325 }
326 }
327}
328
329fn truncate_utf8(mut text: String, max_bytes: usize) -> String {
332 if text.len() <= max_bytes {
333 return text;
334 }
335 let mut end = max_bytes;
336 while end > 0 && !text.is_char_boundary(end) {
337 end -= 1;
338 }
339 text.truncate(end);
340 text
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn field_names_are_truncated_and_deduplicated() {
349 let mut taken = HashMap::new();
350 assert_eq!(
351 sanitize_name("population", &mut taken).unwrap(),
352 "population"
353 );
354 assert_eq!(
356 sanitize_name("population_density", &mut taken).unwrap(),
357 "populatio_2"
358 );
359 assert_eq!(
360 sanitize_name("population", &mut taken).unwrap(),
361 "populatio_3"
362 );
363 }
364
365 #[test]
366 fn names_starting_with_a_digit_get_a_prefix() {
367 let mut taken = HashMap::new();
368 assert_eq!(sanitize_name("2020_pop", &mut taken).unwrap(), "F2020_pop");
369 }
370
371 #[test]
372 fn numeric_width_covers_the_widest_value() {
373 let mut observation = Observation::default();
374 observation.record(&serde_json::json!(1.5));
375 observation.record(&serde_json::json!(-12345.25));
376 match observation.resolve(MAX_CHARACTER_WIDTH) {
377 FieldKind::Numeric { width, decimals } => {
378 assert_eq!(decimals, 2);
379 assert_eq!(width, 9);
381 }
382 other => panic!("expected numeric, got {other:?}"),
383 }
384 }
385
386 #[test]
387 fn mixed_types_fall_back_to_text() {
388 let mut observation = Observation::default();
389 observation.record(&serde_json::json!("a"));
390 observation.record(&serde_json::json!(1));
391 assert!(matches!(
392 observation.resolve(MAX_CHARACTER_WIDTH),
393 FieldKind::Character { .. }
394 ));
395 }
396
397 #[test]
398 fn truncation_respects_char_boundaries() {
399 assert_eq!(truncate_utf8("héllo".to_string(), 2), "h");
400 }
401}