Skip to main content

gukasha_rustrade/
lib.rs

1// Copyright (C) 2026 WFGukasha
2//
3// SPDX-License-Identifier: LGPL-2.1-only
4
5//! # Gukasha Rustrade
6//!
7//! A Rust toolbox for trade and logistics, focusing on HS Code validation,
8//! description lookup, and digit-level comparison.
9//!
10//! ## Current features
11//! - HS Code validation (length: 6–14 even digits)
12//! - Chapter extraction (first 2 digits)
13//! - Diff: find differing digit indices between two codes
14//! - Commodity description lookup (from precompiled HS table)
15//! - `FromStr` trait for `"010121".parse()`
16
17use crate::HscodeError::{HsChapterError, HsCodeLenError, InputError};
18use std::fmt;
19use std::io::Read;
20use std::str::FromStr;
21use thiserror::Error;
22
23include!(concat!(env!("OUT_DIR"), "/hs_data.rs"));
24
25/// Errors that can occur during HS Code parsing and validation.
26#[derive(Error, Debug)]
27pub enum HscodeError {
28    /// Input contains non-digit characters.
29    #[error("Invalid input format: non-digit character")]
30    InputError,
31    /// HS Code length is invalid (expected 6-14 even digits).
32    #[error("Invalid HS code length: expected 6-14 even digits, got {0}")]
33    HsCodeLenError(usize),
34    /// Chapter number (first two digits) is outside the valid range 1..=97.
35    #[error("Chapter out of range: expected 1-97, got {0}")]
36    HsChapterError(u8),
37}
38
39/// A validated HS Code stored as a vector of numeric bytes (0-9).
40///
41/// The inner `Vec<u8>` stores each digit as its numeric value for efficient
42/// comparison and slicing. Use the provided constructors to create a valid `HsCode`.
43///
44/// # Example
45/// ```
46/// let code = HsCode::new_from_str("010121");
47/// assert_eq!(code.get_chapter(), 1);
48/// ```
49#[derive(PartialOrd, PartialEq, Debug)]
50pub struct HsCode(Vec<u8>);
51
52impl fmt::Display for HsCode {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        let s: String = self.0.iter().map(|&d| (d + b'0') as char).collect();
55        write!(f, "{}", s)
56    }
57}
58
59/// Enables parsing an `HsCode` from a string using the `parse()` method.
60///
61/// # Example
62/// ```
63/// use gukasha_rustrade::HsCode;
64/// use std::str::FromStr;
65///
66/// let code = HsCode::from_str("01012100").unwrap();
67/// ```
68impl FromStr for HsCode {
69    type Err = HscodeError;
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        Self::try_new_from_str(s)
72    }
73}
74
75/// Looks up a commodity description by its 6-digit HS code.
76///
77/// # Examples
78///
79/// ```
80/// use gukasha_rustrade::lookup;
81///
82/// let desc = lookup("010121").unwrap();
83/// assert_eq!(desc, "Horses; live");
84/// ```
85pub fn lookup(code: &str) -> Option<&'static str> {
86    let key = if code.len() >= 6 { &code[..6] } else { code };
87    HS_MAP.get(key).copied()
88}
89
90impl HsCode {
91    /// ```
92    /// Creates an `HsCode` from a string, panicking on invalid input.
93    ///
94    /// Use this only when the input is guaranteed to be valid.
95    /// For fallible parsing, use `try_new_from_str` or `FromStr::from_str`.
96    pub fn new_from_str(input: &str) -> Self {
97        Self::try_new_from_str(input).unwrap()
98    }
99
100    /// Returns the chapter number (first two digits) as a `u8`.
101    ///
102    /// # Example
103    /// ```
104    /// # use gukasha_rustrade::HsCode;
105    /// let code = HsCode::new_from_str("01012100");
106    /// assert_eq!(code.get_chapter(), 1);
107    /// ```
108    pub fn get_chapter(&self) -> u8 {
109        self.0[0] * 10 + self.0[1]
110    }
111
112    /// Attempts to parse an `HsCode` from a string.
113    ///
114    /// This is the main fallible constructor. It validates length, digit characters,
115    /// and chapter range.
116    pub fn try_new_from_str(input: &str) -> Result<Self, HscodeError> {
117        verify_and_trans_hs_code(input).map(HsCode)
118    }
119
120    /// Returns the 0‑based indices where two HS Codes differ.
121    ///
122    /// Only valid positions in the shorter code are considered.
123    ///
124    /// # Example
125    /// ```
126    /// let a = HsCode::new_from_str("010121");
127    /// let b = HsCode::new_from_str("010128");
128    /// assert_eq!(a.diff(&b), vec![5]);
129    /// ```
130    pub fn diff(&self, other: &HsCode) -> Vec<usize> {
131        self.0
132            .iter()
133            .zip(other.0.iter())
134            .enumerate()
135            .filter_map(|(idx, (x, y))| if x != y { Some(idx) } else { None })
136            .collect()
137    }
138
139    /// Looks up the commodity description for the first 6 digits.
140    ///
141    /// The description comes from a precompiled static map generated from
142    /// `data/harmonized-system.csv` at build time.
143    /// Returns `None` if the 6‑digit prefix is not found.
144    pub fn description(&self) -> Option<&'static str> {
145        let key: String = self.0.iter().take(6).map(|&d| (d + b'0') as char).collect();
146        HS_MAP.get(&key).copied()
147    }
148
149    /// Returns the total number of digits in this HS code.
150    pub fn len(&self) -> usize {
151        self.0.len()
152    }
153
154    /// HS codes are never empty. This method always returns `false`.
155    pub fn is_empty(&self) -> bool {
156        false
157    }
158
159    /// Returns `true` if this HS code has the standard 6-digit international length.
160    pub fn is_six_digit(&self) -> bool {
161        self.len() == 6
162    }
163
164    /// Returns `true` if this HS code has the full 10-digit China-specific length.
165    pub fn is_ten_digit(&self) -> bool {
166        self.len() == 10
167    }
168
169    /// Returns an iterator over the digits as `u8` values.
170    pub fn iter(&self) -> std::slice::Iter<'_, u8> {
171        self.0.iter()
172    }
173
174    /// Returns an iterator over the digits as `char`s (e.g., '0'..'9').
175    pub fn chars(&self) -> impl Iterator<Item = char> + '_ {
176        self.0.iter().map(|x| (x + b'0') as char)
177    }
178}
179
180/// Converts an HS code string into a vector of numeric bytes.
181///
182/// # Rules
183/// - Length must be between 6–14 digits (inclusive) and even.
184/// - All characters must be ASCII digits.
185/// - The first two digits (chapter) must be in 1..=97.
186///
187/// # Returns
188/// - `Ok(Vec<u8>)` on success, with each element as the numeric value (0-9).
189/// - `Err(HscodeError)` on failure.
190pub(crate) fn verify_and_trans_hs_code(input: &str) -> Result<Vec<u8>, HscodeError> {
191    let bytes = input.as_bytes();
192
193    if bytes.len() < 6 || !bytes.len().is_multiple_of(2) || bytes.len() >= 16 {
194        return Err(HsCodeLenError(bytes.len()));
195    }
196    if !bytes.iter().all(|b| b.is_ascii_digit()) {
197        return Err(InputError);
198    }
199
200    let chap: Vec<_> = bytes.iter().map(|b| b - b'0').collect();
201    let chapter = chap[0] * 10 + chap[1];
202    if !(1..=97).contains(&chapter) {
203        return Err(HsChapterError(chapter));
204    }
205
206    Ok(chap)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_valid_8_digit_hscode() {
215        let code = HsCode::new_from_str("01012900");
216        assert_eq!(code.get_chapter(), 1);
217        assert_eq!(code.to_string(), "01012900");
218    }
219
220    #[test]
221    fn test_valid_10_digit_hscode() {
222        let code = HsCode::new_from_str("0101290010");
223        assert_eq!(code.get_chapter(), 1);
224        assert_eq!(code.to_string(), "0101290010");
225    }
226
227    #[test]
228    fn test_valid_12_digit_hscode() {
229        let code = HsCode::new_from_str("010121001012");
230        assert_eq!(code.get_chapter(), 1);
231    }
232
233    #[test]
234    fn test_invalid_length() {
235        let result = HsCode::try_new_from_str("123");
236        assert!(matches!(result, Err(HsCodeLenError(3))));
237    }
238
239    #[test]
240    fn test_invalid_odd_length() {
241        let result = HsCode::try_new_from_str("0101211");
242        assert!(matches!(result, Err(HsCodeLenError(7))));
243    }
244
245    #[test]
246    fn test_invalid_too_long() {
247        let result = HsCode::try_new_from_str("0101210010121416");
248        assert!(matches!(result, Err(HsCodeLenError(16))));
249    }
250
251    #[test]
252    fn test_non_digit_input() {
253        let result = HsCode::try_new_from_str("ABCDEFGH");
254        assert!(matches!(result, Err(InputError)));
255    }
256
257    #[test]
258    fn test_invalid_chapter() {
259        let result = HsCode::try_new_from_str("9912345678");
260        assert!(matches!(result, Err(HsChapterError(99))));
261    }
262
263    #[test]
264    fn test_chapter_boundary() {
265        let result = HsCode::try_new_from_str("01012900");
266        assert!(result.is_ok());
267
268        let result = HsCode::try_new_from_str("97012900");
269        assert!(result.is_ok());
270
271        let result = HsCode::try_new_from_str("98012900");
272        assert!(matches!(result, Err(HsChapterError(98))));
273    }
274
275    #[test]
276    fn test_diff() {
277        let code1 = HsCode::try_new_from_str("01012900").unwrap();
278        let code2 = HsCode::try_new_from_str("01012800").unwrap();
279        assert_eq!(code1.diff(&code2), vec![5]);
280    }
281
282    #[test]
283    fn test_fromstr() {
284        let code = "10011001".parse::<HsCode>().unwrap();
285        assert_eq!(code, HsCode::try_new_from_str("10011001").unwrap())
286    }
287
288    #[test]
289    fn test_descrip() {
290        let code = HsCode::new_from_str("01012100");
291        assert!(code.description().is_some());
292        assert_eq!(code.description().unwrap(), "Horses; live");
293    }
294}