cnab_fixedwidth/
lib.rs

1//! # Core de Processamento Fixed-Width (CNAB)
2//!
3//! Este módulo fornece as estruturas e funções fundamentais para parsear linhas de texto
4//! com largura fixa (Fixed Width), comum em arquivos bancários (CNAB 240/400).
5//!
6//! O foco deste core é a **extração segura e tipada** dos dados, delegando validações
7//! de negócio (CPF, datas, lógica de banco) para a camada superior.
8pub use cnab_derive::FixedWidth;
9
10use std::collections::HashMap;
11use std::ops::Range;
12use thiserror::Error;
13
14/// Define a posição de um campo conforme manuais bancários (CNAB).
15///
16/// # Importante
17/// Manuais de banco utilizam indexação **baseada em 1** e **inclusiva**.
18/// Exemplo: "Posição 001 a 003" significa os 3 primeiros caracteres.
19#[derive(Debug, Clone, Copy)]
20pub struct FieldPos {
21    /// Posição inicial (1-based, inclusivo).
22    pub start: usize,
23    /// Posição final (1-based, inclusivo).
24    pub end: usize,
25}
26
27impl FieldPos {
28    /// Converte a posição CNAB (1-based inclusivo) para um Range Rust (0-based exclusivo).
29    ///
30    /// Exemplo: CNAB `1..3` (3 chars) torna-se Rust `0..3` (índices 0, 1, 2).
31    fn as_range(&self) -> Range<usize> {
32        (self.start - 1)..self.end
33    }
34
35    /// Retorna a largura total do campo em caracteres.
36    pub fn width(&self) -> usize {
37        self.end - self.start + 1
38    }
39}
40
41/// Define o tipo de dado esperado no campo para conversão.
42#[derive(Debug, Clone, Copy)]
43pub enum FieldKind {
44    /// Texto alfanumérico.
45    /// Geralmente alinhado à esquerda e preenchido com espaços à direita.
46    Alpha,
47
48    /// Numérico inteiro.
49    /// Geralmente alinhado à direita e preenchido com zeros à esquerda.
50    /// Se o campo estiver vazio ou só espaços, será convertido para 0.
51    Numeric,
52
53    /// Numérico com casas decimais implícitas.
54    ///
55    /// Exemplo: A string "000000001234" com `scale: 2` representa `12.34`.
56    Decimal {
57        /// Número de casas decimais a considerar.
58        scale: u8
59    },
60}
61
62/// Metadados que definem um campo no layout.
63///
64/// Esta estrutura é geralmente construída automaticamente pela macro derive.
65#[derive(Debug, Clone)]
66pub struct FieldSpec {
67    /// Nome do campo (deve coincidir com o nome na struct alvo).
68    /// Usamos `&'static str` para performance (zero alocação na definição).
69    pub name: &'static str,
70
71    /// Posição no arquivo.
72    pub pos: FieldPos,
73
74    /// Tipo de dado para tratamento.
75    pub kind: FieldKind,
76}
77
78/// Representação intermediária de um valor parseado.
79///
80/// O parser extrai a string bruta e converte para uma destas variantes
81/// antes de popular a struct final.
82#[derive(Debug, Clone, PartialEq)]
83pub enum Value {
84    /// Valor textual (String owned).
85    Alpha(String),
86    /// Valor inteiro (i64).
87    Numeric(i64),
88    /// Valor decimal representado como inteiro bruto + escala.
89    /// Ex: 12.34 vira `Decimal { raw: 1234, scale: 2 }`.
90    Decimal { raw: i64, scale: u8 },
91}
92
93/// Erros possíveis durante o processo de parsing.
94#[derive(Debug, Error)]
95pub enum FixedWidthError {
96    /// A linha fornecida é mais curta do que a posição final exigida por um campo.
97    #[error("linha é menor que o necessário: len={len}, precisa de >= {needed}")]
98    LineTooShort { len: usize, needed: usize },
99
100    /// O campo foi definido como Numérico/Decimal, mas contém caracteres não numéricos.
101    #[error("campo '{field}' contém caracteres inválidos para numérico: '{snippet}'")]
102    InvalidNumeric {
103        field: &'static str,
104        snippet: String,
105    },
106
107    /// Erro genérico de UTF-8 (embora `&str` já garanta UTF-8 válido na entrada).
108    #[error("erro de UTF-8 na linha")]
109    InvalidUtf8,
110}
111
112/// Resultado padrão utilizado pelo crate.
113pub type Result<T> = std::result::Result<T, FixedWidthError>;
114
115/// Faz o parse de uma linha de texto bruta com base em uma lista de especificações de campos.
116///
117/// # Argumentos
118/// * `line` - A linha bruta do arquivo (pode conter `\r` ou `\n` no final).
119/// * `fields` - Lista de especificações (`FieldSpec`) gerada pela macro.
120///
121/// # Retorno
122/// Retorna um `HashMap` onde a chave é o nome do campo e o valor é o `Value` parseado.
123pub fn parse_line<'a>(
124    line: &'a str,
125    fields: &[FieldSpec],
126) -> Result<HashMap<&'static str, Value>> {
127    // Remove quebras de linha comuns em Windows (\r\n) e Unix (\n)
128    // para evitar que contem no tamanho da string ou sujem o último campo.
129    let line = line.trim_end_matches(&['\r', '\n'][..]);
130    let len = line.len();
131
132    // Pré-aloca o mapa para evitar realocações dinâmicas
133    let mut map = HashMap::with_capacity(fields.len());
134
135    for field in fields {
136        // Validação de limites (Bounds check)
137        let needed = field.pos.end;
138        if len < needed {
139            return Err(FixedWidthError::LineTooShort { len, needed });
140        }
141
142        // Fatia a string (Slice) usando a conversão segura de índices
143        let slice = &line[field.pos.as_range()];
144
145        let value = match field.kind {
146            FieldKind::Alpha => {
147                // Alpha: Remove espaços à direita (padrão CNAB)
148                Value::Alpha(slice.trim_end().to_string())
149            }
150            FieldKind::Numeric => {
151                // Numeric: Remove espaços em volta.
152                // Bancos as vezes mandam campos numéricos zerados como espaços em branco.
153                let s = slice.trim();
154                if s.is_empty() {
155                    Value::Numeric(0)
156                } else if !s.chars().all(|c| c.is_ascii_digit()) {
157                    return Err(FixedWidthError::InvalidNumeric {
158                        field: field.name,
159                        snippet: slice.to_string(),
160                    });
161                } else {
162                    let n = s.parse::<i64>().map_err(|_| FixedWidthError::InvalidNumeric {
163                        field: field.name,
164                        snippet: slice.to_string(),
165                    })?;
166                    Value::Numeric(n)
167                }
168            }
169            FieldKind::Decimal { scale } => {
170                // Decimal: Segue a mesma lógica do numérico, mas preserva a escala.
171                let s = slice.trim();
172                if s.is_empty() {
173                    Value::Decimal { raw: 0, scale }
174                } else if !s.chars().all(|c| c.is_ascii_digit()) {
175                    return Err(FixedWidthError::InvalidNumeric {
176                        field: field.name,
177                        snippet: slice.to_string(),
178                    });
179                } else {
180                    let n = s.parse::<i64>().map_err(|_| FixedWidthError::InvalidNumeric {
181                        field: field.name,
182                        snippet: slice.to_string(),
183                    })?;
184                    Value::Decimal { raw: n, scale }
185                }
186            }
187        };
188
189        map.insert(field.name, value);
190    }
191
192    Ok(map)
193}
194
195/// Trait implementada automaticamente pela macro derive para expor as especificações dos campos.
196pub trait FixedWidthSpec {
197    fn spec() -> &'static [FieldSpec];
198}
199
200/// Trait principal implementada pela macro derive.
201/// Permite instanciar uma Struct a partir de uma linha de texto.
202pub trait FixedWidthParse: Sized {
203    fn parse(line: &str) -> Result<Self>;
204}
205
206// --- Métodos Auxiliares para Value ---
207
208impl Value {
209    /// Tenta converter o valor interno para `f64`.
210    /// Útil para campos `Decimal`. Aplica a divisão pela potência de 10 conforme a escala.
211    pub fn as_f64(&self) -> Option<f64> {
212        match self {
213            Value::Decimal { raw, scale } => {
214                let factor = 10_i64.pow(*scale as u32) as f64;
215                Some(*raw as f64 / factor)
216            }
217            _ => None,
218        }
219    }
220
221    /// Tenta converter o valor interno para `i64`.
222    pub fn as_i64(&self) -> Option<i64> {
223        match self {
224            Value::Numeric(n) => Some(*n),
225            _ => None,
226        }
227    }
228
229    /// Tenta obter a referência da string interna (para campos Alpha).
230    pub fn as_str(&self) -> Option<&str> {
231        match self {
232            Value::Alpha(s) => Some(s),
233            _ => None,
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn parse_cnab_like_header() {
244        // Linha fake com exatamente 240 caracteres para simular CNAB
245        let line = "34100000         2297460810001556256000036236       0625610000000362366 MARTINS RIBEIRO ADMINISTRADORABANCO TESTE                              10312202508440000000108501600";
246
247        // Definição manual de campos (o que a macro faria)
248        let fields = vec![
249            FieldSpec {
250                name: "codigo_banco",
251                pos: FieldPos { start: 1, end: 3 },
252                kind: FieldKind::Numeric,
253            },
254            FieldSpec {
255                name: "lote_servico",
256                pos: FieldPos { start: 4, end: 7 },
257                kind: FieldKind::Numeric,
258            },
259            FieldSpec {
260                name: "tipo_registro",
261                pos: FieldPos { start: 8, end: 8 },
262                kind: FieldKind::Numeric,
263            },
264            FieldSpec {
265                name: "nome_banco",
266                pos: FieldPos { start: 103, end: 113 },
267                kind: FieldKind::Alpha,
268            },
269        ];
270
271        let parsed = parse_line(&line, &fields).unwrap();
272
273        // Validações
274        assert_eq!(parsed["codigo_banco"], Value::Numeric(341));
275        assert_eq!(parsed["lote_servico"], Value::Numeric(0));
276        assert_eq!(parsed["tipo_registro"], Value::Numeric(0));
277
278        if let Value::Alpha(nome) = &parsed["nome_banco"] {
279            // Verifica se o trim funcionou (removeu espaços à direita)
280            assert!(nome.starts_with("BANCO TESTE"));
281        } else {
282            panic!("nome_banco não é Alpha");
283        }
284    }
285}