fix: sessões abrem mesmo sem a origem; mensagens claras de erro (0.3.1)

Antes, abrir uma sessão cuja origem não estava acessível (ou cujo arquivo não podia
ser lido, como em /root) falhava por inteiro com erro vago. Agora a sessão abre,
permite navegar/exportar e a origem pode ser reconectada. Testes cobrem sessões
gravadas pela v0.1.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 16:37:25 -03:00
1 parent 45d535bdb7
commit 682ba8b943
10 files changed
+213 -23

No files matched your search

+7
View File
@@ -2,6 +2,13 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer. Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [0.3.1] - 2026-10-18
### Corrigido
- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar.
- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida.
- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo.
## [0.3.0] - 2026-10-18 ## [0.3.0] - 2026-10-18
### Adicionado ### Adicionado
Generated
+2 -2
View File
@@ -317,7 +317,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-cli" name = "guara-cli"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"clap", "clap",
"ctrlc", "ctrlc",
@@ -329,7 +329,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"flate2", "flate2",
"image", "image",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"] members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.3.1"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara" repository = "https://github.com/lucasrguerra/guara"
+1 -1
View File
@@ -3,7 +3,7 @@
[package] [package]
name = "guara-gui" name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos" description = "Guará: interface gráfica de recuperação de arquivos"
version = "0.3.0" version = "0.3.1"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara" repository = "https://github.com/lucasrguerra/guara"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Guara", "productName": "Guara",
"version": "0.3.0", "version": "0.3.1",
"identifier": "dev.guara.app", "identifier": "dev.guara.app",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+9 -3
View File
@@ -210,7 +210,7 @@ $('#loadSessionBtn').onclick = async () => {
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() }; const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
S.sessions.unshift(s); S.sessions.unshift(s);
openJob(s); openJob(s);
} catch (e) { toast(e.message); } } catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
}; };
// ------------------------------------------------------------------ início // ------------------------------------------------------------------ início
@@ -447,7 +447,8 @@ function paintStatus() {
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</span>` : ''; const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</span>` : '';
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>' const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
: st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : ''; : st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : '';
const tools = !running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : ''; const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
if (running || st.status === 'paused') { if (running || st.status === 'paused') {
setHTML($('#progressArea'), `<div class="progress-card slim"> setHTML($('#progressArea'), `<div class="progress-card slim">
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div> <div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
@@ -473,7 +474,12 @@ async function onProgressClick(e) {
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); } if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
else if (act === 'stop') await rpc('scan_stop', { job: S.job }); else if (act === 'stop') await rpc('scan_stop', { job: S.job });
else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); } else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
else if (act === 'csv') { else if (act === 'relink') {
const path = await ask({ title: 'Escolher a origem', text: `Esta sessão foi feita sobre <code>${esc(S.status?.target || '')}</code>. Informe o dispositivo ou a imagem do mesmo disco para pré-visualizar e recuperar.`, value: S.status?.target || '', ok: 'Usar esta origem' });
if (path) {
try { await rpc('session_relink', { job: S.job, path }); toast('Origem reconectada'); const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' }; openJob(cur); } catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
}
} else if (act === 'csv') {
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' }); const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } } if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } }
} else if (act === 'report') { } else if (act === 'report') {
+4 -1
View File
@@ -279,7 +279,10 @@ export function createExplorer(deps) {
if (img.tagName !== 'IMG' || !img.dataset.th) return; if (img.tagName !== 'IMG' || !img.dataset.th) return;
const wrap = img.closest('.xp-thumb, .xp-lth'); const wrap = img.closest('.xp-thumb, .xp-lth');
img.remove(); img.remove();
wrap?.insertAdjacentHTML('afterbegin', `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`); const offline = E.status && E.status.source === false;
wrap?.insertAdjacentHTML('afterbegin', offline
? '<div class="xp-ico c-image" title="Origem desconectada: sem miniatura"><span>🖼</span><b>IMG</b></div>'
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
}, true); }, true);
// ------------------------------------------------------------ seleção // ------------------------------------------------------------ seleção
+88 -14
View File
@@ -63,7 +63,7 @@ pub struct Job {
opts: ScanOptions, opts: ScanOptions,
cancel: AtomicBool, cancel: AtomicBool,
stop_requested: AtomicBool, stop_requested: AtomicBool,
src: Option<Arc<dyn Source>>, src: Mutex<Option<Arc<dyn Source>>>,
st: Mutex<State>, st: Mutex<State>,
} }
@@ -82,7 +82,7 @@ impl Job {
opts, opts,
cancel: AtomicBool::new(false), cancel: AtomicBool::new(false),
stop_requested: AtomicBool::new(false), stop_requested: AtomicBool::new(false),
src, src: Mutex::new(src),
st: Mutex::new(State { st: Mutex::new(State {
status: Status::Running, status: Status::Running,
progress: Progress::default(), progress: Progress::default(),
@@ -105,6 +105,9 @@ impl Job {
}), }),
}) })
} }
fn source(&self) -> Option<Arc<dyn Source>> {
self.src.lock().unwrap().clone()
}
fn cancelled(&self) -> bool { fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed) self.cancel.load(Ordering::Relaxed)
} }
@@ -254,6 +257,7 @@ impl Engine {
"error": s.error, "error": s.error,
"report": s.report, "report": s.report,
"elapsed": elapsed, "elapsed": elapsed,
"source": j.source().is_some(),
"hits": s.hits.len(), "hits": s.hits.len(),
"enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })), "enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })),
})) }))
@@ -294,7 +298,7 @@ impl Engine {
if let Some(v) = self.thumbs.lock().unwrap().get(&key) { if let Some(v) = self.thumbs.lock().unwrap().get(&key) {
return if v.is_empty() { Err(422) } else { Ok(v) }; return if v.is_empty() { Err(422) } else { Ok(v) };
} }
let (f, src) = self.open_file(job, id).map_err(|_| 404u16)?; let (f, src) = self.open_file(job, id).map_err(status_of)?;
if f.category != Category::Image { if f.category != Category::Image {
return Err(415); return Err(415);
} }
@@ -319,10 +323,7 @@ impl Engine {
.index .index
.get(&file) .get(&file)
.ok_or_else(|| Error::msg("arquivo não encontrado"))?; .ok_or_else(|| Error::msg("arquivo não encontrado"))?;
Ok(( Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?))
s.found[i].clone(),
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
))
} }
// ---------------------------------------------------------------- recuperação // ---------------------------------------------------------------- recuperação
@@ -335,7 +336,7 @@ impl Engine {
dest: &str, dest: &str,
) -> Result<String> { ) -> Result<String> {
let sj = self.job(scan_id)?; let sj = self.job(scan_id)?;
let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?; let src = sj.source().ok_or_else(no_source)?;
let files: Vec<Found> = { let files: Vec<Found> = {
let s = sj.st.lock().unwrap(); let s = sj.st.lock().unwrap();
match (&ids, &filter) { match (&ids, &filter) {
@@ -611,13 +612,31 @@ impl Engine {
} }
pub fn load_session(&self, path: &str) -> Result<String> { pub fn load_session(&self, path: &str) -> Result<String> {
let data: SessionFile = serde_json::from_slice(&std::fs::read(path)?)?; let bytes = std::fs::read(path).map_err(|e| {
let src = open_path(Path::new(&data.target)).map_err(|e| open_error(&data.target, e))?; if e.kind() == std::io::ErrorKind::PermissionDenied {
Error::msg(format!(
"Sem permissão para ler a sessão {path}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta."
))
} else {
Error::msg(format!("Não foi possível ler a sessão {path}: {e}"))
}
})?;
let data: SessionFile = serde_json::from_slice(&bytes).map_err(|e| {
Error::msg(format!(
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
))
})?;
// A sessão abre mesmo sem a origem: dá para navegar, exportar e gerar relatório.
// Pré-visualização e recuperação voltam quando a origem for reconectada.
let (src, missing) = match open_path(Path::new(&data.target)) {
Ok(s) => (Some(s), None),
Err(e) => (None, Some(open_error(&data.target, e).to_string())),
};
let job = self.register( let job = self.register(
JobKind::Scan, JobKind::Scan,
data.target.clone(), data.target.clone(),
data.options.clone(), data.options.clone(),
Some(src), src,
); );
{ {
let mut s = job.st.lock().unwrap(); let mut s = job.st.lock().unwrap();
@@ -636,12 +655,45 @@ impl Engine {
Status::Paused Status::Paused
}; };
} }
if let Some(m) = missing {
job.st.lock().unwrap().warnings.push(format!(
"A origem {} não está disponível agora ({m}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.",
data.target
));
}
if data.done { if data.done {
spawn_enrich(job.clone()); spawn_enrich(job.clone());
} }
Ok(job.id.clone()) Ok(job.id.clone())
} }
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
pub fn relink(&self, id: &str, path: &str) -> Result<()> {
let j = self.job(id)?;
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
{
let s = j.st.lock().unwrap();
if let Some(l) = &s.layout {
if l.size != 0 && l.size != src.size() {
return Err(Error::msg(format!(
"Essa origem tem {} bytes, mas a sessão foi feita sobre um disco de {} bytes. Escolha o mesmo disco ou uma imagem dele.",
src.size(),
l.size
)));
}
}
}
*j.src.lock().unwrap() = Some(src);
j.st.lock()
.unwrap()
.warnings
.retain(|w| !w.starts_with("A origem "));
if j.st.lock().unwrap().status == Status::Done {
spawn_enrich(j);
}
Ok(())
}
// ---------------------------------------------------------------- RPC // ---------------------------------------------------------------- RPC
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> { pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
@@ -758,6 +810,10 @@ impl Engine {
self.save_session(&s("job")?, &s("path")?)?; self.save_session(&s("job")?, &s("path")?)?;
json!({}) json!({})
} }
"session_relink" => {
self.relink(&s("job")?, &s("path")?)?;
json!({})
}
"session_load" => json!({ "job": self.load_session(&s("path")?)? }), "session_load" => json!({ "job": self.load_session(&s("path")?)? }),
"defaults" => { "defaults" => {
let home = std::env::var("HOME") let home = std::env::var("HOME")
@@ -793,6 +849,19 @@ struct SessionFile {
use crate::explorer::cat_key; use crate::explorer::cat_key;
/// 503 quando a origem da sessão está desconectada; 404 nos demais casos.
fn status_of(e: Error) -> u16 {
if e.to_string().contains("não está disponível") {
503
} else {
404
}
}
fn no_source() -> Error {
Error::msg("A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.")
}
fn open_error(path: &str, e: std::io::Error) -> Error { fn open_error(path: &str, e: std::io::Error) -> Error {
if e.kind() == std::io::ErrorKind::PermissionDenied { if e.kind() == std::io::ErrorKind::PermissionDenied {
Error::msg(format!( Error::msg(format!(
@@ -805,7 +874,12 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) { fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
std::thread::spawn(move || { std::thread::spawn(move || {
let src = job.src.clone().expect("origem"); let Some(src) = job.source() else {
let mut s = job.st.lock().unwrap();
s.status = Status::Error;
s.error = Some(no_source().to_string());
return;
};
let existing: Vec<Found> = job.st.lock().unwrap().found.clone(); let existing: Vec<Found> = job.st.lock().unwrap().found.clone();
let cancelled = || job.cancelled(); let cancelled = || job.cancelled();
let mut sink = JobSink(job.clone()); let mut sink = JobSink(job.clone());
@@ -845,7 +919,7 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
/// navegar por data, câmera e resolução. /// navegar por data, câmera e resolução.
fn spawn_enrich(job: Arc<Job>) { fn spawn_enrich(job: Arc<Job>) {
std::thread::spawn(move || { std::thread::spawn(move || {
let Some(src) = job.src.clone() else { return }; let Some(src) = job.source() else { return };
let todo: Vec<(u64, Found)> = { let todo: Vec<(u64, Found)> = {
let s = job.st.lock().unwrap(); let s = job.st.lock().unwrap();
s.found s.found
@@ -1045,7 +1119,7 @@ impl Engine {
id: u64, id: u64,
range: Option<&str>, range: Option<&str>,
) -> std::result::Result<FileSlice, u16> { ) -> std::result::Result<FileSlice, u16> {
let (found, src) = self.open_file(job, id).map_err(|_| 404u16)?; let (found, src) = self.open_file(job, id).map_err(status_of)?;
let total = found.size; let total = found.size;
let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false); let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false);
if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) { if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) {
@@ -0,0 +1 @@
{"version": 1, "target": "@@ALVO@@", "options": {"mode": "deep", "include_allocated": false, "categories": [], "max_carve_size": 8589934592}, "found": [{"id": 1, "name": "tese.docx", "dir": null, "ext": "docx", "category": "document", "size": 411277, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 52428800, "len": 411277}]}, "modified": 1792346095, "note": null}, {"id": 2, "name": "notas.txt", "dir": null, "ext": "txt", "category": "document", "size": 945617, "origin": "ntfs", "confidence": "medium", "data": {"kind": "extents", "extents": [{"offset": 52842496, "len": 945617}]}, "modified": 1792346095, "note": null}, {"id": 3, "name": "relatorio.pdf", "dir": null, "ext": "pdf", "category": "document", "size": 646931, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 53788672, "len": 646931}]}, "modified": 1792346095, "note": null}, {"id": 4, "name": "dados.bin", "dir": null, "ext": "bin", "category": "other", "size": 3000000, "origin": "ntfs", "confidence": "medium", "data": {"kind": "extents", "extents": [{"offset": 54435840, "len": 3000000}]}, "modified": 1792346095, "note": null}, {"id": 5, "name": "contatos.db", "dir": null, "ext": "db", "category": "database", "size": 573440, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 57438208, "len": 573440}]}, "modified": 1792346095, "note": null}, {"id": 6, "name": "praia.jpg", "dir": null, "ext": "jpg", "category": "image", "size": 511391, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 58011648, "len": 511391}]}, "modified": 1792346096, "note": null}, {"id": 7, "name": "montanha.png", "dir": null, "ext": "png", "category": "image", "size": 3337460, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 58638336, "len": 3337460}]}, "modified": 1792346096, "note": null}], "checkpoint": null, "layout": {"size": 100663296, "table": "none", "partitions": [{"index": 0, "start": 0, "len": 100663296, "fs": "ntfs", "label": null, "lost": false, "encrypted": null, "table_type": "volume sem tabela de partições"}], "gaps": [], "warnings": []}, "warnings": [], "done": true}
+99
View File
@@ -302,3 +302,102 @@ fn configuracoes_de_tema_persistem() {
.unwrap(); .unwrap();
assert_eq!(settings::load().theme, "dark"); assert_eq!(settings::load().theme, "dark");
} }
fn wait_done(eng: &service::Engine, job: &str) {
for _ in 0..500 {
if eng.status(job).unwrap()["status"] != "running" {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("job não terminou");
}
/// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas.
#[test]
fn sessao_da_v0_1_0_abre_e_funciona() {
let Some(dir) = fixtures() else { return };
let img = dir.join("ntfs.img");
let raw = include_str!("data/sessao-v0.1.0.json").replace("@@ALVO@@", img.to_str().unwrap());
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), raw).unwrap();
let eng = service::Engine::new();
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
wait_done(&eng, &job);
let st = eng.status(&job).unwrap();
assert_eq!(st["status"], "done");
assert_eq!(st["found"], 7);
assert_eq!(st["source"], true);
let res = eng.results(&job, &service::Query::default()).unwrap();
assert_eq!(res["total"], 7);
// recuperar a partir da sessão antiga continua dando o mesmo conteúdo
let out = tempfile::tempdir().unwrap();
let rj = eng
.start_recover(
&job,
None,
Some(service::Query::default()),
out.path().to_str().unwrap(),
)
.unwrap();
wait_done(&eng, &rj);
let got = hashes_in(out.path());
for (h, p) in expected(&dir, "ntfs") {
assert!(got.contains_key(&h), "faltou {p}");
}
}
/// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo.
#[test]
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
let Some(dir) = fixtures() else { return };
let img = dir.join("ntfs.img");
let raw =
include_str!("data/sessao-v0.1.0.json").replace("@@ALVO@@", "/dev/disco-que-nao-existe");
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), raw).unwrap();
let eng = service::Engine::new();
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
let st = eng.status(&job).unwrap();
assert_eq!(st["source"], false);
assert_eq!(st["found"], 7);
assert!(st["warnings"][0]
.as_str()
.unwrap()
.contains("não está disponível"));
// navegar e exportar funcionam
assert_eq!(
eng.results(&job, &service::Query::default()).unwrap()["total"],
7
);
let csv = tempfile::NamedTempFile::new().unwrap();
assert_eq!(
eng.export_csv(&job, csv.path().to_str().unwrap(), None)
.unwrap(),
7
);
// pré-visualização e recuperação avisam com clareza
assert_eq!(eng.thumb(&job, 1, 128).err(), Some(503));
let out = tempfile::tempdir().unwrap();
let e = eng
.start_recover(&job, None, None, out.path().to_str().unwrap())
.unwrap_err();
assert!(e.to_string().contains("não está disponível"), "{e}");
// reconecta
eng.relink(&job, img.to_str().unwrap()).unwrap();
let st = eng.status(&job).unwrap();
assert_eq!(st["source"], true);
assert!(st["warnings"].as_array().unwrap().is_empty());
let rj = eng
.start_recover(&job, None, None, out.path().to_str().unwrap())
.unwrap();
wait_done(&eng, &rj);
assert_eq!(hashes_in(out.path()).len(), 7);
// arquivo que não é sessão
std::fs::write(tmp.path(), b"nao e json").unwrap();
let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err();
assert!(
e.to_string().contains("não é uma sessão do Guará válida"),
"{e}"
);
}