use crate::cook::execution::interpolation::{InterpolationContext, InterpolationEngine};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
pub struct StandardVariables;
impl StandardVariables {
pub const ITEM: &'static str = "item"; pub const INDEX: &'static str = "item_index"; pub const TOTAL: &'static str = "item_total";
pub const ITEM_VALUE: &'static str = "item.value"; pub const ITEM_PATH: &'static str = "item.path"; pub const ITEM_NAME: &'static str = "item.name";
pub const WORKFLOW_NAME: &'static str = "workflow.name";
pub const WORKFLOW_ID: &'static str = "workflow.id";
pub const ITERATION: &'static str = "workflow.iteration";
pub const STEP_NAME: &'static str = "step.name";
pub const STEP_INDEX: &'static str = "step.index";
pub const LAST_OUTPUT: &'static str = "last.output";
pub const LAST_EXIT_CODE: &'static str = "last.exit_code";
pub const MAP_KEY: &'static str = "map.key"; pub const MAP_RESULTS: &'static str = "map.results"; pub const WORKER_ID: &'static str = "worker.id"; }
#[derive(Debug, Clone)]
pub enum ExecutionInput {
Argument(String),
FilePath(String),
JsonObject(Value),
}
#[derive(Debug, Clone)]
pub enum ExecutionMode {
Standard,
WithArguments,
WithFilePattern,
MapReduce,
}
#[derive(Debug, Clone)]
pub struct VariableContext {
variables: HashMap<String, Value>, aliases: HashMap<String, String>, }
impl VariableContext {
pub fn from_execution_input(
_mode: &ExecutionMode,
input: &ExecutionInput,
index: usize,
total: usize,
) -> Self {
let mut variables = HashMap::new();
let mut aliases = HashMap::new();
match input {
ExecutionInput::Argument(arg) => {
variables.insert(StandardVariables::ITEM.into(), json!(arg));
variables.insert(StandardVariables::ITEM_VALUE.into(), json!(arg));
aliases.insert("ARG".into(), StandardVariables::ITEM_VALUE.into());
aliases.insert("ARGUMENT".into(), StandardVariables::ITEM_VALUE.into());
}
ExecutionInput::FilePath(path) => {
variables.insert(StandardVariables::ITEM.into(), json!(path));
variables.insert(StandardVariables::ITEM_PATH.into(), json!(path));
aliases.insert("FILE".into(), StandardVariables::ITEM_PATH.into());
aliases.insert("FILE_PATH".into(), StandardVariables::ITEM_PATH.into());
}
ExecutionInput::JsonObject(obj) => {
variables.insert(StandardVariables::ITEM.into(), obj.clone());
if let Some(path) = obj.get("file_path") {
variables.insert(StandardVariables::ITEM_PATH.into(), path.clone());
}
if let Some(name) = obj.get("name") {
variables.insert(StandardVariables::ITEM_NAME.into(), name.clone());
}
}
}
variables.insert(StandardVariables::INDEX.into(), json!(index));
variables.insert(StandardVariables::TOTAL.into(), json!(total));
Self { variables, aliases }
}
pub fn empty() -> Self {
Self {
variables: HashMap::new(),
aliases: HashMap::new(),
}
}
pub fn add_variable(&mut self, key: impl Into<String>, value: Value) {
self.variables.insert(key.into(), value);
}
pub fn add_alias(&mut self, old_name: impl Into<String>, new_name: impl Into<String>) {
self.aliases.insert(old_name.into(), new_name.into());
}
pub fn get(&self, key: &str) -> Option<&Value> {
if let Some(actual_key) = self.aliases.get(key) {
self.variables.get(actual_key)
} else {
self.variables.get(key)
}
}
pub fn interpolate(&self, template: &str) -> Result<String> {
let template = self.resolve_aliases(template);
let mut context = InterpolationContext::new();
let mut nested_objects: HashMap<String, HashMap<String, Value>> = HashMap::new();
for (key, value) in &self.variables {
if key.contains('.') {
let parts: Vec<&str> = key.split('.').collect();
if parts.len() == 2 {
nested_objects
.entry(parts[0].to_string())
.or_default()
.insert(parts[1].to_string(), value.clone());
} else {
context.set(key.clone(), value.clone());
}
} else {
context.set(key.clone(), value.clone());
}
}
for (obj_name, fields) in nested_objects {
context.set(obj_name, json!(fields));
}
let mut engine = InterpolationEngine::new(false);
engine
.interpolate(&template, &context)
.context("Failed to interpolate variables")
}
fn resolve_aliases(&self, template: &str) -> String {
self.aliases
.iter()
.fold(template.to_string(), |acc, (old, new)| {
acc.replace(&format!("${{{}}}", old), &format!("${{{}}}", new))
.replace(&format!("${}", old), &format!("${}", new))
})
}
pub fn to_interpolation_context(&self) -> InterpolationContext {
let mut context = InterpolationContext::new();
for (key, value) in &self.variables {
context.set(key.clone(), value.clone());
}
context
}
pub fn set_workflow_metadata(&mut self, name: &str, id: &str, iteration: usize) {
self.variables
.insert(StandardVariables::WORKFLOW_NAME.into(), json!(name));
self.variables
.insert(StandardVariables::WORKFLOW_ID.into(), json!(id));
self.variables
.insert(StandardVariables::ITERATION.into(), json!(iteration));
}
pub fn set_step_metadata(&mut self, name: &str, index: usize) {
self.variables
.insert(StandardVariables::STEP_NAME.into(), json!(name));
self.variables
.insert(StandardVariables::STEP_INDEX.into(), json!(index));
}
pub fn set_last_output(&mut self, output: &str, exit_code: i32) {
self.variables
.insert(StandardVariables::LAST_OUTPUT.into(), json!(output));
self.variables
.insert(StandardVariables::LAST_EXIT_CODE.into(), json!(exit_code));
}
pub fn set_mapreduce_metadata(&mut self, worker_id: Option<usize>, map_key: Option<&str>) {
if let Some(id) = worker_id {
self.variables
.insert(StandardVariables::WORKER_ID.into(), json!(id));
}
if let Some(key) = map_key {
self.variables
.insert(StandardVariables::MAP_KEY.into(), json!(key));
}
}
pub fn set_map_results(&mut self, results: Value) {
self.variables
.insert(StandardVariables::MAP_RESULTS.into(), results);
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum CaptureFormat {
#[default]
String,
Json,
Lines,
Number,
Boolean,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureStreams {
#[serde(default = "default_true")]
pub stdout: bool,
#[serde(default)]
pub stderr: bool,
#[serde(default = "default_true")]
pub exit_code: bool,
#[serde(default = "default_true")]
pub success: bool,
#[serde(default = "default_true")]
pub duration: bool,
}
impl Default for CaptureStreams {
fn default() -> Self {
Self {
stdout: true,
stderr: false,
exit_code: true,
success: true,
duration: true,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CapturedValue {
String(String),
Number(f64),
Boolean(bool),
Json(Value),
Array(Vec<CapturedValue>),
Object(HashMap<String, CapturedValue>),
}
impl std::fmt::Display for CapturedValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CapturedValue::String(s) => write!(f, "{}", s),
CapturedValue::Number(n) => write!(f, "{}", n),
CapturedValue::Boolean(b) => write!(f, "{}", b),
CapturedValue::Json(j) => write!(f, "{}", j),
CapturedValue::Array(_) | CapturedValue::Object(_) => {
let json_value = self.to_json();
write!(f, "{}", json_value)
}
}
}
}
impl CapturedValue {
pub fn to_json(&self) -> Value {
match self {
CapturedValue::String(s) => Value::String(s.clone()),
CapturedValue::Number(n) => json!(n),
CapturedValue::Boolean(b) => Value::Bool(*b),
CapturedValue::Json(j) => j.clone(),
CapturedValue::Array(arr) => {
let values: Vec<Value> = arr.iter().map(|v| v.to_json()).collect();
Value::Array(values)
}
CapturedValue::Object(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
obj.insert(k.clone(), v.to_json());
}
Value::Object(obj)
}
}
}
}
impl From<Value> for CapturedValue {
fn from(value: Value) -> Self {
match value {
Value::String(s) => CapturedValue::String(s),
Value::Number(n) => {
if let Some(f) = n.as_f64() {
CapturedValue::Number(f)
} else if let Some(i) = n.as_i64() {
CapturedValue::Number(i as f64)
} else if let Some(u) = n.as_u64() {
CapturedValue::Number(u as f64)
} else {
CapturedValue::Json(Value::Number(n))
}
}
Value::Bool(b) => CapturedValue::Boolean(b),
Value::Array(arr) => {
let values: Vec<CapturedValue> = arr.into_iter().map(Into::into).collect();
CapturedValue::Array(values)
}
Value::Object(obj) => {
let mut map = HashMap::new();
for (k, v) in obj {
map.insert(k, v.into());
}
CapturedValue::Object(map)
}
Value::Null => CapturedValue::String("null".to_string()),
}
}
}
pub struct CommandResult {
pub stdout: Option<String>,
pub stderr: Option<String>,
pub exit_code: i32,
pub success: bool,
pub duration: Duration,
}
#[derive(Debug, Clone)]
pub struct VariableStore {
variables: Arc<RwLock<HashMap<String, CapturedValue>>>,
parent: Option<Arc<VariableStore>>,
}
impl Default for VariableStore {
fn default() -> Self {
Self::new()
}
}
impl VariableStore {
pub fn new() -> Self {
Self {
variables: Arc::new(RwLock::new(HashMap::new())),
parent: None,
}
}
pub fn child(&self) -> Self {
Self {
variables: Arc::new(RwLock::new(HashMap::new())),
parent: Some(Arc::new(self.clone())),
}
}
pub async fn set(&self, name: impl Into<String>, value: CapturedValue) {
let mut vars = self.variables.write().await;
vars.insert(name.into(), value);
}
pub fn get<'a>(
&'a self,
name: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<CapturedValue>> + Send + 'a>>
{
Box::pin(async move {
let vars = self.variables.read().await;
if let Some(value) = vars.get(name) {
return Some(value.clone());
}
drop(vars);
if let Some(parent) = &self.parent {
parent.get(name).await
} else {
None
}
})
}
pub async fn capture_command_result(
&self,
name: &str,
result: CommandResult,
format: CaptureFormat,
streams: &CaptureStreams,
) -> Result<()> {
if streams.stdout {
let value = match format {
CaptureFormat::String => {
CapturedValue::String(result.stdout.clone().unwrap_or_default())
}
CaptureFormat::Json => {
let json_str = result.stdout.as_deref().unwrap_or("null");
let json_value: Value = serde_json::from_str(json_str)
.map_err(|e| anyhow!("Failed to parse JSON output: {}", e))?;
CapturedValue::from(json_value)
}
CaptureFormat::Lines => {
let lines = result
.stdout
.as_deref()
.unwrap_or("")
.lines()
.map(|s| CapturedValue::String(s.to_string()))
.collect();
CapturedValue::Array(lines)
}
CaptureFormat::Number => {
let num_str = result.stdout.as_deref().unwrap_or("0").trim();
let num = num_str
.parse::<f64>()
.map_err(|e| anyhow!("Failed to parse number '{}': {}", num_str, e))?;
CapturedValue::Number(num)
}
CaptureFormat::Boolean => {
let bool_str = result.stdout.as_deref().unwrap_or("false").trim();
let val = bool_str.parse::<bool>().unwrap_or(result.success);
CapturedValue::Boolean(val)
}
};
self.set(name, value).await;
}
if streams.stderr {
if let Some(stderr) = &result.stderr {
self.set(
format!("{}.stderr", name),
CapturedValue::String(stderr.clone()),
)
.await;
}
}
if streams.exit_code {
self.set(
format!("{}.exit_code", name),
CapturedValue::Number(result.exit_code as f64),
)
.await;
}
if streams.success {
self.set(
format!("{}.success", name),
CapturedValue::Boolean(result.success),
)
.await;
}
if streams.duration {
self.set(
format!("{}.duration", name),
CapturedValue::Number(result.duration.as_secs_f64()),
)
.await;
}
Ok(())
}
pub async fn resolve_path(&self, path: &str) -> Result<CapturedValue> {
let parts: Vec<&str> = path.split('.').collect();
let base_value = self
.get(parts[0])
.await
.ok_or_else(|| anyhow!("Variable '{}' not found", parts[0]))?;
let mut current = base_value;
for part in &parts[1..] {
current = match current {
CapturedValue::Json(ref obj) => {
if let Some(value) = obj.get(*part) {
value.clone().into()
} else {
return Err(anyhow!("Field '{}' not found in JSON object", part));
}
}
CapturedValue::Object(ref map) => map
.get(*part)
.ok_or_else(|| anyhow!("Field '{}' not found in object", part))?
.clone(),
_ => {
return Err(anyhow!(
"Cannot access field '{}' on non-object value",
part
))
}
};
}
Ok(current)
}
pub fn to_hashmap(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = HashMap<String, String>> + Send + '_>>
{
Box::pin(async move {
let mut result = HashMap::new();
if let Some(parent) = &self.parent {
result.extend(parent.to_hashmap().await);
}
let vars = self.variables.read().await;
for (key, value) in vars.iter() {
result.insert(key.clone(), value.to_string());
}
result
})
}
pub fn get_all(
&self,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = HashMap<String, CapturedValue>> + Send + '_>,
> {
Box::pin(async move {
let mut result = HashMap::new();
if let Some(parent) = &self.parent {
let parent_vars = parent.get_all().await;
for (k, v) in parent_vars {
result.insert(k, v);
}
}
let vars = self.variables.read().await;
for (key, value) in vars.iter() {
result.insert(key.clone(), value.clone());
}
result
})
}
pub fn to_json(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Value> + Send + '_>> {
Box::pin(async move {
let mut result = serde_json::Map::new();
if let Some(parent) = &self.parent {
if let Value::Object(parent_map) = parent.to_json().await {
result.extend(parent_map);
}
}
let vars = self.variables.read().await;
for (key, value) in vars.iter() {
result.insert(key.clone(), value.to_json());
}
Value::Object(result)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_standard_variables_from_argument() {
let input = ExecutionInput::Argument("test_arg".to_string());
let ctx =
VariableContext::from_execution_input(&ExecutionMode::WithArguments, &input, 0, 3);
assert_eq!(ctx.get("item"), Some(&json!("test_arg")));
assert_eq!(ctx.get("item.value"), Some(&json!("test_arg")));
assert_eq!(ctx.get("item_index"), Some(&json!(0)));
assert_eq!(ctx.get("item_total"), Some(&json!(3)));
assert_eq!(ctx.get("ARG"), Some(&json!("test_arg")));
}
#[test]
fn test_standard_variables_from_file() {
let input = ExecutionInput::FilePath("/path/to/file.txt".to_string());
let ctx =
VariableContext::from_execution_input(&ExecutionMode::WithFilePattern, &input, 1, 5);
assert_eq!(ctx.get("item"), Some(&json!("/path/to/file.txt")));
assert_eq!(ctx.get("item.path"), Some(&json!("/path/to/file.txt")));
assert_eq!(ctx.get("item_index"), Some(&json!(1)));
assert_eq!(ctx.get("item_total"), Some(&json!(5)));
assert_eq!(ctx.get("FILE"), Some(&json!("/path/to/file.txt")));
assert_eq!(ctx.get("FILE_PATH"), Some(&json!("/path/to/file.txt")));
}
#[test]
fn test_standard_variables_from_json() {
let obj = json!({
"file_path": "/path/to/data.json",
"name": "Test Item",
"value": 42
});
let input = ExecutionInput::JsonObject(obj.clone());
let ctx = VariableContext::from_execution_input(&ExecutionMode::MapReduce, &input, 2, 10);
assert_eq!(ctx.get("item"), Some(&obj));
assert_eq!(ctx.get("item.path"), Some(&json!("/path/to/data.json")));
assert_eq!(ctx.get("item.name"), Some(&json!("Test Item")));
assert_eq!(ctx.get("item_index"), Some(&json!(2)));
assert_eq!(ctx.get("item_total"), Some(&json!(10)));
}
#[test]
fn test_variable_interpolation() {
let input = ExecutionInput::Argument("test_file.txt".to_string());
let mut ctx =
VariableContext::from_execution_input(&ExecutionMode::WithArguments, &input, 0, 1);
ctx.set_workflow_metadata("test_workflow", "wf-123", 1);
ctx.set_step_metadata("process_file", 0);
let template = "Processing ${item.value} in workflow ${workflow.name} (step ${step.index})";
let result = ctx.interpolate(template).unwrap();
assert_eq!(
result,
"Processing test_file.txt in workflow test_workflow (step 0)"
);
}
#[test]
fn test_alias_resolution() {
let input = ExecutionInput::FilePath("/data/file.txt".to_string());
let ctx =
VariableContext::from_execution_input(&ExecutionMode::WithFilePattern, &input, 0, 1);
let template = "File: ${FILE} or ${FILE_PATH} or ${item.path}";
let resolved = ctx.resolve_aliases(template);
assert!(resolved.contains("${item.path}"));
assert_eq!(resolved.matches("${item.path}").count(), 3);
}
#[test]
fn test_mapreduce_metadata() {
let mut ctx = VariableContext::empty();
ctx.set_mapreduce_metadata(Some(3), Some("key_123"));
ctx.set_map_results(json!({"total": 100, "processed": 95}));
assert_eq!(ctx.get("worker.id"), Some(&json!(3)));
assert_eq!(ctx.get("map.key"), Some(&json!("key_123")));
assert_eq!(
ctx.get("map.results"),
Some(&json!({"total": 100, "processed": 95}))
);
}
#[test]
fn test_output_capture() {
let mut ctx = VariableContext::empty();
ctx.set_last_output("Command completed successfully", 0);
assert_eq!(
ctx.get("last.output"),
Some(&json!("Command completed successfully"))
);
assert_eq!(ctx.get("last.exit_code"), Some(&json!(0)));
}
#[tokio::test]
async fn test_variable_store_basic() {
let store = VariableStore::new();
store
.set("name", CapturedValue::String("test".to_string()))
.await;
store.set("count", CapturedValue::Number(42.0)).await;
store.set("enabled", CapturedValue::Boolean(true)).await;
assert_eq!(store.get("name").await.unwrap().to_string(), "test");
assert_eq!(store.get("count").await.unwrap().to_string(), "42");
assert_eq!(store.get("enabled").await.unwrap().to_string(), "true");
}
#[tokio::test]
async fn test_variable_store_hierarchy() {
let parent = VariableStore::new();
parent
.set("parent_var", CapturedValue::String("parent".to_string()))
.await;
let child = parent.child();
child
.set("child_var", CapturedValue::String("child".to_string()))
.await;
assert_eq!(child.get("parent_var").await.unwrap().to_string(), "parent");
assert_eq!(child.get("child_var").await.unwrap().to_string(), "child");
assert!(parent.get("child_var").await.is_none());
}
#[tokio::test]
async fn test_capture_command_result() {
let store = VariableStore::new();
let result = CommandResult {
stdout: Some("hello world".to_string()),
stderr: Some("warning".to_string()),
exit_code: 0,
success: true,
duration: Duration::from_secs(5),
};
store
.capture_command_result(
"cmd",
result,
CaptureFormat::String,
&CaptureStreams {
stdout: true,
stderr: true,
..Default::default()
},
)
.await
.unwrap();
assert_eq!(store.get("cmd").await.unwrap().to_string(), "hello world");
assert_eq!(
store.get("cmd.stderr").await.unwrap().to_string(),
"warning"
);
assert_eq!(store.get("cmd.exit_code").await.unwrap().to_string(), "0");
assert_eq!(store.get("cmd.success").await.unwrap().to_string(), "true");
}
#[tokio::test]
async fn test_json_capture() {
let store = VariableStore::new();
let result = CommandResult {
stdout: Some(r#"{"name": "test", "count": 42}"#.to_string()),
stderr: None,
exit_code: 0,
success: true,
duration: Duration::from_secs(1),
};
store
.capture_command_result(
"data",
result,
CaptureFormat::Json,
&CaptureStreams::default(),
)
.await
.unwrap();
let name = store.resolve_path("data.name").await.unwrap();
assert_eq!(name.to_string(), "test");
let count = store.resolve_path("data.count").await.unwrap();
assert_eq!(count.to_string(), "42");
}
#[tokio::test]
async fn test_lines_capture() {
let store = VariableStore::new();
let result = CommandResult {
stdout: Some("line1\nline2\nline3".to_string()),
stderr: None,
exit_code: 0,
success: true,
duration: Duration::from_secs(1),
};
store
.capture_command_result(
"lines",
result,
CaptureFormat::Lines,
&CaptureStreams::default(),
)
.await
.unwrap();
let lines = store.get("lines").await.unwrap();
match lines {
CapturedValue::Array(arr) => {
assert_eq!(arr.len(), 3);
assert_eq!(arr[0].to_string(), "line1");
assert_eq!(arr[1].to_string(), "line2");
assert_eq!(arr[2].to_string(), "line3");
}
_ => panic!("Expected array value"),
}
}
#[test]
fn test_interpolation_with_captured_variables() {
let mut ctx = VariableContext::empty();
ctx.add_variable("shell.output", json!("build successful"));
ctx.add_variable("test.output", json!("all tests passed"));
ctx.add_variable("custom_var", json!("custom value"));
let template = "Build: ${shell.output}, Tests: ${test.output}, Custom: ${custom_var}";
let result = ctx.interpolate(template).unwrap();
assert_eq!(
result,
"Build: build successful, Tests: all tests passed, Custom: custom value"
);
}
#[test]
fn test_interpolation_missing_variable_fallback() {
let mut ctx = VariableContext::empty();
ctx.add_variable("existing", json!("present"));
let template = "Existing: ${existing}, Missing: ${missing|default:not_found}";
let result = ctx.interpolate(template);
assert!(result.is_ok());
}
#[test]
fn test_complex_nested_interpolation() {
let mut ctx = VariableContext::empty();
let nested = json!({
"build": {
"status": "success",
"time": 123,
"artifacts": ["app.exe", "lib.dll"]
}
});
ctx.add_variable("result", nested);
let template = "Status: ${result.build.status}, Time: ${result.build.time}s";
let result = ctx.interpolate(template).unwrap();
assert_eq!(result, "Status: success, Time: 123s");
}
#[test]
fn test_interpolation_with_mixed_sources() {
let mut ctx = VariableContext::empty();
ctx.set_workflow_metadata("test-workflow", "wf-123", 1);
ctx.add_variable("git.branch", json!("main"));
ctx.add_variable("commit.hash", json!("abc123"));
ctx.set_last_output("Deploy completed", 0);
let template = concat!(
"Workflow: ${workflow.name} (${workflow.id})\n",
"Branch: ${git.branch} @ ${commit.hash}\n",
"Status: ${last.output}"
);
let result = ctx.interpolate(template).unwrap();
assert!(result.contains("Workflow: test-workflow (wf-123)"));
assert!(result.contains("Branch: main @ abc123"));
assert!(result.contains("Status: Deploy completed"));
}
#[tokio::test]
async fn test_variable_store_to_hashmap() {
let store = VariableStore::new();
store
.set("name", CapturedValue::String("test".to_string()))
.await;
store.set("count", CapturedValue::Number(42.0)).await;
store
.set("data", CapturedValue::Json(json!({"key": "value"})))
.await;
let hashmap = store.to_hashmap().await;
assert_eq!(hashmap.get("name"), Some(&"test".to_string()));
assert_eq!(hashmap.get("count"), Some(&"42".to_string()));
assert!(hashmap.contains_key("data"));
}
#[tokio::test]
async fn test_number_capture_format() {
let store = VariableStore::new();
let result = CommandResult {
stdout: Some(" 42.5 \n".to_string()),
stderr: None,
exit_code: 0,
success: true,
duration: Duration::from_secs(1),
};
store
.capture_command_result(
"number",
result,
CaptureFormat::Number,
&CaptureStreams::default(),
)
.await
.unwrap();
let number = store.get("number").await.unwrap();
assert_eq!(number.to_string(), "42.5");
}
#[tokio::test]
async fn test_boolean_capture_format() {
let store = VariableStore::new();
let result_true = CommandResult {
stdout: Some("true".to_string()),
stderr: None,
exit_code: 0,
success: true,
duration: Duration::from_secs(1),
};
store
.capture_command_result(
"bool_true",
result_true,
CaptureFormat::Boolean,
&CaptureStreams::default(),
)
.await
.unwrap();
let bool_val = store.get("bool_true").await.unwrap();
assert_eq!(bool_val.to_string(), "true");
let result_false = CommandResult {
stdout: Some("false".to_string()),
stderr: None,
exit_code: 1,
success: false,
duration: Duration::from_secs(1),
};
store
.capture_command_result(
"bool_false",
result_false,
CaptureFormat::Boolean,
&CaptureStreams::default(),
)
.await
.unwrap();
let bool_val = store.get("bool_false").await.unwrap();
assert_eq!(bool_val.to_string(), "false");
}
#[tokio::test]
async fn test_variable_override_in_child_store() {
let parent = VariableStore::new();
parent
.set(
"shared_var",
CapturedValue::String("parent_value".to_string()),
)
.await;
let child = parent.child();
assert_eq!(
child.get("shared_var").await.unwrap().to_string(),
"parent_value"
);
child
.set(
"shared_var",
CapturedValue::String("child_value".to_string()),
)
.await;
assert_eq!(
child.get("shared_var").await.unwrap().to_string(),
"child_value"
);
assert_eq!(
parent.get("shared_var").await.unwrap().to_string(),
"parent_value"
);
}
#[test]
fn test_captured_value_array_display_outputs_valid_json() {
let items = vec![
CapturedValue::Json(json!({"item_id": "item_0", "status": "Success"})),
CapturedValue::Json(json!({"item_id": "item_1", "status": "Success"})),
CapturedValue::Json(json!({"item_id": "item_2", "status": "Success"})),
];
let captured_array = CapturedValue::Array(items);
let interpolated = captured_array.to_string();
let parsed_result: Result<Value, _> = serde_json::from_str(&interpolated);
assert!(
parsed_result.is_ok(),
"Interpolated string should be valid JSON. Got: {}",
interpolated
);
let parsed = parsed_result.unwrap();
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), 3);
}
#[test]
fn test_captured_value_object_display_outputs_valid_json() {
let mut map = HashMap::new();
map.insert("successful".to_string(), CapturedValue::Number(10.0));
map.insert("failed".to_string(), CapturedValue::Number(0.0));
map.insert("total".to_string(), CapturedValue::Number(10.0));
let captured_object = CapturedValue::Object(map);
let interpolated = captured_object.to_string();
let parsed_result: Result<Value, _> = serde_json::from_str(&interpolated);
assert!(
parsed_result.is_ok(),
"Interpolated string should be valid JSON. Got: {}",
interpolated
);
let parsed = parsed_result.unwrap();
assert!(parsed.is_object());
let obj = parsed.as_object().unwrap();
assert_eq!(obj.get("successful").unwrap(), &json!(10.0));
assert_eq!(obj.get("failed").unwrap(), &json!(0.0));
assert_eq!(obj.get("total").unwrap(), &json!(10.0));
}
}