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 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 app_meta.repository.undo().await.unwrap().unwrap();
562 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}