Skip to main content

slipcase_open/
resident.rs

1//! The instance that holds the sessions, and the loop that serves it.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 8. One process holds every open session; every other invocation
7//! reaches it through the front door and exits.
8//!
9//! **Requests are served on the loop's own thread and the accepting is not.** A
10//! blocking `accept` would starve the watchers, and the watchers are the whole
11//! reason this process exists. So a thread does nothing but accept and hand
12//! connections over, and the loop alternates between answering one, pumping the
13//! sessions, and collecting whatever concept 9's channel has been told.
14//!
15//! ## Three lists rather than one
16//!
17//! A session is open, or it is closed and the application has not finished with
18//! it (concept 6.2), or a question has been asked about it and nothing may
19//! happen until somebody answers (concept 6.3). Concept 8 says the process
20//! lives while any of the three exists and stops when none does, so they are
21//! three lists and `is_idle` is all of them being empty.
22
23use 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
36/// How long the loop waits for a request before going round to pump.
37const TICK: Duration = Duration::from_millis(250);
38
39/// How long a closed session's content directory must go untouched before the
40/// application is taken to have finished with it (concept 8).
41///
42/// Short, because it is the second of two conditions rather than the whole
43/// test: the siblings have to be gone as well, and an application that has
44/// cleaned up after itself is not about to write again. What this guards is the
45/// gap between the last write and the last unlink.
46const SETTLED: Duration = Duration::from_secs(2);
47
48/// How long the instance stays alive holding a question nobody has answered,
49/// where there is nothing standing to keep showing it.
50///
51/// **A judgement, and worth naming as one.** Concept 8 says to bound the linger
52/// and does not say where, because there is no measurement that settles it:
53/// what it trades is a resident process against a question that can still be
54/// acted on. Five minutes is long enough that somebody who saw the notification
55/// and finished a sentence first still finds the buttons live, and short enough
56/// that a process nobody is talking to does not sit there for the afternoon.
57/// When it expires the question is taken back rather than left standing, and
58/// what replaces it says how to reach the same decision from the command line —
59/// a button that does nothing is worse than no button.
60///
61/// **It only applies where the question has nowhere to live**, which is what
62/// [`Resident::let_questions_stand`] turns off. The whole of the reasoning above
63/// is about a process staying alive *for* the question; where an icon is
64/// already keeping it, the trade is not being made and the limit only throws
65/// the question away.
66const HELD: Duration = Duration::from_secs(300);
67
68/// How long the icon shows that a save is going back into its container.
69///
70/// Long enough to be seen and short enough not to be a state. A repack of an
71/// ordinary document is faster than this, so the wait is not the work — it is
72/// the acknowledgement, and it exists because pressing Save and seeing nothing
73/// change anywhere is what makes somebody doubt the tool is running.
74const PULSE: Duration = Duration::from_millis(900);
75
76/// A question the instance has asked and is holding open so that it can act on
77/// the answer.
78struct Pending {
79    /// The session it is about, as `sessions` names it.
80    about: String,
81    /// What the answer acts on.
82    session: Session,
83    /// A container to open once this is settled, where the question was raised
84    /// by an `open` that could not proceed until it was (concept 8).
85    then_open: Option<PathBuf>,
86    /// When it was last put in front of somebody. Reset by a `Reveal`, which is
87    /// somebody saying *not yet* rather than declining to answer.
88    asked: Instant,
89}
90
91/// The sessions this instance is holding.
92pub struct Resident {
93    root: PathBuf,
94    sessions: Table<Opened>,
95    lingering: Vec<Lingering>,
96    pending: Vec<Pending>,
97    /// [`SETTLED`] and [`HELD`], held rather than read, so that the rules they
98    /// express can be tested instead of waited out. Nothing outside the tests
99    /// changes them: a five-minute hold is not a setting concept 10 asks for,
100    /// and making it one would put a second answer to *how long* in a file.
101    settles_after: Duration,
102    /// How long an unanswered question is held before being taken back, or
103    /// `None` where something is standing that can keep showing it.
104    holds_for: Option<Duration>,
105    /// What the standing list is coloured for, until somebody puts it down.
106    ///
107    /// **Held here rather than only spoken**, because a notification is a
108    /// moment and most of these are not. A container that would not open leaves
109    /// no session behind to re-read the trouble from, so the toast saying so
110    /// was the only record there was, and a toast that was missed is a trouble
111    /// that never happened.
112    troubles: Vec<crate::present::Trouble>,
113    /// When a save last went back into a container, for the moment of colour
114    /// that says so. `None` until one has.
115    wrote_back: Option<Instant>,
116}
117
118impl Resident {
119    /// An instance holding nothing, keeping its sessions under `root`.
120    #[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    /// Set the two waits, for the tests of what happens on either side of
135    /// them. A five-minute hold has no other way of being tested, and a
136    /// two-second settle has no other way of being tested quickly.
137    #[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    /// Whether there is anything left to hold, which is concept 8's exit rule.
145    #[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    /// Let questions stand for as long as the instance does.
151    ///
152    /// **For an instance with a standing surface, which is what [`HELD`] was
153    /// standing in for.** That limit exists because a question is the only
154    /// thing keeping the process alive and a process nobody is talking to must
155    /// not sit there for the afternoon. Where an icon is up, the process is
156    /// already staying for its own reasons, so the limit buys nothing and costs
157    /// the question: taking it back and naming two command lines in its place
158    /// is the dead end that made the tray look necessary in the first place.
159    ///
160    /// Not a setting. It follows from whether there is somewhere for the
161    /// question to keep being visible, which is a fact about the platform and
162    /// the invocation rather than a preference.
163    pub fn let_questions_stand(&mut self) {
164        self.holds_for = None;
165    }
166
167    /// Put back an edit that never reached its container.
168    ///
169    /// **Concept 6.3 as amended: this one is not a question.** `recover` only
170    /// answers [`recover::Course::WriteBack`] where it knows the difference is
171    /// this session's own edit and the container is still holding what the two
172    /// last agreed on, so there is nothing for a person to decide and nobody
173    /// else's work to lose. Asking anyway is this tool's failure handed back to
174    /// them in vocabulary they never asked to learn.
175    ///
176    /// **Said, not silent.** The risk that survives the amendment is a save
177    /// that was half-written when the process died, and the only defence left
178    /// against it is somebody seeing what happened while the container is still
179    /// in front of them. So the report is `ordinary` rather than `routine`: no
180    /// setting drops it.
181    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                // The session stays, which is what makes a second attempt
200                // possible, and the icon carries it: an edit that is nowhere
201                // but a session directory is exactly what orange is for.
202                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    /// Take on a trouble, for the icon to carry and the menu to explain.
219    ///
220    /// The same one twice is one trouble. A container that will not open is a
221    /// container somebody is likely to double-click again, and three identical
222    /// lines in a menu say nothing the first did not.
223    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    /// Put down a trouble that has been read.
235    fn dismiss(&mut self, id: &str) {
236        self.troubles.retain(|t| t.id != id);
237    }
238
239    /// What the standing list is coloured for.
240    #[must_use]
241    pub fn troubles(&self) -> &[crate::present::Trouble] {
242        &self.troubles
243    }
244
245    /// What colour the icon is, which is the worst thing currently true.
246    ///
247    /// **Two sources, and they are different in kind.** A trouble is remembered
248    /// until somebody puts it down, because it is a moment that would otherwise
249    /// be gone. A session waiting on a decision is not remembered at all — it
250    /// is read off the sessions every time, so answering the question is what
251    /// clears the colour, with nothing to dismiss and nothing that can fall out
252    /// of step with what is on disk.
253    #[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    /// Answer one request.
275    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    /// Open a container, or bring forward the session that already has it.
285    fn open(&mut self, container: &Path, voice: Voice, outside: &Outside<'_>) -> Response {
286        // Concept 8: a container that already has a live session is not opened
287        // twice. Two sessions would both repack it and the second write-back
288        // would overwrite the first with nothing said. Re-launching is what a
289        // second double-click on an open document does everywhere else.
290        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        // Concept 8: a pending recovery item is resolved first. A session left
315        // by a crash is not in the live table, so nothing refuses it — but
316        // opening a fresh one would extract the container's current content
317        // leave the recovered edit with nowhere to go.
318        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                // **The one refusal that is spoken twice.** Concept 5.1's check
339                // fires close to never and means one thing when it does, and
340                // the person is holding a file somebody sent them believing it
341                // is a document. A notification they may not look at is not
342                // enough for that, so it is insisted on as well as recorded —
343                // and the icon goes red, which is the only thing red is for.
344                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                    // `refuse` deliberately skipped: it would say "Not opened"
360                    // through the same channel that has just been insisted at,
361                    // which on Windows is a box and two toasts for one event.
362                    // What was insisted on is the better sentence of the two,
363                    // and the command line still gets this one back.
364                    return Response::Err(e.to_string());
365                }
366                // Everything else: nothing was extracted and nothing is at
367                // risk, so this is a look rather than a warning — but it is
368                // still the case the standing list exists for. A double-click
369                // that produces no document and no window has to say something
370                // that outlasts a banner, or the tool appears not to work.
371                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    /// Put concept 6.3's question about a session left behind on this
399    /// container, where there is one worth asking about, and hold it.
400    ///
401    /// Answers `true` where the open must wait for it. Concept 8: *the recovery
402    /// question comes first, and the new session follows the answer* — so the
403    /// container is remembered against the question and opened once it is
404    /// settled, rather than the person having to double-click a second time.
405    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            // A container the record names that has since gone cannot be the
414            // one being opened, so it is not this invocation's business.
415            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                // Nothing was lost; the sweep will take it.
421                recover::Course::Sweep => continue,
422                // The person's own edit, and the container has not moved. Put
423                // it back and let the open carry on — being stopped to answer a
424                // question about work they already saved is the thing this
425                // replaces.
426                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            // Already asked, and still waiting. Asking again would put a second
434            // copy of the same question in the message list, and answering
435            // either would leave the other one behind.
436            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                // Short, because a notification body is one paragraph however
446                // it is written. The container is named because that is what
447                // the decision is about; the session directory is not, because
448                // `Reveal` is the button that opens it.
449                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    /// Every session, with the id apart from the words.
471    ///
472    /// One source for both surfaces: `list` puts the id back in front and hands
473    /// the lines to the command line, and concept 12's standing list takes the
474    /// pieces, because a menu needs the id to act on and no room to show it.
475    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        // Everything above is also a directory under the root, so a scan that
517        // did not know what was held would report each of them twice.
518        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                    // Concept 6.3: what needs nobody is swept and never spoken
534                    // of. Listing it in a standing surface is furniture.
535                    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    /// Every session directory this instance is holding, in any of its three
556    /// lists.
557    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            // Concept 6.2 and 8. The close is honoured; the directory is not
585            // removed, and the watch stays on it so that the application's last
586            // save is noticed when it happens rather than at the next launch.
587            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    /// One turn: pump the open sessions, move on whatever has settled, and act
599    /// on whatever has been answered.
600    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    /// Give every open session's watch a turn, and report what came of it.
608    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                    // **The first of a session and not each one.** A write-back
617                    // fires on every save, so an hour's editing is dozens of
618                    // notifications for the same fact. The first is worth
619                    // saying, because it is how somebody learns the loop works
620                    // at all; the rest are the tool congratulating itself.
621                    // Concept 6.2 wants a session visible and wants somewhere
622                    // to look when an edit is expected to have landed, and
623                    // `sessions` answers that better than a stream of banners.
624                    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                // One failing container is not a reason to stop watching the
638                // rest, and it is a reason to say so: concept 6.2 puts the
639                // close at the user's hand, and a save that did not land is the
640                // thing they most need to know did not.
641                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        // A write-back renamed a new file over the container, so the identity
656        // recorded when the session opened is stale. See `table::refresh`.
657        if !wrote_back.is_empty() {
658            // The moment of colour that says a save went home. Set from a
659            // write-back having happened rather than from an event having
660            // arrived, which is the same rule `pump` itself decides by.
661            self.wrote_back = Some(Instant::now());
662        }
663        for container in wrote_back {
664            self.sessions.refresh(&container);
665        }
666        // The promise this tool makes is that a save reaches the container, and
667        // this is that promise outstanding. It stays on the icon until somebody
668        // puts it down, because the alternative is a banner that flashed past
669        // while they were typing into the very document that did not save.
670        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        // And it goes when a later save from the same session lands, without
678        // anybody dismissing anything. The colour is a claim about right now,
679        // so a claim the next save disproves has to answer to it.
680        for id in landed {
681            self.dismiss(&format!("writeback:{id}"));
682        }
683    }
684
685    /// Ask about every lingering session the application has finished with.
686    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            // Concept 6.3: equal to what the container holds means nothing was
698            // lost, so clean up and say nothing. This is that case arriving
699            // while the process is still alive to see it, which is what concept
700            // 8 keeps it alive for.
701            //
702            // And the amended case beside it: a save that landed after the
703            // close is the most ordinary reason to be here at all — somebody
704            // pressed Save on the way out — so it goes back rather than being
705            // put to them as a choice.
706            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    /// Do what somebody chose.
746    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            // A button from a question this instance is no longer holding: the
755            // same decision taken at the command line in the meantime, or a
756            // notification that outlived the process that asked. Saying so
757            // beats a click that appears to do nothing.
758            outside.report(&Report::ordinary(fill(
759                t("{name} has already been dealt with."),
760                &[("name", &answer.about)],
761            )));
762            return;
763        };
764
765        // Reveal is *not yet* rather than an answer, so the question stays and
766        // is put again. A service that closes a notification when one of its
767        // actions is invoked — which GNOME Shell does — would otherwise leave
768        // somebody looking at the folder with no way back to the decision.
769        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        // Owned, because what follows moves the session out from under it.
804        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                    // Nothing is removed. The session stays where it
822                    // was, which is what makes a second attempt possible, and
823                    // the question goes back so there is something to make it
824                    // with.
825                    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        // Concept 8: the new session follows the answer.
848        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    /// Take back the questions nobody has answered inside [`HELD`], and say
859    /// where the same decision still lives.
860    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    /// Withdraw one question and leave the command line in its place.
873    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    /// Close every open session, and put down every question, for a shutdown
892    /// that is not a crash.
893    ///
894    /// A question left standing when this process goes is a button with nobody
895    /// behind it, so each is withdrawn and replaced by the two commands that
896    /// reach the same decision. The session directories stay: concept 6.3
897    /// carries them to the next launch, which is where they were always going
898    /// to be answered if nobody answered here.
899    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
932/// The name `list` prints and `close` takes back.
933fn 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
939/// A report said once: through the channel where the client has nowhere to show
940/// it, and back to the client where it has.
941fn 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
952/// A refusal said once, on the same rule.
953///
954/// Weighted as an interrupt through the channel. A refusal answers something
955/// somebody just double-clicked, and concept 5.1's extensionless case is one of
956/// the things it can be: a quiet refusal there reads as the document simply not
957/// opening.
958fn 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
965/// Remove the sessions left behind that have nothing to say.
966///
967/// Concept 6.3: a recovered content file matching its container means nothing was
968/// lost, so clean up and say nothing.
969///
970/// **This could not be done before Phase 2 and that is why it was not.** A
971/// session that is open and not yet edited reads as unchanged, and no process
972/// could tell a live session from a dead one — a sweep run from a second
973/// terminal would have deleted a directory out from under a running editor.
974/// `live` is what the resident instance knows and nothing else did.
975///
976/// # Errors
977///
978/// Where the session root cannot be read. A session that will not go is left
979/// rather than reported: it is debris, the next sweep will try again, and
980/// failing a launch over it would be the tail wagging the dog.
981pub 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        // `is_quiet` and not `!needs_a_person`: an edit that never landed now
988        // takes neither course, and the negation would have swept it.
989        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
999/// Hold the sessions and serve the front door until nothing is left.
1000///
1001/// # Errors
1002///
1003/// Where the endpoint cannot be served.
1004pub fn run(
1005    listener: crate::endpoint::Listener,
1006    resident: &mut Resident,
1007    outside: &Outside<'_>,
1008    standing: &dyn crate::present::Standing,
1009) -> io::Result<()> {
1010    // A question with somewhere to keep being seen is not on a clock. See
1011    // `HELD`, whose reasoning is entirely about a process staying alive for a
1012    // question that has nowhere else to live.
1013    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        // A caller that went away between connecting and being read is not an
1022        // event: `flatten` drops it and takes the next.
1023        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                    // A request this build cannot read is answered rather than
1036                    // dropped, so a client waiting on the front door is not
1037                    // left waiting on it.
1038                    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            // The accepting thread has gone, which means the listener has.
1044            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
1045        }
1046
1047        resident.turn(outside);
1048
1049        // Concept 12's standing list, told what to say and asked what was
1050        // said back. Only when it changed: this is every 250 milliseconds, and
1051        // all three are the same on almost all of them.
1052        //
1053        // The mood is in that comparison and it is the one that moves on its
1054        // own — [`PULSE`] expires with nothing else happening — which is what
1055        // takes the icon back to blue after a save without needing a timer of
1056        // its own anywhere.
1057        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                // Somebody has read it. Nothing else happens: the trouble was
1070                // the record that it happened at all, and putting it down is
1071                // the person saying they have the record now.
1072                crate::present::Chosen::Dismiss(id) => resident.dismiss(&id),
1073                // The same ending as interrupting the command line, and the
1074                // menu item says so: every session stays where it is and stays
1075                // recoverable.
1076                crate::present::Chosen::Quit => leaving = true,
1077            }
1078        }
1079        if leaving {
1080            break;
1081        }
1082
1083        // Concept 8's exit rule: nothing open, nothing lingering, nothing
1084        // waiting on somebody. Staying resident does nothing for the crash
1085        // case, where this process is dead by definition.
1086        //
1087        // **Where there is a standing list the rule does not apply**, and that
1088        // is a change rather than an exception — see
1089        // [`crate::present::Standing::holding`]. The rule was written for a
1090        // process with no face: nothing for it to be, so no reason for it to
1091        // be. An icon gives warnings somewhere to live, and a warning raised by
1092        // a process on its way out has nowhere to go.
1093        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    /// The three things the engine works against, kept together so a test can
1122    /// hand them over as one and then ask each of them what it saw.
1123    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        /// The same, with the routine reports let through.
1143        fn loud(&self) -> Outside<'_> {
1144            self.outside().saying(crate::policy::Notify::Everything)
1145        }
1146    }
1147
1148    /// An `open` from somewhere with a terminal, which is what most of these
1149    /// are: the response is the assertion.
1150    fn opening(container: PathBuf) -> Request {
1151        Request::Open {
1152            container,
1153            voice: Voice::Client,
1154        }
1155    }
1156
1157    /// An `open` from a double-click, where the instance has to speak.
1158    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    /// The session name the `open` response gives back.
1190    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    /// A session left behind by a process that died with an edit in it.
1199    ///
1200    /// Its container has not moved, so concept 6.3 as amended puts the edit
1201    /// back without asking. Use [`a_diverged_session`] for the tests that are
1202    /// about the question.
1203    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    /// What `a_diverged_session` leaves in the container, for the tests that
1211    /// have to say what the container holds afterwards.
1212    const SOMEBODY_ELSE: &[u8] = b"what somebody else put there in the meantime";
1213
1214    /// The same, and then somebody else repacks the container while the session
1215    /// is not running.
1216    ///
1217    /// **The only shape that still raises concept 6.3's question**, because it
1218    /// is the only one where both sides hold work the other does not and no
1219    /// answer is obviously right.
1220    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        // Concept 8. Two sessions would both repack it and the second
1229        // write-back would overwrite the first with nothing said.
1230        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        // Brought forward means launched again, which is what a second
1242        // double-click does everywhere else.
1243        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        // A session is still the same session after it has saved, even though
1281        // the write-back renamed a new file over the container and so gave it a
1282        // new inode. This passes on the path arm alone — checked by reverting
1283        // `refresh` and watching it stay green — so what it pins is that a save
1284        // does not lose a session, not that `refresh` works. The identity arm
1285        // is covered where it can be seen: `table::refreshing_keeps_the_
1286        // identity_arm_working_after_a_save`, which reaches the container
1287        // through a hard link made after the save and does fail without it.
1288        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        // Watched through the record rather than through what was said. The
1299        // write-back count is what `pump` guarantees; a notification is a
1300        // presentation choice that a threshold may now drop.
1301        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        // Concept 8. Opening a fresh session would extract the container's
1316        // current content file and leave the recovered edit with nowhere to go.
1317        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        // Concept 9 turns the Phase 2 refusal into a question, and it has to
1334        // carry all three of concept 6.3's answers.
1335        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        // And the instance is holding it, which is what makes an answer
1345        // actionable rather than a button with nobody behind it.
1346        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        // Concept 8: the new session follows the answer, so nobody has to
1366        // double-click the container a second time.
1367        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        // The edit is in the container, the question has been taken back, and
1381        // the session that was waiting on the answer is open.
1382        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        // Not asserted by the directory being gone: a session is named for the
1407        // second it started in, so the one that follows the answer takes the
1408        // same name back. What says the edit was discarded is that the session
1409        // now on disk holds the container's content file rather than the edit.
1410        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        // Reveal is *not yet* rather than an answer. A service that closes a
1423        // notification when one of its actions is invoked would otherwise leave
1424        // somebody looking at a folder with no way back to the decision.
1425        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        // Concept 8 says to bound the linger. What it costs is that the buttons
1462        // stop working, so they are removed rather than left to do nothing, and
1463        // what replaces them reaches the same decision.
1464        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        // The session itself stays. Concept 6.3 carries it to the next launch.
1486        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        // The same setup as above, and the opposite outcome, because something
1493        // is standing that can keep showing it. `HELD` exists to stop a process
1494        // sitting there for a question nobody can see; where an icon is up the
1495        // process is staying anyway, so the limit would only throw the question
1496        // away — and naming two command lines in its place is the dead end that
1497        // made the icon look necessary to begin with.
1498        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        // And it is still the instance's to act on, which is the point: the
1524        // buttons in front of somebody still reach a question that is here.
1525        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        // The complaint this whole amendment came from: an edit was saved, the
1532        // process that was watching stopped, and reopening the container put a
1533        // question in the way instead of the document. The container has not
1534        // moved, so the edit is the person's own — it goes back, and the open
1535        // proceeds in the same breath rather than waiting on an answer.
1536        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        // The edit reached the container, and the session that carried it is
1557        // gone rather than left to be listed as needing a decision.
1558        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        // One session on disk: the new one, holding what the container now has.
1564        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        // The edit is the one thing that must not be dropped on the floor. A
1575        // failed recovery write-back leaves the session where it was, so a
1576        // second attempt is possible, and puts the icon in the state that means
1577        // *work is not in its container*.
1578        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        // The container has to stay *readable*, or `recover` answers
1584        // `Unreadable` and asks rather than reaching the write-back at all.
1585        // Both of these, because the two platforms refuse in different places:
1586        // Windows will not rename over a read-only file, and Unix will not
1587        // create the temporary beside it in a directory it cannot write.
1588        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        // The record still names the old path, so put the session back onto
1596        // this one by rebuilding it there.
1597        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    /// Make a path refuse writes, and let it accept them again.
1622    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        // Only a recovery item worth asking about blocks. One that matches its
1637        // container has nothing to lose and should not stop somebody working.
1638        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        // Concept 6.2 and 8. The close is honoured, the directory stays, and
1669        // the process keeps watching so the application's last save is noticed
1670        // when it happens rather than at the next launch.
1671        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        // The most ordinary reason to be here at all: somebody pressed Save on
1698        // the way out. Concept 6.3 as amended — the container has not moved, so
1699        // the save is theirs and it goes home. Putting it to them as *write
1700        // back, discard, or reveal the folder* would be this tool's own timing
1701        // handed back to them as a decision.
1702        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        // The application's last save, and then it tidies up after itself.
1715        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        // It reached the container, which is the whole point.
1725        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        // Said rather than done silently: the risk this accepts is a save that
1730        // was half-written, and being able to see what happened is the only
1731        // defence left against it.
1732        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        // Concept 6.3: equal means nothing was lost, so clean up and say
1744        // nothing. This is that case arriving while the process is still alive
1745        // to see it, rather than at the next launch.
1746        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        // Concept 9. An invocation with nowhere to print is why the instance
1782        // has a channel at all, and one with a terminal of its own would
1783        // otherwise hear everything twice.
1784        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        // And each of them once. A session the instance is holding is also on
1822        // disk, so a list that read both without checking would double it.
1823        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        // The reason this could not be written before Phase 2. A session that
1849        // is open and not yet edited reads as unchanged, and deleting it would
1850        // take the directory out from under a running editor.
1851        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        // And it would have gone, had the sweep not been told.
1871        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        // Worse arrives and wins, whatever order they were taken on in.
1929        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        // And it comes back down as they are put down, rather than sticking at
1939        // the worst thing that ever happened.
1940        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        // The case the standing list exists for: a double-click that produces
1971        // no document and no window. The notification saying so is a moment,
1972        // and a moment that was missed is indistinguishable from the tool being
1973        // broken.
1974        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        // Concept 5.1's check, which fires close to never and means one thing
1992        // when it does. Nothing else in this file may reach `Danger`.
1993        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        // Insisted on rather than reported. A notification can be missed, and
2013        // this is the one refusal where being missed matters: the person is
2014        // holding a file somebody sent them believing it is a document.
2015        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        // The property that makes the colour worth reading: it is not a banner
2030        // that expires while somebody is looking the other way. Turning the
2031        // loop over changes nothing about it.
2032        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        // Read off the sessions rather than remembered, so there is nothing to
2052        // dismiss and nothing that can disagree with what is on disk.
2053        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        // Opening the same container is what raises concept 6.3's question.
2063        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}