diff --git a/docs/FIX_SALVAR_LAYOUT.md b/docs/FIX_SALVAR_LAYOUT.md deleted file mode 100644 index a84064f..0000000 --- a/docs/FIX_SALVAR_LAYOUT.md +++ /dev/null @@ -1,353 +0,0 @@ -# FIX — Salvar Layout no Banco de Dados - -**Data:** 04/03/2026 -**Versão do PRD:** 1.7 -**Status:** Pendente de Implementação - ---- - -## Sumário dos Problemas - -| # | Severidade | Arquivo(s) | Descrição | -|---|-----------|------------|-----------| -| 1 | **Crítico** | `migrations.rs` | `migration_v3` sem transação — falha parcial deixa banco permanentemente quebrado | -| 2 | **Crítico** | `migrations.rs` | `schema_version` atualizada fora da transação das migrations | -| 3 | **Crítico** | `usecases/layouts.rs` | Erros SQL mapeados como `ErroLayout::JsonMalformado` — mensagem completamente enganosa | -| 4 | Moderado | `layout_repository.rs` | `delimitador` TAB salvo como byte de controle invisível no banco | -| 5 | Moderado | `usecases/layouts.rs` | `pos_numero` / `pos_serie` XLSX podem ser strings vazias — sem validação no fluxo de save da UI | -| 6 | Moderado | `layout_repository.rs` | Índices posicionais hardcoded em `row.get(N)` — quebra silenciosa se colunas do SELECT forem reordenadas | -| 7 | Menor | `usecases/layouts.rs` + `layout_repository.rs` | Renomear layout para nome já existente (UPDATE) gera `JsonMalformado` em vez de `NomeConflitante` | -| 8 | Menor | `connection.rs` | `SELECT 1` não detecta corrupção real de páginas SQLite | -| 9 | Menor | `layout_repository.rs` | UPDATE não zera campos do tipo oposto ao tipo atual do layout | - ---- - -## Problema 1 (Crítico) — `migration_v3` sem transação atômica - -### Local -`src/infrastructure/sqlite/migrations.rs` — função `migration_v3` - -### Situação Atual -```rust -fn migration_v3(conn: &Connection) -> Result<()> { - conn.execute_batch( - "ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; - ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;", - )?; - Ok(()) -} -``` - -`execute_batch` executa os dois `ALTER TABLE` sem transação explícita. Se o processo for interrompido após o primeiro e antes do segundo, o banco fica em estado parcial: - -- `indice_documento_tipo` existe, `pos_documento_tipo` não existe. -- A versão no banco não é atualizada (o erro propaga antes). -- Na próxima execução, `migration_v3` tenta adicionar `indice_documento_tipo` novamente → SQLite retorna `"duplicate column name"` → **o app nunca mais inicializa sem intervenção manual**. - -### Correção Necessária -Envolver cada migration em uma transação explícita e atualizar `schema_version` **dentro da mesma transação**: - -```rust -fn migration_v3(conn: &Connection) -> Result<()> { - conn.execute_batch(" - BEGIN; - ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; - ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT; - UPDATE schema_version SET versao = 3; - COMMIT; - ")?; - Ok(()) -} -``` - -> **Nota:** O SQLite suporta `ALTER TABLE` dentro de transação explícita desde a versão 3.x. `execute_batch` executa múltiplos statements quando delimitados por `;` dentro do mesmo bloco `BEGIN/COMMIT`. - ---- - -## Problema 2 (Crítico) — `schema_version` atualizada fora da transação das migrations - -### Local -`src/infrastructure/sqlite/migrations.rs` — função `aplicar_migrations` - -### Situação Atual -O fluxo atual é: -1. Executa `migration_v1(conn)?` -2. Executa `migration_v2(conn)?` -3. Executa `migration_v3(conn)?` -4. **Depois** executa `INSERT OR REPLACE INTO schema_version ...` - -Se qualquer migration falhar após outras já terem sido aplicadas, a versão não é atualizada, causando re-execução problemática na próxima abertura. - -### Correção Necessária -Cada migration deve atualizar `schema_version` internamente (dentro de sua própria transação), como mostrado no Problema 1. A função `aplicar_migrations` não deve mais atualizar a versão centralmente — ela apenas chama as migrations que ainda não foram aplicadas. - -Adicionalmente, as migrations v1 e v2 devem seguir o mesmo padrão: - -```rust -fn migration_v1(conn: &Connection) -> Result<()> { - conn.execute_batch(" - BEGIN; - CREATE TABLE IF NOT EXISTS layouts ( ... ); - INSERT OR REPLACE INTO schema_version (id, versao) VALUES (1, 1); - COMMIT; - ")?; - Ok(()) -} - -fn migration_v2(conn: &Connection) -> Result<()> { - conn.execute_batch(" - BEGIN; - CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome); - -- renomeia duplicatas existentes se houver - UPDATE schema_version SET versao = 2; - COMMIT; - ")?; - Ok(()) -} -``` - ---- - -## Problema 3 (Crítico) — Erros SQL mapeados como `ErroLayout::JsonMalformado` - -### Local -`src/application/usecases/layouts.rs` — função `salvar_layout` e `atualizar_layout` - -### Situação Atual -```rust -layout_repository::salvar(conn, layout) - .map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; - -layout_repository::atualizar(conn, layout) - .map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; -``` - -Qualquer erro do banco de dados (violação de constraint, coluna ausente, banco travado) é apresentado ao usuário como **"JSON malformado"**, o que é completamente incorreto. Exemplos de mensagens enganosas que o usuário veria: - -- `"UNIQUE constraint failed: layouts.nome"` → exibido como "JSON malformado" -- `"no such column: pos_documento_tipo"` → exibido como "JSON malformado" -- `"attempt to write a readonly database"` → exibido como "JSON malformado" - -### Correção Necessária - -1. Adicionar variante própria em `domain/errors.rs`: -```rust -pub enum ErroLayout { - // ... variantes existentes ... - ErroBanco(String), // erros genéricos de I/O do banco - NomeConflitante, // já existe (para uso tanto em INSERT quanto em UPDATE) -} -``` - -2. No use case, inspecionar o erro antes de mapear: -```rust -layout_repository::salvar(conn, layout) - .map_err(|e| { - let msg = e.to_string(); - if msg.contains("UNIQUE constraint failed") { - ErroLayout::NomeConflitante - } else { - ErroLayout::ErroBanco(msg) - } - })?; -``` - -3. Na UI (`ui/screens/layouts.rs`), tratar `ErroBanco` com mensagem clara ao usuário: `"Erro ao salvar no banco de dados: {mensagem}"`. - ---- - -## Problema 4 (Moderado) — `delimitador` TAB salvo como byte de controle no banco - -### Local -`src/infrastructure/sqlite/layout_repository.rs` — funções `salvar` e `atualizar` (CSV) - -### Situação Atual -O `char` `'\t'` é convertido para `String` via `.to_string()`, armazenando um caractere de tabulação literal (byte `0x09`) no campo `TEXT` do banco. Embora funcional, é opaco para inspeção manual do banco e incompatível com exports/backups que não preservam bytes de controle. - -### Correção Necessária -Normalizar o delimitador para um token legível antes de salvar: - -```rust -// Ao salvar: -let delim_str = match config.delimitador { - '\t' => "tab".to_string(), - c => c.to_string(), -}; - -// Ao ler: -let delimitador = match delim_str.as_str() { - "tab" => '\t', - s => s.chars().next().unwrap_or(';'), -}; -``` - -> Isso também melhora a legibilidade dos arquivos JSON de export/import de layouts. - ---- - -## Problema 5 (Moderado) — `pos_numero` e `pos_serie` XLSX sem validação no fluxo de save da UI - -### Local -`src/application/usecases/layouts.rs` — função `salvar_layout` - -### Situação Atual -A validação atual verifica apenas que `nome` não esteja vazio. Os campos `pos_numero` e `pos_serie` de layouts XLSX são `String` obrigatórias (não `Option`), mas podem ser strings vazias `""`. O banco os aceita sem restrição, e o erro só apareceria na análise, sem indicar que o layout está incompleto. - -### Correção Necessária -Expandir a validação no use case `salvar_layout`: - -```rust -match layout { - Layout::Csv { config, .. } => { - // indice_numero e indice_serie são usize — sempre válidos se presentes - // nenhuma validação adicional necessária aqui - } - Layout::Xlsx { config, .. } => { - if config.pos_numero.trim().is_empty() { - return Err(ErroLayout::CampoObrigatorioAusente("pos_numero".to_string())); - } - if config.pos_serie.trim().is_empty() { - return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string())); - } - } -} -``` - ---- - -## Problema 6 (Moderado) — Índices posicionais hardcoded em `row.get(N)` - -### Local -`src/infrastructure/sqlite/layout_repository.rs` — função `listar` - -### Situação Atual -O mapeamento usa índices numéricos (`row.get(0)`, `row.get(1)`, ..., `row.get(16)`). Qualquer reordenação das colunas no `SELECT` quebra silenciosamente o mapeamento sem erro de compilação. - -### Correção Necessária -Substituir por nomes de colunas usando `row.get::<_, T>(nome_coluna)`: - -```rust -// Em vez de: -let id: i64 = row.get(0)?; -let nome: String = row.get(1)?; - -// Usar: -let id: i64 = row.get("id")?; -let nome: String = row.get("nome")?; -``` - -O `rusqlite` suporta `row.get("nome_coluna")` desde a versão 0.26. A versão usada no projeto é 0.32, portanto compatível. - ---- - -## Problema 7 (Menor) — Renomear para nome conflitante em UPDATE gera mensagem errada - -### Local -`src/application/usecases/layouts.rs` + `src/infrastructure/sqlite/layout_repository.rs` - -### Situação Atual -No fluxo de UPDATE (re-save de layout existente), não há verificação de conflito de nome antes de chamar `atualizar`. Se o usuário renomear um layout para um nome já em uso, a constraint `UNIQUE` do banco rejeita o UPDATE, o erro é mapeado como `JsonMalformado` (Problema 3 acima). - -### Correção Necessária -No use case `atualizar_layout`, verificar se o novo nome conflita com outro layout (excluindo o próprio ID): - -```rust -// Verificar conflito excluindo o próprio registro -if layout_repository::existe_nome_excluindo_id(conn, layout.nome(), layout.id())? { - return Err(ErroLayout::NomeConflitante); -} -``` - -Adicionar função no repository: -```rust -pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: Option) -> Result { - match id { - Some(id) => { - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2", - params![nome, id], - |row| row.get(0), - )?; - Ok(count > 0) - } - None => layout_repository::existe_nome(conn, nome), - } -} -``` - ---- - -## Problema 8 (Menor) — `SELECT 1` não detecta corrupção real do banco - -### Local -`src/infrastructure/sqlite/connection.rs` - -### Situação Atual -```rust -match conn.execute_batch("SELECT 1;") { - Ok(_) => return Ok((conn, false)), - Err(_) => { /* trata como banco corrompido */ } -} -``` - -`SELECT 1` não acessa nenhuma página de dados do SQLite. Um banco com tabelas corrompidas, índices inválidos ou páginas com checksum errado passaria nessa verificação sem ser detectado, causando erros inesperados posteriormente. - -### Correção Necessária -```rust -match conn.execute_batch("PRAGMA quick_check;") { - Ok(_) => return Ok((conn, false)), - Err(_) => { /* banco corrompido */ } -} -``` - -`PRAGMA quick_check` verifica a integridade estrutural do banco (sem verificar cada valor de dado, como `integrity_check` faz). É mais rápido que `integrity_check` e muito mais confiável que `SELECT 1`. - ---- - -## Problema 9 (Menor) — UPDATE não zera campos do tipo oposto - -### Local -`src/infrastructure/sqlite/layout_repository.rs` — função `atualizar` - -### Situação Atual -O UPDATE de CSV não zera campos XLSX (`aba`, `pos_numero`, etc.) e o UPDATE de XLSX não zera campos CSV. Na prática não ocorre troca de tipo, mas se ocorrer (via importação JSON com mesmo nome e tipo diferente + sobrescrita), os campos do tipo anterior ficam no banco. - -### Correção Necessária -Adicionar `SET campo = NULL` explícito para os campos do tipo oposto em cada UPDATE: - -```sql --- UPDATE CSV: zerar campos XLSX -UPDATE layouts SET - nome = ?1, delimitador = ?2, encoding = ?3, linha_cabecalho = ?4, - indice_numero = ?5, indice_serie = ?6, indice_valor = ?7, - indice_data = ?8, indice_documento_tipo = ?9, - -- zerar campos XLSX: - aba = NULL, pos_numero = NULL, pos_serie = NULL, - pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL -WHERE id = ?10 -``` - -```sql --- UPDATE XLSX: zerar campos CSV -UPDATE layouts SET - nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, - pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7, - -- zerar campos CSV: - delimitador = NULL, encoding = NULL, linha_cabecalho = NULL, - indice_numero = NULL, indice_serie = NULL, indice_valor = NULL, - indice_data = NULL, indice_documento_tipo = NULL -WHERE id = ?8 -``` - ---- - -## Ordem de Implementação Recomendada - -1. **Problema 1 + 2** (migrations com transação) — Risco de banco permanentemente inutilizável; implementar primeiro. -2. **Problema 3** (mapeamento de erros) — Sem isso o usuário não entende o que está errado. -3. **Problema 5** (validação de campos XLSX) — Evita layouts inválidos serem salvos. -4. **Problema 7** (conflito de nome em UPDATE) — Depende da solução do Problema 3. -5. **Problema 4** (delimitador TAB) — Melhoria de robustez; não causa crash. -6. **Problema 6** (índices posicionais) — Refactor preventivo. -7. **Problema 8** (PRAGMA quick_check) — Melhoria de confiabilidade. -8. **Problema 9** (zerar campos opostos) — Limpeza defensiva. diff --git a/src/ui/app.rs b/src/ui/app.rs index 2d3701b..fdc2096 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -614,9 +614,11 @@ impl App { let modal = self.modal.take(); if let Some(estado) = modal { match estado { - EstadoModal::Confirmacao { acao, .. } - | EstadoModal::InputTexto { acao, .. } => { - return self.executar_acao_modal(acao); + EstadoModal::Confirmacao { acao, .. } => { + return self.executar_acao_modal(acao, None); + } + EstadoModal::InputTexto { acao, texto, .. } => { + return self.executar_acao_modal(acao, Some(texto)); } _ => {} } @@ -991,7 +993,7 @@ impl App { } } - fn executar_acao_modal(&mut self, acao: AcaoModal) -> Task { + fn executar_acao_modal(&mut self, acao: AcaoModal, texto_input: Option) -> Task { match acao { AcaoModal::ConfirmarExpansaoFaltantes => { self.update(Message::ConfirmarExpansaoFaltantes) @@ -1009,13 +1011,7 @@ impl App { Task::none() } AcaoModal::SalvarLayoutConfig => { - let texto = if let Some(EstadoModal::InputTexto { texto, .. }) = &self.modal { - texto.clone() - } else { - String::new() - }; - self.modal = None; - let nome = texto.trim().to_string(); + let nome = texto_input.unwrap_or_default().trim().to_string(); if nome.is_empty() { self.exibir_erro("O nome do layout não pode ser vazio."); return Task::none();