Skip to main content

cordis_include/
options.rs

1//! Serializable entry description — the on-disk shape of one plugin entry.
2
3use crate::node::Node;
4use serde::{Deserialize, Serialize};
5
6/// One entry's disable state: a static flag or a `!!js` expression
7/// evaluated at activation, with the raw text kept for write-back.
8///
9/// The YAML dialect maps `disabled: true` to [`Disabled::Flag`] and
10/// `disabled: !!js <expr>` to [`Disabled::Expr`]; the expression stays
11/// unevaluated in the tree and only takes effect through
12/// [`crate::Entry::resolved_disabled`]. The serde (JSON) path has no
13/// `!!js`, so a flag serializes as a boolean and an expression as its
14/// raw string.
15#[derive(Debug, Clone, PartialEq)]
16pub enum Disabled {
17    /// Statically on/off. The default is off.
18    Flag(bool),
19    /// `disabled: !!js <expr>` — the raw expression, evaluated when the
20    /// entry is activated ([`crate::expr::evaluate`]).
21    Expr(String),
22}
23
24impl Default for Disabled {
25    fn default() -> Self {
26        Self::Flag(false)
27    }
28}
29
30impl Disabled {
31    /// Whether this statically disables the entry. An unevaluated
32    /// expression does not: see [`crate::Entry::resolved_disabled`].
33    pub fn is_disabled(&self) -> bool {
34        matches!(self, Self::Flag(true))
35    }
36
37    /// The raw `!!js` expression text, when the slot holds one.
38    pub fn as_expr(&self) -> Option<&str> {
39        match self {
40            Self::Expr(source) => Some(source),
41            _ => None,
42        }
43    }
44}
45
46/// Whether the slot is the default (off), used by `skip_serializing_if`.
47fn disabled_is_default(value: &Disabled) -> bool {
48    matches!(value, Disabled::Flag(false))
49}
50
51impl Serialize for Disabled {
52    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
53        match self {
54            Self::Flag(flag) => serializer.serialize_bool(*flag),
55            Self::Expr(source) => serializer.serialize_str(source),
56        }
57    }
58}
59
60impl<'de> Deserialize<'de> for Disabled {
61    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
62        struct DisabledVisitor;
63
64        impl serde::de::Visitor<'_> for DisabledVisitor {
65            type Value = Disabled;
66
67            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68                f.write_str("a boolean or a `!!js` expression string")
69            }
70
71            fn visit_bool<E: serde::de::Error>(self, value: bool) -> Result<Disabled, E> {
72                Ok(Disabled::Flag(value))
73            }
74
75            fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Disabled, E> {
76                Ok(Disabled::Expr(value.to_owned()))
77            }
78
79            fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Disabled, E> {
80                Ok(Disabled::Expr(value))
81            }
82        }
83
84        deserializer.deserialize_any(DisabledVisitor)
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    /// The serde (JSON) path: flags are booleans, expressions are their raw
93    /// text — JSON has no `!!js`, so a string round-trips the expression.
94    #[test]
95    fn disabled_serde_json_round_trips() {
96        let options: EntryOptions =
97            serde_json::from_str(r#"{"name":"n","disabled":true}"#).unwrap();
98        assert_eq!(options.disabled, Disabled::Flag(true));
99        let options: EntryOptions =
100            serde_json::from_str(r#"{"name":"n","disabled":"process.platform"}"#).unwrap();
101        assert_eq!(
102            options.disabled,
103            Disabled::Expr("process.platform".to_owned())
104        );
105        let text = serde_json::to_string(&options).unwrap();
106        assert_eq!(text, r#"{"name":"n","disabled":"process.platform"}"#);
107        // The default flag is omitted entirely.
108        let text = serde_json::to_string(&EntryOptions::new("n")).unwrap();
109        assert_eq!(text, r#"{"name":"n"}"#);
110    }
111}
112
113/// Entry name that mounts another config file as a subtree
114/// (`name: import` with `config: { url: "…" }`).
115pub const IMPORT_NAME: &str = "import";
116
117/// Entry name of the built-in group plugin: a row named `group` (or any row
118/// with children) is a group.
119pub const GROUP_NAME: &str = "group";
120
121/// One entry in a config file: a plugin instance plus its group position.
122///
123/// The declared field order is the serialization order (`id` and `name`
124/// first, `config` last), keeping files readable and diff-stable. Entries
125/// with a `group` array are groups; the array order is the child order.
126///
127/// `config` is stored raw: `${{ env.NAME }}` templates stay intact in the
128/// entry tree and are only expanded when the config is handed to a plugin
129/// (see [`crate::Entry::resolved_config`]).
130#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
131pub struct EntryOptions {
132    /// Stable identity of the entry. Missing ids are filled with a random
133    /// 6-character base36 id when the entry enters a tree and persisted on
134    /// the next write-back.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub id: Option<String>,
137    /// Plugin name used to resolve the plugin implementation.
138    #[serde(default)]
139    pub name: String,
140    /// Whether the entry (and transitively its subtree) is disabled: a
141    /// static flag, or a `!!js` expression evaluated at activation whose
142    /// raw text round-trips through the file.
143    #[serde(default, skip_serializing_if = "disabled_is_default")]
144    pub disabled: Disabled,
145    /// Names of services that must be active before this entry starts.
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub inject: Vec<String>,
148    /// Child entries, in order. Non-empty only for group entries.
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub group: Vec<EntryOptions>,
151    /// Raw plugin configuration (templates unexpanded).
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub config: Option<Node>,
154}
155
156impl EntryOptions {
157    /// Create options for a plugin with the given name.
158    pub fn new(name: impl Into<String>) -> Self {
159        Self {
160            name: name.into(),
161            ..Self::default()
162        }
163    }
164
165    /// Set the explicit entry id.
166    pub fn with_id(mut self, id: impl Into<String>) -> Self {
167        self.id = Some(id.into());
168        self
169    }
170
171    /// Set the raw plugin configuration.
172    pub fn with_config(mut self, config: Node) -> Self {
173        self.config = Some(config);
174        self
175    }
176
177    /// Set the child entries (turning this entry into a group).
178    pub fn with_group(mut self, group: Vec<EntryOptions>) -> Self {
179        self.group = group;
180        self
181    }
182
183    /// Set the static disable flag (see [`EntryOptions::disabled`]; use the
184    /// field directly for a `!!js` expression).
185    pub fn with_disabled(mut self, disabled: bool) -> Self {
186        self.disabled = Disabled::Flag(disabled);
187        self
188    }
189
190    /// The url this import entry mounts, when the entry is an import
191    /// (`name: import` with a string `config.url`).
192    pub fn import_url(&self) -> Option<&str> {
193        if self.name != IMPORT_NAME {
194            return None;
195        }
196        self.config.as_ref()?.as_object()?.get("url")?.as_str()
197    }
198
199    /// Declare services that must be active before this entry starts.
200    pub fn with_inject<I, S>(mut self, inject: I) -> Self
201    where
202        I: IntoIterator<Item = S>,
203        S: Into<String>,
204    {
205        self.inject = inject.into_iter().map(Into::into).collect();
206        self
207    }
208}