use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::flow::{self, Lingering, Opened};
use crate::ipc::{Request, Response, Voice};
use crate::outside::Outside;
use crate::present::{Answer, Choice, Question, Report};
use crate::recover;
use crate::session::{self, Session};
use crate::table::Table;
const TICK: Duration = Duration::from_millis(250);
const SETTLED: Duration = Duration::from_secs(2);
const HELD: Duration = Duration::from_secs(300);
const PULSE: Duration = Duration::from_millis(900);
struct Pending {
about: String,
session: Session,
then_open: Option<PathBuf>,
asked: Instant,
}
pub struct Resident {
root: PathBuf,
sessions: Table<Opened>,
lingering: Vec<Lingering>,
pending: Vec<Pending>,
settles_after: Duration,
holds_for: Option<Duration>,
troubles: Vec<crate::present::Trouble>,
wrote_back: Option<Instant>,
}
impl Resident {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
sessions: Table::new(),
lingering: Vec::new(),
pending: Vec::new(),
settles_after: SETTLED,
holds_for: Some(HELD),
troubles: Vec::new(),
wrote_back: None,
}
}
#[cfg(test)]
fn waiting(mut self, settles_after: Duration, holds_for: Duration) -> Self {
self.settles_after = settles_after;
self.holds_for = Some(holds_for);
self
}
#[must_use]
pub fn is_idle(&self) -> bool {
self.sessions.is_empty() && self.lingering.is_empty() && self.pending.is_empty()
}
pub fn let_questions_stand(&mut self) {
self.holds_for = None;
}
fn put_it_back(&mut self, mut session: Session, outside: &Outside<'_>) {
let name = slpc::display_name(&session.record().payload).into_owned();
let into = slpc::display_path(&session.record().container);
match crate::writeback::write_back(&mut session) {
Ok(()) => {
outside.report(
&Report::ordinary(format!("{name} was recovered and written back."))
.and(format!("Into {into}."))
.and("It had been edited after the session holding it stopped."),
);
let _ = session.remove();
}
Err(e) => {
outside.report(
&Report::interrupt(format!("{name} could not be written back."))
.and(e.to_string()),
);
self.note(
crate::present::Mood::AtRisk,
format!("recover:{}", id_of(&session)),
format!("{name} - an edit is not in its container"),
);
}
}
}
fn note(&mut self, mood: crate::present::Mood, id: impl Into<String>, summary: String) {
let id = id.into();
if let Some(had) = self.troubles.iter_mut().find(|t| t.id == id) {
had.mood = mood;
had.summary = summary;
return;
}
self.troubles
.push(crate::present::Trouble { id, mood, summary });
}
fn dismiss(&mut self, id: &str) {
self.troubles.retain(|t| t.id != id);
}
#[must_use]
pub fn troubles(&self) -> &[crate::present::Trouble] {
&self.troubles
}
#[must_use]
pub fn mood(&self) -> crate::present::Mood {
use crate::present::Mood;
let worst = self
.troubles
.iter()
.map(|t| t.mood)
.max()
.unwrap_or(Mood::Settled);
let waiting = if self.pending.is_empty() {
Mood::Settled
} else {
Mood::Look
};
let saving = match self.wrote_back {
Some(at) if at.elapsed() < PULSE => Mood::Working,
_ => Mood::Settled,
};
worst.max(waiting).max(saving)
}
pub fn handle(&mut self, request: Request, outside: &Outside<'_>) -> Response {
match request {
Request::Ping => Response::Ok(Vec::new()),
Request::List => self.list(),
Request::Open { container, voice } => self.open(&container, voice, outside),
Request::Close(id) => self.close(&id),
}
}
fn open(&mut self, container: &Path, voice: Voice, outside: &Outside<'_>) -> Response {
if let Some(open) = self.sessions.find_mut(container) {
return match outside.launcher.launch(&open.payload_path()) {
Ok(()) => say(
voice,
outside,
Report::ordinary(format!(
"{} is already open; brought forward.",
slpc::display_name(&open.session().record().payload)
)),
),
Err(e) => refuse(voice, outside, format!("could not bring it forward: {e}")),
};
}
match self.ask_about_what_was_left(container, outside) {
Err(e) => return refuse(voice, outside, e),
Ok(true) => {
return refuse(
voice,
outside,
"a session on this container was left behind, and what to do with it \
comes first"
.to_string(),
)
}
Ok(false) => {}
}
match flow::open(&self.root, container, outside) {
Err(e) => {
let named = container.file_name().map_or_else(
|| container.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
if let flow::Error::Misrepresented(what) = &e {
outside.channel.insist(
&Report::interrupt(format!("{named} was not opened."))
.and(format!(
"Its payload is {}, not a document.",
what.describes()
))
.and("That is the shape of a phishing attachment.")
.and("Nothing was extracted and nothing was run."),
);
self.note(
crate::present::Mood::Danger,
format!("content:{}", container.display()),
format!("{named} - is {}, not a document", what.describes()),
);
return Response::Err(e.to_string());
}
self.note(
crate::present::Mood::Look,
format!("open:{}", container.display()),
format!("{named} - did not open: {e}"),
);
refuse(voice, outside, e.to_string())
}
Ok(opened) => {
let name = slpc::display_name(&opened.session().record().payload).into_owned();
let mut report = Report::routine(format!("{name} is open."))
.and(format!("Session {}", id_of(opened.session())));
if opened.mark != slpc::provenance::Mark::Silent {
report = report.and("It came from somewhere else, and the copy says so.");
}
if let Err(e) = self.sessions.insert(container, opened) {
return refuse(
voice,
outside,
format!("the session could not be tracked: {e}"),
);
}
say(voice, outside, report)
}
}
}
fn ask_about_what_was_left(
&mut self,
container: &Path,
outside: &Outside<'_>,
) -> Result<bool, String> {
let want = crate::identity::of(container).map_err(|e| e.to_string())?;
let sessions = session::scan(&self.root).map_err(|e| e.to_string())?;
for left in sessions {
if !crate::identity::of(&left.record().container).is_ok_and(|is| is == want) {
continue;
}
let state = recover::state(&left);
match state.course() {
recover::Course::Sweep => continue,
recover::Course::WriteBack => {
self.put_it_back(left, outside);
continue;
}
recover::Course::Ask => {}
}
let about = id_of(&left);
if self.pending.iter().any(|p| p.about == about) {
return Ok(true);
}
outside.channel.ask(&Question {
about: about.clone(),
summary: format!(
"{} was left behind.",
slpc::display_name(&left.record().payload)
),
detail: vec![
format!("It is {state}."),
format!("From {}.", slpc::display_path(&left.record().container)),
"It will open once you have decided.".into(),
],
choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
});
self.pending.push(Pending {
about,
session: left,
then_open: Some(container.to_path_buf()),
asked: Instant::now(),
});
return Ok(true);
}
Ok(false)
}
pub(crate) fn listed(&self) -> Vec<crate::present::Listed> {
let mut out: Vec<crate::present::Listed> = self
.sessions
.iter()
.map(|o| crate::present::Listed {
id: id_of(o.session()),
payload: slpc::display_name(&o.session().record().payload).into_owned(),
label: format!(
"{} open, {} write-back(s)",
slpc::display_name(&o.session().record().payload),
o.session().record().write_backs
),
live: true,
needs_a_person: false,
write_backs: Some(o.session().record().write_backs),
})
.collect();
out.extend(self.lingering.iter().map(|l| crate::present::Listed {
id: id_of(l.session()),
payload: slpc::display_name(&l.session().record().payload).into_owned(),
label: format!(
"{} closed, waiting for the application to finish",
slpc::display_name(&l.session().record().payload)
),
live: true,
needs_a_person: false,
write_backs: Some(l.session().record().write_backs),
}));
out.extend(self.pending.iter().map(|p| crate::present::Listed {
id: p.about.clone(),
payload: slpc::display_name(&p.session.record().payload).into_owned(),
label: format!(
"{} {}, waiting for you",
slpc::display_name(&p.session.record().payload),
recover::state(&p.session)
),
live: false,
needs_a_person: true,
write_backs: None,
}));
let held = self.held_directories();
if let Ok(left) = session::scan(&self.root) {
for s in left
.iter()
.filter(|s| !held.contains(&s.dir().to_path_buf()))
{
out.push(crate::present::Listed {
id: id_of(s),
payload: slpc::display_name(&s.record().payload).into_owned(),
label: format!(
"{} {}",
slpc::display_name(&s.record().payload),
recover::state(s)
),
live: false,
needs_a_person: recover::state(s).needs_a_person(),
write_backs: None,
});
}
}
out
}
pub(crate) fn list(&self) -> Response {
let mut lines: Vec<String> = self
.listed()
.into_iter()
.map(|e| format!("{} {}", e.id, e.label))
.collect();
if lines.is_empty() {
lines.push("No sessions.".into());
}
Response::Ok(lines)
}
fn held_directories(&self) -> Vec<PathBuf> {
self.sessions
.iter()
.map(|o| o.session().dir().to_path_buf())
.chain(
self.lingering
.iter()
.map(|l| l.session().dir().to_path_buf()),
)
.chain(self.pending.iter().map(|p| p.session.dir().to_path_buf()))
.collect()
}
fn close(&mut self, id: &str) -> Response {
let Some(container) = self
.sessions
.iter()
.find(|o| id_of(o.session()) == id)
.map(|o| o.session().record().container.clone())
else {
return Response::Err(format!("no open session {id}"));
};
let Some(opened) = self.sessions.remove(&container) else {
return Response::Err(format!("no open session {id}"));
};
match opened.close() {
Ok(flow::Closed::Cleared) => Response::Ok(vec!["Session closed.".into()]),
Ok(flow::Closed::LeftForRecovery(lingering)) => {
self.lingering.push(*lingering);
Response::Ok(vec![
"Session closed, and the application still has the payload open.".into(),
"It is being watched until the application finishes.".into(),
])
}
Err(e) => Response::Err(e.to_string()),
}
}
pub fn turn(&mut self, outside: &Outside<'_>) {
self.pump_all(outside);
self.ask_about_what_has_settled(outside);
self.act_on_answers(outside);
self.let_go_of_the_unanswered(outside);
}
fn pump_all(&mut self, outside: &Outside<'_>) {
let mut wrote_back = Vec::new();
let mut landed = Vec::new();
let mut failed = Vec::new();
for open in self.sessions.iter_mut() {
match open.pump() {
Ok(true) => {
let s = open.session();
if s.record().write_backs <= 1 {
outside.report(
&Report::routine(format!(
"{} written back.",
slpc::display_name(&s.record().payload)
))
.and("Saves from here on are written back quietly."),
);
}
landed.push(id_of(s));
wrote_back.push(s.record().container.clone());
}
Ok(false) => {}
Err(e) => {
let name = slpc::display_name(&open.session().record().payload).into_owned();
outside.report(
&Report::interrupt(format!("{name} could not be written back."))
.and(e.to_string()),
);
failed.push((id_of(open.session()), name));
}
}
}
if !wrote_back.is_empty() {
self.wrote_back = Some(Instant::now());
}
for container in wrote_back {
self.sessions.refresh(&container);
}
for (id, name) in failed {
self.note(
crate::present::Mood::AtRisk,
format!("writeback:{id}"),
format!("{name} - a save did not reach its container"),
);
}
for id in landed {
self.dismiss(&format!("writeback:{id}"));
}
}
fn ask_about_what_has_settled(&mut self, outside: &Outside<'_>) {
let mut still_waiting = Vec::new();
for mut lingering in std::mem::take(&mut self.lingering) {
if !lingering.has_settled(self.settles_after) {
still_waiting.push(lingering);
continue;
}
let about = id_of(lingering.session());
let session = lingering.into_session();
let state = recover::state(&session);
let name = slpc::display_name(&session.record().payload).into_owned();
match state.course() {
recover::Course::Sweep => {
let _ = session.remove();
continue;
}
recover::Course::WriteBack => {
self.put_it_back(session, outside);
continue;
}
recover::Course::Ask => {}
}
outside.channel.ask(&Question {
about: about.clone(),
summary: format!("{name} was saved after you closed the session."),
detail: vec![
format!("It is {state}."),
format!("Into {}.", slpc::display_path(&session.record().container)),
],
choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
});
self.pending.push(Pending {
about,
session,
then_open: None,
asked: Instant::now(),
});
}
self.lingering = still_waiting;
}
fn act_on_answers(&mut self, outside: &Outside<'_>) {
for answer in outside.channel.answers() {
self.settle(&answer, outside);
}
}
fn settle(&mut self, answer: &Answer, outside: &Outside<'_>) {
let Some(at) = self.pending.iter().position(|p| p.about == answer.about) else {
outside.report(&Report::ordinary(format!(
"{} has already been dealt with.",
answer.about
)));
return;
};
if answer.choice == Choice::Reveal {
let pending = &mut self.pending[at];
pending.asked = Instant::now();
let dir = pending.session.payload_dir();
let question = Question {
about: pending.about.clone(),
summary: format!(
"{} is still waiting.",
slpc::display_name(&pending.session.record().payload)
),
detail: vec![format!("The payload is in {}", dir.display())],
choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
};
if let Err(e) = outside.launcher.launch(&dir) {
outside.report(&Report::ordinary(format!(
"{} could not be shown: {e}",
dir.display()
)));
}
outside.channel.ask(&question);
return;
}
let mut pending = self.pending.remove(at);
outside.channel.withdraw(&pending.about);
let name = slpc::display_name(&pending.session.record().payload).into_owned();
match answer.choice {
Choice::WriteBack => match crate::writeback::write_back(&mut pending.session) {
Ok(()) => {
outside.report(&Report::ordinary(format!(
"{name} written back to {}.",
slpc::display_path(&pending.session.record().container)
)));
let _ = pending.session.remove();
}
Err(e) => {
outside.report(
&Report::interrupt(format!("{name} could not be written back."))
.and(e.to_string()),
);
pending.asked = Instant::now();
self.pending.push(pending);
return;
}
},
Choice::Discard => {
let _ = pending.session.remove();
outside.report(&Report::ordinary(format!("{name} discarded.")));
}
Choice::Reveal => unreachable!("answered above"),
}
if let Some(container) = pending.then_open {
if let Response::Err(why) = self.open(&container, Voice::Instance, outside) {
outside.report(&Report::interrupt(format!("{name} did not open: {why}")));
}
}
}
fn let_go_of_the_unanswered(&mut self, outside: &Outside<'_>) {
let (gone, kept) = std::mem::take(&mut self.pending)
.into_iter()
.partition::<Vec<_>, _>(|p| {
self.holds_for.is_some_and(|held| p.asked.elapsed() >= held)
});
self.pending = kept;
for p in &gone {
Self::stop_asking(p, outside);
}
}
fn stop_asking(pending: &Pending, outside: &Outside<'_>) {
outside.channel.withdraw(&pending.about);
outside.report(
&Report::ordinary(format!(
"{} is still undecided.",
slpc::display_name(&pending.session.record().payload)
))
.and(format!(
"slipcase-open recover {} --write-back",
pending.about
))
.and(format!("slipcase-open recover {} --discard", pending.about)),
);
}
pub fn stand_down(&mut self, outside: &Outside<'_>) {
for open in self.sessions.drain().collect::<Vec<_>>() {
match open.close() {
Ok(flow::Closed::Cleared) => {}
Ok(flow::Closed::LeftForRecovery(lingering)) => self.lingering.push(*lingering),
Err(e) => {
outside.report(&Report::interrupt(format!("a session did not close: {e}")));
}
}
}
for lingering in std::mem::take(&mut self.lingering) {
let session = lingering.into_session();
if recover::state(&session).is_quiet() {
let _ = session.remove();
} else {
outside.report(
&Report::ordinary(format!(
"{} was closed while its application was still working.",
slpc::display_name(&session.record().payload)
))
.and("It is left for recovery: run `slipcase-open sessions`."),
);
}
}
for pending in &std::mem::take(&mut self.pending) {
Self::stop_asking(pending, outside);
}
}
}
fn id_of(s: &Session) -> String {
s.dir()
.file_name()
.map_or_else(|| "?".to_string(), |n| n.to_string_lossy().into_owned())
}
fn say(voice: Voice, outside: &Outside<'_>, report: Report) -> Response {
if voice == Voice::Instance {
outside.report(&report);
}
Response::Ok(
std::iter::once(report.summary)
.chain(report.detail.into_iter().map(|d| format!(" {d}")))
.collect(),
)
}
fn refuse(voice: Voice, outside: &Outside<'_>, why: String) -> Response {
if voice == Voice::Instance {
outside.report(&Report::interrupt("Not opened.").and(why.clone()));
}
Response::Err(why)
}
pub fn sweep(root: &Path, live: &[PathBuf]) -> io::Result<usize> {
let mut removed = 0;
for s in session::scan(root)? {
if live.iter().any(|d| d == s.dir()) {
continue;
}
if !recover::state(&s).is_quiet() {
continue;
}
if s.remove().is_ok() {
removed += 1;
}
}
Ok(removed)
}
pub fn run(
listener: crate::endpoint::Listener,
resident: &mut Resident,
outside: &Outside<'_>,
standing: &dyn crate::present::Standing,
) -> io::Result<()> {
if standing.holding() {
resident.let_questions_stand();
}
let mut shown: Vec<crate::present::Listed> = Vec::new();
let mut carried: Vec<crate::present::Trouble> = Vec::new();
let mut wearing = crate::present::Mood::Settled;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
if tx.send(stream).is_err() {
return;
}
}
});
loop {
match rx.recv_timeout(TICK) {
Ok(mut stream) => {
let response = match crate::ipc::take(&mut stream) {
Ok(request) => resident.handle(request, outside),
Err(e) => Response::Err(e.to_string()),
};
let _ = crate::ipc::answer(&mut stream, &response);
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
resident.turn(outside);
let listed = resident.listed();
let troubles = resident.troubles().to_vec();
let mood = resident.mood();
if (&listed, &troubles, mood) != (&shown, &carried, wearing) {
standing.show(&listed, &troubles, mood);
shown = listed;
carried = troubles;
wearing = mood;
}
let mut leaving = false;
for chosen in standing.taken() {
match chosen {
crate::present::Chosen::Dismiss(id) => resident.dismiss(&id),
crate::present::Chosen::Quit => leaving = true,
}
}
if leaving {
break;
}
if resident.is_idle() && !standing.holding() {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{sweep, Resident};
use crate::ipc::{Request, Response, Voice};
use crate::outside::Outside;
use crate::platform::testing::Recording;
use crate::policy::{Origin, Read, Source};
use crate::present::testing::Recording as Told;
use crate::present::Choice;
use crate::{extract, recover, session};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
struct Default_;
impl Source for Default_ {
fn layer(&self, _o: Origin) -> Read {
Ok(None)
}
}
struct World {
policy: Default_,
launcher: Recording,
channel: Told,
}
impl World {
fn new() -> Self {
Self {
policy: Default_,
launcher: Recording::default(),
channel: Told::default(),
}
}
fn outside(&self) -> Outside<'_> {
Outside::new(&self.policy, &self.launcher, &self.channel)
}
fn loud(&self) -> Outside<'_> {
self.outside().saying(crate::policy::Notify::Everything)
}
}
fn opening(container: PathBuf) -> Request {
Request::Open {
container,
voice: Voice::Client,
}
}
fn announcing(container: PathBuf) -> Request {
Request::Open {
container,
voice: Voice::Instance,
}
}
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 ok(r: Response) -> Vec<String> {
match r {
Response::Ok(lines) => lines,
Response::Err(e) => panic!("{e}"),
}
}
fn err(r: Response) -> String {
match r {
Response::Err(e) => e,
Response::Ok(lines) => panic!("expected a refusal, got {lines:?}"),
}
}
fn session_named_in(lines: &[String]) -> String {
lines
.iter()
.find_map(|l| l.strip_prefix(" Session "))
.unwrap_or_else(|| panic!("no session named in {lines:?}"))
.to_string()
}
fn a_crashed_session(root: &Path, c: &Path, name: &str, edit: &[u8]) -> session::Session {
let mut left = session::create(root, c, name).unwrap();
extract::extract(&mut slpc::Container::open(c).unwrap(), &mut left).unwrap();
fs::write(left.payload_path(), edit).unwrap();
left
}
const SOMEBODY_ELSE: &[u8] = b"what somebody else put there in the meantime";
fn a_diverged_session(root: &Path, c: &Path, name: &str, edit: &[u8]) -> session::Session {
let left = a_crashed_session(root, c, name, edit);
container(c.parent().unwrap(), name, SOMEBODY_ELSE);
left
}
#[test]
fn opening_a_container_twice_brings_the_session_forward() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let w = World::new();
let mut r = Resident::new(&root);
ok(r.handle(opening(c.clone()), &w.outside()));
let again = ok(r.handle(opening(c.clone()), &w.outside()));
assert!(again[0].contains("already open"), "{again:?}");
assert_eq!(session::scan(&root).unwrap().len(), 1);
assert_eq!(w.launcher.launched().len(), 2);
}
#[cfg(unix)]
#[test]
fn the_same_container_under_another_hard_link_is_the_same_session() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let link = tmp.path().join("other-name.slpc");
fs::hard_link(&c, &link).unwrap();
let w = World::new();
let mut r = Resident::new(&root);
ok(r.handle(opening(c), &w.outside()));
let again = ok(r.handle(opening(link), &w.outside()));
assert!(again[0].contains("already open"), "{again:?}");
assert_eq!(session::scan(&root).unwrap().len(), 1);
}
#[test]
fn two_different_containers_get_two_sessions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let a = container(tmp.path(), "report.pdf", b"a");
let b = container(tmp.path(), "notes.txt", b"b");
let w = World::new();
let mut r = Resident::new(&root);
ok(r.handle(opening(a), &w.outside()));
ok(r.handle(opening(b), &w.outside()));
assert_eq!(session::scan(&root).unwrap().len(), 2);
assert!(!r.is_idle());
}
#[test]
fn a_session_survives_a_write_back_still_being_the_same_container() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let w = World::new();
let mut r = Resident::new(&root);
ok(r.handle(opening(c.clone()), &w.outside()));
let payload = session::scan(&root).unwrap()[0].payload_path();
fs::write(&payload, b"edited").unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let saved = || session::scan(&root).unwrap()[0].record().write_backs;
while std::time::Instant::now() < deadline && saved() == 0 {
r.turn(&w.outside());
}
assert!(saved() >= 1, "nothing was written back");
let again = ok(r.handle(opening(c), &w.outside()));
assert!(again[0].contains("already open"), "{again:?}");
assert_eq!(session::scan(&root).unwrap().len(), 1);
}
#[test]
fn a_recovery_item_on_the_same_container_is_asked_about_first() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let left = a_diverged_session(&root, &c, "report.pdf", b"edited then the process died");
let w = World::new();
let mut r = Resident::new(&root);
let refused = err(r.handle(opening(c), &w.outside()));
assert!(refused.contains("left behind"), "{refused}");
assert!(
w.launcher.launched().is_empty(),
"nothing should have opened"
);
assert_eq!(session::scan(&root).unwrap().len(), 1);
let asked = w.channel.questions();
assert_eq!(asked.len(), 1);
assert!(asked[0]
.about
.starts_with(left.dir().file_name().unwrap().to_str().unwrap()));
assert_eq!(
asked[0].choices,
vec![Choice::WriteBack, Choice::Discard, Choice::Reveal]
);
assert!(!r.is_idle());
}
#[test]
fn one_question_is_asked_however_many_times_the_container_is_double_clicked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
a_diverged_session(&root, &c, "report.pdf", b"edited");
let w = World::new();
let mut r = Resident::new(&root);
err(r.handle(opening(c.clone()), &w.outside()));
err(r.handle(opening(c), &w.outside()));
assert_eq!(w.channel.questions().len(), 1);
}
#[test]
fn writing_back_a_recovered_session_opens_the_one_that_was_waiting() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
a_diverged_session(&root, &c, "report.pdf", b"the edit that never landed");
let w = World::new();
let mut r = Resident::new(&root);
err(r.handle(opening(c.clone()), &w.outside()));
let about = w.channel.questions()[0].about.clone();
w.channel.answer(&about, Choice::WriteBack);
r.turn(&w.outside());
let mut held = slpc::Container::open(&c).unwrap();
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut held.payload().unwrap(), &mut bytes).unwrap();
assert_eq!(bytes, b"the edit that never landed");
assert_eq!(w.channel.withdrawn(), vec![about]);
assert_eq!(w.launcher.launched().len(), 1);
assert_eq!(session::scan(&root).unwrap().len(), 1);
}
#[test]
fn discarding_a_recovered_session_opens_the_one_that_was_waiting() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
a_diverged_session(&root, &c, "report.pdf", b"edited");
let w = World::new();
let mut r = Resident::new(&root);
err(r.handle(opening(c.clone()), &w.outside()));
let about = w.channel.questions()[0].about.clone();
w.channel.answer(&about, Choice::Discard);
r.turn(&w.outside());
let now = session::scan(&root).unwrap();
assert_eq!(now.len(), 1);
assert_eq!(fs::read(now[0].payload_path()).unwrap(), SOMEBODY_ELSE);
let mut held = slpc::Container::open(&c).unwrap();
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut held.payload().unwrap(), &mut bytes).unwrap();
assert_eq!(bytes, SOMEBODY_ELSE, "discard must not touch the container");
assert_eq!(w.launcher.launched().len(), 1);
}
#[test]
fn revealing_shows_the_folder_and_puts_the_question_again() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let left = a_diverged_session(&root, &c, "report.pdf", b"edited");
let w = World::new();
let mut r = Resident::new(&root);
err(r.handle(opening(c), &w.outside()));
let about = w.channel.questions()[0].about.clone();
w.channel.answer(&about, Choice::Reveal);
r.turn(&w.outside());
assert_eq!(w.launcher.launched(), vec![left.payload_dir()]);
assert_eq!(w.channel.questions().len(), 2);
assert!(w.channel.withdrawn().is_empty());
assert!(left.dir().exists());
assert!(!r.is_idle());
}
#[test]
fn an_answer_about_a_session_nobody_is_holding_says_so() {
let tmp = tempfile::tempdir().unwrap();
let w = World::new();
let mut r = Resident::new(tmp.path().join("sessions"));
w.channel.answer("gone-0", Choice::WriteBack);
r.turn(&w.outside());
assert!(
w.channel.said().contains("already been dealt with"),
"{}",
w.channel.said()
);
}
#[test]
fn a_question_nobody_answers_is_taken_back_and_replaced_by_the_commands() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let left = a_diverged_session(&root, &c, "report.pdf", b"edited");
let w = World::new();
let mut r = Resident::new(&root).waiting(Duration::ZERO, Duration::ZERO);
err(r.handle(opening(c), &w.outside()));
let about = w.channel.questions()[0].about.clone();
r.turn(&w.outside());
assert_eq!(w.channel.withdrawn(), vec![about.clone()]);
assert!(w
.channel
.said()
.contains(&format!("recover {about} --write-back")));
assert!(w
.channel
.said()
.contains(&format!("recover {about} --discard")));
assert!(left.dir().exists());
assert!(r.is_idle());
}
#[test]
fn a_question_with_somewhere_to_live_is_not_taken_back() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let left = a_diverged_session(&root, &c, "report.pdf", b"edited");
let w = World::new();
let mut r = Resident::new(&root).waiting(Duration::ZERO, Duration::ZERO);
err(r.handle(opening(c), &w.outside()));
let about = w.channel.questions()[0].about.clone();
r.let_questions_stand();
for _ in 0..5 {
r.turn(&w.outside());
}
assert!(
w.channel.withdrawn().is_empty(),
"the question was taken back: {:?}",
w.channel.withdrawn()
);
assert!(
!w.channel.said().contains(&format!("recover {about}")),
"it fell back to the command line while a surface was showing it"
);
assert!(left.dir().exists());
assert!(!r.is_idle());
assert_eq!(r.mood(), crate::present::Mood::Look);
}
#[test]
fn a_leftover_edit_goes_back_and_the_open_carries_on() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
a_crashed_session(&root, &c, "report.pdf", b"the edit that never landed");
let w = World::new();
let mut r = Resident::new(&root);
let opened = ok(r.handle(opening(c.clone()), &w.loud()));
assert!(
w.channel.questions().is_empty(),
"the person was asked about their own save: {:?}",
w.channel.questions()
);
assert!(
opened.iter().any(|l| l.contains("is open")),
"the open did not carry on: {opened:?}"
);
assert_eq!(w.launcher.launched().len(), 1);
let mut held = slpc::Container::open(&c).unwrap();
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut held.payload().unwrap(), &mut bytes).unwrap();
assert_eq!(bytes, b"the edit that never landed");
assert!(w.channel.said().contains("recovered and written back"));
let now = session::scan(&root).unwrap();
assert_eq!(now.len(), 1, "{now:?}");
assert_eq!(
fs::read(now[0].payload_path()).unwrap(),
b"the edit that never landed"
);
}
#[test]
fn a_write_back_that_fails_on_recovery_keeps_the_session_and_colours_the_icon() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let left = a_crashed_session(&root, &c, "report.pdf", b"an edit worth keeping");
let held = tmp.path().join("held");
fs::create_dir(&held).unwrap();
let c = {
let moved = held.join("report.pdf.slpc");
fs::rename(&c, &moved).unwrap();
moved
};
fs::remove_dir_all(left.dir()).unwrap();
let left = a_crashed_session(&root, &c, "report.pdf", b"an edit worth keeping");
readonly(&c, true);
readonly(&held, true);
let w = World::new();
let mut r = Resident::new(&root);
let _ = r.handle(opening(c.clone()), &w.outside());
readonly(&held, false);
readonly(&c, false);
assert!(left.dir().exists(), "the edit was thrown away");
assert_eq!(
fs::read(left.payload_path()).unwrap(),
b"an edit worth keeping"
);
assert_eq!(
r.mood(),
crate::present::Mood::AtRisk,
"an edit that is nowhere but a session directory is what orange is for"
);
}
fn readonly(at: &Path, yes: bool) {
let mut perms = fs::metadata(at).unwrap().permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
perms.set_mode(if yes { 0o500 } else { 0o700 });
}
#[cfg(not(unix))]
perms.set_readonly(yes);
fs::set_permissions(at, perms).unwrap();
}
#[test]
fn a_quiet_leftover_does_not_stand_in_the_way() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let mut left = session::create(&root, &c, "report.pdf").unwrap();
extract::extract(&mut slpc::Container::open(&c).unwrap(), &mut left).unwrap();
assert!(matches!(recover::state(&left), recover::State::Unchanged));
let w = World::new();
let mut r = Resident::new(&root);
ok(r.handle(opening(c), &w.outside()));
assert_eq!(w.launcher.launched().len(), 1);
assert!(w.channel.questions().is_empty());
}
#[test]
fn closing_by_name_closes_that_session() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let w = World::new();
let mut r = Resident::new(&root);
let opened = ok(r.handle(opening(c), &w.outside()));
ok(r.handle(Request::Close(session_named_in(&opened)), &w.outside()));
assert!(r.is_idle());
assert!(session::scan(&root).unwrap().is_empty());
}
#[test]
fn closing_while_the_application_is_working_keeps_the_watch_on_it() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let w = World::new();
let mut r = Resident::new(&root);
let opened = ok(r.handle(opening(c), &w.outside()));
let dir = session::scan(&root).unwrap()[0].payload_dir();
fs::write(dir.join(".~lock.report.pdf#"), b"still working").unwrap();
let closed = ok(r.handle(Request::Close(session_named_in(&opened)), &w.outside()));
assert!(
closed
.iter()
.any(|l| l.contains("still has the payload open")),
"{closed:?}"
);
assert!(
!r.is_idle(),
"the process has to stay for the watch to be worth anything"
);
assert_eq!(session::scan(&root).unwrap().len(), 1);
}
#[test]
fn a_lingering_session_saved_after_the_close_is_written_back_not_asked_about() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let w = World::new();
let mut r = Resident::new(&root).waiting(Duration::ZERO, Duration::from_secs(300));
let opened = ok(r.handle(opening(c.clone()), &w.outside()));
let dir = session::scan(&root).unwrap()[0].payload_dir();
let sibling = dir.join(".~lock.report.pdf#");
fs::write(&sibling, b"still working").unwrap();
ok(r.handle(Request::Close(session_named_in(&opened)), &w.outside()));
fs::write(dir.join("report.pdf"), b"the last save").unwrap();
fs::remove_file(&sibling).unwrap();
r.turn(&w.loud());
assert!(
w.channel.questions().is_empty(),
"{:?}",
w.channel.questions()
);
let mut held = slpc::Container::open(&c).unwrap();
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut held.payload().unwrap(), &mut bytes).unwrap();
assert_eq!(bytes, b"the last save");
assert!(
w.channel.said().contains("recovered and written back"),
"{}",
w.channel.said()
);
assert!(session::scan(&root).unwrap().is_empty());
assert!(r.is_idle());
}
#[test]
fn a_lingering_session_that_matches_its_container_goes_quietly() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let w = World::new();
let mut r = Resident::new(&root).waiting(Duration::ZERO, Duration::from_secs(300));
let opened = ok(r.handle(opening(c), &w.outside()));
let dir = session::scan(&root).unwrap()[0].payload_dir();
let sibling = dir.join(".~lock.report.pdf#");
fs::write(&sibling, b"still working").unwrap();
ok(r.handle(Request::Close(session_named_in(&opened)), &w.outside()));
fs::remove_file(&sibling).unwrap();
r.turn(&w.outside());
assert!(
w.channel.questions().is_empty(),
"{:?}",
w.channel.questions()
);
assert!(session::scan(&root).unwrap().is_empty());
assert!(r.is_idle());
}
#[test]
fn closing_a_session_that_is_not_open_says_so() {
let tmp = tempfile::tempdir().unwrap();
let w = World::new();
let mut r = Resident::new(tmp.path().join("sessions"));
let refused = err(r.handle(Request::Close("nothing-0".into()), &w.outside()));
assert!(refused.contains("no open session"), "{refused}");
}
#[test]
fn a_double_click_is_spoken_for_and_a_terminal_is_not() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let quiet = container(tmp.path(), "quiet.pdf", b"a");
let loud = container(tmp.path(), "loud.pdf", b"b");
let w = World::new();
let mut r = Resident::new(&root);
ok(r.handle(opening(quiet), &w.loud()));
assert!(w.channel.reports().is_empty(), "{:?}", w.channel.reports());
ok(r.handle(announcing(loud), &w.loud()));
assert!(
w.channel.said().contains("loud.pdf is open"),
"{}",
w.channel.said()
);
}
#[test]
fn listing_shows_what_is_open_and_what_was_left() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let open_one = container(tmp.path(), "report.pdf", b"a");
let crashed = container(tmp.path(), "notes.txt", b"b");
a_crashed_session(&root, &crashed, "notes.txt", b"edited");
let w = World::new();
let mut r = Resident::new(&root);
ok(r.handle(opening(open_one), &w.outside()));
let lines = ok(r.handle(Request::List, &w.outside()));
assert!(lines
.iter()
.any(|l| l.contains("report.pdf") && l.contains("open")));
assert!(lines
.iter()
.any(|l| l.contains("notes.txt") && l.contains("edited")));
assert_eq!(lines.len(), 2, "{lines:?}");
}
#[test]
fn the_sweep_takes_the_quiet_ones_and_leaves_the_rest() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let a = container(tmp.path(), "quiet.pdf", b"a");
let b = container(tmp.path(), "edited.pdf", b"b");
let mut quiet = session::create(&root, &a, "quiet.pdf").unwrap();
extract::extract(&mut slpc::Container::open(&a).unwrap(), &mut quiet).unwrap();
let edited = a_crashed_session(&root, &b, "edited.pdf", b"an edit that never landed");
let half_made = session::create(&root, &a, "quiet.pdf").unwrap();
assert_eq!(sweep(&root, &[]).unwrap(), 2);
let left = session::scan(&root).unwrap();
assert_eq!(left.len(), 1);
assert_eq!(left[0].dir(), edited.dir());
assert!(!half_made.dir().exists());
}
#[test]
fn the_sweep_will_not_touch_a_live_session() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let w = World::new();
let mut r = Resident::new(&root);
ok(r.handle(opening(c), &w.outside()));
let live: Vec<_> = session::scan(&root)
.unwrap()
.iter()
.map(|s| s.dir().to_path_buf())
.collect();
assert!(matches!(
recover::state(&session::scan(&root).unwrap()[0]),
recover::State::Unchanged
));
assert_eq!(sweep(&root, &live).unwrap(), 0);
assert_eq!(session::scan(&root).unwrap().len(), 1);
assert_eq!(sweep(&root, &[]).unwrap(), 1);
}
#[test]
fn standing_down_takes_back_every_question_it_was_holding() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let c = container(tmp.path(), "report.pdf", b"first");
let left = a_diverged_session(&root, &c, "report.pdf", b"edited");
let w = World::new();
let mut r = Resident::new(&root);
err(r.handle(opening(c), &w.outside()));
let about = w.channel.questions()[0].about.clone();
r.stand_down(&w.outside());
assert_eq!(w.channel.withdrawn(), vec![about.clone()]);
assert!(w
.channel
.said()
.contains(&format!("recover {about} --write-back")));
assert!(
left.dir().exists(),
"the session carries the question to the next launch"
);
assert!(r.is_idle());
}
#[test]
fn a_ping_is_answered_and_changes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let w = World::new();
let mut r = Resident::new(tmp.path().join("sessions"));
assert_eq!(
r.handle(Request::Ping, &w.outside()),
Response::Ok(Vec::new())
);
assert!(r.is_idle());
}
#[test]
fn nothing_wrong_is_the_ordinary_colour() {
let tmp = tempfile::tempdir().unwrap();
let r = Resident::new(tmp.path().join("sessions"));
assert_eq!(r.mood(), crate::present::Mood::Settled);
assert!(r.troubles().is_empty());
}
#[test]
fn the_icon_wears_the_worst_thing_currently_true() {
use crate::present::Mood;
let tmp = tempfile::tempdir().unwrap();
let mut r = Resident::new(tmp.path().join("sessions"));
r.note(Mood::Look, "a", "one did not open".into());
assert_eq!(r.mood(), Mood::Look);
r.note(Mood::Danger, "b", "one is a program".into());
assert_eq!(r.mood(), Mood::Danger);
r.note(Mood::AtRisk, "c", "one did not save".into());
assert_eq!(
r.mood(),
Mood::Danger,
"a lesser trouble does not talk the icon down"
);
r.dismiss("b");
assert_eq!(r.mood(), Mood::AtRisk);
r.dismiss("c");
assert_eq!(r.mood(), Mood::Look);
r.dismiss("a");
assert_eq!(r.mood(), Mood::Settled);
}
#[test]
fn the_same_trouble_twice_is_one_trouble() {
use crate::present::Mood;
let tmp = tempfile::tempdir().unwrap();
let mut r = Resident::new(tmp.path().join("sessions"));
r.note(
Mood::Look,
"open:report",
"report.slpc - did not open".into(),
);
r.note(
Mood::Look,
"open:report",
"report.slpc - did not open".into(),
);
r.note(Mood::Look, "open:report", "report.slpc - still not".into());
assert_eq!(r.troubles().len(), 1, "{:?}", r.troubles());
assert_eq!(r.troubles()[0].summary, "report.slpc - still not");
}
#[test]
fn a_container_that_will_not_open_leaves_something_behind() {
let tmp = tempfile::tempdir().unwrap();
let w = World::new();
let mut r = Resident::new(tmp.path().join("sessions"));
let nowhere = tmp.path().join("not-a-container.slpc");
fs::write(&nowhere, b"this is not a container").unwrap();
let _ = err(r.handle(opening(nowhere), &w.outside()));
assert_eq!(r.mood(), crate::present::Mood::Look);
let said = &r.troubles()[0].summary;
assert!(
said.starts_with("not-a-container.slpc"),
"it names the file the person clicked, not the session: {said}"
);
}
#[test]
fn a_payload_that_is_a_program_is_the_one_thing_red_is_for() {
let tmp = tempfile::tempdir().unwrap();
let w = World::new();
let mut r = Resident::new(tmp.path().join("sessions"));
let c = container(tmp.path(), "invoice.txt", b"MZ\x90\x00 not a document");
let why = err(r.handle(opening(c), &w.outside()));
assert!(why.contains("was not opened"), "{why}");
assert!(r.is_idle(), "nothing opened, so nothing is being held");
assert!(
w.launcher.launched().is_empty(),
"nothing was handed to the desktop"
);
assert!(
session::scan(&tmp.path().join("sessions"))
.unwrap_or_default()
.is_empty(),
"the refusal is before the session, so nothing reached the disk"
);
let insisted = w.channel.insisted();
assert_eq!(insisted.len(), 1, "{insisted:?}");
assert!(insisted[0].summary.contains("invoice.txt"), "{insisted:?}");
assert_eq!(r.mood(), crate::present::Mood::Danger);
let said = &r.troubles()[0].summary;
assert!(
said.contains("invoice.txt") && said.contains("Windows executable"),
"{said}"
);
}
#[test]
fn a_trouble_stays_until_it_is_put_down() {
let tmp = tempfile::tempdir().unwrap();
let w = World::new();
let mut r = Resident::new(tmp.path().join("sessions"));
let c = container(tmp.path(), "invoice.txt", b"MZ\x90\x00 not a document");
let _ = err(r.handle(opening(c), &w.outside()));
for _ in 0..5 {
r.turn(&w.outside());
}
assert_eq!(r.mood(), crate::present::Mood::Danger);
let id = r.troubles()[0].id.clone();
r.dismiss(&id);
assert!(r.troubles().is_empty());
assert_eq!(r.mood(), crate::present::Mood::Settled);
}
#[test]
fn a_question_waiting_colours_the_icon_and_answering_clears_it() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sessions");
let w = World::new();
let c = container(tmp.path(), "report.txt", b"a report\n");
a_diverged_session(&root, &c, "report.txt", b"an edit nobody wrote back\n");
let mut r = Resident::new(&root);
assert_eq!(r.mood(), crate::present::Mood::Settled);
let _ = r.handle(announcing(c), &w.loud());
assert_eq!(
r.mood(),
crate::present::Mood::Look,
"a decision waiting is worth a look and nothing more"
);
assert!(
r.troubles().is_empty(),
"and it is not a trouble: it is on the sessions, so answering ends it"
);
let about = w.channel.questions()[0].about.clone();
w.channel.answer(&about, Choice::Discard);
r.turn(&w.outside());
assert_eq!(r.mood(), crate::present::Mood::Settled);
}
}