slipcase_open/present/terminal.rs
1//! The floor beneath the notifications: lines on the terminal.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 9 keeps the command line always available, and this is what the
7//! instance narrates through when there is nothing better — no notification
8//! service, no session bus, or a build for a platform whose arm is not written
9//! yet.
10//!
11//! **It writes to the error stream, and the reason is the first invocation.**
12//! Where nobody is holding the front door, the `open` that starts the instance
13//! is also its own client, so one process is both narrating and answering a
14//! request. The answer to the request goes to the output stream and belongs to
15//! whoever ran the command; the narration that follows it belongs to the
16//! session and continues for as long as the session lives. Separating them
17//! keeps a shell pipeline reading the first without collecting the second.
18
19use std::io::Write as _;
20
21use super::{Answer, Channel, Choice, Question, Report, Weight};
22
23/// Reports and questions as lines.
24#[derive(Debug, Default, Clone, Copy)]
25pub struct Terminal;
26
27impl Channel for Terminal {
28 fn report(&self, report: &Report) {
29 let mut err = std::io::stderr().lock();
30 // Concept 9's weights have no terminal equivalent and none is invented:
31 // a bell or a colour would be this tool deciding it knows more about
32 // somebody's terminal than their terminal does. The weight is carried
33 // for the channels that can act on it.
34 let _ = writeln!(err, "{}", report.summary);
35 for line in &report.detail {
36 let _ = writeln!(err, " {line}");
37 }
38 if report.weight == Weight::Interrupt {
39 let _ = err.flush();
40 }
41 }
42
43 fn ask(&self, question: &Question) {
44 let mut err = std::io::stderr().lock();
45 let _ = writeln!(err, "{}", question.summary);
46 for line in &question.detail {
47 let _ = writeln!(err, " {line}");
48 }
49 for choice in &question.choices {
50 // Reveal has no verb to name, and inventing one to fill out the
51 // list would be adding a command to the interface so that a
52 // rendering could be symmetrical. The question's own detail names
53 // the directory, which is what reveal-the-folder is for.
54 if let Some(flag) = flag_for(*choice) {
55 let _ = writeln!(
56 err,
57 " slipcase-open recover {} {flag}",
58 question.about.as_str()
59 );
60 }
61 }
62 let _ = err.flush();
63 }
64
65 fn withdraw(&self, _about: &str) {
66 // A line already read cannot be taken back.
67 }
68
69 fn answers(&self) -> Vec<Answer> {
70 // A terminal has no way to deliver an answer to a question asked
71 // minutes ago. It is answered by running the verb the question named,
72 // which reaches the instance through the front door as a request rather
73 // than arriving here as an answer.
74 Vec::new()
75 }
76}
77
78/// The `recover` flag that performs a choice, where one does.
79fn flag_for(choice: Choice) -> Option<&'static str> {
80 match choice {
81 Choice::WriteBack => Some("--write-back"),
82 Choice::Discard => Some("--discard"),
83 Choice::Reveal => None,
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::{flag_for, Terminal};
90 use crate::present::{Answer, Channel, Choice};
91
92 #[test]
93 fn every_choice_is_either_a_verb_or_deliberately_not_one() {
94 // The point of the assertion is that adding a choice makes somebody
95 // decide, rather than letting it fall silently off the terminal.
96 assert_eq!(flag_for(Choice::WriteBack), Some("--write-back"));
97 assert_eq!(flag_for(Choice::Discard), Some("--discard"));
98 assert_eq!(flag_for(Choice::Reveal), None);
99 }
100
101 #[test]
102 fn a_terminal_never_answers() {
103 assert_eq!(Terminal.answers(), Vec::<Answer>::new());
104 }
105}