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}