use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolKind {
Http,
Postgres,
Duckdb,
Ducklake,
Python,
Workbook,
Playbook,
Playbooks,
Secrets,
Iterator,
Container,
Script,
Snowflake,
Transfer,
SnowflakeTransfer,
Gcs,
Gateway,
Nats,
Shell,
Artifact,
Noop,
TaskSequence,
Rhai,
}
impl std::fmt::Display for ToolKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
ToolKind::Http => "http",
ToolKind::Postgres => "postgres",
ToolKind::Duckdb => "duckdb",
ToolKind::Ducklake => "ducklake",
ToolKind::Python => "python",
ToolKind::Workbook => "workbook",
ToolKind::Playbook => "playbook",
ToolKind::Playbooks => "playbooks",
ToolKind::Secrets => "secrets",
ToolKind::Iterator => "iterator",
ToolKind::Container => "container",
ToolKind::Script => "script",
ToolKind::Snowflake => "snowflake",
ToolKind::Transfer => "transfer",
ToolKind::SnowflakeTransfer => "snowflake_transfer",
ToolKind::Gcs => "gcs",
ToolKind::Gateway => "gateway",
ToolKind::Nats => "nats",
ToolKind::Shell => "shell",
ToolKind::Artifact => "artifact",
ToolKind::Noop => "noop",
ToolKind::TaskSequence => "task_sequence",
ToolKind::Rhai => "rhai",
};
write!(f, "{}", s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalCondition {
#[serde(default)]
pub expr: Option<String>,
#[serde(rename = "do")]
pub action: String,
#[serde(default)]
pub attempts: Option<i32>,
#[serde(default)]
pub backoff: Option<String>,
#[serde(default)]
pub delay: Option<f64>,
#[serde(default)]
pub set_vars: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub target: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalElse {
#[serde(rename = "do")]
pub action: String,
#[serde(default)]
pub set_vars: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub kind: ToolKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eval: Option<Vec<EvalEntry>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub libs: Option<serde_json::Value>,
#[serde(default, alias = "input", skip_serializing_if = "Option::is_none")]
pub args: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<HashMap<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_select: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EvalEntry {
Condition(EvalCondition),
Else { r#else: EvalElse },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineTask {
pub label: String,
pub tool: ToolSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolDefinition {
Single(ToolSpec),
Pipeline(Vec<PipelineItem>),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum PipelineItem {
Flat(ToolSpec),
Nested(HashMap<String, ToolSpec>),
}
impl serde::Serialize for PipelineItem {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
PipelineItem::Flat(spec) => {
let label = spec
.extra
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mut map = HashMap::new();
map.insert(label, spec.clone());
map.serialize(serializer)
}
PipelineItem::Nested(map) => map.serialize(serializer),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LoopMode {
#[default]
Sequential,
Parallel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopSpec {
#[serde(default)]
pub mode: LoopMode,
#[serde(default)]
pub max_in_flight: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Loop {
#[serde(rename = "in")]
pub in_expr: String,
pub iterator: String,
#[serde(default)]
pub spec: Option<LoopSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextArc {
pub step: String,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub args: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextRouterSpec {
#[serde(default)]
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextRouter {
#[serde(default)]
pub spec: Option<NextRouterSpec>,
#[serde(default)]
pub arcs: Vec<NextArc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalNextTarget {
pub step: String,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub args: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum NextSpec {
Targets(Vec<CanonicalNextTarget>),
List(Vec<String>),
Single(String),
Router(NextRouter),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StepSpec {
#[serde(default)]
pub next_mode: Option<String>,
#[serde(default)]
pub timeout: Option<String>,
#[serde(default)]
pub on_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
pub step: String,
#[serde(default)]
pub desc: Option<String>,
#[serde(default)]
pub spec: Option<StepSpec>,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub args: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub vars: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub r#loop: Option<Loop>,
pub tool: ToolDefinition,
#[serde(default)]
pub next: Option<NextSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkbookTask {
pub name: String,
pub tool: ToolSpec,
#[serde(default)]
pub sink: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeychainDef {
pub name: String,
#[serde(default)]
pub credential: Option<String>,
#[serde(default)]
pub token_type: Option<String>,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub auth: Option<String>,
#[serde(default)]
pub map: Option<HashMap<String, String>>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub residency: crate::secrets::residency::Residency,
#[serde(default)]
pub allowed_regions: Vec<String>,
#[serde(default)]
pub no_broker_fallback: bool,
#[serde(default)]
pub auto_renew: bool,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metadata {
pub name: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub labels: Option<HashMap<String, String>>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playbook {
#[serde(rename = "apiVersion")]
pub api_version: String,
pub kind: String,
pub metadata: Metadata,
#[serde(default)]
pub workload: Option<serde_json::Value>,
#[serde(default)]
pub vars: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub keychain: Option<Vec<KeychainDef>>,
#[serde(default)]
pub workbook: Option<Vec<WorkbookTask>>,
pub workflow: Vec<Step>,
}
impl Playbook {
pub fn has_start_step(&self) -> bool {
self.workflow.iter().any(|s| s.step == "start")
}
pub fn get_step(&self, name: &str) -> Option<&Step> {
self.workflow.iter().find(|s| s.step == name)
}
pub fn step_names(&self) -> Vec<&str> {
self.workflow.iter().map(|s| s.step.as_str()).collect()
}
pub fn path(&self) -> Option<&str> {
self.metadata.path.as_deref()
}
pub fn find_keychain(&self, alias: &str) -> Option<&KeychainDef> {
self.keychain.as_ref()?.iter().find(|kc| kc.name == alias)
}
pub fn name(&self) -> &str {
&self.metadata.name
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommandSpec {
#[serde(default)]
pub next_mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextTargetInfo {
pub step: String,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub args: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub kind: ToolKind,
#[serde(default)]
pub config: HashMap<String, serde_json::Value>,
}
impl ToolCall {
pub fn from_spec(spec: &ToolSpec) -> Self {
let mut config = spec.extra.clone();
if let Some(ref auth) = spec.auth {
config.insert("auth".to_string(), auth.clone());
}
if let Some(ref libs) = spec.libs {
config.insert("libs".to_string(), libs.clone());
}
if let Some(ref args) = spec.args {
config.insert("args".to_string(), args.clone());
}
if let Some(ref code) = spec.code {
config.insert("code".to_string(), serde_json::Value::String(code.clone()));
}
if let Some(ref url) = spec.url {
config.insert("url".to_string(), serde_json::Value::String(url.clone()));
}
if let Some(ref method) = spec.method {
config.insert(
"method".to_string(),
serde_json::Value::String(method.clone()),
);
}
if let Some(ref query) = spec.query {
config.insert(
"query".to_string(),
serde_json::Value::String(query.clone()),
);
}
if let Some(ref command) = spec.command {
config.insert(
"command".to_string(),
serde_json::Value::String(command.clone()),
);
}
if let Some(ref connection) = spec.connection {
config.insert(
"connection".to_string(),
serde_json::Value::String(connection.clone()),
);
}
if let Some(ref params) = spec.params {
config.insert(
"params".to_string(),
serde_json::to_value(params).unwrap_or_default(),
);
}
if let Some(ref headers) = spec.headers {
config.insert(
"headers".to_string(),
serde_json::to_value(headers).unwrap_or_default(),
);
}
if let Some(ref eval) = spec.eval {
config.insert(
"eval".to_string(),
serde_json::to_value(eval).unwrap_or_default(),
);
}
if let Some(ref output_select) = spec.output_select {
config.insert("output_select".to_string(), output_select.clone());
}
Self {
kind: spec.kind.clone(),
config,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Command {
pub execution_id: String,
pub step: String,
pub tool: ToolCall,
#[serde(default)]
pub args: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub render_context: HashMap<String, serde_json::Value>,
#[serde(default)]
pub pipeline: Option<Vec<HashMap<String, serde_json::Value>>>,
#[serde(default)]
pub next_targets: Option<Vec<NextTargetInfo>>,
#[serde(default)]
pub spec: Option<CommandSpec>,
#[serde(default = "default_attempt")]
pub attempt: i32,
#[serde(default)]
pub priority: i32,
#[serde(default)]
pub backoff: Option<f64>,
#[serde(default)]
pub max_attempts: Option<i32>,
#[serde(default)]
pub retry_delay: Option<f64>,
#[serde(default)]
pub retry_backoff: Option<String>,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
fn default_attempt() -> i32 {
1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_playbook() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: test_playbook
path: test/simple
workflow:
- step: start
tool:
kind: python
code: |
result = {"status": "ok"}
next:
- step: end
- step: end
tool:
kind: python
code: |
result = {"status": "done"}
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
assert_eq!(playbook.api_version, "noetl.io/v2");
assert_eq!(playbook.kind, "Playbook");
assert_eq!(playbook.name(), "test_playbook");
assert!(playbook.has_start_step());
assert_eq!(playbook.workflow.len(), 2);
}
#[test]
fn test_parse_playbook_with_loop_spec() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: loop_test
workload:
items: [1, 2, 3]
workflow:
- step: start
loop:
in: "{{ workload.items }}"
iterator: item
spec:
mode: sequential
tool:
kind: python
code: |
result = {"item": input_data.get("item")}
args:
item: "{{ item }}"
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
let step = playbook.get_step("start").unwrap();
assert!(step.r#loop.is_some());
let loop_config = step.r#loop.as_ref().unwrap();
assert_eq!(loop_config.iterator, "item");
assert!(loop_config.spec.is_some());
assert_eq!(
loop_config.spec.as_ref().unwrap().mode,
LoopMode::Sequential
);
}
#[test]
fn test_parse_playbook_with_next_when() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: routing_test
workflow:
- step: start
tool:
kind: python
code: |
result = {"value": 10}
next:
- step: high
when: "{{ start.value > 5 }}"
- step: low
when: "{{ start.value <= 5 }}"
- step: high
tool:
kind: python
code: |
result = {"path": "high"}
- step: low
tool:
kind: python
code: |
result = {"path": "low"}
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
let step = playbook.get_step("start").unwrap();
assert!(step.next.is_some());
if let Some(NextSpec::Targets(targets)) = &step.next {
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].step, "high");
assert_eq!(targets[0].when, Some("{{ start.value > 5 }}".to_string()));
assert_eq!(targets[1].step, "low");
} else {
panic!("Expected NextSpec::Targets");
}
}
#[test]
fn test_parse_playbook_with_v10_router_format() {
let yaml = r#"
apiVersion: noetl.io/v10
kind: Playbook
metadata:
name: v10_routing_test
workflow:
- step: start
tool:
kind: python
code: |
result = {"value": 10}
next:
spec:
mode: exclusive
arcs:
- step: high
when: "{{ start.value > 5 }}"
- step: low
when: "{{ start.value <= 5 }}"
- step: high
tool:
kind: python
code: |
result = {"path": "high"}
- step: low
tool:
kind: python
code: |
result = {"path": "low"}
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
assert_eq!(playbook.api_version, "noetl.io/v10");
let step = playbook.get_step("start").unwrap();
assert!(step.next.is_some());
if let Some(NextSpec::Router(router)) = &step.next {
assert!(router.spec.is_some());
assert_eq!(
router.spec.as_ref().unwrap().mode,
Some("exclusive".to_string())
);
assert_eq!(router.arcs.len(), 2);
assert_eq!(router.arcs[0].step, "high");
assert_eq!(
router.arcs[0].when,
Some("{{ start.value > 5 }}".to_string())
);
assert_eq!(router.arcs[1].step, "low");
} else {
panic!("Expected NextSpec::Router, got {:?}", step.next);
}
}
#[test]
fn test_parse_playbook_with_step_when() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: guard_test
workflow:
- step: conditional
when: "{{ workload.enabled }}"
tool:
kind: python
code: |
result = {"status": "ran"}
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
let step = playbook.get_step("conditional").unwrap();
assert_eq!(step.when, Some("{{ workload.enabled }}".to_string()));
}
#[test]
fn test_parse_playbook_with_pipeline() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: pipeline_test
workflow:
- step: fetch_transform
tool:
- fetch:
kind: http
url: "https://api.example.com/data"
method: GET
- transform:
kind: python
args:
data: "{{ _prev }}"
code: |
result = {"processed": True}
next:
- step: end
- step: end
tool:
kind: noop
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
let step = playbook.get_step("fetch_transform").unwrap();
if let ToolDefinition::Pipeline(tasks) = &step.tool {
assert_eq!(tasks.len(), 2);
match &tasks[0] {
PipelineItem::Nested(map) => assert!(map.contains_key("fetch")),
PipelineItem::Flat(_) => panic!("Expected Nested form for label-as-key YAML"),
}
match &tasks[1] {
PipelineItem::Nested(map) => assert!(map.contains_key("transform")),
PipelineItem::Flat(_) => panic!("Expected Nested form for label-as-key YAML"),
}
} else {
panic!("Expected ToolDefinition::Pipeline");
}
}
#[test]
fn test_parse_pipeline_with_name_as_field_shape() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: pipeline_flat
workflow:
- step: fetch_transform
tool:
- name: fetch
kind: http
url: "https://api.example.com"
- name: transform
kind: python
code: "result = {'ok': True}"
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
let step = playbook.get_step("fetch_transform").unwrap();
if let ToolDefinition::Pipeline(tasks) = &step.tool {
assert_eq!(tasks.len(), 2);
for (i, expected_label) in [(0usize, "fetch"), (1usize, "transform")] {
match &tasks[i] {
PipelineItem::Flat(spec) => {
let name = spec
.extra
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(name, expected_label);
}
PipelineItem::Nested(_) => {
panic!("Expected Flat form for name-as-field YAML")
}
}
}
} else {
panic!("Expected ToolDefinition::Pipeline");
}
}
#[test]
fn test_parse_tool_with_eval() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: eval_test
workflow:
- step: fetch
tool:
kind: http
url: "https://api.example.com/data"
eval:
- expr: "{{ outcome.error.retryable }}"
do: retry
attempts: 3
backoff: exponential
delay: 1.0
- expr: "{{ outcome.status == 'error' }}"
do: fail
- else:
do: continue
next:
- step: end
- step: end
tool:
kind: noop
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
let step = playbook.get_step("fetch").unwrap();
if let ToolDefinition::Single(spec) = &step.tool {
assert!(spec.eval.is_some());
let eval = spec.eval.as_ref().unwrap();
assert_eq!(eval.len(), 3);
} else {
panic!("Expected ToolDefinition::Single");
}
}
#[test]
fn test_tool_call_from_spec() {
let spec = ToolSpec {
kind: ToolKind::Python,
eval: None,
auth: None,
libs: None,
args: None,
code: Some("return {}".to_string()),
url: None,
method: None,
query: None,
command: None,
connection: None,
params: None,
headers: None,
output_select: None,
extra: HashMap::new(),
};
let call = ToolCall::from_spec(&spec);
assert_eq!(call.kind, ToolKind::Python);
assert!(call.config.contains_key("code"));
}
#[test]
fn test_tool_spec_accepts_input_alias_for_args() {
let yaml = r#"
kind: python
input:
message: "Hello World"
count: 42
code: |
print(f"hello {message}")
"#;
let spec: ToolSpec = serde_yaml::from_str(yaml).unwrap();
let args = spec
.args
.clone()
.expect("input alias should decode into args");
assert_eq!(
args.get("message").and_then(|v| v.as_str()),
Some("Hello World")
);
assert_eq!(args.get("count").and_then(|v| v.as_i64()), Some(42));
let call = ToolCall::from_spec(&spec);
let call_args = call
.config
.get("args")
.expect("ToolCall::from_spec should propagate args");
assert_eq!(
call_args.get("message").and_then(|v| v.as_str()),
Some("Hello World")
);
}
#[test]
fn test_tool_spec_accepts_args_field_directly() {
let yaml = r#"
kind: python
args:
x: 10
code: "print(x * 2)"
"#;
let spec: ToolSpec = serde_yaml::from_str(yaml).unwrap();
let args = spec.args.expect("args field decodes");
assert_eq!(args.get("x").and_then(|v| v.as_i64()), Some(10));
}
#[test]
fn test_step_names() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: test
workflow:
- step: start
tool:
kind: python
code: ""
- step: process
tool:
kind: python
code: ""
- step: end
tool:
kind: python
code: ""
"#;
let playbook: Playbook = serde_yaml::from_str(yaml).unwrap();
let names = playbook.step_names();
assert_eq!(names, vec!["start", "process", "end"]);
}
const KEYCHAIN_YAML: &str = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: kc_test
keychain:
- name: openai_token
kind: secrets
provider: gcp
scope: global
auth: "{{ gcp_auth }}"
map:
api_key: "{{ openai_secret_path }}"
- name: plain_token
credential: some_cred
workflow:
- step: start
tool:
kind: python
code: ""
"#;
#[test]
fn keychain_def_parses_typed_provider_auth_map() {
let pb: Playbook = serde_yaml::from_str(KEYCHAIN_YAML).unwrap();
let kc = pb.find_keychain("openai_token").expect("openai_token");
assert_eq!(kc.provider.as_deref(), Some("gcp"));
assert_eq!(kc.auth.as_deref(), Some("{{ gcp_auth }}"));
assert_eq!(kc.scope.as_deref(), Some("global"));
let map = kc.map.as_ref().expect("map");
assert_eq!(
map.get("api_key").map(String::as_str),
Some("{{ openai_secret_path }}")
);
assert_eq!(
kc.extra.get("kind").and_then(|v| v.as_str()),
Some("secrets")
);
}
#[test]
fn find_keychain_handles_missing_and_provider_less_entries() {
let pb: Playbook = serde_yaml::from_str(KEYCHAIN_YAML).unwrap();
let plain = pb.find_keychain("plain_token").expect("plain_token");
assert_eq!(plain.provider, None);
assert_eq!(plain.map, None);
assert_eq!(plain.credential.as_deref(), Some("some_cred"));
assert!(pb.find_keychain("nope").is_none());
}
#[test]
fn find_keychain_none_when_no_keychain_block() {
let yaml = r#"
apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: no_kc
workflow:
- step: start
tool:
kind: python
code: ""
"#;
let pb: Playbook = serde_yaml::from_str(yaml).unwrap();
assert!(pb.find_keychain("anything").is_none());
}
}