1use 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#[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 pub name: String,
30 pub description: Option<String>,
32 pub repository: Option<String>,
34 #[serde(default)]
36 pub authors: Vec<String>,
37 pub default_prefix: Option<String>,
38 #[serde(default)]
39 pub schema: KeyedList<PluginConfigSchema>,
40 #[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#[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#[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")]
149pub struct SchemaMap {
151 pub value_type: Box<SchemaType>,
152 #[serde(default)]
153 pub min_items: u32,
154}
155
156#[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#[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#[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
243impl<'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
265trait FromStrVariants {
269 fn expected_variants() -> &'static [&'static str];
270 fn from_str(s: &str) -> Option<Self>
271 where
272 Self: Sized;
273}
274
275fn 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
316mod 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}