use super::backup::export;
use super::{Change, Record, RecordStatus, RepositoryError};
use crate::app::{
AppMeta, Autocomplete, AutocompleteSuggestion, CommandAlias, CommandMatches, ContextAwareParse,
Event, Runnable,
};
use crate::utils::CaseInsensitiveStr;
use crate::world::thing::{Thing, ThingData};
use async_trait::async_trait;
use futures::join;
use std::cmp::Ordering;
use std::fmt;
use std::iter::repeat;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StorageCommand {
Delete { name: String },
Export,
Import,
Journal,
Load { name: String },
Redo,
Save { name: String },
Undo,
}
#[async_trait(?Send)]
impl Runnable for StorageCommand {
async fn run(self, _input: &str, app_meta: &mut AppMeta) -> Result<String, String> {
match self {
Self::Journal => {
let mut output = "# Journal".to_string();
let [mut npcs, mut places] = [Vec::new(), Vec::new()];
let record_count = app_meta
.repository
.journal()
.await
.map_err(|_| "Couldn't access the journal.".to_string())?
.into_iter()
.map(|thing| match &thing.data {
ThingData::Npc(_) => npcs.push(thing),
ThingData::Place(_) => places.push(thing),
})
.count();
let mut add_section = |title: &str, mut things: Vec<Thing>| {
if !things.is_empty() {
output.push_str("\n\n## ");
output.push_str(title);
things.sort_unstable_by(|a, b| {
if let (Some(a), Some(b)) = (a.name().value(), b.name().value()) {
a.cmp_ci(b)
} else {
Ordering::Equal
}
});
things.into_iter().enumerate().for_each(|(i, thing)| {
if i > 0 {
output.push('\\');
}
output.push_str(&format!("\n{}", thing.display_summary()));
});
}
};
add_section("NPCs", npcs);
add_section("Places", places);
if record_count == 0 {
output.push_str("\n\n*Your journal is currently empty.*");
} else {
output.push_str("\n\n*To export the contents of your journal, use `export`.*");
}
Ok(output)
}
Self::Delete { name } => {
let result = match app_meta.repository.get_by_name(&name).await {
Ok(Record { thing, .. }) => {
app_meta
.repository
.modify(Change::Delete { uuid: thing.uuid, name: thing.name().to_string() })
.await
.map_err(|(_, e)| e)
}
Err(e) => Err(e),
};
match result {
Ok(Some(Record { thing, .. })) => Ok(format!("{} was successfully deleted. Use `undo` to reverse this.", thing.name())),
Ok(None) | Err(RepositoryError::NotFound) => Err(format!("There is no entity named \"{}\".", name)),
Err(_) => Err(format!("Couldn't delete `{}`.", name)),
}
}
Self::Save { name } => {
app_meta
.repository
.modify(Change::Save { name: name.clone(), uuid: None })
.await
.map(|_| format!("{} was successfully saved. Use `undo` to reverse this.", name))
.map_err(|(_, e)| {
if e == RepositoryError::NotFound {
format!("There is no entity named \"{}\".", name)
} else {
format!("Couldn't save `{}`.", name)
}
})
}
Self::Export => {
(app_meta.event_dispatcher)(Event::Export(export(&app_meta.repository).await));
Ok("The journal is exporting. Your download should begin shortly.".to_string())
}
Self::Import => {
(app_meta.event_dispatcher)(Event::Import);
Ok("The file upload popup should appear momentarily. Please select a compatible JSON file, such as that produced by the `export` command.".to_string())
}
Self::Load { name } => {
let record = app_meta.repository.get_by_name(&name).await;
let mut save_command = None;
let output = if let Ok(Record { thing, status }) = record {
if status == RecordStatus::Unsaved {
save_command = Some(CommandAlias::literal(
"save",
format!("save {}", name),
StorageCommand::Save { name }.into(),
));
Ok(format!(
"{}\n\n_{} has not yet been saved. Use ~save~ to save {} to your `journal`._",
thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default()),
thing.name(),
thing.gender().them(),
))
} else {
Ok(format!("{}", thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default())))
}
} else {
Err(format!("No matches for \"{}\"", name))
};
if let Some(save_command) = save_command {
app_meta.command_aliases.insert(save_command);
}
output
}
Self::Redo => match app_meta.repository.redo().await {
Some(Ok(option_record)) => {
let action = app_meta
.repository
.undo_history()
.next()
.unwrap()
.display_undo();
match option_record {
Some(Record { thing, status }) if status != RecordStatus::Deleted => Ok(format!(
"{}\n\n_Successfully redid {}. Use `undo` to reverse this._",
thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default()),
action,
)),
_ => Ok(format!(
"Successfully redid {}. Use `undo` to reverse this.",
action,
)),
}
}
Some(Err(_)) => Err("Failed to redo.".to_string()),
None => Err("Nothing to redo.".to_string()),
},
Self::Undo => match app_meta.repository.undo().await {
Some(Ok(option_record)) => {
let action = app_meta.repository.get_redo().unwrap().display_redo();
if let Some(Record { thing, .. }) = option_record {
Ok(format!(
"{}\n\n_Successfully undid {}. Use `redo` to reverse this._",
thing.display_details(app_meta.repository.load_relations(&thing).await.unwrap_or_default()),
action,
))
} else {
Ok(format!(
"Successfully undid {}. Use `redo` to reverse this.",
action,
))
}
}
Some(Err(_)) => Err("Failed to undo.".to_string()),
None => Err("Nothing to undo.".to_string()),
},
}
.map(|mut s| {
if !app_meta.repository.data_store_enabled() {
s.push_str("\n\n! Your browser does not support local storage. Any changes will not persist beyond this session.");
}
s
})
}
}
#[async_trait(?Send)]
impl ContextAwareParse for StorageCommand {
async fn parse_input(input: &str, app_meta: &AppMeta) -> CommandMatches<Self> {
let mut matches = CommandMatches::default();
if app_meta.repository.get_by_name(input).await.is_ok() {
matches.push_fuzzy(Self::Load {
name: input.to_string(),
});
}
if let Some(name) = input.strip_prefix_ci("delete ") {
matches.push_canonical(Self::Delete {
name: name.to_string(),
});
} else if let Some(name) = input.strip_prefix_ci("load ") {
matches.push_canonical(Self::Load {
name: name.to_string(),
});
} else if let Some(name) = input.strip_prefix_ci("save ") {
matches.push_canonical(Self::Save {
name: name.to_string(),
});
} else if input.eq_ci("journal") {
matches.push_canonical(Self::Journal);
} else if input.eq_ci("undo") {
matches.push_canonical(Self::Undo);
} else if input.eq_ci("redo") {
matches.push_canonical(Self::Redo);
} else if input.eq_ci("export") {
matches.push_canonical(Self::Export);
} else if input.eq_ci("import") {
matches.push_canonical(Self::Import);
}
matches
}
}
#[async_trait(?Send)]
impl Autocomplete for StorageCommand {
async fn autocomplete(input: &str, app_meta: &AppMeta) -> Vec<AutocompleteSuggestion> {
let mut suggestions: Vec<AutocompleteSuggestion> = [
("delete", "delete [name]", "remove an entry from journal"),
("export", "export", "export the journal contents"),
("import", "import", "import a journal backup"),
("journal", "journal", "list journal contents"),
("load", "load [name]", "load an entry"),
("save", "save [name]", "save an entry to journal"),
]
.into_iter()
.filter(|(s, _, _)| s.starts_with_ci(input))
.map(|(_, term, summary)| AutocompleteSuggestion::new(term, summary))
.chain(
["undo"]
.into_iter()
.filter(|term| term.starts_with_ci(input))
.map(|term| {
if let Some(change) = app_meta.repository.undo_history().next() {
AutocompleteSuggestion::new(term, format!("undo {}", change.display_undo()))
} else {
AutocompleteSuggestion::new(term, "Nothing to undo.")
}
}),
)
.chain(
["redo"]
.into_iter()
.filter(|term| term.starts_with_ci(input))
.map(|term| {
if let Some(change) = app_meta.repository.get_redo() {
AutocompleteSuggestion::new(term, format!("redo {}", change.display_redo()))
} else {
AutocompleteSuggestion::new(term, "Nothing to redo.")
}
}),
)
.collect();
let ((full_matches, partial_matches), prefix) = if let Some((prefix, name)) =
["delete ", "load ", "save "]
.iter()
.find_map(|prefix| input.strip_prefix_ci(prefix).map(|name| (*prefix, name)))
{
(
join!(
app_meta.repository.get_by_name_start(input, Some(10)),
app_meta.repository.get_by_name_start(name, Some(10)),
),
prefix,
)
} else {
(
(
app_meta.repository.get_by_name_start(input, Some(10)).await,
Ok(Vec::new()),
),
"",
)
};
for (record, prefix) in full_matches
.unwrap_or_default()
.iter()
.zip(repeat(""))
.chain(
partial_matches
.unwrap_or_default()
.iter()
.zip(repeat(prefix)),
)
{
if (prefix == "save " && record.is_saved())
|| (prefix == "delete " && record.is_unsaved())
{
continue;
}
let thing = &record.thing;
let suggestion_term = format!("{}{}", prefix, thing.name());
let matches = Self::parse_input(&suggestion_term, app_meta).await;
if let Some(command) = matches.take_best_match() {
suggestions.push(AutocompleteSuggestion::new(
suggestion_term,
match command {
Self::Delete { .. } => format!("remove {} from journal", thing.as_str()),
Self::Save { .. } => format!("save {} to journal", thing.as_str()),
Self::Load { .. } => {
if record.is_saved() {
format!("{}", thing.display_description())
} else {
format!("{} (unsaved)", thing.display_description())
}
}
_ => unreachable!(),
},
))
}
}
suggestions
}
}
impl fmt::Display for StorageCommand {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
match self {
Self::Delete { name } => write!(f, "delete {}", name),
Self::Export => write!(f, "export"),
Self::Import => write!(f, "import"),
Self::Journal => write!(f, "journal"),
Self::Load { name } => write!(f, "load {}", name),
Self::Redo => write!(f, "redo"),
Self::Save { name } => write!(f, "save {}", name),
Self::Undo => write!(f, "undo"),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::app::assert_autocomplete;
use crate::storage::MemoryDataStore;
use crate::world::npc::{Age, Gender, NpcData, Species};
use crate::world::place::{PlaceData, PlaceType};
use crate::Event;
use tokio_test::block_on;
#[test]
fn parse_input_test() {
let app_meta = app_meta();
assert_eq!(
CommandMatches::default(),
block_on(StorageCommand::parse_input("Gandalf the Grey", &app_meta)),
);
assert_eq!(
CommandMatches::new_canonical(StorageCommand::Delete {
name: "Gandalf the Grey".to_string(),
}),
block_on(StorageCommand::parse_input(
"delete Gandalf the Grey",
&app_meta
)),
);
assert_eq!(
block_on(StorageCommand::parse_input(
"delete Gandalf the Grey",
&app_meta
)),
block_on(StorageCommand::parse_input(
"DELETE Gandalf the Grey",
&app_meta
)),
);
assert_eq!(
CommandMatches::new_canonical(StorageCommand::Save {
name: "Gandalf the Grey".to_string(),
}),
block_on(StorageCommand::parse_input(
"save Gandalf the Grey",
&app_meta
)),
);
assert_eq!(
block_on(StorageCommand::parse_input(
"save Gandalf the Grey",
&app_meta
)),
block_on(StorageCommand::parse_input(
"SAVE Gandalf the Grey",
&app_meta
)),
);
assert_eq!(
CommandMatches::new_canonical(StorageCommand::Load {
name: "Gandalf the Grey".to_string()
}),
block_on(StorageCommand::parse_input(
"load Gandalf the Grey",
&app_meta
)),
);
assert_eq!(
block_on(StorageCommand::parse_input(
"load Gandalf the Grey",
&app_meta
)),
block_on(StorageCommand::parse_input(
"LOAD Gandalf the Grey",
&app_meta
)),
);
assert_eq!(
CommandMatches::new_canonical(StorageCommand::Journal),
block_on(StorageCommand::parse_input("journal", &app_meta)),
);
assert_eq!(
CommandMatches::new_canonical(StorageCommand::Journal),
block_on(StorageCommand::parse_input("JOURNAL", &app_meta)),
);
assert_eq!(
CommandMatches::default(),
block_on(StorageCommand::parse_input("potato", &app_meta)),
);
}
#[test]
fn autocomplete_test() {
let mut app_meta = app_meta();
block_on(
app_meta.repository.modify(Change::Create {
thing_data: NpcData {
name: "Potato Johnson".into(),
species: Species::Elf.into(),
gender: Gender::NonBinaryThey.into(),
age: Age::Adult.into(),
..Default::default()
}
.into(),
uuid: None,
}),
)
.unwrap();
block_on(
app_meta.repository.modify(Change::Create {
thing_data: NpcData {
name: "potato can be lowercase".into(),
..Default::default()
}
.into(),
uuid: None,
}),
)
.unwrap();
block_on(
app_meta.repository.modify(Change::Create {
thing_data: PlaceData {
name: "Potato & Meat".into(),
subtype: "inn".parse::<PlaceType>().ok().into(),
..Default::default()
}
.into(),
uuid: None,
}),
)
.unwrap();
assert!(block_on(StorageCommand::autocomplete("delete P", &app_meta)).is_empty());
assert_autocomplete(
&[
("save Potato Johnson", "save character to journal"),
("save potato can be lowercase", "save character to journal"),
("save Potato & Meat", "save place to journal"),
][..],
block_on(StorageCommand::autocomplete("save ", &app_meta)),
);
assert_eq!(
block_on(StorageCommand::autocomplete("save ", &app_meta)),
block_on(StorageCommand::autocomplete("SAve ", &app_meta)),
);
assert_autocomplete(
&[
("load Potato Johnson", "adult elf, they/them (unsaved)"),
("load Potato & Meat", "inn (unsaved)"),
("load potato can be lowercase", "person (unsaved)"),
][..],
block_on(StorageCommand::autocomplete("load P", &app_meta)),
);
assert_eq!(
block_on(StorageCommand::autocomplete("load P", &app_meta)),
block_on(StorageCommand::autocomplete("LOad p", &app_meta)),
);
assert_autocomplete(
&[("delete [name]", "remove an entry from journal")][..],
block_on(StorageCommand::autocomplete("delete", &app_meta)),
);
assert_autocomplete(
&[("delete [name]", "remove an entry from journal")][..],
block_on(StorageCommand::autocomplete("DELete", &app_meta)),
);
assert_autocomplete(
&[("load [name]", "load an entry")][..],
block_on(StorageCommand::autocomplete("load", &app_meta)),
);
assert_autocomplete(
&[("load [name]", "load an entry")][..],
block_on(StorageCommand::autocomplete("LOad", &app_meta)),
);
assert_autocomplete(
&[("save [name]", "save an entry to journal")][..],
block_on(StorageCommand::autocomplete("s", &app_meta)),
);
assert_autocomplete(
&[("save [name]", "save an entry to journal")][..],
block_on(StorageCommand::autocomplete("S", &app_meta)),
);
assert_autocomplete(
&[("journal", "list journal contents")][..],
block_on(StorageCommand::autocomplete("j", &app_meta)),
);
assert_autocomplete(
&[("journal", "list journal contents")][..],
block_on(StorageCommand::autocomplete("J", &app_meta)),
);
assert_autocomplete(
&[("export", "export the journal contents")][..],
block_on(StorageCommand::autocomplete("e", &app_meta)),
);
assert_autocomplete(
&[("export", "export the journal contents")][..],
block_on(StorageCommand::autocomplete("E", &app_meta)),
);
assert_autocomplete(
&[("import", "import a journal backup")][..],
block_on(StorageCommand::autocomplete("i", &app_meta)),
);
assert_autocomplete(
&[("import", "import a journal backup")][..],
block_on(StorageCommand::autocomplete("I", &app_meta)),
);
assert_autocomplete(
&[
("Potato & Meat", "inn (unsaved)"),
("Potato Johnson", "adult elf, they/them (unsaved)"),
("potato can be lowercase", "person (unsaved)"),
][..],
block_on(StorageCommand::autocomplete("p", &app_meta)),
);
assert_eq!(
block_on(StorageCommand::autocomplete("p", &app_meta)),
block_on(StorageCommand::autocomplete("P", &app_meta)),
);
assert_autocomplete(
&[("Potato Johnson", "adult elf, they/them (unsaved)")][..],
block_on(StorageCommand::autocomplete("Potato Johnson", &app_meta)),
);
assert_autocomplete(
&[("Potato Johnson", "adult elf, they/them (unsaved)")][..],
block_on(StorageCommand::autocomplete("pOTATO jOHNSON", &app_meta)),
);
assert_autocomplete(
&[("undo", "undo creating Potato & Meat")][..],
block_on(StorageCommand::autocomplete("undo", &app_meta)),
);
assert_autocomplete(
&[("redo", "Nothing to redo.")][..],
block_on(StorageCommand::autocomplete("redo", &app_meta)),
);
block_on(app_meta.repository.undo()).unwrap().unwrap();
assert_autocomplete(
&[("redo", "redo creating Potato & Meat")][..],
block_on(StorageCommand::autocomplete("redo", &app_meta)),
);
assert_autocomplete(
&[("undo", "Nothing to undo.")][..],
block_on(StorageCommand::autocomplete(
"undo",
&AppMeta::new(MemoryDataStore::default(), &event_dispatcher),
)),
);
}
#[test]
fn display_test() {
let app_meta = app_meta();
[
StorageCommand::Delete {
name: "Potato Johnson".to_string(),
},
StorageCommand::Save {
name: "Potato Johnson".to_string(),
},
StorageCommand::Export,
StorageCommand::Import,
StorageCommand::Journal,
StorageCommand::Load {
name: "Potato Johnson".to_string(),
},
]
.into_iter()
.for_each(|command| {
let command_string = command.to_string();
assert_ne!("", command_string);
assert_eq!(
CommandMatches::new_canonical(command),
block_on(StorageCommand::parse_input(&command_string, &app_meta)),
"{}",
command_string,
);
});
}
fn event_dispatcher(_event: Event) {}
fn app_meta() -> AppMeta {
AppMeta::new(MemoryDataStore::default(), &event_dispatcher)
}
}