covey_schema/
manifest.rs

1//! Types for the plugin manifest.
2
3use std::{
4    collections::BTreeMap,
5    fmt::{self, Debug},
6    marker::PhantomData,
7};
8
9use serde::{
10    Deserialize, Deserializer, Serialize,
11    de::{self, MapAccess, Visitor},
12};
13
14use crate::{
15    hotkey::Hotkey,
16    keyed_list::{Id, Identify, KeyedList},
17};
18
19/// A manifest for a single plugin.
20///
21/// This should be a TOML file stored in
22/// `data directory/covey/plugins/<plugin>/manifest.toml`.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
25#[non_exhaustive]
26#[serde(rename_all = "kebab-case")]
27pub struct PluginManifest {
28    /// User-facing name of the plugin.
29    pub name: String,
30    /// Plugin description. Can be multiple lines. Supports markdown.
31    pub description: Option<String>,
32    /// URL to the plugin's repository.
33    pub repository: Option<String>,
34    /// List of authors of the plugin.
35    #[serde(default)]
36    pub authors: Vec<String>,
37    pub default_prefix: Option<String>,
38    #[serde(default)]
39    pub schema: KeyedList<PluginConfigSchema>,
40    /// All commands that the user can run on some list item.
41    ///
42    /// The key an ID for the command, which is used when calling commands
43    /// on the plugin.
44    ///
45    /// Several commands can have the same hotkey, but the commands that
46    /// a single list item has should have different hotkeys.
47    #[serde(default = "default_commands")]
48    pub commands: KeyedList<Command>,
49}
50
51impl PluginManifest {
52    pub fn try_from_toml(s: &str) -> Result<Self, toml::de::Error> {
53        toml::from_str(s)
54    }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
58#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
59#[serde(rename_all = "kebab-case")]
60pub struct Command {
61    pub id: Id,
62    pub title: String,
63    pub description: Option<String>,
64    pub default_hotkeys: Option<Vec<Hotkey>>,
65}
66
67impl Identify for Command {
68    fn id(&self) -> &Id {
69        &self.id
70    }
71}
72
73fn default_commands() -> KeyedList<Command> {
74    KeyedList::new(vec![
75        Command {
76            id: Id::new("activate"),
77            title: String::from("Activate"),
78            description: None,
79            default_hotkeys: Some(vec!["enter".parse().expect("enter should be a hotkey")]),
80        },
81        Command {
82            id: Id::new("complete"),
83            title: String::from("Complete"),
84            description: None,
85            default_hotkeys: Some(vec!["tab".parse().expect("tab should be a hotkey")]),
86        },
87        Command {
88            id: Id::new("alt-activate"),
89            title: String::from("Alt activate"),
90            description: None,
91            default_hotkeys: Some(vec![
92                "alt+enter".parse().expect("alt+enter should be a hotkey"),
93            ]),
94        },
95    ])
96    .expect("ids are unique")
97}
98
99#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
100#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
101#[non_exhaustive]
102pub struct PluginConfigSchema {
103    pub id: Id,
104    pub title: String,
105    pub description: Option<String>,
106    pub r#type: SchemaType,
107}
108
109impl Identify for PluginConfigSchema {
110    fn id(&self) -> &Id {
111        &self.id
112    }
113}
114
115/// TODO: better docs
116///
117/// If there is no default, then this type will be *required*.
118#[derive(Debug, PartialEq, Clone, Serialize)]
119#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
120#[serde(rename_all = "kebab-case")]
121pub enum SchemaType {
122    Int(SchemaInt),
123    Text(SchemaText),
124    Bool(SchemaBool),
125    FilePath(SchemaFilePath),
126    FolderPath(SchemaFolderPath),
127    Selection(SchemaSelection),
128    List(SchemaList),
129    Map(SchemaMap),
130    Struct(SchemaStruct),
131}
132
133// the below structs can't use the macro because they have extra
134// required fields.
135// all structs should have the same serde meta tag.
136
137#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
138#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
139#[serde(rename_all = "kebab-case")]
140pub struct SchemaList {
141    pub item_type: Box<SchemaType>,
142    #[serde(default)]
143    pub min_items: u32,
144}
145
146#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
147#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
148#[serde(rename_all = "kebab-case")]
149/// A map from any string to a specified value.
150pub struct SchemaMap {
151    pub value_type: Box<SchemaType>,
152    #[serde(default)]
153    pub min_items: u32,
154}
155
156/// A map with specific key-value pairs.
157#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
158#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
159#[serde(rename_all = "kebab-case")]
160pub struct SchemaStruct {
161    pub fields: BTreeMap<String, SchemaType>,
162}
163
164/// A selection of one of multiple strings.
165#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
167#[serde(rename_all = "kebab-case")]
168pub struct SchemaSelection {
169    pub allowed_values: Vec<String>,
170    #[serde(default)]
171    pub default: Option<String>,
172}
173
174macros::make_config_subtypes! {
175    pub struct SchemaInt {
176        pub min: i32 = i32::MIN,
177        pub max: i32 = i32::MAX,
178        pub default: Option<i32> = None,
179    }
180    pub struct SchemaText {
181        pub min_length: u32 = u32::MIN,
182        pub max_length: u32 = u32::MAX,
183        pub default: Option<String> = None,
184    }
185    pub struct SchemaBool {
186        pub default: Option<bool> = None,
187    }
188    pub struct SchemaFilePath {
189        pub extension: Option<Vec<String>> = None,
190        pub default: Option<String> = None,
191    }
192    pub struct SchemaFolderPath {
193        pub default: Option<String> = None,
194    }
195}
196
197/// Equivalent to [`SchemaType`] but with a derived deserialisation
198/// implementation.
199///
200/// This is needed to avoid adding `#[deserialize_with = "string_or_struct"]`
201/// on every instance of [`SchemaType`], and to be used in nested types like
202/// a [`HashMap<_, SchemaType>`].
203///
204/// [`SchemaType`] has a manual deserialisation implementation that uses
205/// the deserialisation of this.
206///
207/// [`SchemaType`] isn't a struct wrapper around this so that users can match
208/// on it's variants.
209#[derive(Deserialize)]
210#[serde(rename_all = "kebab-case")]
211enum __SchemaTypeSerdeDerive {
212    Int(SchemaInt),
213    Text(SchemaText),
214    Bool(SchemaBool),
215    FilePath(SchemaFilePath),
216    FolderPath(SchemaFolderPath),
217    Selection(SchemaSelection),
218    List(SchemaList),
219    Map(SchemaMap),
220    Struct(SchemaStruct),
221}
222
223impl FromStrVariants for __SchemaTypeSerdeDerive {
224    fn expected_variants() -> &'static [&'static str] {
225        &["int", "text", "bool", "file-path", "folder-path"]
226    }
227
228    fn from_str(s: &str) -> Option<Self>
229    where
230        Self: Sized,
231    {
232        Some(match s {
233            "int" => Self::Int(SchemaInt::default()),
234            "text" => Self::Text(SchemaText::default()),
235            "bool" => Self::Bool(SchemaBool::default()),
236            "file-path" => Self::FilePath(SchemaFilePath::default()),
237            "folder-path" => Self::FolderPath(SchemaFolderPath::default()),
238            _ => return None,
239        })
240    }
241}
242
243// other misc implementation details //
244
245impl<'de> Deserialize<'de> for SchemaType {
246    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
247    where
248        D: Deserializer<'de>,
249    {
250        use __SchemaTypeSerdeDerive as Derived;
251        string_or_struct::<'de, Derived, _>(deserializer).map(|value| match value {
252            Derived::Int(config_int) => Self::Int(config_int),
253            Derived::Text(config_str) => Self::Text(config_str),
254            Derived::Bool(config_bool) => Self::Bool(config_bool),
255            Derived::FilePath(config_file_path) => Self::FilePath(config_file_path),
256            Derived::FolderPath(config_folder_path) => Self::FolderPath(config_folder_path),
257            Derived::Selection(selection) => Self::Selection(selection),
258            Derived::List(config_list) => Self::List(config_list),
259            Derived::Map(config_map) => Self::Map(config_map),
260            Derived::Struct(config_struct) => Self::Struct(config_struct),
261        })
262    }
263}
264
265/// [`FromStr`] that is just one of several possibilities.
266///
267/// The error type should be the possible variants.
268trait FromStrVariants {
269    fn expected_variants() -> &'static [&'static str];
270    fn from_str(s: &str) -> Option<Self>
271    where
272        Self: Sized;
273}
274
275// https://serde.rs/string-or-struct.html
276// slightly modified from requiring `FromStr<Err = Infallible>`
277// to one of a selection of strings
278fn string_or_struct<'de, T, D>(deserializer: D) -> Result<T, D::Error>
279where
280    T: Deserialize<'de> + FromStrVariants,
281    D: Deserializer<'de>,
282{
283    struct StringOrStruct<T>(PhantomData<fn() -> T>);
284
285    impl<'de, T> Visitor<'de> for StringOrStruct<T>
286    where
287        T: Deserialize<'de> + FromStrVariants,
288    {
289        type Value = T;
290
291        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
292            formatter.write_str("string or map")
293        }
294
295        fn visit_str<E>(self, value: &str) -> Result<T, E>
296        where
297            E: de::Error,
298        {
299            match FromStrVariants::from_str(value) {
300                Some(variant) => Ok(variant),
301                None => Err(de::Error::unknown_variant(value, T::expected_variants())),
302            }
303        }
304
305        fn visit_map<M>(self, map: M) -> Result<T, M::Error>
306        where
307            M: MapAccess<'de>,
308        {
309            Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))
310        }
311    }
312
313    deserializer.deserialize_any(StringOrStruct(PhantomData))
314}
315
316// defining this in a module so that i can use it above
317mod macros {
318    macro_rules! make_config_subtypes {
319        (
320            $(
321                $(#[$inner_meta:meta])*
322                pub struct $variant:ident {
323                    $(
324                        $field_vis:vis $field:ident : $field_ty:ty = $field_default:expr
325                    ),*
326                    $(,)?
327                }
328            )*
329        ) => {
330            $(
331                $(#[$inner_meta])*
332                #[derive(Debug, Deserialize, PartialEq, Clone, Serialize)]
333                #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
334                #[serde(default, rename_all = "kebab-case")]
335                pub struct $variant {
336                    $( $field_vis $field : $field_ty ),*
337                }
338
339                impl Default for $variant {
340                    fn default() -> Self {
341                        Self {
342                            $( $field: $field_default ),*
343                        }
344                    }
345                }
346            )*
347        };
348    }
349    pub(super) use make_config_subtypes;
350}
351
352#[cfg(test)]
353mod tests {
354    use std::collections::BTreeMap;
355
356    use super::{
357        PluginConfigSchema, PluginManifest, SchemaInt, SchemaList, SchemaMap, SchemaStruct,
358        SchemaType,
359    };
360    use crate::{
361        keyed_list::{Id, KeyedList},
362        manifest::{SchemaSelection, default_commands},
363    };
364
365    #[test]
366    fn full() -> Result<(), toml::de::Error> {
367        let input = r#"
368            name = "test"
369            description = "my description"
370
371            [[schema]]
372            id = "first-option"
373            title = "first option"
374            type = "int"
375        "#;
376        let output: PluginManifest = toml::from_str(input)?;
377        assert_eq!(
378            output,
379            PluginManifest {
380                name: "test".to_string(),
381                description: Some("my description".to_string()),
382                repository: None,
383                authors: vec![],
384                default_prefix: None,
385                schema: KeyedList::new([PluginConfigSchema {
386                    id: Id::new("first-option"),
387                    title: "first option".to_string(),
388                    description: None,
389                    r#type: SchemaType::Int(SchemaInt::default())
390                }])
391                .unwrap(),
392                commands: default_commands(),
393            }
394        );
395
396        Ok(())
397    }
398
399    #[test]
400    fn int() {
401        let input = r#"
402            int = { min = 0 }
403        "#;
404        let output: SchemaType = toml::from_str(&input).unwrap();
405        assert_eq!(
406            output,
407            SchemaType::Int(SchemaInt {
408                min: 0,
409                ..Default::default()
410            })
411        );
412    }
413
414    #[test]
415    fn list() {
416        let input = r#"
417            id = "thing-id"
418            title = "thing"
419            type = { list = { item-type = "int" } }
420        "#;
421        let output: PluginConfigSchema = toml::from_str(input).unwrap();
422        assert_eq!(
423            output,
424            PluginConfigSchema {
425                id: Id::new("thing-id"),
426                title: "thing".to_string(),
427                description: None,
428                r#type: SchemaType::List(SchemaList {
429                    item_type: Box::new(SchemaType::Int(SchemaInt::default())),
430                    min_items: 0,
431                })
432            }
433        );
434    }
435
436    #[test]
437    fn open_plugin() {
438        let input = r#"
439            name = "Open"
440            description = "Open URLs with a query"
441            repository = "https://github.com/blorbb/covey-plugins"
442            authors = ["blorbb"]
443            default-prefix = "@"
444
445            [[schema]]
446            id = "urls"
447            title = "List of URLs to show"
448            type.map.value-type.struct.fields = { name = "text", url = "text" }
449        "#;
450        let output: PluginManifest = toml::from_str(input).unwrap();
451        assert_eq!(
452            output,
453            PluginManifest {
454                name: "Open".to_string(),
455                description: Some("Open URLs with a query".to_string()),
456                repository: Some("https://github.com/blorbb/covey-plugins".to_string()),
457                authors: vec!["blorbb".to_string()],
458                default_prefix: Some("@".to_string()),
459                schema: KeyedList::new([PluginConfigSchema {
460                    id: Id::new("urls"),
461                    title: "List of URLs to show".to_string(),
462                    description: None,
463                    r#type: SchemaType::Map(SchemaMap {
464                        value_type: Box::new(SchemaType::Struct(SchemaStruct {
465                            fields: BTreeMap::from([
466                                ("name".to_string(), SchemaType::Text(Default::default())),
467                                ("url".to_string(), SchemaType::Text(Default::default()))
468                            ])
469                        })),
470                        min_items: Default::default()
471                    })
472                }])
473                .unwrap(),
474                commands: default_commands(),
475            }
476        )
477    }
478
479    #[test]
480    fn selection() {
481        let input = r#"
482            selection.allowed-values = ["some-thing", "another-thing", "and-yet-another"]
483            selection.default = "some-thing"
484        "#;
485        let output: SchemaType = toml::from_str(&input).unwrap();
486        assert_eq!(
487            output,
488            SchemaType::Selection(SchemaSelection {
489                allowed_values: vec![
490                    "some-thing".to_string(),
491                    "another-thing".to_string(),
492                    "and-yet-another".to_string()
493                ],
494                default: Some("some-thing".to_string())
495            })
496        )
497    }
498}