texttale/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::fs::read_to_string;
4use std::io::Write;
5use std::path::Path;
6
7use rustyline::error::ReadlineError;
8use rustyline::history::MemHistory;
9use rustyline::Editor;
10
11///////////////////////////////////////////// TextTale /////////////////////////////////////////////
12
13/// A [TextTale] creates a text-mode adventure by feeding the next command from the command prompt
14/// as a string.  Will return None when the user kills the session or the underlying writer is
15/// exhausted.
16pub trait TextTale: Write {
17    /// Handle an unexpected EOF.
18    fn unexpected_eof(&mut self);
19    /// Get the current prompt.
20    fn get_prompt(&mut self) -> &'static str;
21    /// Set the current prompt.
22    fn set_prompt(&mut self, prompt: &'static str);
23    /// Return the next command, according to the texttale's rules.
24    fn next_command(&mut self) -> Option<String>;
25}
26
27/////////////////////////////////////////// ShellTextTale //////////////////////////////////////////
28
29/// A [ShellTextTale] gives an interactive shell for testing.  It's intended to be interactive.
30pub struct ShellTextTale {
31    rl: Editor<(), MemHistory>,
32    prompt: &'static str,
33}
34
35impl ShellTextTale {
36    /// Create a new texttale shell, using the provided readline editor and prompt.
37    pub fn new(rl: Editor<(), MemHistory>, prompt: &'static str) -> Self {
38        Self { rl, prompt }
39    }
40}
41
42impl Write for ShellTextTale {
43    fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
44        std::io::stdout().write(buf)
45    }
46
47    fn flush(&mut self) -> Result<(), std::io::Error> {
48        std::io::stdout().flush()
49    }
50}
51
52impl TextTale for ShellTextTale {
53    fn unexpected_eof(&mut self) {
54        std::process::exit(1);
55    }
56
57    fn get_prompt(&mut self) -> &'static str {
58        self.prompt
59    }
60
61    fn set_prompt(&mut self, prompt: &'static str) {
62        self.prompt = prompt;
63    }
64
65    fn next_command(&mut self) -> Option<String> {
66        let line = self.rl.readline(self.prompt);
67        match line {
68            Ok(line) => Some(line.trim().to_owned()),
69            Err(ReadlineError::Interrupted) => {
70                std::process::exit(1);
71            }
72            Err(ReadlineError::Eof) => None,
73            Err(err) => {
74                panic!("could not read line: {err}");
75            }
76        }
77    }
78}
79
80////////////////////////////////////////// ExpectTextTale //////////////////////////////////////////
81
82/// An [ExpectTextTale] gives an adventure that gets recorded and compared against the input.  It's
83/// intended to run the script and compare its output to the file.  See `CHECKSUMS.zsh` for an
84/// example of the tests in the `scripts/` directory.
85#[derive(Default)]
86pub struct ExpectTextTale {
87    prompt: &'static str,
88    input_lines: Vec<String>,
89    output_buffer: Vec<u8>,
90}
91
92impl ExpectTextTale {
93    /// Create a new expect text tale that reads from script and compares the output of the
94    /// texttale against the expected output in the script.
95    pub fn new<P: AsRef<Path>>(script: P, prompt: &'static str) -> Result<Self, std::io::Error> {
96        let script = read_to_string(script)?;
97        let input_lines = script.lines().map(|s| s.to_string()).collect();
98        Ok(Self {
99            prompt,
100            input_lines,
101            output_buffer: Vec::new(),
102        })
103    }
104}
105
106impl Write for ExpectTextTale {
107    fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
108        self.output_buffer.write(buf)
109    }
110
111    fn flush(&mut self) -> Result<(), std::io::Error> {
112        self.output_buffer.flush()
113    }
114}
115
116fn diff(exp: &str, got: &str) {
117    if exp == got {
118        return;
119    }
120    let exp: Vec<String> = exp.trim_end().split('\n').map(String::from).collect();
121    let got: Vec<String> = got.trim_end().split('\n').map(String::from).collect();
122    let mut arr = vec![vec![0; got.len() + 1]; exp.len() + 1];
123    for i in 0..exp.len() {
124        #[allow(clippy::needless_range_loop)]
125        for j in 0..got.len() {
126            if exp[i] == got[j] {
127                arr[i + 1][j + 1] = arr[i][j] + 1;
128            } else {
129                arr[i + 1][j + 1] = std::cmp::max(arr[i][j + 1], arr[i + 1][j]);
130            }
131        }
132    }
133    let mut e = exp.len();
134    let mut g = got.len();
135    let mut diff = vec![];
136    while e > 0 && g > 0 {
137        if exp[e - 1] == got[g - 1] {
138            diff.push(" ".to_string() + &exp[e - 1]);
139            e -= 1;
140            g -= 1;
141        } else if arr[e][g] == arr[e][g - 1] {
142            diff.push("+".to_string() + &got[g - 1]);
143            g -= 1;
144        } else {
145            diff.push("-".to_string() + &exp[e - 1]);
146            e -= 1;
147        }
148    }
149    while g > 0 {
150        diff.push(format!("+{}", got[g - 1]));
151        g -= 1;
152    }
153    while e > 0 {
154        diff.push(format!("-{}", exp[e - 1]));
155        e -= 1;
156    }
157    diff.reverse();
158    panic!(
159        "texttale doesn't meet expectations\n-expected +returned:\n{}",
160        diff.join("\n")
161    );
162}
163
164impl TextTale for ExpectTextTale {
165    fn unexpected_eof(&mut self) {
166        panic!("unexpected end of file");
167    }
168
169    fn get_prompt(&mut self) -> &'static str {
170        self.prompt
171    }
172
173    fn set_prompt(&mut self, prompt: &'static str) {
174        self.prompt = prompt;
175    }
176
177    fn next_command(&mut self) -> Option<String> {
178        let mut expected_output = String::new();
179        loop {
180            if !self.input_lines.is_empty() && self.input_lines[0].starts_with(self.prompt) {
181                let cmd = self.input_lines.remove(0);
182                let exp = expected_output.trim();
183                let got = String::from_utf8(self.output_buffer.clone()).unwrap();
184                let got = got.trim();
185                diff(exp, got);
186                if !expected_output.is_empty() {
187                    println!("{expected_output}");
188                }
189                println!("{cmd}");
190                self.output_buffer.clear();
191                return Some(cmd[self.prompt.len()..].to_owned());
192            } else if !self.input_lines.is_empty() {
193                if !expected_output.is_empty() {
194                    expected_output += "\n";
195                }
196                expected_output += &self.input_lines.remove(0);
197            } else {
198                if !expected_output.is_empty() {
199                    panic!("expected output truncated: are you ending with a prompt?");
200                }
201                return None;
202            }
203        }
204    }
205}
206
207/////////////////////////////////////////// StoryElement ///////////////////////////////////////////
208
209/// A [StoryElement] dictates what to do next in the story.
210pub enum StoryElement {
211    /// Continue with the story.
212    Continue,
213    /// Return from the current function defined by the story macro.
214    Return,
215    /// Print the provided help string before the next prompt.
216    PrintHelp,
217}
218
219//////////////////////////////////////////// story macro ///////////////////////////////////////////
220
221/// A [story] always takes the same form.  It is addressed to the method that it will generate for
222/// the author.  It is always followed by the prompt that will be displayed when the user asks for
223/// help.  Then, it's a sequence of commands that need to be interpreted.
224///
225/// ```
226/// use texttale::{story, StoryElement, TextTale};
227///
228/// struct Player<T: TextTale> {
229///     name: String,
230///     age: u8,
231///     gender: String,
232///     race: String,
233///     tale: T,
234/// }
235///
236/// story! {
237///     self cmd,
238///     character by Player<T>;
239/// "Craft your character.
240///
241/// help: .... Print this help menu.
242/// name: .... Set your character's name.
243/// age: ..... Set your character's age.
244/// gender: .. Set your character's gender.
245/// race: .... Set your character's race.
246/// print: ... Print your character.
247/// save: .... Commit changes to the configuration and return to previous menu.
248/// ";
249///     "name" => {
250///         self.name = cmd[1..].to_vec().join(" ");
251///         StoryElement::Continue
252///     }
253///     "gender" => {
254///         self.gender = cmd[1..].to_vec().join(" ");
255///         StoryElement::Continue
256///     }
257///     "race" => {
258///         self.race = cmd[1..].to_vec().join(" ");
259///         StoryElement::Continue
260///     }
261///     "age" => {
262///         if cmd.len() != 2 {
263///             writeln!(self.tale, "USAGE: age [age]").unwrap();
264///         } else {
265///             match cmd[1].parse::<u8>() {
266///                 Ok(age) => {
267///                     self.age = age;
268///                 },
269///                 Err(err) => {
270///                     writeln!(self.tale, "invalid age: {}", err).unwrap();
271///                 },
272///             };
273///         }
274///         StoryElement::Continue
275///     }
276///     "print" => {
277///         let debug = format!("{:#?}", self);
278///         writeln!(self.tale, "{}", debug).unwrap();
279///         StoryElement::Continue
280///     }
281///     "save" => {
282///         StoryElement::Return
283///     }
284/// }
285///
286/// impl<T: TextTale> std::fmt::Debug for Player<T> {
287///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288///         f.debug_struct("Player")
289///             .field("name", &self.name)
290///             .field("age", &self.age)
291///             .field("gender", &self.gender)
292///             .field("race", &self.race)
293///             .finish()
294///     }
295/// }
296/// ```
297#[macro_export]
298macro_rules! story {
299    ($this:ident $cmd:ident, $story_title:ident by $story_teller:ty; $help:literal; $($command:literal => $code:tt)*) => {
300        impl<T: TextTale> $story_teller {
301            pub fn $story_title(&mut $this) {
302                let mut print_help = true;
303                'adventuring:
304                loop {
305                    if print_help {
306                        writeln!($this.tale, "{}", $help).expect("print help");
307                        print_help = false;
308                    }
309                    if let Some(ref line) = $this.tale.next_command() {
310                        let $cmd: Vec<&str> = line.split_whitespace().collect();
311                        if $cmd.is_empty() {
312                            continue 'adventuring;
313                        }
314                        let element: $crate::StoryElement = match $cmd[0] {
315                            $($command => $code),*
316                            _ => {
317                                writeln!($this.tale, "unknown command: {}", line.as_str()).expect("unknown command");
318                                continue 'adventuring;
319                            },
320                        };
321                        match element {
322                            StoryElement::Continue => {
323                                continue 'adventuring;
324                            },
325                            StoryElement::Return => {
326                                break 'adventuring;
327                            }
328                            StoryElement::PrintHelp => {
329                                print_help = true;
330                                continue 'adventuring;
331                            }
332                        }
333                    } else {
334                        break 'adventuring;
335                    }
336                }
337            }
338        }
339    };
340}
341
342/////////////////////////////////////////////// Menu ///////////////////////////////////////////////
343
344/// A [Menu] dictates what to do next within a menu.
345pub enum Menu {
346    /// Continue to the next prompt in the menu.
347    Continue,
348    /// Retry the current prompt; usually this is used for data that doesn't validate.
349    Retry,
350    /// Announce an unexpected end-of-file.
351    UnexpectedEof,
352}
353
354//////////////////////////////////////////// menu macro ////////////////////////////////////////////
355
356/// An [menu] is a series of interactive prompts to be answered in order.  Where a story provides
357/// choice via branches, an menu sequences  prompts in-order and expects an answer to each prompt.
358///
359/// ```
360/// use texttale::{menu, story, Menu, StoryElement, TextTale};
361///
362/// #[derive(Debug)]
363/// struct Player<T: TextTale> {
364///     name: String,
365///     age: u8,
366///     gender: String,
367///     race: String,
368///     tale: T,
369/// }
370///
371/// story! {
372///     self cmd,
373///     character by Player<T>;
374/// "Craft your character.
375///
376/// help: ....... Print this help menu.
377/// interview: .. Answer questions to fill in your character's details.
378/// ";
379///     "name" => {
380///         menu! {
381///             self cmd;
382///             "name" => {
383///                 /* code */
384///                 Menu::Continue
385///             }
386///             "age" => {
387///                 /* more code */
388///                 Menu::Continue
389///             }
390///         }
391///         StoryElement::Continue
392///     }
393/// }
394/// ```
395#[macro_export]
396macro_rules! menu {
397    ($this:ident $cmd:ident; $($prompt:literal => $code:tt)*) => {
398        {
399            let prompt = $this.tale.get_prompt();
400            $(
401                'retrying: loop {
402                    $this.tale.set_prompt($prompt);
403                    let $cmd = $this.tale.next_command();
404                    let action = if let Some($cmd) = $cmd {
405                        $code
406                    } else {
407                        $crate::Menu::UnexpectedEof
408                    };
409                    match action {
410                        $crate::Menu::Continue => {
411                            break 'retrying;
412                        }
413                        $crate::Menu::Retry => {}
414                        $crate::Menu::UnexpectedEof => {
415                            $this.tale.unexpected_eof();
416                        },
417                    }
418                }
419            )*
420            $this.tale.set_prompt(prompt);
421        }
422    };
423}