initiative_core/app/command/
app.rs

1use crate::app::{
2    AppMeta, Autocomplete, AutocompleteSuggestion, CommandMatches, ContextAwareParse, Runnable,
3};
4use crate::utils::CaseInsensitiveStr;
5use async_trait::async_trait;
6use caith::Roller;
7use initiative_macros::changelog;
8use std::fmt;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum AppCommand {
12    Changelog,
13    Debug,
14    Help,
15    Roll(String),
16}
17
18#[async_trait(?Send)]
19impl Runnable for AppCommand {
20    async fn run(self, _input: &str, app_meta: &mut AppMeta) -> Result<String, String> {
21        Ok(match self {
22            Self::Debug => format!(
23                "{:?}\n\n{:?}",
24                app_meta,
25                app_meta.repository.journal().await,
26            ),
27            Self::Changelog => changelog!().to_string(),
28            Self::Help => include_str!("../../../../data/help.md")
29                .trim_end()
30                .to_string(),
31            Self::Roll(s) => Roller::new(&s)
32                .ok()
33                .and_then(|r| r.roll_with(&mut app_meta.rng).ok())
34                .map(|result| {
35                    result
36                        .to_string()
37                        .trim_end()
38                        .replace('\n', "\\\n")
39                        .replace('`', "")
40                })
41                .ok_or_else(|| {
42                    format!(
43                        "\"{}\" is not a valid dice formula. See `help` for some examples.",
44                        s
45                    )
46                })?,
47        })
48    }
49}
50
51#[async_trait(?Send)]
52impl ContextAwareParse for AppCommand {
53    async fn parse_input(input: &str, _app_meta: &AppMeta) -> CommandMatches<Self> {
54        if input.eq_ci("changelog") {
55            CommandMatches::new_canonical(Self::Changelog)
56        } else if input.eq_ci("debug") {
57            CommandMatches::new_canonical(Self::Debug)
58        } else if input.eq_ci("help") {
59            CommandMatches::new_canonical(Self::Help)
60        } else if input.starts_with_ci("roll ") {
61            CommandMatches::new_canonical(Self::Roll(input[5..].to_string()))
62        } else if !input.chars().all(|c| c.is_ascii_digit())
63            && Roller::new(input).is_ok_and(|r| r.roll().is_ok())
64        {
65            CommandMatches::new_fuzzy(Self::Roll(input.to_string()))
66        } else {
67            CommandMatches::default()
68        }
69    }
70}
71
72#[async_trait(?Send)]
73impl Autocomplete for AppCommand {
74    async fn autocomplete(input: &str, _app_meta: &AppMeta) -> Vec<AutocompleteSuggestion> {
75        if input.is_empty() {
76            return Vec::new();
77        }
78
79        [
80            AutocompleteSuggestion::new("changelog", "show latest updates"),
81            AutocompleteSuggestion::new("help", "how to use initiative.sh"),
82        ]
83        .into_iter()
84        .filter(|suggestion| suggestion.term.starts_with_ci(input))
85        .chain(
86            ["roll"]
87                .into_iter()
88                .filter(|s| s.starts_with_ci(input))
89                .map(|_| AutocompleteSuggestion::new("roll [dice]", "roll eg. 8d6 or d20+3")),
90        )
91        .collect()
92    }
93}
94
95impl fmt::Display for AppCommand {
96    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
97        match self {
98            Self::Changelog => write!(f, "changelog"),
99            Self::Debug => write!(f, "debug"),
100            Self::Help => write!(f, "help"),
101            Self::Roll(s) => write!(f, "roll {}", s),
102        }
103    }
104}
105
106#[cfg(test)]
107mod test {
108    use super::*;
109    use crate::test_utils as test;
110
111    #[tokio::test]
112    async fn parse_input_test() {
113        let app_meta = test::app_meta();
114
115        assert_eq!(
116            CommandMatches::new_canonical(AppCommand::Debug),
117            AppCommand::parse_input("debug", &app_meta).await,
118        );
119
120        assert_eq!(
121            CommandMatches::new_canonical(AppCommand::Roll("d20".to_string())),
122            AppCommand::parse_input("roll d20", &app_meta).await,
123        );
124
125        assert_eq!(
126            CommandMatches::new_fuzzy(AppCommand::Roll("d20".to_string())),
127            AppCommand::parse_input("d20", &app_meta).await,
128        );
129
130        assert_eq!(
131            CommandMatches::default(),
132            AppCommand::parse_input("potato", &app_meta).await,
133        );
134    }
135
136    #[tokio::test]
137    async fn autocomplete_test() {
138        let app_meta = test::app_meta();
139
140        for (term, summary) in [
141            ("changelog", "show latest updates"),
142            ("help", "how to use initiative.sh"),
143        ] {
144            test::assert_autocomplete_eq!(
145                [(term, summary)],
146                AppCommand::autocomplete(term, &app_meta).await,
147            );
148
149            test::assert_autocomplete_eq!(
150                [(term, summary)],
151                AppCommand::autocomplete(&term.to_uppercase(), &app_meta).await,
152            );
153        }
154
155        test::assert_autocomplete_eq!(
156            [("roll [dice]", "roll eg. 8d6 or d20+3")],
157            AppCommand::autocomplete("roll", &app_meta).await,
158        );
159
160        // Debug should be excluded from the autocomplete results.
161        assert_eq!(
162            Vec::<AutocompleteSuggestion>::new(),
163            AppCommand::autocomplete("debug", &app_meta).await,
164        );
165    }
166
167    #[tokio::test]
168    async fn display_test() {
169        let app_meta = test::app_meta();
170
171        for command in [AppCommand::Changelog, AppCommand::Debug, AppCommand::Help] {
172            let command_string = command.to_string();
173            assert_ne!("", command_string);
174
175            assert_eq!(
176                CommandMatches::new_canonical(command.clone()),
177                AppCommand::parse_input(&command_string, &app_meta).await,
178                "{}",
179                command_string,
180            );
181
182            assert_eq!(
183                CommandMatches::new_canonical(command),
184                AppCommand::parse_input(&command_string.to_uppercase(), &app_meta).await,
185                "{}",
186                command_string.to_uppercase(),
187            );
188        }
189
190        assert_eq!("roll d20", AppCommand::Roll("d20".to_string()).to_string());
191
192        assert_eq!(
193            CommandMatches::new_canonical(AppCommand::Roll("d20".to_string())),
194            AppCommand::parse_input("roll d20", &app_meta).await,
195        );
196
197        assert_eq!(
198            CommandMatches::new_canonical(AppCommand::Roll("D20".to_string())),
199            AppCommand::parse_input("ROLL D20", &app_meta).await,
200        );
201    }
202}