use std::fmt;
use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
use crate::i18n::{fill, t};
use crate::session::Session;
#[derive(Debug)]
pub enum State {
NothingExtracted,
Unchanged,
Edited,
Diverged,
ContainerGone,
ContainerChanged {
recorded: String,
found: String,
},
Unreadable(String),
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NothingExtracted => write!(f, "{}", t("nothing was extracted")),
Self::Unchanged => write!(f, "{}", t("unchanged since it came out of the container")),
Self::Edited => write!(
f,
"{}",
t("edited, and the edit never reached the container")
),
Self::Diverged => write!(
f,
"{}",
t("edited, and the container changed too, so both hold work the other does not")
),
Self::ContainerGone => {
write!(f, "{}", t("the container is no longer where it was"))
}
Self::ContainerChanged { recorded, found } => write!(
f,
"{}",
fill(
t("the container now holds {found} rather than {recorded}"),
&[("found", found), ("recorded", recorded)],
)
),
Self::Unreadable(e) => write!(
f,
"{}",
fill(t("cannot be read: {reason}"), &[("reason", e)])
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Course {
Sweep,
WriteBack,
Ask,
}
impl State {
#[must_use]
pub fn course(&self) -> Course {
match self {
Self::NothingExtracted | Self::Unchanged => Course::Sweep,
Self::Edited => Course::WriteBack,
Self::Diverged
| Self::ContainerGone
| Self::ContainerChanged { .. }
| Self::Unreadable(_) => Course::Ask,
}
}
#[must_use]
pub fn needs_a_person(&self) -> bool {
self.course() == Course::Ask
}
#[must_use]
pub fn is_ours_to_write_back(&self) -> bool {
self.course() == Course::WriteBack
}
#[must_use]
pub fn is_quiet(&self) -> bool {
self.course() == Course::Sweep
}
}
#[must_use]
pub fn state(session: &Session) -> State {
let payload = session.payload_path();
if !payload.is_file() {
return State::NothingExtracted;
}
let container = match slpc::Container::open(&session.record().container) {
Ok(c) => c,
Err(slpc::Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => {
return State::ContainerGone
}
Err(e) => return State::Unreadable(e.to_string()),
};
if container.payload_name() != session.record().payload {
return State::ContainerChanged {
recorded: session.record().payload.clone(),
found: container.payload_name().to_string(),
};
}
let (stored, made) = match (container.payload_crc(), crc_of(&payload)) {
(Ok(a), Ok(b)) => (a, b),
(Err(e), _) => return State::Unreadable(e.to_string()),
(_, Err(e)) => return State::Unreadable(e.to_string()),
};
if stored == made {
return State::Unchanged;
}
match session.record().agreed {
Some(agreed) if agreed == stored => State::Edited,
_ => State::Diverged,
}
}
pub fn crc_of(path: &Path) -> io::Result<u32> {
let mut file = File::open(path)?;
let mut hasher = crc32fast::Hasher::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
match file.read(&mut buf)? {
0 => break,
n => hasher.update(&buf[..n]),
}
}
Ok(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::{crc_of, state, Course, State};
use crate::{extract, session, writeback};
use std::fs;
use std::path::{Path, PathBuf};
fn container(at: &Path, name: &str, payload: &[u8]) -> PathBuf {
let doc: slpc::toml_edit::DocumentMut =
format!("slipcase_version = \"1.0\"\n\n[payload]\nfile = \"{name}\"\n")
.parse()
.unwrap();
let path = at.join(format!("{name}.slpc"));
slpc::pack_reader(name, payload, doc, fs::File::create(&path).unwrap()).unwrap();
path
}
fn opened(root: &Path, c: &Path, name: &str) -> session::Session {
let mut s = session::create(root, c, name).unwrap();
extract::extract(&mut slpc::Container::open(c).unwrap(), &mut s).unwrap();
s
}
#[test]
fn a_payload_nobody_touched_is_the_quiet_case() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let s = opened(&root, &c, "report.pdf");
assert!(matches!(state(&s), State::Unchanged));
assert!(!state(&s).needs_a_person());
}
#[test]
fn an_edit_that_never_landed_goes_back_without_being_asked_about() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let s = opened(&root, &c, "report.pdf");
fs::write(s.payload_path(), b"edited and then the process died").unwrap();
assert!(matches!(state(&s), State::Edited));
assert_eq!(state(&s).course(), Course::WriteBack);
assert!(!state(&s).needs_a_person());
assert!(!state(&s).is_quiet(), "it must not be swept away");
}
#[test]
fn an_edit_whose_container_also_moved_is_a_question() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let s = opened(&root, &c, "report.pdf");
fs::write(s.payload_path(), b"our edit").unwrap();
container(tmp.path(), "report.pdf", b"somebody else's second thoughts");
assert!(matches!(state(&s), State::Diverged));
assert_eq!(state(&s).course(), Course::Ask);
}
#[test]
fn a_session_that_never_recorded_an_agreement_is_asked_about() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let s = opened(&root, &c, "report.pdf");
fs::write(s.payload_path(), b"edited").unwrap();
assert_eq!(state(&s).course(), Course::WriteBack);
let record = s.dir().join("session.toml");
let text = fs::read_to_string(&record).unwrap();
let without: Vec<&str> = text.lines().filter(|l| !l.starts_with("agreed")).collect();
fs::write(&record, without.join("\n")).unwrap();
let reread = &session::scan(&root).unwrap()[0];
assert!(reread.record().agreed.is_none());
assert_eq!(state(reread).course(), Course::Ask);
}
#[test]
fn every_state_takes_exactly_one_course() {
for (state, want) in [
(State::NothingExtracted, Course::Sweep),
(State::Unchanged, Course::Sweep),
(State::Edited, Course::WriteBack),
(State::Diverged, Course::Ask),
(State::ContainerGone, Course::Ask),
(
State::ContainerChanged {
recorded: "a".into(),
found: "b".into(),
},
Course::Ask,
),
(State::Unreadable("why".into()), Course::Ask),
] {
assert_eq!(state.course(), want, "{state:?}");
assert_eq!(state.is_quiet(), want == Course::Sweep, "{state:?}");
assert_eq!(state.needs_a_person(), want == Course::Ask, "{state:?}");
assert_eq!(
state.is_ours_to_write_back(),
want == Course::WriteBack,
"{state:?}"
);
}
}
#[test]
fn a_write_back_returns_the_session_to_quiet() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let mut s = opened(&root, &c, "report.pdf");
fs::write(s.payload_path(), b"edited").unwrap();
assert!(matches!(state(&s), State::Edited));
writeback::write_back(&mut s).unwrap();
assert!(matches!(state(&s), State::Unchanged));
}
#[test]
fn a_session_that_died_before_extracting_has_nothing_to_ask_about() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let s = session::create(&root, &c, "report.pdf").unwrap();
assert!(matches!(state(&s), State::NothingExtracted));
assert!(!state(&s).needs_a_person());
}
#[test]
fn a_container_that_went_away_leaves_the_payload_worth_offering() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let s = opened(&root, &c, "report.pdf");
fs::write(s.payload_path(), b"edited").unwrap();
fs::remove_file(&c).unwrap();
assert!(matches!(state(&s), State::ContainerGone));
assert!(state(&s).needs_a_person());
assert!(s.payload_path().is_file());
}
#[test]
fn a_different_container_at_the_same_path_is_not_written_over() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let s = opened(&root, &c, "report.pdf");
fs::write(s.payload_path(), b"edited").unwrap();
let other = container(tmp.path(), "plan.dwg", b"unrelated");
fs::rename(&other, &c).unwrap();
match state(&s) {
State::ContainerChanged { recorded, found } => {
assert_eq!(recorded, "report.pdf");
assert_eq!(found, "plan.dwg");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_zero_length_payload_compares_rather_than_erroring() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "empty.txt", b"");
let s = opened(&root, &c, "empty.txt");
assert!(matches!(state(&s), State::Unchanged));
fs::write(s.payload_path(), b"no longer empty").unwrap();
assert!(matches!(state(&s), State::Edited));
}
#[test]
fn the_crc_is_streamed_rather_than_read_whole() {
let tmp = tempfile::tempdir().unwrap();
let big = tmp.path().join("big.bin");
let bytes = vec![0xa5u8; 300 * 1024];
fs::write(&big, &bytes).unwrap();
assert_eq!(crc_of(&big).unwrap(), crc32fast::hash(&bytes));
}
}