initiative_core/storage/
command.rs

1use super::backup::export;
2use super::{Change, Record, RecordStatus, RepositoryError};
3use crate::app::{
4    AppMeta, Autocomplete, AutocompleteSuggestion, CommandAlias, CommandMatches, ContextAwareParse,
5    Event, Runnable,
6};
7use crate::utils::CaseInsensitiveStr;
8use crate::world::thing::{Thing, ThingData};
9use async_trait::async_trait;
10use futures::join;
11use std::cmp::Ordering;
12use std::fmt;
13use std::iter::repeat;
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub enum StorageCommand {
17    Delete { name: String },
18    Export,
19    Import,
20    Journal,
21    Load { name: String },
22    Redo,
23    Save { name: String },
24    Undo,
25}
26
27#[async_trait(?Send)]
28impl Runnable for StorageCommand {
29    async fn run(self, _input: &str, app_meta: &mut AppMeta) -> Result<String, String> {
30        match self {
31            Self::Journal => {
32                let mut output = "# Journal".to_string();
33                let [mut npcs, mut places] = [Vec::new(), Vec::new()];
34
35                let record_count = app_meta
36                    .repository
37                    .journal()
38                    .await
39                    .map_err(|_| "Couldn't access the journal.".to_string())?
40                    .into_iter()
41                    .map(|thing| match &thing.data {
42                        ThingData::Npc(_) => npcs.push(thing),
43                        ThingData::Place(_) => places.push(thing),
44                    })
45                    .count();
46
47                let mut add_section = |title: &str, mut things: Vec<Thing>| {
48                    if !things.is_empty() {
49                        output.push_str("\n\n## ");
50                        output.push_str(title);
51
52                        things.sort_unstable_by(|a, b| {
53                            if let (Some(a), Some(b)) = (a.name().value(), b.name().value()) {
54                                a.cmp_ci(b)
55                            } else {
56                                // This shouldn't happen.
57                                Ordering::Equal
58                            }
59                        });
60
61                        things.into_iter().enumerate().for_each(|(i, thing)| {
62                            if i > 0 {
63                                output.push('\\');
64                            }
65
66                            output.push_str(&format!("\n{}", thing.display_summary()));
67                        });
68                    }
69                };
70
71                add_section("NPCs", npcs);
72                add_section("Places", places);
73
74                if record_count == 0 {
75                    output.push_str("\n\n*Your journal is currently empty.*");
76                } else {
77                    output.push_str("\n\n*To export the contents of your journal, use `export`.*");
78                }
79
80                Ok(output)
81            }
82            Self::Delete { name } => {
83                let result = match app_meta.repository.get_by_name(&name).await {
84                    Ok(Record { thing, .. }) => {
85                        app_meta
86                                .repository
87                                .modify(Change::Delete { uuid: thing.uuid, name: thing.name().to_string() })
88                                .await
89                                .map_err(|(_, e)| e)
90                    }
91                    Err(e) => Err(e),
92                };
93
94                match result {
95                    Ok(Some(Record { thing, .. })) => Ok(format!("{} was successfully deleted. Use `undo` to reverse this.", thing.name())),
96                    Ok(None) | Err(RepositoryError::NotFound) => Err(format!("There is no entity named \"{}\".", name)),
97                    Err(_) => Err(format!("Couldn't delete `{}`.", name)),
98                }
99            }
100            Self::Save { name } => {
101                 app_meta
102                    .repository
103                    .modify(Change::Save { name: name.clone(), uuid: None })
104                    .await
105                    .map(|_| format!("{} was successfully saved. Use `undo` to reverse this.", name))
106                    .map_err(|(_, e)| {
107                        if e == RepositoryError::NotFound {
108                            format!("There is no entity named \"{}\".", name)
109                        } else {
110                            format!("Couldn't save `{}`.", name)
111                        }
112                    })
113            }
114            Self::Export => {
115                (app_meta.event_dispatcher)(Event::Export(export(&app_meta.repository).await));
116                Ok("The journal is exporting. Your download should begin shortly.".to_string())
117            }
118            Self::Import => {
119                (app_meta.event_dispatcher)(Event::Import);
120                Ok("The file upload popup should appear momentarily. Please select a compatible JSON file, such as that produced by the `export` command.".to_string())
121            }
122            Self::Load { name } => {
123                let record = app_meta.repository.get_by_name(&name).await;
124                let mut save_command = None;
125                let output = if let Ok(Record { thing, status }) = record {
126                    if status == RecordStatus::Unsaved {
127                        save_command = Some(CommandAlias::literal(
128                            "save",
129                            format!("save {}", name),
130                            StorageCommand::Save { name }.into(),
131                        ));
132
133                        Ok(format!(
134                            "{}\n\n_{} has not yet been saved. Use ~save~ to save {} to your `journal`._",
135                            thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default()),
136                            thing.name(),
137                            thing.gender().them(),
138                        ))
139                    } else {
140                        Ok(format!("{}", thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default())))
141                    }
142                } else {
143                    Err(format!("No matches for \"{}\"", name))
144                };
145
146                if let Some(save_command) = save_command {
147                    app_meta.command_aliases.insert(save_command);
148                }
149
150                output
151            }
152            Self::Redo => match app_meta.repository.redo().await {
153                Some(Ok(option_record)) => {
154                    let action = app_meta
155                        .repository
156                        .undo_history()
157                        .next()
158                        .unwrap()
159                        .display_undo();
160
161                    match option_record {
162                        Some(Record { thing, status }) if status != RecordStatus::Deleted => Ok(format!(
163                            "{}\n\n_Successfully redid {}. Use `undo` to reverse this._",
164                            thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default()),
165                            action,
166                        )),
167                        _ => Ok(format!(
168                            "Successfully redid {}. Use `undo` to reverse this.",
169                            action,
170                        )),
171                    }
172                }
173                Some(Err(_)) => Err("Failed to redo.".to_string()),
174                None => Err("Nothing to redo.".to_string()),
175            },
176            Self::Undo => match app_meta.repository.undo().await {
177                Some(Ok(option_record)) => {
178                    let action = app_meta.repository.get_redo().unwrap().display_redo();
179
180                    if let Some(Record { thing, .. }) = option_record {
181                        Ok(format!(
182                            "{}\n\n_Successfully undid {}. Use `redo` to reverse this._",
183                            thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default()),
184                            action,
185                        ))
186                    } else {
187                        Ok(format!(
188                            "Successfully undid {}. Use `redo` to reverse this.",
189                            action,
190                        ))
191                    }
192                }
193                Some(Err(_)) => Err("Failed to undo.".to_string()),
194                None => Err("Nothing to undo.".to_string()),
195            },
196        }
197        .map(|mut s| {
198            if !app_meta.repository.data_store_enabled() {
199                s.push_str("\n\n! Your browser does not support local storage. Any changes will not persist beyond this session.");
200            }
201            s
202        })
203    }
204}
205
206#[async_trait(?Send)]
207impl ContextAwareParse for StorageCommand {
208    async fn parse_input(input: &str, app_meta: &AppMeta) -> CommandMatches<Self> {
209        let mut matches = CommandMatches::default();
210
211        if app_meta.repository.get_by_name(input).await.is_ok() {
212            matches.push_fuzzy(Self::Load {
213                name: input.to_string(),
214            });
215        }
216
217        if let Some(name) = input.strip_prefix_ci("delete ") {
218            matches.push_canonical(Self::Delete {
219                name: name.to_string(),
220            });
221        } else if let Some(name) = input.strip_prefix_ci("load ") {
222            matches.push_canonical(Self::Load {
223                name: name.to_string(),
224            });
225        } else if let Some(name) = input.strip_prefix_ci("save ") {
226            matches.push_canonical(Self::Save {
227                name: name.to_string(),
228            });
229        } else if input.eq_ci("journal") {
230            matches.push_canonical(Self::Journal);
231        } else if input.eq_ci("undo") {
232            matches.push_canonical(Self::Undo);
233        } else if input.eq_ci("redo") {
234            matches.push_canonical(Self::Redo);
235        } else if input.eq_ci("export") {
236            matches.push_canonical(Self::Export);
237        } else if input.eq_ci("import") {
238            matches.push_canonical(Self::Import);
239        }
240
241        matches
242    }
243}
244
245#[async_trait(?Send)]
246impl Autocomplete for StorageCommand {
247    async fn autocomplete(input: &str, app_meta: &AppMeta) -> Vec<AutocompleteSuggestion> {
248        let mut suggestions: Vec<AutocompleteSuggestion> = [
249            ("delete", "delete [name]", "remove an entry from journal"),
250            ("export", "export", "export the journal contents"),
251            ("import", "import", "import a journal backup"),
252            ("journal", "journal", "list journal contents"),
253            ("load", "load [name]", "load an entry"),
254            ("save", "save [name]", "save an entry to journal"),
255        ]
256        .into_iter()
257        .filter(|(s, _, _)| s.starts_with_ci(input))
258        .map(|(_, term, summary)| AutocompleteSuggestion::new(term, summary))
259        .chain(
260            ["undo"]
261                .into_iter()
262                .filter(|term| term.starts_with_ci(input))
263                .map(|term| {
264                    if let Some(change) = app_meta.repository.undo_history().next() {
265                        AutocompleteSuggestion::new(term, format!("undo {}", change.display_undo()))
266                    } else {
267                        AutocompleteSuggestion::new(term, "Nothing to undo.")
268                    }
269                }),
270        )
271        .chain(
272            ["redo"]
273                .into_iter()
274                .filter(|term| term.starts_with_ci(input))
275                .map(|term| {
276                    if let Some(change) = app_meta.repository.get_redo() {
277                        AutocompleteSuggestion::new(term, format!("redo {}", change.display_redo()))
278                    } else {
279                        AutocompleteSuggestion::new(term, "Nothing to redo.")
280                    }
281                }),
282        )
283        .collect();
284
285        let ((full_matches, partial_matches), prefix) = if let Some((prefix, name)) =
286            ["delete ", "load ", "save "]
287                .iter()
288                .find_map(|prefix| input.strip_prefix_ci(prefix).map(|name| (*prefix, name)))
289        {
290            (
291                join!(
292                    app_meta.repository.get_by_name_start(input),
293                    app_meta.repository.get_by_name_start(name),
294                ),
295                prefix,
296            )
297        } else {
298            (
299                (
300                    app_meta.repository.get_by_name_start(input).await,
301                    Ok(Vec::new()),
302                ),
303                "",
304            )
305        };
306
307        for (record, prefix) in full_matches
308            .unwrap_or_default()
309            .iter()
310            .zip(repeat(""))
311            .chain(
312                partial_matches
313                    .unwrap_or_default()
314                    .iter()
315                    .zip(repeat(prefix)),
316            )
317        {
318            if (prefix == "save " && record.is_saved())
319                || (prefix == "delete " && record.is_unsaved())
320            {
321                continue;
322            }
323
324            let thing = &record.thing;
325
326            let suggestion_term = format!("{}{}", prefix, thing.name());
327            let matches = Self::parse_input(&suggestion_term, app_meta).await;
328
329            if let Some(command) = matches.take_best_match() {
330                suggestions.push(AutocompleteSuggestion::new(
331                    suggestion_term,
332                    match command {
333                        Self::Delete { .. } => format!("remove {} from journal", thing.as_str()),
334                        Self::Save { .. } => format!("save {} to journal", thing.as_str()),
335                        Self::Load { .. } => {
336                            if record.is_saved() {
337                                format!("{}", thing.display_description())
338                            } else {
339                                format!("{} (unsaved)", thing.display_description())
340                            }
341                        }
342                        _ => unreachable!(),
343                    },
344                ))
345            }
346        }
347
348        suggestions
349    }
350}
351
352impl fmt::Display for StorageCommand {
353    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
354        match self {
355            Self::Delete { name } => write!(f, "delete {}", name),
356            Self::Export => write!(f, "export"),
357            Self::Import => write!(f, "import"),
358            Self::Journal => write!(f, "journal"),
359            Self::Load { name } => write!(f, "load {}", name),
360            Self::Redo => write!(f, "redo"),
361            Self::Save { name } => write!(f, "save {}", name),
362            Self::Undo => write!(f, "undo"),
363        }
364    }
365}
366
367#[cfg(test)]
368mod test {
369    use super::*;
370    use crate::test_utils as test;
371
372    #[tokio::test]
373    async fn parse_input_test() {
374        let app_meta = test::app_meta();
375
376        assert_eq!(
377            CommandMatches::default(),
378            StorageCommand::parse_input("Odysseus", &app_meta).await,
379        );
380
381        assert_eq!(
382            CommandMatches::new_canonical(StorageCommand::Delete {
383                name: "Odysseus".to_string(),
384            }),
385            StorageCommand::parse_input("delete Odysseus", &app_meta).await,
386        );
387
388        assert_eq!(
389            StorageCommand::parse_input("delete Odysseus", &app_meta).await,
390            StorageCommand::parse_input("DELETE Odysseus", &app_meta).await,
391        );
392
393        assert_eq!(
394            CommandMatches::new_canonical(StorageCommand::Save {
395                name: "Odysseus".to_string(),
396            }),
397            StorageCommand::parse_input("save Odysseus", &app_meta).await,
398        );
399
400        assert_eq!(
401            StorageCommand::parse_input("save Odysseus", &app_meta).await,
402            StorageCommand::parse_input("SAVE Odysseus", &app_meta).await,
403        );
404
405        assert_eq!(
406            CommandMatches::new_canonical(StorageCommand::Load {
407                name: "Odysseus".to_string()
408            }),
409            StorageCommand::parse_input("load Odysseus", &app_meta).await,
410        );
411
412        assert_eq!(
413            StorageCommand::parse_input("load Odysseus", &app_meta).await,
414            StorageCommand::parse_input("LOAD Odysseus", &app_meta).await,
415        );
416
417        assert_eq!(
418            CommandMatches::new_canonical(StorageCommand::Journal),
419            StorageCommand::parse_input("journal", &app_meta).await,
420        );
421
422        assert_eq!(
423            CommandMatches::new_canonical(StorageCommand::Journal),
424            StorageCommand::parse_input("JOURNAL", &app_meta).await,
425        );
426
427        assert_eq!(
428            CommandMatches::default(),
429            StorageCommand::parse_input("potato", &app_meta).await,
430        );
431    }
432
433    #[tokio::test]
434    async fn autocomplete_test() {
435        let mut app_meta = test::app_meta::with_test_data().await;
436
437        assert!(StorageCommand::autocomplete("delete z", &app_meta)
438            .await
439            .is_empty());
440
441        test::assert_autocomplete_eq!(
442            [
443                ("save Odysseus", "save character to journal"),
444                ("save Polyphemus", "save character to journal"),
445                ("save Pylos", "save place to journal"),
446            ],
447            StorageCommand::autocomplete("save ", &app_meta).await,
448        );
449
450        assert_eq!(
451            StorageCommand::autocomplete("save ", &app_meta).await,
452            StorageCommand::autocomplete("SAve ", &app_meta).await,
453        );
454
455        test::assert_autocomplete_eq!(
456            [
457                ("load Penelope", "middle-aged human, she/her"),
458                ("load Phoenicia", "territory"),
459                ("load Polyphemus", "adult half-orc, he/him (unsaved)"),
460                ("load Pylos", "city (unsaved)"),
461            ],
462            StorageCommand::autocomplete("load P", &app_meta).await,
463        );
464
465        assert_eq!(
466            StorageCommand::autocomplete("load P", &app_meta).await,
467            StorageCommand::autocomplete("LOad p", &app_meta).await,
468        );
469
470        test::assert_autocomplete_eq!(
471            [("delete [name]", "remove an entry from journal")],
472            StorageCommand::autocomplete("delete", &app_meta).await,
473        );
474
475        test::assert_autocomplete_eq!(
476            [("delete [name]", "remove an entry from journal")],
477            StorageCommand::autocomplete("DELete", &app_meta).await,
478        );
479
480        test::assert_autocomplete_eq!(
481            [("load [name]", "load an entry")],
482            StorageCommand::autocomplete("load", &app_meta).await,
483        );
484
485        test::assert_autocomplete_eq!(
486            [("load [name]", "load an entry")],
487            StorageCommand::autocomplete("LOad", &app_meta).await,
488        );
489
490        test::assert_autocomplete_eq!(
491            [("save [name]", "save an entry to journal")],
492            StorageCommand::autocomplete("sa", &app_meta).await,
493        );
494
495        test::assert_autocomplete_eq!(
496            [("save [name]", "save an entry to journal")],
497            StorageCommand::autocomplete("SA", &app_meta).await,
498        );
499
500        test::assert_autocomplete_eq!(
501            [("journal", "list journal contents")],
502            StorageCommand::autocomplete("j", &app_meta).await,
503        );
504
505        test::assert_autocomplete_eq!(
506            [("journal", "list journal contents")],
507            StorageCommand::autocomplete("J", &app_meta).await,
508        );
509
510        test::assert_autocomplete_eq!(
511            [("export", "export the journal contents")],
512            StorageCommand::autocomplete("e", &app_meta).await,
513        );
514
515        test::assert_autocomplete_eq!(
516            [("export", "export the journal contents")],
517            StorageCommand::autocomplete("E", &app_meta).await,
518        );
519
520        test::assert_autocomplete_eq!(
521            [("import", "import a journal backup")],
522            StorageCommand::autocomplete("im", &app_meta).await,
523        );
524
525        test::assert_autocomplete_eq!(
526            [("import", "import a journal backup")],
527            StorageCommand::autocomplete("IM", &app_meta).await,
528        );
529
530        test::assert_autocomplete_eq!(
531            [
532                ("Penelope", "middle-aged human, she/her"),
533                ("Phoenicia", "territory"),
534                ("Polyphemus", "adult half-orc, he/him (unsaved)"),
535                ("Pylos", "city (unsaved)"),
536            ],
537            StorageCommand::autocomplete("p", &app_meta).await,
538        );
539
540        assert_eq!(
541            StorageCommand::autocomplete("p", &app_meta).await,
542            StorageCommand::autocomplete("P", &app_meta).await,
543        );
544
545        test::assert_autocomplete_eq!(
546            [("Odysseus", "middle-aged human, he/him (unsaved)")],
547            StorageCommand::autocomplete("Odysseus", &app_meta).await,
548        );
549
550        test::assert_autocomplete_eq!(
551            [("Odysseus", "middle-aged human, he/him (unsaved)")],
552            StorageCommand::autocomplete("oDYSSEUS", &app_meta).await,
553        );
554
555        test::assert_autocomplete_eq!(
556            [("redo", "Nothing to redo.")],
557            StorageCommand::autocomplete("redo", &app_meta).await,
558        );
559
560        // undo Polyphemus
561        app_meta.repository.undo().await.unwrap().unwrap();
562        // undo Pylos
563        app_meta.repository.undo().await.unwrap().unwrap();
564
565        test::assert_autocomplete_eq!(
566            [("undo", "undo creating Odysseus")],
567            StorageCommand::autocomplete("undo", &app_meta).await,
568        );
569
570        app_meta.repository.undo().await.unwrap().unwrap();
571
572        test::assert_autocomplete_eq!(
573            [("redo", "redo creating Odysseus")],
574            StorageCommand::autocomplete("redo", &app_meta).await,
575        );
576
577        test::assert_autocomplete_eq!(
578            [("undo", "Nothing to undo.")],
579            StorageCommand::autocomplete("undo", &app_meta).await,
580        );
581    }
582
583    #[tokio::test]
584    async fn display_test() {
585        let app_meta = test::app_meta();
586
587        for command in [
588            StorageCommand::Delete {
589                name: "Odysseus".to_string(),
590            },
591            StorageCommand::Save {
592                name: "Odysseus".to_string(),
593            },
594            StorageCommand::Export,
595            StorageCommand::Import,
596            StorageCommand::Journal,
597            StorageCommand::Load {
598                name: "Odysseus".to_string(),
599            },
600        ] {
601            let command_string = command.to_string();
602            assert_ne!("", command_string);
603            assert_eq!(
604                CommandMatches::new_canonical(command),
605                StorageCommand::parse_input(&command_string, &app_meta).await,
606                "{}",
607                command_string,
608            );
609        }
610    }
611}