1use std::io;
24use std::path::{Path, PathBuf};
25use std::time::{Duration, Instant};
26
27use crate::flow::{self, Lingering, Opened};
28use crate::i18n::{fill, t};
29use crate::ipc::{Request, Response, Voice};
30use crate::outside::Outside;
31use crate::present::{Answer, Choice, Question, Report};
32use crate::recover;
33use crate::session::{self, Session};
34use crate::table::Table;
35
36const TICK: Duration = Duration::from_millis(250);
38
39const SETTLED: Duration = Duration::from_secs(2);
47
48const HELD: Duration = Duration::from_secs(300);
67
68const PULSE: Duration = Duration::from_millis(900);
75
76struct Pending {
79 about: String,
81 session: Session,
83 then_open: Option<PathBuf>,
86 asked: Instant,
89}
90
91pub struct Resident {
93 root: PathBuf,
94 sessions: Table<Opened>,
95 lingering: Vec<Lingering>,
96 pending: Vec<Pending>,
97 settles_after: Duration,
102 holds_for: Option<Duration>,
105 troubles: Vec<crate::present::Trouble>,
113 wrote_back: Option<Instant>,
116}
117
118impl Resident {
119 #[must_use]
121 pub fn new(root: impl Into<PathBuf>) -> Self {
122 Self {
123 root: root.into(),
124 sessions: Table::new(),
125 lingering: Vec::new(),
126 pending: Vec::new(),
127 settles_after: SETTLED,
128 holds_for: Some(HELD),
129 troubles: Vec::new(),
130 wrote_back: None,
131 }
132 }
133
134 #[cfg(test)]
138 fn waiting(mut self, settles_after: Duration, holds_for: Duration) -> Self {
139 self.settles_after = settles_after;
140 self.holds_for = Some(holds_for);
141 self
142 }
143
144 #[must_use]
146 pub fn is_idle(&self) -> bool {
147 self.sessions.is_empty() && self.lingering.is_empty() && self.pending.is_empty()
148 }
149
150 pub fn let_questions_stand(&mut self) {
164 self.holds_for = None;
165 }
166
167 fn put_it_back(&mut self, mut session: Session, outside: &Outside<'_>) {
182 let name = slpc::display_name(&session.record().content_name).into_owned();
183 let into = slpc::display_path(&session.record().container);
184 match crate::writeback::write_back(&mut session) {
185 Ok(()) => {
186 outside.report(
187 &Report::ordinary(fill(
188 t("{name} was recovered and written back."),
189 &[("name", &name)],
190 ))
191 .and(fill(t("Into {container}."), &[("container", &into)]))
192 .and(t(
193 "It had been edited after the session holding it stopped.",
194 )),
195 );
196 let _ = session.remove();
197 }
198 Err(e) => {
199 outside.report(
203 &Report::interrupt(fill(
204 t("{name} could not be written back."),
205 &[("name", &name)],
206 ))
207 .and(e.to_string()),
208 );
209 self.note(
210 crate::present::Mood::AtRisk,
211 format!("recover:{}", id_of(&session)),
212 format!("{name} - an edit is not in its container"),
213 );
214 }
215 }
216 }
217
218 fn note(&mut self, mood: crate::present::Mood, id: impl Into<String>, summary: String) {
224 let id = id.into();
225 if let Some(had) = self.troubles.iter_mut().find(|t| t.id == id) {
226 had.mood = mood;
227 had.summary = summary;
228 return;
229 }
230 self.troubles
231 .push(crate::present::Trouble { id, mood, summary });
232 }
233
234 fn dismiss(&mut self, id: &str) {
236 self.troubles.retain(|t| t.id != id);
237 }
238
239 #[must_use]
241 pub fn troubles(&self) -> &[crate::present::Trouble] {
242 &self.troubles
243 }
244
245 #[must_use]
254 pub fn mood(&self) -> crate::present::Mood {
255 use crate::present::Mood;
256 let worst = self
257 .troubles
258 .iter()
259 .map(|t| t.mood)
260 .max()
261 .unwrap_or(Mood::Settled);
262 let waiting = if self.pending.is_empty() {
263 Mood::Settled
264 } else {
265 Mood::Look
266 };
267 let saving = match self.wrote_back {
268 Some(at) if at.elapsed() < PULSE => Mood::Working,
269 _ => Mood::Settled,
270 };
271 worst.max(waiting).max(saving)
272 }
273
274 pub fn handle(&mut self, request: Request, outside: &Outside<'_>) -> Response {
276 match request {
277 Request::Ping => Response::Ok(Vec::new()),
278 Request::List => self.list(),
279 Request::Open { container, voice } => self.open(&container, voice, outside),
280 Request::Close(id) => self.close(&id),
281 }
282 }
283
284 fn open(&mut self, container: &Path, voice: Voice, outside: &Outside<'_>) -> Response {
286 if let Some(open) = self.sessions.find_mut(container) {
291 return match outside.launcher.launch(&open.content_path()) {
292 Ok(()) => say(
293 voice,
294 outside,
295 Report::ordinary(fill(
296 t("{name} is already open; brought forward."),
297 &[(
298 "name",
299 &slpc::display_name(&open.session().record().content_name),
300 )],
301 )),
302 ),
303 Err(e) => refuse(
304 voice,
305 outside,
306 fill(
307 t("could not bring it forward: {reason}"),
308 &[("reason", &e.to_string())],
309 ),
310 ),
311 };
312 }
313
314 match self.ask_about_what_was_left(container, outside) {
319 Err(e) => return refuse(voice, outside, e),
320 Ok(true) => {
321 return refuse(
322 voice,
323 outside,
324 "a session on this container was left behind, and what to do with it \
325 comes first"
326 .to_string(),
327 )
328 }
329 Ok(false) => {}
330 }
331
332 match flow::open(&self.root, container, outside) {
333 Err(e) => {
334 let named = container.file_name().map_or_else(
335 || container.display().to_string(),
336 |n| n.to_string_lossy().into_owned(),
337 );
338 if let flow::Error::Misrepresented(what) = &e {
345 outside.channel.insist(
346 &Report::interrupt(fill(t("{name} was not opened."), &[("name", &named)]))
347 .and(format!(
348 "Its content file is {}, not a document.",
349 what.describes()
350 ))
351 .and(t("That is the shape of a phishing attachment."))
352 .and(t("Nothing was extracted and nothing was run.")),
353 );
354 self.note(
355 crate::present::Mood::Danger,
356 format!("content:{}", container.display()),
357 format!("{named} - is {}, not a document", what.describes()),
358 );
359 return Response::Err(e.to_string());
365 }
366 self.note(
372 crate::present::Mood::Look,
373 format!("open:{}", container.display()),
374 format!("{named} - did not open: {e}"),
375 );
376 refuse(voice, outside, e.to_string())
377 }
378 Ok(opened) => {
379 let name = slpc::display_name(&opened.session().record().content_name).into_owned();
380 let session_id = id_of(opened.session());
381 let mut report = Report::routine(fill(t("{name} is open."), &[("name", &name)]))
382 .and(fill(t("Session {id}"), &[("id", &session_id)]));
383 if opened.mark != slpc::provenance::Mark::Silent {
384 report = report.and(t("It came from somewhere else, and the copy says so."));
385 }
386 if let Err(e) = self.sessions.insert(container, opened) {
387 return refuse(
388 voice,
389 outside,
390 format!("the session could not be tracked: {e}"),
391 );
392 }
393 say(voice, outside, report)
394 }
395 }
396 }
397
398 fn ask_about_what_was_left(
406 &mut self,
407 container: &Path,
408 outside: &Outside<'_>,
409 ) -> Result<bool, String> {
410 let want = crate::identity::of(container).map_err(|e| e.to_string())?;
411 let sessions = session::scan(&self.root).map_err(|e| e.to_string())?;
412 for left in sessions {
413 if !crate::identity::of(&left.record().container).is_ok_and(|is| is == want) {
416 continue;
417 }
418 let state = recover::state(&left);
419 match state.course() {
420 recover::Course::Sweep => continue,
422 recover::Course::WriteBack => {
427 self.put_it_back(left, outside);
428 continue;
429 }
430 recover::Course::Ask => {}
431 }
432 let about = id_of(&left);
433 if self.pending.iter().any(|p| p.about == about) {
437 return Ok(true);
438 }
439 outside.channel.ask(&Question {
440 about: about.clone(),
441 summary: fill(
442 t("{name} was left behind."),
443 &[("name", &slpc::display_name(&left.record().content_name))],
444 ),
445 detail: vec![
450 fill(t("It is {state}."), &[("state", &state.to_string())]),
451 fill(
452 t("From {container}."),
453 &[("container", &slpc::display_path(&left.record().container))],
454 ),
455 t("It will open once you have decided.").into(),
456 ],
457 choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
458 });
459 self.pending.push(Pending {
460 about,
461 session: left,
462 then_open: Some(container.to_path_buf()),
463 asked: Instant::now(),
464 });
465 return Ok(true);
466 }
467 Ok(false)
468 }
469
470 pub(crate) fn listed(&self) -> Vec<crate::present::Listed> {
476 let mut out: Vec<crate::present::Listed> = self
477 .sessions
478 .iter()
479 .map(|o| crate::present::Listed {
480 id: id_of(o.session()),
481 content_name: slpc::display_name(&o.session().record().content_name).into_owned(),
482 label: format!(
483 "{} open, {} write-back(s)",
484 slpc::display_name(&o.session().record().content_name),
485 o.session().record().write_backs
486 ),
487 live: true,
488 needs_a_person: false,
489 write_backs: Some(o.session().record().write_backs),
490 })
491 .collect();
492 out.extend(self.lingering.iter().map(|l| crate::present::Listed {
493 id: id_of(l.session()),
494 content_name: slpc::display_name(&l.session().record().content_name).into_owned(),
495 label: format!(
496 "{} closed, waiting for the application to finish",
497 slpc::display_name(&l.session().record().content_name)
498 ),
499 live: true,
500 needs_a_person: false,
501 write_backs: Some(l.session().record().write_backs),
502 }));
503 out.extend(self.pending.iter().map(|p| crate::present::Listed {
504 id: p.about.clone(),
505 content_name: slpc::display_name(&p.session.record().content_name).into_owned(),
506 label: format!(
507 "{} {}, waiting for you",
508 slpc::display_name(&p.session.record().content_name),
509 recover::state(&p.session)
510 ),
511 live: false,
512 needs_a_person: true,
513 write_backs: None,
514 }));
515
516 let held = self.held_directories();
519 if let Ok(left) = session::scan(&self.root) {
520 for s in left
521 .iter()
522 .filter(|s| !held.contains(&s.dir().to_path_buf()))
523 {
524 out.push(crate::present::Listed {
525 id: id_of(s),
526 content_name: slpc::display_name(&s.record().content_name).into_owned(),
527 label: format!(
528 "{} {}",
529 slpc::display_name(&s.record().content_name),
530 recover::state(s)
531 ),
532 live: false,
533 needs_a_person: recover::state(s).needs_a_person(),
536 write_backs: None,
537 });
538 }
539 }
540 out
541 }
542
543 pub(crate) fn list(&self) -> Response {
544 let mut lines: Vec<String> = self
545 .listed()
546 .into_iter()
547 .map(|e| format!("{} {}", e.id, e.label))
548 .collect();
549 if lines.is_empty() {
550 lines.push("No sessions.".into());
551 }
552 Response::Ok(lines)
553 }
554
555 fn held_directories(&self) -> Vec<PathBuf> {
558 self.sessions
559 .iter()
560 .map(|o| o.session().dir().to_path_buf())
561 .chain(
562 self.lingering
563 .iter()
564 .map(|l| l.session().dir().to_path_buf()),
565 )
566 .chain(self.pending.iter().map(|p| p.session.dir().to_path_buf()))
567 .collect()
568 }
569
570 fn close(&mut self, id: &str) -> Response {
571 let Some(container) = self
572 .sessions
573 .iter()
574 .find(|o| id_of(o.session()) == id)
575 .map(|o| o.session().record().container.clone())
576 else {
577 return Response::Err(format!("no open session {id}"));
578 };
579 let Some(opened) = self.sessions.remove(&container) else {
580 return Response::Err(format!("no open session {id}"));
581 };
582 match opened.close() {
583 Ok(flow::Closed::Cleared) => Response::Ok(vec!["Session closed.".into()]),
584 Ok(flow::Closed::LeftForRecovery(lingering)) => {
588 self.lingering.push(*lingering);
589 Response::Ok(vec![
590 "Session closed, and the application still has the content file open.".into(),
591 "It is being watched until the application finishes.".into(),
592 ])
593 }
594 Err(e) => Response::Err(e.to_string()),
595 }
596 }
597
598 pub fn turn(&mut self, outside: &Outside<'_>) {
601 self.pump_all(outside);
602 self.ask_about_what_has_settled(outside);
603 self.act_on_answers(outside);
604 self.let_go_of_the_unanswered(outside);
605 }
606
607 fn pump_all(&mut self, outside: &Outside<'_>) {
609 let mut wrote_back = Vec::new();
610 let mut landed = Vec::new();
611 let mut failed = Vec::new();
612 for open in self.sessions.iter_mut() {
613 match open.pump() {
614 Ok(true) => {
615 let s = open.session();
616 if s.record().write_backs <= 1 {
625 outside.report(
626 &Report::routine(fill(
627 t("{name} written back."),
628 &[("name", &slpc::display_name(&s.record().content_name))],
629 ))
630 .and(t("Saves from here on are written back quietly.")),
631 );
632 }
633 landed.push(id_of(s));
634 wrote_back.push(s.record().container.clone());
635 }
636 Ok(false) => {}
637 Err(e) => {
642 let name =
643 slpc::display_name(&open.session().record().content_name).into_owned();
644 outside.report(
645 &Report::interrupt(fill(
646 t("{name} could not be written back."),
647 &[("name", &name)],
648 ))
649 .and(e.to_string()),
650 );
651 failed.push((id_of(open.session()), name));
652 }
653 }
654 }
655 if !wrote_back.is_empty() {
658 self.wrote_back = Some(Instant::now());
662 }
663 for container in wrote_back {
664 self.sessions.refresh(&container);
665 }
666 for (id, name) in failed {
671 self.note(
672 crate::present::Mood::AtRisk,
673 format!("writeback:{id}"),
674 format!("{name} - a save did not reach its container"),
675 );
676 }
677 for id in landed {
681 self.dismiss(&format!("writeback:{id}"));
682 }
683 }
684
685 fn ask_about_what_has_settled(&mut self, outside: &Outside<'_>) {
687 let mut still_waiting = Vec::new();
688 for mut lingering in std::mem::take(&mut self.lingering) {
689 if !lingering.has_settled(self.settles_after) {
690 still_waiting.push(lingering);
691 continue;
692 }
693 let about = id_of(lingering.session());
694 let session = lingering.into_session();
695 let state = recover::state(&session);
696 let name = slpc::display_name(&session.record().content_name).into_owned();
697 match state.course() {
707 recover::Course::Sweep => {
708 let _ = session.remove();
709 continue;
710 }
711 recover::Course::WriteBack => {
712 self.put_it_back(session, outside);
713 continue;
714 }
715 recover::Course::Ask => {}
716 }
717 outside.channel.ask(&Question {
718 about: about.clone(),
719 summary: fill(
720 t("{name} was saved after you closed the session."),
721 &[("name", &name)],
722 ),
723 detail: vec![
724 fill(t("It is {state}."), &[("state", &state.to_string())]),
725 fill(
726 t("Into {container}."),
727 &[(
728 "container",
729 &slpc::display_path(&session.record().container),
730 )],
731 ),
732 ],
733 choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
734 });
735 self.pending.push(Pending {
736 about,
737 session,
738 then_open: None,
739 asked: Instant::now(),
740 });
741 }
742 self.lingering = still_waiting;
743 }
744
745 fn act_on_answers(&mut self, outside: &Outside<'_>) {
747 for answer in outside.channel.answers() {
748 self.settle(&answer, outside);
749 }
750 }
751
752 fn settle(&mut self, answer: &Answer, outside: &Outside<'_>) {
753 let Some(at) = self.pending.iter().position(|p| p.about == answer.about) else {
754 outside.report(&Report::ordinary(fill(
759 t("{name} has already been dealt with."),
760 &[("name", &answer.about)],
761 )));
762 return;
763 };
764
765 if answer.choice == Choice::Reveal {
770 let pending = &mut self.pending[at];
771 pending.asked = Instant::now();
772 let dir = pending.session.content_dir();
773 let question = Question {
774 about: pending.about.clone(),
775 summary: fill(
776 t("{name} is still waiting."),
777 &[(
778 "name",
779 &slpc::display_name(&pending.session.record().content_name),
780 )],
781 ),
782 detail: vec![fill(
783 t("The content file is in {folder}"),
784 &[("folder", &dir.display().to_string())],
785 )],
786 choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
787 };
788 if let Err(e) = outside.launcher.launch(&dir) {
789 outside.report(&Report::ordinary(fill(
790 t("{folder} could not be shown: {reason}"),
791 &[
792 ("folder", &dir.display().to_string()),
793 ("reason", &e.to_string()),
794 ],
795 )));
796 }
797 outside.channel.ask(&question);
798 return;
799 }
800
801 let mut pending = self.pending.remove(at);
802 outside.channel.withdraw(&pending.about);
803 let name = slpc::display_name(&pending.session.record().content_name).into_owned();
805 match answer.choice {
806 Choice::WriteBack => match crate::writeback::write_back(&mut pending.session) {
807 Ok(()) => {
808 outside.report(&Report::ordinary(fill(
809 t("{name} written back to {container}."),
810 &[
811 ("name", &name),
812 (
813 "container",
814 &slpc::display_path(&pending.session.record().container),
815 ),
816 ],
817 )));
818 let _ = pending.session.remove();
819 }
820 Err(e) => {
821 outside.report(
826 &Report::interrupt(fill(
827 t("{name} could not be written back."),
828 &[("name", &name)],
829 ))
830 .and(e.to_string()),
831 );
832 pending.asked = Instant::now();
833 self.pending.push(pending);
834 return;
835 }
836 },
837 Choice::Discard => {
838 let _ = pending.session.remove();
839 outside.report(&Report::ordinary(fill(
840 t("{name} discarded."),
841 &[("name", &name)],
842 )));
843 }
844 Choice::Reveal => unreachable!("answered above"),
845 }
846
847 if let Some(container) = pending.then_open {
849 if let Response::Err(why) = self.open(&container, Voice::Instance, outside) {
850 outside.report(&Report::interrupt(fill(
851 t("{name} did not open: {reason}"),
852 &[("name", &name), ("reason", &why)],
853 )));
854 }
855 }
856 }
857
858 fn let_go_of_the_unanswered(&mut self, outside: &Outside<'_>) {
861 let (gone, kept) = std::mem::take(&mut self.pending)
862 .into_iter()
863 .partition::<Vec<_>, _>(|p| {
864 self.holds_for.is_some_and(|held| p.asked.elapsed() >= held)
865 });
866 self.pending = kept;
867 for p in &gone {
868 Self::stop_asking(p, outside);
869 }
870 }
871
872 fn stop_asking(pending: &Pending, outside: &Outside<'_>) {
874 outside.channel.withdraw(&pending.about);
875 outside.report(
876 &Report::ordinary(fill(
877 t("{name} is still undecided."),
878 &[(
879 "name",
880 &slpc::display_name(&pending.session.record().content_name),
881 )],
882 ))
883 .and(format!(
884 "slipcase-open recover {} --write-back",
885 pending.about
886 ))
887 .and(format!("slipcase-open recover {} --discard", pending.about)),
888 );
889 }
890
891 pub fn stand_down(&mut self, outside: &Outside<'_>) {
900 for open in self.sessions.drain().collect::<Vec<_>>() {
901 match open.close() {
902 Ok(flow::Closed::Cleared) => {}
903 Ok(flow::Closed::LeftForRecovery(lingering)) => self.lingering.push(*lingering),
904 Err(e) => {
905 outside.report(&Report::interrupt(fill(
906 t("a session did not close: {reason}"),
907 &[("reason", &e.to_string())],
908 )));
909 }
910 }
911 }
912 for lingering in std::mem::take(&mut self.lingering) {
913 let session = lingering.into_session();
914 if recover::state(&session).is_quiet() {
915 let _ = session.remove();
916 } else {
917 outside.report(
918 &Report::ordinary(fill(
919 t("{name} was closed while its application was still working."),
920 &[("name", &slpc::display_name(&session.record().content_name))],
921 ))
922 .and(t("It is left for recovery: run `slipcase-open sessions`.")),
923 );
924 }
925 }
926 for pending in &std::mem::take(&mut self.pending) {
927 Self::stop_asking(pending, outside);
928 }
929 }
930}
931
932fn id_of(s: &Session) -> String {
934 s.dir()
935 .file_name()
936 .map_or_else(|| "?".to_string(), |n| n.to_string_lossy().into_owned())
937}
938
939fn say(voice: Voice, outside: &Outside<'_>, report: Report) -> Response {
942 if voice == Voice::Instance {
943 outside.report(&report);
944 }
945 Response::Ok(
946 std::iter::once(report.summary)
947 .chain(report.detail.into_iter().map(|d| format!(" {d}")))
948 .collect(),
949 )
950}
951
952fn refuse(voice: Voice, outside: &Outside<'_>, why: String) -> Response {
959 if voice == Voice::Instance {
960 outside.report(&Report::interrupt(t("Not opened.")).and(why.clone()));
961 }
962 Response::Err(why)
963}
964
965pub fn sweep(root: &Path, live: &[PathBuf]) -> io::Result<usize> {
982 let mut removed = 0;
983 for s in session::scan(root)? {
984 if live.iter().any(|d| d == s.dir()) {
985 continue;
986 }
987 if !recover::state(&s).is_quiet() {
990 continue;
991 }
992 if s.remove().is_ok() {
993 removed += 1;
994 }
995 }
996 Ok(removed)
997}
998
999pub fn run(
1005 listener: crate::endpoint::Listener,
1006 resident: &mut Resident,
1007 outside: &Outside<'_>,
1008 standing: &dyn crate::present::Standing,
1009) -> io::Result<()> {
1010 if standing.holding() {
1014 resident.let_questions_stand();
1015 }
1016 let mut shown: Vec<crate::present::Listed> = Vec::new();
1017 let mut carried: Vec<crate::present::Trouble> = Vec::new();
1018 let mut wearing = crate::present::Mood::Settled;
1019 let (tx, rx) = std::sync::mpsc::channel();
1020 std::thread::spawn(move || {
1021 for stream in listener.incoming().flatten() {
1024 if tx.send(stream).is_err() {
1025 return;
1026 }
1027 }
1028 });
1029
1030 loop {
1031 match rx.recv_timeout(TICK) {
1032 Ok(mut stream) => {
1033 let response = match crate::ipc::take(&mut stream) {
1034 Ok(request) => resident.handle(request, outside),
1035 Err(e) => Response::Err(e.to_string()),
1039 };
1040 let _ = crate::ipc::answer(&mut stream, &response);
1041 }
1042 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1043 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
1045 }
1046
1047 resident.turn(outside);
1048
1049 let listed = resident.listed();
1058 let troubles = resident.troubles().to_vec();
1059 let mood = resident.mood();
1060 if (&listed, &troubles, mood) != (&shown, &carried, wearing) {
1061 standing.show(&listed, &troubles, mood);
1062 shown = listed;
1063 carried = troubles;
1064 wearing = mood;
1065 }
1066 let mut leaving = false;
1067 for chosen in standing.taken() {
1068 match chosen {
1069 crate::present::Chosen::Dismiss(id) => resident.dismiss(&id),
1073 crate::present::Chosen::Quit => leaving = true,
1077 }
1078 }
1079 if leaving {
1080 break;
1081 }
1082
1083 if resident.is_idle() && !standing.holding() {
1094 break;
1095 }
1096 }
1097 Ok(())
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102 use super::{sweep, Resident};
1103 use crate::ipc::{Request, Response, Voice};
1104 use crate::outside::Outside;
1105 use crate::platform::testing::Recording;
1106 use crate::policy::{Origin, Read, Source};
1107 use crate::present::testing::Recording as Told;
1108 use crate::present::Choice;
1109 use crate::{extract, recover, session};
1110 use std::fs;
1111 use std::path::{Path, PathBuf};
1112 use std::time::Duration;
1113
1114 struct Default_;
1115 impl Source for Default_ {
1116 fn layer(&self, _o: Origin) -> Read {
1117 Ok(None)
1118 }
1119 }
1120
1121 struct World {
1124 policy: Default_,
1125 launcher: Recording,
1126 channel: Told,
1127 }
1128
1129 impl World {
1130 fn new() -> Self {
1131 Self {
1132 policy: Default_,
1133 launcher: Recording::default(),
1134 channel: Told::default(),
1135 }
1136 }
1137
1138 fn outside(&self) -> Outside<'_> {
1139 Outside::new(&self.policy, &self.launcher, &self.channel)
1140 }
1141
1142 fn loud(&self) -> Outside<'_> {
1144 self.outside().saying(crate::policy::Notify::Everything)
1145 }
1146 }
1147
1148 fn opening(container: PathBuf) -> Request {
1151 Request::Open {
1152 container,
1153 voice: Voice::Client,
1154 }
1155 }
1156
1157 fn announcing(container: PathBuf) -> Request {
1159 Request::Open {
1160 container,
1161 voice: Voice::Instance,
1162 }
1163 }
1164
1165 fn container(at: &Path, name: &str, content_bytes: &[u8]) -> PathBuf {
1166 let doc: slpc::toml_edit::DocumentMut =
1167 format!("slipcase_version = \"1.1\"\n\n[content]\nfile = \"{name}\"\n")
1168 .parse()
1169 .unwrap();
1170 let path = at.join(format!("{name}.slpc"));
1171 slpc::pack_reader(name, content_bytes, doc, fs::File::create(&path).unwrap()).unwrap();
1172 path
1173 }
1174
1175 fn ok(r: Response) -> Vec<String> {
1176 match r {
1177 Response::Ok(lines) => lines,
1178 Response::Err(e) => panic!("{e}"),
1179 }
1180 }
1181
1182 fn err(r: Response) -> String {
1183 match r {
1184 Response::Err(e) => e,
1185 Response::Ok(lines) => panic!("expected a refusal, got {lines:?}"),
1186 }
1187 }
1188
1189 fn session_named_in(lines: &[String]) -> String {
1191 lines
1192 .iter()
1193 .find_map(|l| l.strip_prefix(" Session "))
1194 .unwrap_or_else(|| panic!("no session named in {lines:?}"))
1195 .to_string()
1196 }
1197
1198 fn a_crashed_session(root: &Path, c: &Path, name: &str, edit: &[u8]) -> session::Session {
1204 let mut left = session::create(root, c, name).unwrap();
1205 extract::extract(&mut slpc::Container::open(c).unwrap(), &mut left).unwrap();
1206 fs::write(left.content_path(), edit).unwrap();
1207 left
1208 }
1209
1210 const SOMEBODY_ELSE: &[u8] = b"what somebody else put there in the meantime";
1213
1214 fn a_diverged_session(root: &Path, c: &Path, name: &str, edit: &[u8]) -> session::Session {
1221 let left = a_crashed_session(root, c, name, edit);
1222 container(c.parent().unwrap(), name, SOMEBODY_ELSE);
1223 left
1224 }
1225
1226 #[test]
1227 fn opening_a_container_twice_brings_the_session_forward() {
1228 let tmp = tempfile::tempdir().unwrap();
1231 let root = tmp.path().join("sessions");
1232 let c = container(tmp.path(), "report.pdf", b"first");
1233 let w = World::new();
1234 let mut r = Resident::new(&root);
1235
1236 ok(r.handle(opening(c.clone()), &w.outside()));
1237 let again = ok(r.handle(opening(c.clone()), &w.outside()));
1238
1239 assert!(again[0].contains("already open"), "{again:?}");
1240 assert_eq!(session::scan(&root).unwrap().len(), 1);
1241 assert_eq!(w.launcher.launched().len(), 2);
1244 }
1245
1246 #[cfg(unix)]
1247 #[test]
1248 fn the_same_container_under_another_hard_link_is_the_same_session() {
1249 let tmp = tempfile::tempdir().unwrap();
1250 let root = tmp.path().join("sessions");
1251 let c = container(tmp.path(), "report.pdf", b"first");
1252 let link = tmp.path().join("other-name.slpc");
1253 fs::hard_link(&c, &link).unwrap();
1254 let w = World::new();
1255 let mut r = Resident::new(&root);
1256
1257 ok(r.handle(opening(c), &w.outside()));
1258 let again = ok(r.handle(opening(link), &w.outside()));
1259 assert!(again[0].contains("already open"), "{again:?}");
1260 assert_eq!(session::scan(&root).unwrap().len(), 1);
1261 }
1262
1263 #[test]
1264 fn two_different_containers_get_two_sessions() {
1265 let tmp = tempfile::tempdir().unwrap();
1266 let root = tmp.path().join("sessions");
1267 let a = container(tmp.path(), "report.pdf", b"a");
1268 let b = container(tmp.path(), "notes.txt", b"b");
1269 let w = World::new();
1270 let mut r = Resident::new(&root);
1271
1272 ok(r.handle(opening(a), &w.outside()));
1273 ok(r.handle(opening(b), &w.outside()));
1274 assert_eq!(session::scan(&root).unwrap().len(), 2);
1275 assert!(!r.is_idle());
1276 }
1277
1278 #[test]
1279 fn a_session_survives_a_write_back_still_being_the_same_container() {
1280 let tmp = tempfile::tempdir().unwrap();
1289 let root = tmp.path().join("sessions");
1290 let c = container(tmp.path(), "report.pdf", b"first");
1291 let w = World::new();
1292 let mut r = Resident::new(&root);
1293
1294 ok(r.handle(opening(c.clone()), &w.outside()));
1295 let content_path = session::scan(&root).unwrap()[0].content_path();
1296 fs::write(&content_path, b"edited").unwrap();
1297
1298 let deadline = std::time::Instant::now() + Duration::from_secs(10);
1302 let saved = || session::scan(&root).unwrap()[0].record().write_backs;
1303 while std::time::Instant::now() < deadline && saved() == 0 {
1304 r.turn(&w.outside());
1305 }
1306 assert!(saved() >= 1, "nothing was written back");
1307
1308 let again = ok(r.handle(opening(c), &w.outside()));
1309 assert!(again[0].contains("already open"), "{again:?}");
1310 assert_eq!(session::scan(&root).unwrap().len(), 1);
1311 }
1312
1313 #[test]
1314 fn a_recovery_item_on_the_same_container_is_asked_about_first() {
1315 let tmp = tempfile::tempdir().unwrap();
1318 let root = tmp.path().join("sessions");
1319 let c = container(tmp.path(), "report.pdf", b"first");
1320 let left = a_diverged_session(&root, &c, "report.pdf", b"edited then the process died");
1321
1322 let w = World::new();
1323 let mut r = Resident::new(&root);
1324 let refused = err(r.handle(opening(c), &w.outside()));
1325
1326 assert!(refused.contains("left behind"), "{refused}");
1327 assert!(
1328 w.launcher.launched().is_empty(),
1329 "nothing should have opened"
1330 );
1331 assert_eq!(session::scan(&root).unwrap().len(), 1);
1332
1333 let asked = w.channel.questions();
1336 assert_eq!(asked.len(), 1);
1337 assert!(asked[0]
1338 .about
1339 .starts_with(left.dir().file_name().unwrap().to_str().unwrap()));
1340 assert_eq!(
1341 asked[0].choices,
1342 vec![Choice::WriteBack, Choice::Discard, Choice::Reveal]
1343 );
1344 assert!(!r.is_idle());
1347 }
1348
1349 #[test]
1350 fn one_question_is_asked_however_many_times_the_container_is_double_clicked() {
1351 let tmp = tempfile::tempdir().unwrap();
1352 let root = tmp.path().join("sessions");
1353 let c = container(tmp.path(), "report.pdf", b"first");
1354 a_diverged_session(&root, &c, "report.pdf", b"edited");
1355
1356 let w = World::new();
1357 let mut r = Resident::new(&root);
1358 err(r.handle(opening(c.clone()), &w.outside()));
1359 err(r.handle(opening(c), &w.outside()));
1360 assert_eq!(w.channel.questions().len(), 1);
1361 }
1362
1363 #[test]
1364 fn writing_back_a_recovered_session_opens_the_one_that_was_waiting() {
1365 let tmp = tempfile::tempdir().unwrap();
1368 let root = tmp.path().join("sessions");
1369 let c = container(tmp.path(), "report.pdf", b"first");
1370 a_diverged_session(&root, &c, "report.pdf", b"the edit that never landed");
1371
1372 let w = World::new();
1373 let mut r = Resident::new(&root);
1374 err(r.handle(opening(c.clone()), &w.outside()));
1375 let about = w.channel.questions()[0].about.clone();
1376
1377 w.channel.answer(&about, Choice::WriteBack);
1378 r.turn(&w.outside());
1379
1380 let mut held = slpc::Container::open(&c).unwrap();
1383 let mut bytes = Vec::new();
1384 std::io::Read::read_to_end(&mut held.content().unwrap(), &mut bytes).unwrap();
1385 assert_eq!(bytes, b"the edit that never landed");
1386 assert_eq!(w.channel.withdrawn(), vec![about]);
1387 assert_eq!(w.launcher.launched().len(), 1);
1388 assert_eq!(session::scan(&root).unwrap().len(), 1);
1389 }
1390
1391 #[test]
1392 fn discarding_a_recovered_session_opens_the_one_that_was_waiting() {
1393 let tmp = tempfile::tempdir().unwrap();
1394 let root = tmp.path().join("sessions");
1395 let c = container(tmp.path(), "report.pdf", b"first");
1396 a_diverged_session(&root, &c, "report.pdf", b"edited");
1397
1398 let w = World::new();
1399 let mut r = Resident::new(&root);
1400 err(r.handle(opening(c.clone()), &w.outside()));
1401 let about = w.channel.questions()[0].about.clone();
1402
1403 w.channel.answer(&about, Choice::Discard);
1404 r.turn(&w.outside());
1405
1406 let now = session::scan(&root).unwrap();
1411 assert_eq!(now.len(), 1);
1412 assert_eq!(fs::read(now[0].content_path()).unwrap(), SOMEBODY_ELSE);
1413 let mut held = slpc::Container::open(&c).unwrap();
1414 let mut bytes = Vec::new();
1415 std::io::Read::read_to_end(&mut held.content().unwrap(), &mut bytes).unwrap();
1416 assert_eq!(bytes, SOMEBODY_ELSE, "discard must not touch the container");
1417 assert_eq!(w.launcher.launched().len(), 1);
1418 }
1419
1420 #[test]
1421 fn revealing_shows_the_folder_and_puts_the_question_again() {
1422 let tmp = tempfile::tempdir().unwrap();
1426 let root = tmp.path().join("sessions");
1427 let c = container(tmp.path(), "report.pdf", b"first");
1428 let left = a_diverged_session(&root, &c, "report.pdf", b"edited");
1429
1430 let w = World::new();
1431 let mut r = Resident::new(&root);
1432 err(r.handle(opening(c), &w.outside()));
1433 let about = w.channel.questions()[0].about.clone();
1434
1435 w.channel.answer(&about, Choice::Reveal);
1436 r.turn(&w.outside());
1437
1438 assert_eq!(w.launcher.launched(), vec![left.content_dir()]);
1439 assert_eq!(w.channel.questions().len(), 2);
1440 assert!(w.channel.withdrawn().is_empty());
1441 assert!(left.dir().exists());
1442 assert!(!r.is_idle());
1443 }
1444
1445 #[test]
1446 fn an_answer_about_a_session_nobody_is_holding_says_so() {
1447 let tmp = tempfile::tempdir().unwrap();
1448 let w = World::new();
1449 let mut r = Resident::new(tmp.path().join("sessions"));
1450 w.channel.answer("gone-0", Choice::WriteBack);
1451 r.turn(&w.outside());
1452 assert!(
1453 w.channel.said().contains("already been dealt with"),
1454 "{}",
1455 w.channel.said()
1456 );
1457 }
1458
1459 #[test]
1460 fn a_question_nobody_answers_is_taken_back_and_replaced_by_the_commands() {
1461 let tmp = tempfile::tempdir().unwrap();
1465 let root = tmp.path().join("sessions");
1466 let c = container(tmp.path(), "report.pdf", b"first");
1467 let left = a_diverged_session(&root, &c, "report.pdf", b"edited");
1468
1469 let w = World::new();
1470 let mut r = Resident::new(&root).waiting(Duration::ZERO, Duration::ZERO);
1471 err(r.handle(opening(c), &w.outside()));
1472 let about = w.channel.questions()[0].about.clone();
1473
1474 r.turn(&w.outside());
1475
1476 assert_eq!(w.channel.withdrawn(), vec![about.clone()]);
1477 assert!(w
1478 .channel
1479 .said()
1480 .contains(&format!("recover {about} --write-back")));
1481 assert!(w
1482 .channel
1483 .said()
1484 .contains(&format!("recover {about} --discard")));
1485 assert!(left.dir().exists());
1487 assert!(r.is_idle());
1488 }
1489
1490 #[test]
1491 fn a_question_with_somewhere_to_live_is_not_taken_back() {
1492 let tmp = tempfile::tempdir().unwrap();
1499 let root = tmp.path().join("sessions");
1500 let c = container(tmp.path(), "report.pdf", b"first");
1501 let left = a_diverged_session(&root, &c, "report.pdf", b"edited");
1502
1503 let w = World::new();
1504 let mut r = Resident::new(&root).waiting(Duration::ZERO, Duration::ZERO);
1505 err(r.handle(opening(c), &w.outside()));
1506 let about = w.channel.questions()[0].about.clone();
1507
1508 r.let_questions_stand();
1509 for _ in 0..5 {
1510 r.turn(&w.outside());
1511 }
1512
1513 assert!(
1514 w.channel.withdrawn().is_empty(),
1515 "the question was taken back: {:?}",
1516 w.channel.withdrawn()
1517 );
1518 assert!(
1519 !w.channel.said().contains(&format!("recover {about}")),
1520 "it fell back to the command line while a surface was showing it"
1521 );
1522 assert!(left.dir().exists());
1523 assert!(!r.is_idle());
1526 assert_eq!(r.mood(), crate::present::Mood::Look);
1527 }
1528
1529 #[test]
1530 fn a_leftover_edit_goes_back_and_the_open_carries_on() {
1531 let tmp = tempfile::tempdir().unwrap();
1537 let root = tmp.path().join("sessions");
1538 let c = container(tmp.path(), "report.pdf", b"first");
1539 a_crashed_session(&root, &c, "report.pdf", b"the edit that never landed");
1540
1541 let w = World::new();
1542 let mut r = Resident::new(&root);
1543 let opened = ok(r.handle(opening(c.clone()), &w.loud()));
1544
1545 assert!(
1546 w.channel.questions().is_empty(),
1547 "the person was asked about their own save: {:?}",
1548 w.channel.questions()
1549 );
1550 assert!(
1551 opened.iter().any(|l| l.contains("is open")),
1552 "the open did not carry on: {opened:?}"
1553 );
1554 assert_eq!(w.launcher.launched().len(), 1);
1555
1556 let mut held = slpc::Container::open(&c).unwrap();
1559 let mut bytes = Vec::new();
1560 std::io::Read::read_to_end(&mut held.content().unwrap(), &mut bytes).unwrap();
1561 assert_eq!(bytes, b"the edit that never landed");
1562 assert!(w.channel.said().contains("recovered and written back"));
1563 let now = session::scan(&root).unwrap();
1565 assert_eq!(now.len(), 1, "{now:?}");
1566 assert_eq!(
1567 fs::read(now[0].content_path()).unwrap(),
1568 b"the edit that never landed"
1569 );
1570 }
1571
1572 #[test]
1573 fn a_write_back_that_fails_on_recovery_keeps_the_session_and_colours_the_icon() {
1574 let tmp = tempfile::tempdir().unwrap();
1579 let root = tmp.path().join("sessions");
1580 let c = container(tmp.path(), "report.pdf", b"first");
1581 let left = a_crashed_session(&root, &c, "report.pdf", b"an edit worth keeping");
1582
1583 let held = tmp.path().join("held");
1589 fs::create_dir(&held).unwrap();
1590 let c = {
1591 let moved = held.join("report.pdf.slpc");
1592 fs::rename(&c, &moved).unwrap();
1593 moved
1594 };
1595 fs::remove_dir_all(left.dir()).unwrap();
1598 let left = a_crashed_session(&root, &c, "report.pdf", b"an edit worth keeping");
1599 readonly(&c, true);
1600 readonly(&held, true);
1601
1602 let w = World::new();
1603 let mut r = Resident::new(&root);
1604 let _ = r.handle(opening(c.clone()), &w.outside());
1605
1606 readonly(&held, false);
1607 readonly(&c, false);
1608
1609 assert!(left.dir().exists(), "the edit was thrown away");
1610 assert_eq!(
1611 fs::read(left.content_path()).unwrap(),
1612 b"an edit worth keeping"
1613 );
1614 assert_eq!(
1615 r.mood(),
1616 crate::present::Mood::AtRisk,
1617 "an edit that is nowhere but a session directory is what orange is for"
1618 );
1619 }
1620
1621 fn readonly(at: &Path, yes: bool) {
1623 let mut perms = fs::metadata(at).unwrap().permissions();
1624 #[cfg(unix)]
1625 {
1626 use std::os::unix::fs::PermissionsExt as _;
1627 perms.set_mode(if yes { 0o500 } else { 0o700 });
1628 }
1629 #[cfg(not(unix))]
1630 perms.set_readonly(yes);
1631 fs::set_permissions(at, perms).unwrap();
1632 }
1633
1634 #[test]
1635 fn a_quiet_leftover_does_not_stand_in_the_way() {
1636 let tmp = tempfile::tempdir().unwrap();
1639 let root = tmp.path().join("sessions");
1640 let c = container(tmp.path(), "report.pdf", b"first");
1641 let mut left = session::create(&root, &c, "report.pdf").unwrap();
1642 extract::extract(&mut slpc::Container::open(&c).unwrap(), &mut left).unwrap();
1643 assert!(matches!(recover::state(&left), recover::State::Unchanged));
1644
1645 let w = World::new();
1646 let mut r = Resident::new(&root);
1647 ok(r.handle(opening(c), &w.outside()));
1648 assert_eq!(w.launcher.launched().len(), 1);
1649 assert!(w.channel.questions().is_empty());
1650 }
1651
1652 #[test]
1653 fn closing_by_name_closes_that_session() {
1654 let tmp = tempfile::tempdir().unwrap();
1655 let root = tmp.path().join("sessions");
1656 let c = container(tmp.path(), "report.pdf", b"first");
1657 let w = World::new();
1658 let mut r = Resident::new(&root);
1659
1660 let opened = ok(r.handle(opening(c), &w.outside()));
1661 ok(r.handle(Request::Close(session_named_in(&opened)), &w.outside()));
1662 assert!(r.is_idle());
1663 assert!(session::scan(&root).unwrap().is_empty());
1664 }
1665
1666 #[test]
1667 fn closing_while_the_application_is_working_keeps_the_watch_on_it() {
1668 let tmp = tempfile::tempdir().unwrap();
1672 let root = tmp.path().join("sessions");
1673 let c = container(tmp.path(), "report.pdf", b"first");
1674 let w = World::new();
1675 let mut r = Resident::new(&root);
1676
1677 let opened = ok(r.handle(opening(c), &w.outside()));
1678 let dir = session::scan(&root).unwrap()[0].content_dir();
1679 fs::write(dir.join(".~lock.report.pdf#"), b"still working").unwrap();
1680
1681 let closed = ok(r.handle(Request::Close(session_named_in(&opened)), &w.outside()));
1682 assert!(
1683 closed
1684 .iter()
1685 .any(|l| l.contains("still has the content file open")),
1686 "{closed:?}"
1687 );
1688 assert!(
1689 !r.is_idle(),
1690 "the process has to stay for the watch to be worth anything"
1691 );
1692 assert_eq!(session::scan(&root).unwrap().len(), 1);
1693 }
1694
1695 #[test]
1696 fn a_lingering_session_saved_after_the_close_is_written_back_not_asked_about() {
1697 let tmp = tempfile::tempdir().unwrap();
1703 let root = tmp.path().join("sessions");
1704 let c = container(tmp.path(), "report.pdf", b"first");
1705 let w = World::new();
1706 let mut r = Resident::new(&root).waiting(Duration::ZERO, Duration::from_secs(300));
1707
1708 let opened = ok(r.handle(opening(c.clone()), &w.outside()));
1709 let dir = session::scan(&root).unwrap()[0].content_dir();
1710 let sibling = dir.join(".~lock.report.pdf#");
1711 fs::write(&sibling, b"still working").unwrap();
1712 ok(r.handle(Request::Close(session_named_in(&opened)), &w.outside()));
1713
1714 fs::write(dir.join("report.pdf"), b"the last save").unwrap();
1716 fs::remove_file(&sibling).unwrap();
1717 r.turn(&w.loud());
1718
1719 assert!(
1720 w.channel.questions().is_empty(),
1721 "{:?}",
1722 w.channel.questions()
1723 );
1724 let mut held = slpc::Container::open(&c).unwrap();
1726 let mut bytes = Vec::new();
1727 std::io::Read::read_to_end(&mut held.content().unwrap(), &mut bytes).unwrap();
1728 assert_eq!(bytes, b"the last save");
1729 assert!(
1733 w.channel.said().contains("recovered and written back"),
1734 "{}",
1735 w.channel.said()
1736 );
1737 assert!(session::scan(&root).unwrap().is_empty());
1738 assert!(r.is_idle());
1739 }
1740
1741 #[test]
1742 fn a_lingering_session_that_matches_its_container_goes_quietly() {
1743 let tmp = tempfile::tempdir().unwrap();
1747 let root = tmp.path().join("sessions");
1748 let c = container(tmp.path(), "report.pdf", b"first");
1749 let w = World::new();
1750 let mut r = Resident::new(&root).waiting(Duration::ZERO, Duration::from_secs(300));
1751
1752 let opened = ok(r.handle(opening(c), &w.outside()));
1753 let dir = session::scan(&root).unwrap()[0].content_dir();
1754 let sibling = dir.join(".~lock.report.pdf#");
1755 fs::write(&sibling, b"still working").unwrap();
1756 ok(r.handle(Request::Close(session_named_in(&opened)), &w.outside()));
1757
1758 fs::remove_file(&sibling).unwrap();
1759 r.turn(&w.outside());
1760
1761 assert!(
1762 w.channel.questions().is_empty(),
1763 "{:?}",
1764 w.channel.questions()
1765 );
1766 assert!(session::scan(&root).unwrap().is_empty());
1767 assert!(r.is_idle());
1768 }
1769
1770 #[test]
1771 fn closing_a_session_that_is_not_open_says_so() {
1772 let tmp = tempfile::tempdir().unwrap();
1773 let w = World::new();
1774 let mut r = Resident::new(tmp.path().join("sessions"));
1775 let refused = err(r.handle(Request::Close("nothing-0".into()), &w.outside()));
1776 assert!(refused.contains("no open session"), "{refused}");
1777 }
1778
1779 #[test]
1780 fn a_double_click_is_spoken_for_and_a_terminal_is_not() {
1781 let tmp = tempfile::tempdir().unwrap();
1785 let root = tmp.path().join("sessions");
1786 let quiet = container(tmp.path(), "quiet.pdf", b"a");
1787 let loud = container(tmp.path(), "loud.pdf", b"b");
1788 let w = World::new();
1789 let mut r = Resident::new(&root);
1790
1791 ok(r.handle(opening(quiet), &w.loud()));
1792 assert!(w.channel.reports().is_empty(), "{:?}", w.channel.reports());
1793
1794 ok(r.handle(announcing(loud), &w.loud()));
1795 assert!(
1796 w.channel.said().contains("loud.pdf is open"),
1797 "{}",
1798 w.channel.said()
1799 );
1800 }
1801
1802 #[test]
1803 fn listing_shows_what_is_open_and_what_was_left() {
1804 let tmp = tempfile::tempdir().unwrap();
1805 let root = tmp.path().join("sessions");
1806 let open_one = container(tmp.path(), "report.pdf", b"a");
1807 let crashed = container(tmp.path(), "notes.txt", b"b");
1808 a_crashed_session(&root, &crashed, "notes.txt", b"edited");
1809
1810 let w = World::new();
1811 let mut r = Resident::new(&root);
1812 ok(r.handle(opening(open_one), &w.outside()));
1813
1814 let lines = ok(r.handle(Request::List, &w.outside()));
1815 assert!(lines
1816 .iter()
1817 .any(|l| l.contains("report.pdf") && l.contains("open")));
1818 assert!(lines
1819 .iter()
1820 .any(|l| l.contains("notes.txt") && l.contains("edited")));
1821 assert_eq!(lines.len(), 2, "{lines:?}");
1824 }
1825
1826 #[test]
1827 fn the_sweep_takes_the_quiet_ones_and_leaves_the_rest() {
1828 let tmp = tempfile::tempdir().unwrap();
1829 let root = tmp.path().join("sessions");
1830 let a = container(tmp.path(), "quiet.pdf", b"a");
1831 let b = container(tmp.path(), "edited.pdf", b"b");
1832
1833 let mut quiet = session::create(&root, &a, "quiet.pdf").unwrap();
1834 extract::extract(&mut slpc::Container::open(&a).unwrap(), &mut quiet).unwrap();
1835
1836 let edited = a_crashed_session(&root, &b, "edited.pdf", b"an edit that never landed");
1837 let half_made = session::create(&root, &a, "quiet.pdf").unwrap();
1838
1839 assert_eq!(sweep(&root, &[]).unwrap(), 2);
1840 let left = session::scan(&root).unwrap();
1841 assert_eq!(left.len(), 1);
1842 assert_eq!(left[0].dir(), edited.dir());
1843 assert!(!half_made.dir().exists());
1844 }
1845
1846 #[test]
1847 fn the_sweep_will_not_touch_a_live_session() {
1848 let tmp = tempfile::tempdir().unwrap();
1852 let root = tmp.path().join("sessions");
1853 let c = container(tmp.path(), "report.pdf", b"first");
1854 let w = World::new();
1855 let mut r = Resident::new(&root);
1856 ok(r.handle(opening(c), &w.outside()));
1857
1858 let live: Vec<_> = session::scan(&root)
1859 .unwrap()
1860 .iter()
1861 .map(|s| s.dir().to_path_buf())
1862 .collect();
1863 assert!(matches!(
1864 recover::state(&session::scan(&root).unwrap()[0]),
1865 recover::State::Unchanged
1866 ));
1867
1868 assert_eq!(sweep(&root, &live).unwrap(), 0);
1869 assert_eq!(session::scan(&root).unwrap().len(), 1);
1870 assert_eq!(sweep(&root, &[]).unwrap(), 1);
1872 }
1873
1874 #[test]
1875 fn standing_down_takes_back_every_question_it_was_holding() {
1876 let tmp = tempfile::tempdir().unwrap();
1877 let root = tmp.path().join("sessions");
1878 let c = container(tmp.path(), "report.pdf", b"first");
1879 let left = a_diverged_session(&root, &c, "report.pdf", b"edited");
1880
1881 let w = World::new();
1882 let mut r = Resident::new(&root);
1883 err(r.handle(opening(c), &w.outside()));
1884 let about = w.channel.questions()[0].about.clone();
1885
1886 r.stand_down(&w.outside());
1887
1888 assert_eq!(w.channel.withdrawn(), vec![about.clone()]);
1889 assert!(w
1890 .channel
1891 .said()
1892 .contains(&format!("recover {about} --write-back")));
1893 assert!(
1894 left.dir().exists(),
1895 "the session carries the question to the next launch"
1896 );
1897 assert!(r.is_idle());
1898 }
1899
1900 #[test]
1901 fn a_ping_is_answered_and_changes_nothing() {
1902 let tmp = tempfile::tempdir().unwrap();
1903 let w = World::new();
1904 let mut r = Resident::new(tmp.path().join("sessions"));
1905 assert_eq!(
1906 r.handle(Request::Ping, &w.outside()),
1907 Response::Ok(Vec::new())
1908 );
1909 assert!(r.is_idle());
1910 }
1911
1912 #[test]
1913 fn nothing_wrong_is_the_ordinary_colour() {
1914 let tmp = tempfile::tempdir().unwrap();
1915 let r = Resident::new(tmp.path().join("sessions"));
1916 assert_eq!(r.mood(), crate::present::Mood::Settled);
1917 assert!(r.troubles().is_empty());
1918 }
1919
1920 #[test]
1921 fn the_icon_wears_the_worst_thing_currently_true() {
1922 use crate::present::Mood;
1923 let tmp = tempfile::tempdir().unwrap();
1924 let mut r = Resident::new(tmp.path().join("sessions"));
1925
1926 r.note(Mood::Look, "a", "one did not open".into());
1927 assert_eq!(r.mood(), Mood::Look);
1928 r.note(Mood::Danger, "b", "one is a program".into());
1930 assert_eq!(r.mood(), Mood::Danger);
1931 r.note(Mood::AtRisk, "c", "one did not save".into());
1932 assert_eq!(
1933 r.mood(),
1934 Mood::Danger,
1935 "a lesser trouble does not talk the icon down"
1936 );
1937
1938 r.dismiss("b");
1941 assert_eq!(r.mood(), Mood::AtRisk);
1942 r.dismiss("c");
1943 assert_eq!(r.mood(), Mood::Look);
1944 r.dismiss("a");
1945 assert_eq!(r.mood(), Mood::Settled);
1946 }
1947
1948 #[test]
1949 fn the_same_trouble_twice_is_one_trouble() {
1950 use crate::present::Mood;
1951 let tmp = tempfile::tempdir().unwrap();
1952 let mut r = Resident::new(tmp.path().join("sessions"));
1953 r.note(
1954 Mood::Look,
1955 "open:report",
1956 "report.slpc - did not open".into(),
1957 );
1958 r.note(
1959 Mood::Look,
1960 "open:report",
1961 "report.slpc - did not open".into(),
1962 );
1963 r.note(Mood::Look, "open:report", "report.slpc - still not".into());
1964 assert_eq!(r.troubles().len(), 1, "{:?}", r.troubles());
1965 assert_eq!(r.troubles()[0].summary, "report.slpc - still not");
1966 }
1967
1968 #[test]
1969 fn a_container_that_will_not_open_leaves_something_behind() {
1970 let tmp = tempfile::tempdir().unwrap();
1975 let w = World::new();
1976 let mut r = Resident::new(tmp.path().join("sessions"));
1977 let nowhere = tmp.path().join("not-a-container.slpc");
1978 fs::write(&nowhere, b"this is not a container").unwrap();
1979
1980 let _ = err(r.handle(opening(nowhere), &w.outside()));
1981 assert_eq!(r.mood(), crate::present::Mood::Look);
1982 let said = &r.troubles()[0].summary;
1983 assert!(
1984 said.starts_with("not-a-container.slpc"),
1985 "it names the file the person clicked, not the session: {said}"
1986 );
1987 }
1988
1989 #[test]
1990 fn a_content_file_that_is_a_program_is_the_one_thing_red_is_for() {
1991 let tmp = tempfile::tempdir().unwrap();
1994 let w = World::new();
1995 let mut r = Resident::new(tmp.path().join("sessions"));
1996 let c = container(tmp.path(), "invoice.txt", b"MZ\x90\x00 not a document");
1997
1998 let why = err(r.handle(opening(c), &w.outside()));
1999 assert!(why.contains("was not opened"), "{why}");
2000 assert!(r.is_idle(), "nothing opened, so nothing is being held");
2001 assert!(
2002 w.launcher.launched().is_empty(),
2003 "nothing was handed to the desktop"
2004 );
2005 assert!(
2006 session::scan(&tmp.path().join("sessions"))
2007 .unwrap_or_default()
2008 .is_empty(),
2009 "the refusal is before the session, so nothing reached the disk"
2010 );
2011
2012 let insisted = w.channel.insisted();
2016 assert_eq!(insisted.len(), 1, "{insisted:?}");
2017 assert!(insisted[0].summary.contains("invoice.txt"), "{insisted:?}");
2018
2019 assert_eq!(r.mood(), crate::present::Mood::Danger);
2020 let said = &r.troubles()[0].summary;
2021 assert!(
2022 said.contains("invoice.txt") && said.contains("Windows executable"),
2023 "{said}"
2024 );
2025 }
2026
2027 #[test]
2028 fn a_trouble_stays_until_it_is_put_down() {
2029 let tmp = tempfile::tempdir().unwrap();
2033 let w = World::new();
2034 let mut r = Resident::new(tmp.path().join("sessions"));
2035 let c = container(tmp.path(), "invoice.txt", b"MZ\x90\x00 not a document");
2036 let _ = err(r.handle(opening(c), &w.outside()));
2037
2038 for _ in 0..5 {
2039 r.turn(&w.outside());
2040 }
2041 assert_eq!(r.mood(), crate::present::Mood::Danger);
2042
2043 let id = r.troubles()[0].id.clone();
2044 r.dismiss(&id);
2045 assert!(r.troubles().is_empty());
2046 assert_eq!(r.mood(), crate::present::Mood::Settled);
2047 }
2048
2049 #[test]
2050 fn a_question_waiting_colours_the_icon_and_answering_clears_it() {
2051 let tmp = tempfile::tempdir().unwrap();
2054 let root = tmp.path().join("sessions");
2055 let w = World::new();
2056 let c = container(tmp.path(), "report.txt", b"a report\n");
2057
2058 a_diverged_session(&root, &c, "report.txt", b"an edit nobody wrote back\n");
2059
2060 let mut r = Resident::new(&root);
2061 assert_eq!(r.mood(), crate::present::Mood::Settled);
2062 let _ = r.handle(announcing(c), &w.loud());
2064 assert_eq!(
2065 r.mood(),
2066 crate::present::Mood::Look,
2067 "a decision waiting is worth a look and nothing more"
2068 );
2069 assert!(
2070 r.troubles().is_empty(),
2071 "and it is not a trouble: it is on the sessions, so answering ends it"
2072 );
2073
2074 let about = w.channel.questions()[0].about.clone();
2075 w.channel.answer(&about, Choice::Discard);
2076 r.turn(&w.outside());
2077 assert_eq!(r.mood(), crate::present::Mood::Settled);
2078 }
2079}