ixa_fips/parser.rs
1//! Simple parsing utilities for parsing text representations of FIPS codes and variations thereupon.
2//!
3//! # Terminology and Textual Representation
4//!
5//! We use the phrase *census tract* (block, place, etc.) to refer to the full 11-digit encoding, while the phrase *census
6//! tract code* (resp. block code, place code, etc.) refers to the 6 digits for the tract designation itself. The digits of
7//! more specific structures are generally the rightmost decimal digits of the encoding. Thus the census tract code is the
8//! rightmost 6 digits of the census tract.
9//!
10//! # FIPS Code Structure
11//!
12//! Source: <https://www.census.gov/programs-surveys/geography/guidance/geo-identifiers.html>
13//!
14//! | **Area Type** | **GEOID Structure** | **Number of Digits** | **Example Geographic Area** | **Example GEOID** |
15//! | ------------------------------------------ | ------------------------------ | -------------------- | ------------------------------------------------------- | ----------------- |
16//! | State | STATE | 2 | Texas | 48 |
17//! | County | STATE+COUNTY | 2+3=5 | Harris County, TX | 48201 |
18//! | County Subdivision | STATE+COUNTY+COUSUB | 2+3+5=10 | Pasadena CCD, Harris County, TX | 4820192975 |
19//! | Census Tract | STATE+COUNTY+TRACT | 2+3+6=11 | Census Tract 2231 in Harris County, TX | 48201223100 |
20//! | Block Group | STATE+COUNTY+TRACT+BLOCK GROUP | 2+3+6+1=12 | Block Group 1 in Census Tract 2231 in Harris County, TX | 482012231001 |
21//! | Block* | STATE+COUNTY+TRACT+BLOCK | 2+3+6+4=15 | Block 1050 in Census Tract 2231 in Harris County, TX | 482012231001050 |
22//! | Places | STATE+PLACE | 2+5=7 | Houston, TX | 4835000 |
23//! | Congressional District (113th Congress) | STATE+CD | 2+2=4 | Connecticut District 2 | 902 |
24//! | State Legislative District (Upper Chamber) | STATE+SLDU | 2+3=5 | Connecticut State Senate District 33 | 9033 |
25//! | State Legislative District (Lower Chamber) | STATE+SLDL | 2+3=5 | Connecticut State House District 147 | 9147 |
26//! | ZCTA ** | ZCTA | 5 | Suitland, MD ZCTA | 20746 |
27//!
28//! \* The block group code is not included in the census block GEOID code
29//! because the first digit of a census block code represents the block group
30//! code. Note – some blocks also contain a one-character suffix (A, B, C, etc.)
31//!
32//! \** ZIP Code Tabulation Areas (ZCTAs) are generalized areal representations
33//! of United States Postal Service (USPS) ZIP Code service areas.
34
35use std::fmt::{Debug, Display};
36
37use crate::StateCode;
38
39/// The FIPS parser error type.
40/// The assumption is that the parsing context is so small that it isn't necessary to track source location information.
41#[derive(Copy, Clone, PartialEq, Eq)]
42pub enum FIPSParserError {
43 InvalidDigit { found: char },
44 InvalidLength { expected: u32, found: u32 },
45 ValueExceedsCapacity { value: u64, capacity: u64 },
46}
47
48impl Display for FIPSParserError {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 FIPSParserError::InvalidDigit { found } => write!(f, "Invalid digit: {}", found),
52 FIPSParserError::InvalidLength { expected, found } => {
53 write!(f, "Expected {} characters, found {}", expected, found)
54 }
55 FIPSParserError::ValueExceedsCapacity { value, capacity } => {
56 write!(f, "Value {} exceeds max capacity {}", value, capacity)
57 }
58 }
59 }
60}
61
62impl Debug for FIPSParserError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 Display::fmt(&self, f)
65 }
66}
67
68impl std::error::Error for FIPSParserError {}
69
70/// Similar to how Nom structures its results. We have:
71/// `I`: The input type, i.e. `&str`
72/// `O`: The output type, i.e. `u32`
73/// `E`: The error type returns a tuple of the original input and the error.
74/// A successful result consists of the remaining unparsed input and the parsed value.
75pub type IResult<I, O, E = (I, FIPSParserError)> = Result<(I, O), E>;
76pub type FIPSParseResult<'a, T> = IResult<&'a str, T>;
77
78/// A function that parses a specified number of decimal digits, enforcing the
79/// constraint that the parsed value of those digits be representable by the
80/// specified number of binary bits. Upon success, returns the remainder of the
81/// input after consuming the parsed digits together with the value of the
82/// parsed digits. If there is an error, the original input is returned along
83/// with the [`FIPSParserError`] variant describing the error.
84///
85/// This function assumes ASCII decimal digits. (The rest of the string can be any valid UTF-8.)
86pub fn parse_decimal_digits_to_bits(
87 digit_count: u32,
88 bit_count: u8,
89 input: &str,
90) -> IResult<&str, u64> {
91 let maximum_allowed_value = (1u64 << bit_count) - 1;
92 let mut input_bytes = input.as_bytes().iter();
93 let mut computed_value: u64 = 0;
94
95 for idx in 0..digit_count {
96 match input_bytes.next() {
97 Some(c) => {
98 if c.is_ascii_digit() {
99 computed_value = 10 * computed_value + (c - b'0') as u64;
100 } else {
101 return Err((
102 input,
103 FIPSParserError::InvalidDigit {
104 // The UTF-8 encoded character at `idx` might not be represented as a single byte.
105 // However, as we assume ASCII decimal digits, we are guaranteed that the first
106 // `idx-1` bytes represent `idx-1` characters.
107 found: input.chars().nth(idx as usize).unwrap(),
108 },
109 ));
110 }
111 }
112
113 None => {
114 // Ran out of digits before we were done parsing.
115 return Err((
116 input,
117 FIPSParserError::InvalidLength {
118 expected: digit_count,
119 found: idx,
120 },
121 ));
122 }
123 } // end match next byte
124 } // end for idx
125
126 // Enforce the bit count constraint.
127 if computed_value > maximum_allowed_value {
128 return Err((
129 input,
130 FIPSParserError::ValueExceedsCapacity {
131 value: computed_value,
132 capacity: maximum_allowed_value,
133 },
134 ));
135 }
136
137 let remaining = &input[digit_count as usize..];
138 Ok((remaining, computed_value))
139}
140
141/// Parses the first two decimal digits of `input` into a [`StateCode`].
142pub fn parse_state_code(input: &str) -> FIPSParseResult<StateCode> {
143 parse_decimal_digits_to_bits(2, 7, input).map(|(rest, value)| (rest, value as StateCode))
144}
145
146/// Parses the first three digits of `input` as a FIPS county code.
147pub fn parse_county_code(input: &str) -> FIPSParseResult<u16> {
148 parse_decimal_digits_to_bits(3, 10, input).map(|(rest, value)| {
149 // The `parse_decimal_digits_to_bits` function guarantees `value` fits in 10 bits.
150 (rest, value as u16)
151 })
152}
153
154/// Parses the first six digits of `input` as a FIPS census tract code.
155pub fn parse_tract_code(input: &str) -> FIPSParseResult<u32> {
156 parse_decimal_digits_to_bits(6, 20, input).map(|(rest, value)| {
157 // The `parse_decimal_digits_to_bits` function guarantees `value` fits in 20 bits.
158 (rest, value as u32)
159 })
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::USState;
166
167 #[test]
168 fn test_parse_decimal_digits_to_bits_valid_cases() {
169 // Test with different digit and bit counts
170 assert_eq!(
171 parse_decimal_digits_to_bits(2, 6, "42rest"),
172 Ok(("rest", 42))
173 );
174 assert_eq!(
175 parse_decimal_digits_to_bits(3, 10, "123more"),
176 Ok(("more", 123))
177 );
178 assert_eq!(parse_decimal_digits_to_bits(1, 4, "7end"), Ok(("end", 7)));
179 assert_eq!(
180 parse_decimal_digits_to_bits(6, 20, "123456extra"),
181 Ok(("extra", 123456))
182 );
183
184 // Test maximum values for given bit constraints
185 assert_eq!(
186 parse_decimal_digits_to_bits(2, 6, "63text"),
187 Ok(("text", 63))
188 );
189 assert_eq!(
190 parse_decimal_digits_to_bits(3, 10, "999text"),
191 Ok(("text", 999))
192 );
193 assert_eq!(
194 parse_decimal_digits_to_bits(6, 20, "999999text"),
195 Ok(("text", 999999))
196 );
197 }
198
199 #[test]
200 fn test_parse_decimal_digits_to_bits_invalid_cases() {
201 // Too few digits in input
202 assert!(parse_decimal_digits_to_bits(2, 6, "4").is_err());
203 assert!(parse_decimal_digits_to_bits(3, 10, "12").is_err());
204
205 // Non-digit characters
206 assert!(parse_decimal_digits_to_bits(2, 6, "a4rest").is_err());
207 assert!(parse_decimal_digits_to_bits(3, 10, "1x3more").is_err());
208
209 // Value exceeds bit constraint
210 assert!(parse_decimal_digits_to_bits(2, 6, "64text").is_err()); // 64 doesn't fit in 6 bits
211 assert_eq!(
212 parse_decimal_digits_to_bits(3, 8, "256text"),
213 Err((
214 "256text",
215 FIPSParserError::ValueExceedsCapacity {
216 value: 256,
217 capacity: 255
218 }
219 ))
220 ); // 256 doesn't fit in 8 bits
221 assert_eq!(
222 parse_decimal_digits_to_bits(7, 20, "1048576text"),
223 Err((
224 "1048576text",
225 FIPSParserError::ValueExceedsCapacity {
226 value: 1048576,
227 capacity: 1048575
228 }
229 ))
230 ); // 2^20 = 1048576
231 }
232
233 #[test]
234 fn test_parse_state_code_valid_cases() {
235 // Test with valid state codes (assuming implementation of USState enum)
236 assert!(parse_state_code("01rest").is_ok()); // Alabama
237 assert!(parse_state_code("06rest").is_ok()); // California
238 assert!(parse_state_code("48rest").is_ok()); // Texas
239 assert!(parse_state_code("36rest").is_ok()); // New York
240
241 // Check that remainder is correctly returned
242 let (remainder, _) = parse_state_code("42Pennsylvania").unwrap();
243 assert_eq!(remainder, "Pennsylvania");
244 }
245
246 #[test]
247 fn test_parse_state_code_invalid_cases() {
248 // Non-digit characters
249 assert!(parse_state_code("A1rest").is_err());
250
251 // Too few digits
252 assert!(parse_state_code("4").is_err());
253
254 // Empty input
255 assert!(parse_state_code("").is_err());
256 }
257
258 #[test]
259 fn test_parse_county_code_valid_cases() {
260 // Test with valid county codes
261 assert_eq!(parse_county_code("001rest").unwrap().1, 1);
262 assert_eq!(parse_county_code("123rest").unwrap().1, 123);
263 assert_eq!(parse_county_code("999rest").unwrap().1, 999);
264
265 // Check that remainder is correctly returned
266 let (remainder, _) = parse_county_code("001CountyName").unwrap();
267 assert_eq!(remainder, "CountyName");
268 }
269
270 #[test]
271 fn test_parse_county_code_invalid_cases() {
272 // Non-digit characters
273 assert_eq!(
274 parse_county_code("x01rest"),
275 Err(("x01rest", FIPSParserError::InvalidDigit { found: 'x' }))
276 );
277
278 // Too few digits
279 assert_eq!(
280 parse_county_code("12"),
281 Err((
282 "12",
283 FIPSParserError::InvalidLength {
284 expected: 3,
285 found: 2
286 }
287 ))
288 );
289
290 // Empty input
291 assert_eq!(
292 parse_county_code(""),
293 Err((
294 "",
295 FIPSParserError::InvalidLength {
296 expected: 3,
297 found: 0
298 }
299 ))
300 );
301 }
302
303 #[test]
304 fn test_parse_tract_code_valid_cases() {
305 // Test with valid tract codes
306 assert_eq!(parse_tract_code("000001rest").unwrap().1, 1);
307 assert_eq!(parse_tract_code("123456rest").unwrap().1, 123456);
308 assert_eq!(parse_tract_code("999999rest").unwrap().1, 999999);
309
310 // Check that remainder is correctly returned
311 let (remainder, _) = parse_tract_code("123456TractInfo").unwrap();
312 assert_eq!(remainder, "TractInfo");
313 }
314
315 #[test]
316 fn test_parse_tract_code_invalid_cases() {
317 // Non-digit characters
318 assert!(parse_tract_code("12345xrest").is_err());
319
320 // Too few digits
321 assert!(parse_tract_code("12345").is_err());
322
323 // Empty input
324 assert!(parse_tract_code("").is_err());
325 }
326
327 #[test]
328 fn test_integration_fips_parsing() {
329 // Test parsing a complete FIPS code (state + county + tract)
330 // Example: "01001020100" = Alabama (01), Autauga County (001), Tract 020100
331
332 let input = "01001020100RestOfData";
333
334 // First parse the state
335 let (remainder1, state) = parse_state_code(input).unwrap();
336 assert_eq!(remainder1, "001020100RestOfData");
337
338 // Then parse the county
339 let (remainder2, county) = parse_county_code(remainder1).unwrap();
340 assert_eq!(remainder2, "020100RestOfData");
341
342 // Finally, parse the tract
343 let (remainder3, tract) = parse_tract_code(remainder2).unwrap();
344 assert_eq!(remainder3, "RestOfData");
345
346 // Verify the parsed values (assuming USState enum implementation)
347 assert_eq!(state, USState::AL.into());
348 assert_eq!(county, 1);
349 assert_eq!(tract, 20100);
350 }
351}