Versão 0.5.0: bancos SQLite, saúde do disco, notificações, retomada, mapa GPS, inglês

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 20:01:31 -03:00
1 parent 4bb2397e3b
commit bbcb42ae6e
27 files changed
+2681 -41

No files matched your search

+3 -1
View File
@@ -111,7 +111,9 @@ pub fn hashes_in(dir: &Path) -> HashMap<String, PathBuf> {
let p = e.path();
if p.is_dir() {
walk(&p, m);
} else if p.file_name().unwrap() != "guara-manifesto.json" {
} else if !["guara-manifesto.json", "guara-diario.jsonl"]
.contains(&p.file_name().unwrap().to_str().unwrap())
{
m.insert(sha256_file(&p), p);
}
}
Binary file not shown.
+1
View File
@@ -300,6 +300,7 @@ fn configuracoes_de_tema_persistem() {
assert_eq!(settings::load().theme, "auto");
settings::save(&settings::Settings {
theme: "dark".into(),
..Default::default()
})
.unwrap();
assert_eq!(settings::load().theme, "dark");
+223
View File
@@ -0,0 +1,223 @@
//! Saúde do disco, notificações, configurações e retomada da recuperação.
mod common;
use common::*;
use guara_core::*;
use serde_json::json;
// ------------------------------------------------------------------ saúde (SMART)
#[test]
fn smart_reprovado_e_setores_ruins_viram_alerta_serio() {
let v = json!({
"model_name": "WDC WD5000", "serial_number": "ABC123",
"power_on_time": {"hours": 43000}, "temperature": {"current": 38},
"smart_status": {"passed": false},
"ata_smart_attributes": {"table": [
{"id": 5, "raw": {"value": 120}}, {"id": 197, "raw": {"value": 3}}, {"id": 199, "raw": {"value": 0}}
]}
});
let h = health::parse_smartctl(&v);
assert!(h.available);
assert_eq!(h.status, "failing");
assert!(h
.details
.iter()
.any(|(k, v)| k == "Setores realocados" && v == "120"));
assert!(h
.details
.iter()
.any(|(k, _)| k == "Setores pendentes de realocação"));
assert!(
h.advice.iter().any(|a| a.contains("imagem")),
"deve recomendar a imagem primeiro"
);
}
#[test]
fn smart_saudavel_nvme_desgastado_e_nvme_critico() {
let ok = json!({"smart_status": {"passed": true}, "ata_smart_attributes": {"table": [{"id": 5, "raw": {"value": 0}}, {"id": 197, "raw": {"value": 0}}]}});
let h = health::parse_smartctl(&ok);
assert_eq!(h.status, "ok");
assert!(h.advice.is_empty());
let worn = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {"critical_warning": 0, "percentage_used": 93, "media_errors": 0, "available_spare": 80, "available_spare_threshold": 10}});
assert_eq!(health::parse_smartctl(&worn).status, "warning");
let crit = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {"critical_warning": 4, "percentage_used": 10, "media_errors": 0}});
assert_eq!(health::parse_smartctl(&crit).status, "failing");
let media = json!({"nvme_smart_health_information_log": {"critical_warning": 0, "percentage_used": 10, "media_errors": 7}});
assert_eq!(health::parse_smartctl(&media).status, "failing");
}
#[test]
fn imagem_de_disco_nao_tem_smart() {
let f = tempfile::NamedTempFile::new().unwrap();
let h = health::check(f.path().to_str().unwrap());
assert!(!h.available);
assert_eq!(h.status, "unknown");
}
// ------------------------------------------------------------------ configurações
#[test]
fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
init();
// arquivo gravado pela v0.3: só tinha o tema
std::fs::create_dir_all(settings::config_dir()).unwrap();
std::fs::write(
settings::config_dir().join("settings.json"),
br#"{"theme":"dark"}"#,
)
.unwrap();
let s = settings::load();
assert_eq!(
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
("dark", "auto", true, false)
);
settings::save(&settings::Settings {
theme: "light".into(),
lang: "en".into(),
notify: false,
maps: true,
})
.unwrap();
let s = settings::load();
assert_eq!(
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
("light", "en", false, true)
);
}
// ------------------------------------------------------------------ notificações
#[cfg(target_os = "linux")]
#[test]
fn notificacao_chama_o_notify_send() {
use std::os::unix::fs::PermissionsExt;
let bin = tempfile::tempdir().unwrap();
let marker = bin.path().join("recebido");
let script = bin.path().join("notify-send");
std::fs::write(
&script,
format!("#!/bin/sh\nprintf '%s|' \"$@\" > '{}'\n", marker.display()),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let old = std::env::var("PATH").unwrap_or_default();
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
notify::send("Guará: varredura concluída", "7 arquivos");
for _ in 0..100 {
if marker.exists() && !std::fs::read(&marker).unwrap().is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(30));
}
std::env::set_var("PATH", old);
let got = String::from_utf8(std::fs::read(&marker).unwrap()).unwrap();
assert!(
got.contains("Guará: varredura concluída") && got.contains("7 arquivos"),
"{got}"
);
}
// ------------------------------------------------------------------ retomar recuperação
#[test]
fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
let Some(dir) = fixtures() else { return };
let img = dir.join("fat32.img");
let res = scan_path(
&img,
&ScanOptions {
mode: ScanMode::Quick,
..Default::default()
},
);
assert_eq!(res.found.len(), 7);
let src = source::open_path(&img).unwrap();
let files: Vec<&Found> = res.found.iter().collect();
let out = tempfile::tempdir().unwrap();
let key = "disco-de-teste";
// 1ª execução: "cai a energia" depois de 3 arquivos
let n = std::sync::atomic::AtomicUsize::new(0);
let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 3;
let r1 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&cancel,
&mut |_, _, _| {},
)
.unwrap();
assert!(r1.cancelled);
assert_eq!(r1.recovered.len(), 3);
let journal = std::fs::read_to_string(out.path().join(recover::JOURNAL)).unwrap();
assert_eq!(journal.lines().count(), 3);
// 2ª execução: retoma, pula os 3 e faz os outros 4
let r2 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert!(!r2.cancelled);
assert_eq!((r2.skipped, r2.recovered.len()), (3, 7));
let got = hashes_in(out.path());
for (h, p) in expected(&dir, "fat32") {
assert!(got.contains_key(&h), "faltou {p}");
}
assert_eq!(
std::fs::read_to_string(out.path().join(recover::JOURNAL))
.unwrap()
.lines()
.count(),
7
);
// um arquivo é danificado entre as execuções: é refeito, os outros 6 são pulados
let victim = out.path().join(&r2.recovered[0].path);
std::fs::write(&victim, b"lixo").unwrap();
let r3 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert_eq!((r3.skipped, r3.recovered.len(), r3.failed.len()), (6, 7, 0));
assert!(
hashes_in(out.path()).contains_key(&r2.recovered[0].sha256),
"o arquivo danificado deveria ter sido refeito"
);
// outro disco (outra chave) não reaproveita o diário
let r4 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
"outro-disco",
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert_eq!(r4.skipped, 0);
let _ = json!({});
}
+160
View File
@@ -0,0 +1,160 @@
//! Leitor de SQLite: esquema, linhas (com overflow e árvores de vários níveis),
//! registros apagados e robustez contra bancos danificados.
use guara_core::source::MemSource;
use guara_core::sqlite::{is_sqlite, Cell, Db};
const DB: &[u8] = include_bytes!("data/exemplo.sqlite");
fn text(c: &Cell) -> &str {
match c {
Cell::Text(t) => t,
other => panic!("esperava texto, veio {other:?}"),
}
}
#[test]
fn esquema_com_tabelas_indices_e_views() {
let src = MemSource(DB.to_vec());
assert!(is_sqlite(DB));
let db = Db::open(&src).unwrap();
let s = db.schema();
assert_eq!(s.page_size, 1024);
assert_eq!(s.encoding, "UTF-8");
let names: Vec<_> = s.tables.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["usuarios", "notas", "sem_rowid"]);
let u = &s.tables[0];
assert_eq!(
u.columns
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
["id", "nome", "email", "idade", "saldo", "foto"]
);
assert_eq!(u.columns[0].decl_type, "INTEGER");
assert!(u.columns[0].pk && u.rowid_alias == Some(0));
assert!(s.tables[2].without_rowid);
assert_eq!(s.indexes, ["idx_usuarios_email"]);
assert_eq!(s.views, ["maiores"]);
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
assert_eq!(db.count_rows(u), 493); // 500 menos 7 apagados
}
#[test]
fn linhas_em_arvore_de_varios_niveis_e_paginacao() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let p = db.rows("usuarios", 0, 5).unwrap();
assert_eq!(p.total, 493);
assert_eq!(p.rows.len(), 5);
// o alias do rowid (INTEGER PRIMARY KEY) vem preenchido
assert!(matches!(p.rows[0].cells[0], Cell::Int(1)));
assert_eq!(text(&p.rows[0].cells[1]), "Pessoa 0001");
assert_eq!(text(&p.rows[0].cells[2]), "pessoa1@exemplo.com");
assert!(matches!(p.rows[0].cells[3], Cell::Int(19)));
assert!(matches!(p.rows[1].cells[4], Cell::Real(r) if (r - 3.0).abs() < 1e-9));
assert!(matches!(&p.rows[2].cells[5], Cell::Blob { len: 3, .. }));
// pula os apagados 101..103: a linha de índice 100 é o id 104
let q = db.rows("usuarios", 100, 2).unwrap();
assert!(matches!(q.rows[0].cells[0], Cell::Int(104)));
// última linha
let last = db.rows("usuarios", 492, 10).unwrap();
assert_eq!(last.rows.len(), 1);
assert_eq!(text(&last.rows[0].cells[1]), "Pessoa 0500");
}
#[test]
fn texto_longo_com_overflow_e_acentos() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let n = db.rows("notas", 0, 10).unwrap();
assert_eq!(n.rows.len(), 3);
assert_eq!(text(&n.rows[0].cells[1]), "texto pequeno");
assert_eq!(
text(&n.rows[1].cells[1]).len(),
"Lorem ipsum dolor sit amet. ".len() * 400
);
assert!(text(&n.rows[1].cells[1]).ends_with("amet. "));
assert_eq!(text(&n.rows[2].cells[1]), "coração, ação, relatório");
assert_eq!(text(&n.rows[2].cells[0]), "acentuação");
}
#[test]
fn recupera_registros_apagados_das_areas_livres() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let d = db.deleted_rows("usuarios").unwrap();
let nomes: Vec<String> = d
.rows
.iter()
.filter_map(|r| r.cells.get(1))
.filter_map(|c| {
if let Cell::Text(t) = c {
Some(t.clone())
} else {
None
}
})
.collect();
let apagados = [
"Pessoa 0101",
"Pessoa 0102",
"Pessoa 0103",
"Pessoa 0250",
"Pessoa 0251",
"Pessoa 0252",
"Pessoa 0253",
];
let achados = apagados
.iter()
.filter(|n| nomes.iter().any(|x| x == *n))
.count();
assert!(
achados >= 5,
"só {achados} de 7 registros apagados recuperados: {nomes:?}"
);
assert!(d.rows.iter().all(|r| r.deleted));
// a tabela de notas não teve nada apagado
assert!(db.deleted_rows("notas").unwrap().rows.is_empty());
}
#[test]
fn banco_truncado_e_corrompido_nao_derruba_nem_trava() {
// metade do arquivo
let half = DB[..DB.len() / 2].to_vec();
let src = MemSource(half);
let db = Db::open(&src).unwrap();
let s = db.schema();
assert!(
s.warnings.iter().any(|w| w.contains("truncado")),
"{:?}",
s.warnings
);
let p = db.rows("usuarios", 0, 1000).unwrap();
assert!(!p.rows.is_empty() && p.rows.len() < 493, "{}", p.rows.len());
// sem cabeçalho
let mut bad = DB.to_vec();
bad[0] = b'X';
assert!(Db::open(&MemSource(bad)).is_err());
// corrupção aleatória (semente fixa): nunca pode dar panic
let mut x: u64 = 0x1234_5678_9abc_def0;
for _ in 0..60 {
let mut d = DB.to_vec();
for _ in 0..40 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let i = 100 + (x as usize % (d.len() - 100));
d[i] = (x >> 24) as u8;
}
let src = MemSource(d);
if let Ok(db) = Db::open(&src) {
let s = db.schema();
for t in &s.tables {
let _ = db.rows(&t.name, 0, 50);
let _ = db.deleted_rows(&t.name);
let _ = db.count_rows(t);
}
}
}
}