Skip to main content

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}