Skip to main content

zengin_fmt/
lib.rs

1use serde::{Deserialize, Serialize, de::DeserializeOwned};
2use std::fmt;
3
4pub mod account_transfer;
5pub mod account_transfer_result;
6mod fixed;
7pub mod general_transfer;
8pub mod payment_notice;
9pub mod payroll_transfer;
10pub mod transfer_account_inquiry;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
13#[serde(untagged)]
14pub enum ParsedFile {
15    GeneralTransfer(general_transfer::File),
16    PayrollTransfer(payroll_transfer::File),
17    AccountTransfer(account_transfer::File),
18    AccountTransferResult(account_transfer_result::File),
19    TransferAccountInquiry(transfer_account_inquiry::File),
20    PaymentNotice(payment_notice::File),
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum FileType {
25    Auto,
26    GeneralTransfer,
27    PayrollTransfer,
28    AccountTransfer,
29    AccountTransferResult,
30    TransferAccountInquiry,
31    PaymentNotice,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum CodeDivision {
36    #[default]
37    Jis,
38    Ebcdic,
39}
40
41impl CodeDivision {
42    pub const fn as_u8(self) -> u8 {
43        match self {
44            Self::Jis => 0,
45            Self::Ebcdic => 1,
46        }
47    }
48
49    pub const fn from_u8(value: u8) -> Option<Self> {
50        match value {
51            0 => Some(Self::Jis),
52            1 => Some(Self::Ebcdic),
53            _ => None,
54        }
55    }
56}
57
58impl Serialize for CodeDivision {
59    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60    where
61        S: serde::Serializer,
62    {
63        serializer.serialize_u8(self.as_u8())
64    }
65}
66
67impl<'de> Deserialize<'de> for CodeDivision {
68    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69    where
70        D: serde::Deserializer<'de>,
71    {
72        struct Visitor;
73
74        impl serde::de::Visitor<'_> for Visitor {
75            type Value = CodeDivision;
76
77            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78                formatter.write_str("0 or 1")
79            }
80
81            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
82            where
83                E: serde::de::Error,
84            {
85                let value = u8::try_from(value).map_err(E::custom)?;
86                CodeDivision::from_u8(value)
87                    .ok_or_else(|| E::custom(format!("invalid code division {value}")))
88            }
89
90            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
91            where
92                E: serde::de::Error,
93            {
94                match value {
95                    "0" => Ok(CodeDivision::Jis),
96                    "1" => Ok(CodeDivision::Ebcdic),
97                    other => Err(E::custom(format!("invalid code division {other:?}"))),
98                }
99            }
100        }
101
102        deserializer.deserialize_any(Visitor)
103    }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum Encoding {
108    Ascii,
109    Jis,
110    Ebcdic,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum LineEnding {
115    None,
116    Lf,
117    Crlf,
118}
119
120impl LineEnding {
121    pub(crate) const fn as_bytes(self) -> &'static [u8] {
122        match self {
123            Self::None => b"",
124            Self::Lf => b"\n",
125            Self::Crlf => b"\r\n",
126        }
127    }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct OutputFormat {
132    pub encoding: Encoding,
133    pub line_ending: LineEnding,
134    pub eof: bool,
135}
136
137impl OutputFormat {
138    pub const fn canonical() -> Self {
139        Self {
140            encoding: Encoding::Jis,
141            line_ending: LineEnding::None,
142            eof: false,
143        }
144    }
145
146    pub const fn readable() -> Self {
147        Self {
148            encoding: Encoding::Jis,
149            line_ending: LineEnding::Lf,
150            eof: false,
151        }
152    }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum Error {
157    UnsupportedEncoding(Encoding),
158    AmbiguousInput(String),
159    InvalidInput(String),
160    InvalidField {
161        record: &'static str,
162        field: &'static str,
163        message: String,
164    },
165    Validation(String),
166    Serde(String),
167}
168
169impl core::fmt::Display for Error {
170    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
171        match self {
172            Self::UnsupportedEncoding(encoding) => {
173                write!(f, "unsupported encoding: {encoding:?}")
174            }
175            Self::AmbiguousInput(message) => f.write_str(message),
176            Self::InvalidInput(message) => f.write_str(message),
177            Self::InvalidField {
178                record,
179                field,
180                message,
181            } => {
182                write!(f, "{record}.{field}: {message}")
183            }
184            Self::Validation(message) => f.write_str(message),
185            Self::Serde(message) => f.write_str(message),
186        }
187    }
188}
189
190impl std::error::Error for Error {}
191
192impl From<serde_json::Error> for Error {
193    fn from(error: serde_json::Error) -> Self {
194        Self::Serde(error.to_string())
195    }
196}
197
198pub fn from_bytes<T>(input: &[u8]) -> Result<T, Error>
199where
200    T: DeserializeOwned,
201{
202    from_bytes_as(input, FileType::Auto)
203}
204
205pub fn from_bytes_as<T>(input: &[u8], file_type: FileType) -> Result<T, Error>
206where
207    T: DeserializeOwned,
208{
209    let file = parse_as(input, file_type)?;
210    let value = serde_json::to_value(file)?;
211    Ok(serde_json::from_value(value)?)
212}
213
214pub fn parse(input: &[u8]) -> Result<ParsedFile, Error> {
215    parse_as(input, FileType::Auto)
216}
217
218pub fn parse_as(input: &[u8], file_type: FileType) -> Result<ParsedFile, Error> {
219    match file_type {
220        FileType::Auto => parse_auto(input),
221        FileType::GeneralTransfer => parse_general_transfer(input).map(ParsedFile::GeneralTransfer),
222        FileType::PayrollTransfer => parse_payroll_transfer(input).map(ParsedFile::PayrollTransfer),
223        FileType::AccountTransfer => parse_account_transfer(input).map(ParsedFile::AccountTransfer),
224        FileType::AccountTransferResult => {
225            parse_account_transfer_result(input).map(ParsedFile::AccountTransferResult)
226        }
227        FileType::TransferAccountInquiry => {
228            parse_transfer_account_inquiry(input).map(ParsedFile::TransferAccountInquiry)
229        }
230        FileType::PaymentNotice => parse_payment_notice(input).map(ParsedFile::PaymentNotice),
231    }
232}
233
234pub fn parse_general_transfer(input: &[u8]) -> Result<general_transfer::File, Error> {
235    general_transfer::parse(input)
236}
237
238pub fn parse_payroll_transfer(input: &[u8]) -> Result<payroll_transfer::File, Error> {
239    payroll_transfer::parse(input)
240}
241
242pub fn parse_account_transfer(input: &[u8]) -> Result<account_transfer::File, Error> {
243    account_transfer::parse(input)
244}
245
246pub fn parse_account_transfer_result(input: &[u8]) -> Result<account_transfer_result::File, Error> {
247    account_transfer_result::parse(input)
248}
249
250pub fn parse_transfer_account_inquiry(
251    input: &[u8],
252) -> Result<transfer_account_inquiry::File, Error> {
253    transfer_account_inquiry::parse(input)
254}
255
256pub fn parse_payment_notice(input: &[u8]) -> Result<payment_notice::File, Error> {
257    payment_notice::parse(input)
258}
259
260fn parse_auto(input: &[u8]) -> Result<ParsedFile, Error> {
261    let mut matches = Vec::new();
262    let mut errors = Vec::new();
263
264    match general_transfer::parse(input) {
265        Ok(file) => matches.push(("general transfer", ParsedFile::GeneralTransfer(file))),
266        Err(error) => errors.push(("general transfer", error)),
267    }
268    match payroll_transfer::parse(input) {
269        Ok(file) => matches.push(("payroll transfer", ParsedFile::PayrollTransfer(file))),
270        Err(error) => errors.push(("payroll transfer", error)),
271    }
272    match account_transfer::parse(input) {
273        Ok(file) => matches.push((
274            "account transfer request",
275            ParsedFile::AccountTransfer(file),
276        )),
277        Err(error) => errors.push(("account transfer request", error)),
278    }
279    match account_transfer_result::parse(input) {
280        Ok(file) => matches.push((
281            "account transfer result",
282            ParsedFile::AccountTransferResult(file),
283        )),
284        Err(error) => errors.push(("account transfer result", error)),
285    }
286    match transfer_account_inquiry::parse(input) {
287        Ok(file) => matches.push((
288            "transfer account inquiry",
289            ParsedFile::TransferAccountInquiry(file),
290        )),
291        Err(error) => errors.push(("transfer account inquiry", error)),
292    }
293    match payment_notice::parse(input) {
294        Ok(file) => matches.push(("payment notice", ParsedFile::PaymentNotice(file))),
295        Err(error) => errors.push(("payment notice", error)),
296    }
297
298    match matches.len() {
299        1 => Ok(matches.pop().expect("one match").1),
300        0 => {
301            let summary = errors
302                .into_iter()
303                .map(|(name, error)| format!("{name}: {error}"))
304                .collect::<Vec<_>>()
305                .join("; ");
306            Err(Error::InvalidInput(format!(
307                "unsupported zengin file: {summary}"
308            )))
309        }
310        _ => {
311            let names = matches
312                .into_iter()
313                .map(|(name, _)| name)
314                .collect::<Vec<_>>()
315                .join(", ");
316            Err(Error::AmbiguousInput(format!(
317                "input is valid as both or more supported file types ({names}); pass an explicit file type"
318            )))
319        }
320    }
321}
322
323pub fn to_bytes<T>(value: &T, format: OutputFormat) -> Result<Vec<u8>, Error>
324where
325    T: Serialize,
326{
327    to_bytes_as(value, FileType::Auto, format)
328}
329
330pub fn to_bytes_as<T>(
331    value: &T,
332    file_type: FileType,
333    format: OutputFormat,
334) -> Result<Vec<u8>, Error>
335where
336    T: Serialize,
337{
338    let value = serde_json::to_value(value)?;
339    match file_type {
340        FileType::Auto => write_auto_value(&value, format),
341        FileType::GeneralTransfer => {
342            write_value_as::<general_transfer::File>(&value, format, general_transfer::write)
343        }
344        FileType::PayrollTransfer => {
345            write_value_as::<payroll_transfer::File>(&value, format, payroll_transfer::write)
346        }
347        FileType::AccountTransfer => {
348            write_value_as::<account_transfer::File>(&value, format, account_transfer::write)
349        }
350        FileType::AccountTransferResult => write_value_as::<account_transfer_result::File>(
351            &value,
352            format,
353            account_transfer_result::write,
354        ),
355        FileType::TransferAccountInquiry => write_value_as::<transfer_account_inquiry::File>(
356            &value,
357            format,
358            transfer_account_inquiry::write,
359        ),
360        FileType::PaymentNotice => {
361            write_value_as::<payment_notice::File>(&value, format, payment_notice::write)
362        }
363    }
364}
365
366fn write_value_as<T>(
367    value: &serde_json::Value,
368    format: OutputFormat,
369    write: fn(&T, OutputFormat) -> Result<Vec<u8>, Error>,
370) -> Result<Vec<u8>, Error>
371where
372    T: DeserializeOwned,
373{
374    let file = serde_json::from_value(value.clone())?;
375    write(&file, format)
376}
377
378fn write_auto_value(value: &serde_json::Value, format: OutputFormat) -> Result<Vec<u8>, Error> {
379    let mut matches = Vec::new();
380
381    if let Ok(file) = serde_json::from_value::<general_transfer::File>(value.clone()) {
382        matches.push(("general transfer", general_transfer::write(&file, format)?));
383    }
384    if let Ok(file) = serde_json::from_value::<payroll_transfer::File>(value.clone()) {
385        matches.push(("payroll transfer", payroll_transfer::write(&file, format)?));
386    }
387    if let Ok(file) = serde_json::from_value::<account_transfer::File>(value.clone()) {
388        matches.push((
389            "account transfer request",
390            account_transfer::write(&file, format)?,
391        ));
392    }
393    if let Ok(file) = serde_json::from_value::<account_transfer_result::File>(value.clone()) {
394        matches.push((
395            "account transfer result",
396            account_transfer_result::write(&file, format)?,
397        ));
398    }
399    if let Ok(file) = serde_json::from_value::<transfer_account_inquiry::File>(value.clone()) {
400        matches.push((
401            "transfer account inquiry",
402            transfer_account_inquiry::write(&file, format)?,
403        ));
404    }
405    if let Ok(file) = serde_json::from_value::<payment_notice::File>(value.clone()) {
406        matches.push(("payment notice", payment_notice::write(&file, format)?));
407    }
408
409    match matches.len() {
410        1 => Ok(matches.pop().expect("one match").1),
411        0 => Err(Error::InvalidInput(
412            "unsupported zengin output value; pass a supported file type".to_string(),
413        )),
414        _ => {
415            let names = matches
416                .into_iter()
417                .map(|(name, _)| name)
418                .collect::<Vec<_>>()
419                .join(", ");
420            Err(Error::AmbiguousInput(format!(
421                "value can be written as multiple supported file types ({names}); pass an explicit file type"
422            )))
423        }
424    }
425}
426
427#[cfg(doctest)]
428mod readme_doctests {
429    doc_comment::doctest!("../../../README.md");
430}
431
432#[cfg(test)]
433mod tests {
434    use super::{
435        CodeDivision, Encoding, Error, FileType, LineEnding, OutputFormat,
436        account_transfer::Detail, account_transfer::End, account_transfer::File,
437        account_transfer::Header, account_transfer::Trailer, from_bytes_as, parse_account_transfer,
438        to_bytes,
439    };
440
441    fn sample_file() -> File {
442        File {
443            header: Header {
444                kind_code: 91,
445                code_division: CodeDivision::Jis,
446                collector_code: "1234567890".to_string(),
447                collection_date: "0430".to_string(),
448                collector_name: "ACME COLLECT".to_string(),
449                bank_code: "0001".to_string(),
450                bank_name: "BANK ALPHA".to_string(),
451                branch_code: "123".to_string(),
452                branch_name: "MAIN BRANCH".to_string(),
453                account_type: 1,
454                account_number: "7654321".to_string(),
455            },
456            details: vec![Detail {
457                bank_code: "0005".to_string(),
458                bank_name: "BANK BETA".to_string(),
459                branch_code: "001".to_string(),
460                branch_name: "WEST".to_string(),
461                account_type: 1,
462                account_number: "1234567".to_string(),
463                payer_name: "TARO YAMADA".to_string(),
464                amount: 1200,
465                new_code: "0".to_string(),
466                customer_number: "00000000001234567890".to_string(),
467            }],
468            trailer: Trailer {
469                record_count: 1,
470                total_amount: 1200,
471            },
472            end: End,
473        }
474    }
475
476    fn sample_jis_file() -> File {
477        File {
478            header: Header {
479                kind_code: 91,
480                code_division: CodeDivision::Jis,
481                collector_code: "1234567890".to_string(),
482                collection_date: "0430".to_string(),
483                collector_name: "テストシュウキン".to_string(),
484                bank_code: "0001".to_string(),
485                bank_name: "テストギンコウ".to_string(),
486                branch_code: "123".to_string(),
487                branch_name: "ホンテン".to_string(),
488                account_type: 1,
489                account_number: "7654321".to_string(),
490            },
491            details: vec![Detail {
492                bank_code: "0005".to_string(),
493                bank_name: "テストギンコウ".to_string(),
494                branch_code: "001".to_string(),
495                branch_name: "シテン".to_string(),
496                account_type: 1,
497                account_number: "1234567".to_string(),
498                payer_name: "ヤマダタロウ".to_string(),
499                amount: 1200,
500                new_code: "0".to_string(),
501                customer_number: "00000000001234567890".to_string(),
502            }],
503            trailer: Trailer {
504                record_count: 1,
505                total_amount: 1200,
506            },
507            end: End,
508        }
509    }
510
511    #[test]
512    fn roundtrips_readable_format() {
513        let file = sample_file();
514        let encoded = to_bytes(&file, OutputFormat::readable()).unwrap();
515
516        assert!(encoded.contains(&b'\n'));
517
518        let decoded = parse_account_transfer(&encoded).unwrap();
519        assert_eq!(decoded, file);
520    }
521
522    #[test]
523    fn canonical_format_has_no_line_breaks_or_eof() {
524        let encoded = to_bytes(&sample_file(), OutputFormat::canonical()).unwrap();
525
526        assert!(!encoded.contains(&b'\n'));
527        assert!(!encoded.contains(&b'\r'));
528        assert_ne!(encoded.last(), Some(&0x1a));
529    }
530
531    #[test]
532    fn roundtrips_crlf_with_eof() {
533        let encoded = to_bytes(
534            &sample_file(),
535            OutputFormat {
536                encoding: Encoding::Ascii,
537                line_ending: LineEnding::Crlf,
538                eof: true,
539            },
540        )
541        .unwrap();
542
543        assert!(encoded.windows(2).any(|window| window == b"\r\n"));
544        assert_eq!(encoded.last(), Some(&0x1a));
545
546        let decoded = parse_account_transfer(&encoded).unwrap();
547        assert_eq!(decoded, sample_file());
548    }
549
550    #[test]
551    fn roundtrips_jis_halfwidth_text_as_unicode() {
552        let file = sample_jis_file();
553        let encoded = to_bytes(&file, OutputFormat::readable()).unwrap();
554
555        assert!(encoded.iter().any(|byte| *byte >= 0xA1));
556
557        let decoded = parse_account_transfer(&encoded).unwrap();
558        assert_eq!(decoded, file);
559        assert_eq!(decoded.header.collector_name, "テストシュウキン");
560        assert_eq!(decoded.details[0].payer_name, "ヤマダタロウ");
561    }
562
563    #[test]
564    fn ascii_output_rejects_jis_text() {
565        let error = to_bytes(
566            &sample_jis_file(),
567            OutputFormat {
568                encoding: Encoding::Ascii,
569                line_ending: LineEnding::Lf,
570                eof: false,
571            },
572        )
573        .unwrap_err();
574
575        assert!(error.to_string().contains("must be encodable as ASCII"));
576    }
577
578    #[test]
579    fn rejects_trailer_mismatch_on_write() {
580        let mut file = sample_file();
581        file.trailer.total_amount = 9999;
582
583        let error = to_bytes(&file, OutputFormat::canonical()).unwrap_err();
584        assert!(error.to_string().contains("trailer total_amount"));
585    }
586
587    #[test]
588    fn auto_parse_rejects_ambiguous_files() {
589        let encoded = to_bytes(&sample_file(), OutputFormat::readable()).unwrap();
590
591        let error = super::parse(&encoded).unwrap_err();
592        assert!(matches!(error, Error::AmbiguousInput(_)));
593    }
594
595    #[test]
596    fn explicit_from_bytes_as_parses_ambiguous_files() {
597        let encoded = to_bytes(&sample_file(), OutputFormat::readable()).unwrap();
598
599        let decoded: File = from_bytes_as(&encoded, FileType::AccountTransfer).unwrap();
600        assert_eq!(decoded, sample_file());
601    }
602
603    #[test]
604    fn auto_parse_handles_malformed_inputs_without_panicking() {
605        for len in 0..=512 {
606            let input = (0..len)
607                .map(|index| ((index * 37 + len) % 256) as u8)
608                .collect::<Vec<_>>();
609
610            let _ = super::parse(&input);
611        }
612    }
613}