Skip to main content

alien_core/stack_authoring/
operations.rs

1//! Operations as a stack file declares them.
2//!
3//! The same rules as the TypeScript SDK's `toOperationsConfig`: custom plugins are pinned to
4//! an exact `name@version`, and a declaration names at least one plugin.
5
6use indexmap::IndexMap;
7
8use super::invalid;
9use crate::error::Result;
10use crate::{CustomPluginOperationsConfig, OperationsConfig, PluginOperationsConfig};
11
12/// A published custom plugin written as `name@version`.
13pub fn custom_plugin(spec: &str) -> Result<CustomPluginOperationsConfig> {
14    match spec.rsplit_once('@') {
15        Some((name, version)) if !name.is_empty() && !version.is_empty() => {
16            Ok(CustomPluginOperationsConfig {
17                name: name.to_string(),
18                version: version.to_string(),
19                config: PluginOperationsConfig::default(),
20            })
21        }
22        _ => Err(invalid(
23            "operations.custom",
24            format!("custom plugin '{spec}' must be written as 'name@version'"),
25        )),
26    }
27}
28
29/// The operations a stack declares, from its built-in and custom plugins.
30pub fn operations_config(
31    plugins: IndexMap<String, PluginOperationsConfig>,
32    custom: Vec<CustomPluginOperationsConfig>,
33) -> Result<OperationsConfig> {
34    let config = OperationsConfig { plugins, custom };
35    if config.is_empty() {
36        return Err(invalid("operations", "needs at least one plugin"));
37    }
38    Ok(config)
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44    use crate::{ErrorData, OperationApproval, OperationApprovalDecision, OperationSettingValue};
45    use alien_error::AlienError;
46
47    fn field_and_reason(error: AlienError<ErrorData>) -> (String, String) {
48        match error.error.expect("error data") {
49            ErrorData::StackDefinitionInvalid { field, reason } => (field, reason),
50            other => panic!("unexpected error {other:?}"),
51        }
52    }
53
54    #[test]
55    fn a_custom_plugin_is_pinned_to_a_version() {
56        let plugin = custom_plugin("mine@1.2.0").unwrap();
57        assert_eq!(plugin.name, "mine");
58        assert_eq!(plugin.version, "1.2.0");
59        assert_eq!(plugin.config, PluginOperationsConfig::default());
60
61        let scoped = custom_plugin("@acme/diagnostics@1.4.0").unwrap();
62        assert_eq!(scoped.name, "@acme/diagnostics");
63        assert_eq!(scoped.version, "1.4.0");
64
65        for spec in ["mine", "@1.0.0", "mine@", ""] {
66            let (field, reason) = field_and_reason(custom_plugin(spec).unwrap_err());
67            assert_eq!(field, "operations.custom");
68            assert!(reason.contains("name@version"), "{reason}");
69        }
70    }
71
72    #[test]
73    fn a_declaration_names_a_plugin_and_serializes_as_the_sdk_does() {
74        let (field, reason) =
75            field_and_reason(operations_config(IndexMap::new(), vec![]).unwrap_err());
76        assert_eq!(field, "operations");
77        assert!(reason.contains("at least one plugin"), "{reason}");
78
79        let config = operations_config(
80            IndexMap::from([(
81                "kubernetes".to_string(),
82                PluginOperationsConfig {
83                    settings: IndexMap::from([(
84                        "database".to_string(),
85                        OperationSettingValue::Literal("app".to_string()),
86                    )]),
87                    approval: IndexMap::from([(
88                        "*".to_string(),
89                        OperationApproval::Decision(OperationApprovalDecision::Auto),
90                    )]),
91                },
92            )]),
93            vec![custom_plugin("mine@1.2.0").unwrap()],
94        )
95        .unwrap();
96        assert_eq!(
97            serde_json::to_value(&config).unwrap(),
98            serde_json::json!({
99                "plugins": {
100                    "kubernetes": {
101                        "settings": { "database": "app" },
102                        "approval": { "*": "auto" }
103                    }
104                },
105                "custom": [{ "name": "mine", "version": "1.2.0" }]
106            })
107        );
108    }
109}