use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use parking_lot::RwLock;
use rustc_hash::{FxBuildHasher, FxHashMap};
use serde_json::Value;
use super::context::LoadedContext;
use crate::binding::jsonpath;
#[derive(Debug, Clone)]
pub enum TaskOutcome {
Success,
Failed(String),
DependencyFailed {
dependency: String,
},
Skipped {
reason: String,
},
}
#[derive(Debug, Clone)]
pub struct TaskResult {
pub output: Arc<Value>,
pub duration: Duration,
pub status: TaskOutcome,
pub media: Vec<crate::media::MediaRef>,
}
impl TaskResult {
pub fn success(output: impl Into<Value>, duration: Duration) -> Self {
Self {
output: Arc::new(output.into()),
duration,
status: TaskOutcome::Success,
media: Vec::new(),
}
}
pub fn success_str(output: impl Into<String>, duration: Duration) -> Self {
Self {
output: Arc::new(Value::String(output.into())),
duration,
status: TaskOutcome::Success,
media: Vec::new(),
}
}
pub fn failed(error: impl Into<String>, duration: Duration) -> Self {
Self {
output: Arc::new(Value::Null),
duration,
status: TaskOutcome::Failed(error.into()),
media: Vec::new(),
}
}
pub fn dependency_failed(dependency: impl Into<String>) -> Self {
Self {
output: Arc::new(Value::Null),
duration: Duration::ZERO,
status: TaskOutcome::DependencyFailed {
dependency: dependency.into(),
},
media: Vec::new(),
}
}
pub fn skipped(reason: impl Into<String>) -> Self {
Self {
output: Arc::new(Value::Null),
duration: Duration::ZERO,
status: TaskOutcome::Skipped {
reason: reason.into(),
},
media: Vec::new(),
}
}
pub fn with_media(mut self, media: Vec<crate::media::MediaRef>) -> Self {
self.media = media;
self
}
pub fn is_success(&self) -> bool {
matches!(self.status, TaskOutcome::Success)
}
pub fn is_dependency_failed(&self) -> bool {
matches!(self.status, TaskOutcome::DependencyFailed { .. })
}
pub fn is_skipped(&self) -> bool {
matches!(self.status, TaskOutcome::Skipped { .. })
}
pub fn is_terminal(&self) -> bool {
true }
pub fn failed_dependency(&self) -> Option<&str> {
match &self.status {
TaskOutcome::DependencyFailed { dependency } => Some(dependency),
_ => None,
}
}
pub fn error(&self) -> Option<&str> {
match &self.status {
TaskOutcome::Failed(e) => Some(e),
TaskOutcome::DependencyFailed { dependency } => Some(dependency),
TaskOutcome::Skipped { reason } => Some(reason),
TaskOutcome::Success => None,
}
}
pub fn output_str(&self) -> Cow<'_, str> {
match &*self.output {
Value::String(s) => Cow::Borrowed(s),
other => Cow::Owned(other.to_string()),
}
}
}
#[derive(Clone)]
pub struct RunContext {
results: Arc<DashMap<Arc<str>, TaskResult, FxBuildHasher>>,
context: Arc<RwLock<LoadedContext>>,
inputs: Arc<RwLock<FxHashMap<String, Value>>>,
media_staging: Arc<DashMap<Arc<str>, Vec<crate::media::MediaRef>, FxBuildHasher>>,
media_budget: Arc<crate::media::MediaBudget>,
workspace_root: Arc<RwLock<PathBuf>>,
}
impl Default for RunContext {
fn default() -> Self {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self {
results: Arc::new(DashMap::with_hasher(FxBuildHasher)),
context: Arc::default(),
inputs: Arc::default(),
media_staging: Arc::new(DashMap::with_hasher(FxBuildHasher)),
media_budget: Arc::new({
let max = std::env::var("NIKA_MEDIA_BUDGET")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(crate::media::MediaBudget::DEFAULT_MAX_PER_RUN);
crate::media::MediaBudget::with_max_per_run(max)
}),
workspace_root: Arc::new(RwLock::new(workspace_root)),
}
}
}
impl RunContext {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&self, task_id: Arc<str>, result: TaskResult) {
self.results.insert(task_id, result);
}
pub fn get(&self, task_id: &str) -> Option<TaskResult> {
self.results.get(task_id).map(|r| r.value().clone())
}
pub fn contains(&self, task_id: &str) -> bool {
self.results.contains_key(task_id)
}
pub(crate) fn iter_results(&self) -> Vec<(Arc<str>, TaskResult)> {
self.results
.iter()
.map(|entry| (entry.key().clone(), entry.value().clone()))
.collect()
}
pub fn is_success(&self, task_id: &str) -> bool {
self.get(task_id).is_some_and(|r| r.is_success())
}
pub fn is_failed(&self, task_id: &str) -> bool {
self.get(task_id).is_some_and(|r| {
matches!(
r.status,
TaskOutcome::Failed(_) | TaskOutcome::DependencyFailed { .. }
)
})
}
pub fn is_dependency_failed(&self, task_id: &str) -> bool {
self.get(task_id).is_some_and(|r| r.is_dependency_failed())
}
pub fn get_failed_dependency(&self, task_id: &str) -> Option<String> {
self.get(task_id)
.and_then(|r| r.failed_dependency().map(String::from))
}
pub fn get_output(&self, task_id: &str) -> Option<Arc<Value>> {
self.results.get(task_id).map(|r| Arc::clone(&r.output))
}
pub fn set_media(&self, task_id: &Arc<str>, media: Vec<crate::media::MediaRef>) {
if !media.is_empty() {
self.media_staging.insert(Arc::clone(task_id), media);
}
}
pub fn take_media(&self, task_id: &Arc<str>) -> Vec<crate::media::MediaRef> {
self.media_staging
.remove(task_id)
.map(|(_, v)| v)
.unwrap_or_default()
}
pub fn media_budget(&self) -> &Arc<crate::media::MediaBudget> {
&self.media_budget
}
pub fn set_workspace_root(&self, root: PathBuf) {
*self.workspace_root.write() = root;
}
pub fn workspace_root(&self) -> PathBuf {
self.workspace_root.read().clone()
}
pub fn resolve_path(&self, path: &str) -> Option<Value> {
let mut parts = path.splitn(2, '.');
let task_id = parts.next()?;
let Some(remaining) = parts.next() else {
let output = self.get_output(task_id)?;
return Some((*output).clone());
};
if remaining == "media"
|| remaining.starts_with("media.")
|| remaining.starts_with("media[")
{
let result = self.results.get(task_id)?.value().clone();
if result.media.is_empty() {
return Some(Value::Array(vec![]));
}
let media_json = serde_json::to_value(&result.media).ok()?;
if remaining == "media" {
return Some(media_json);
}
let media_remaining = &remaining[5..]; if let Some(dot_rest) = media_remaining.strip_prefix('.') {
return jsonpath::resolve(&media_json, dot_rest).ok().flatten();
}
if media_remaining.starts_with('[') {
return jsonpath::resolve(&media_json, media_remaining)
.ok()
.flatten();
}
return Some(media_json);
}
let output = self.get_output(task_id)?;
match jsonpath::resolve(&output, remaining) {
Ok(v) => v,
Err(e) => {
tracing::debug!(path = %remaining, error = %e, "JSONPath resolution failed for task output");
None
}
}
}
pub fn set_context(&self, context: LoadedContext) {
*self.context.write() = context;
}
pub fn get_context_file(&self, alias: &str) -> Option<Value> {
self.context.read().get_file(alias).cloned()
}
pub fn get_context_session(&self) -> Option<Value> {
self.context.read().get_session().cloned()
}
pub fn has_context(&self) -> bool {
!self.context.read().is_empty()
}
pub fn resolve_context_path(&self, path: &str) -> Option<Value> {
let parts: Vec<&str> = path.split('.').collect();
if parts.len() < 2 {
return None;
}
let context = self.context.read();
match parts[1] {
"files" => {
if parts.len() < 3 {
return None;
}
let alias = parts[2];
let value = context.get_file(alias)?;
if parts.len() == 3 {
Some(value.clone())
} else {
let remaining = parts[3..].join(".");
match jsonpath::resolve(value, &remaining) {
Ok(v) => v,
Err(e) => {
tracing::debug!(path = %remaining, error = %e, "JSONPath resolution failed for context file");
None
}
}
}
}
"session" => {
let session = context.get_session()?;
if parts.len() == 2 {
Some(session.clone())
} else {
let remaining = parts[2..].join(".");
match jsonpath::resolve(session, &remaining) {
Ok(v) => v,
Err(e) => {
tracing::debug!(path = %remaining, error = %e, "JSONPath resolution failed for session");
None
}
}
}
}
_ => None,
}
}
pub fn set_inputs(&self, inputs: FxHashMap<String, Value>) {
*self.inputs.write() = inputs;
}
pub fn get_input_default(&self, name: &str) -> Option<Value> {
let inputs = self.inputs.read();
let definition = inputs.get(name)?;
if let Some(obj) = definition.as_object() {
if let Some(default_val) = obj.get("default").or_else(|| obj.get("value")) {
return Some(default_val.clone());
}
if obj.contains_key("type") || obj.contains_key("description") {
return None;
}
}
Some(definition.clone())
}
pub fn has_inputs(&self) -> bool {
!self.inputs.read().is_empty()
}
pub fn resolve_input_path(&self, path: &str) -> Option<Value> {
let parts: Vec<&str> = path.split('.').collect();
if parts.is_empty() || parts[0] != "inputs" {
return None;
}
if parts.len() < 2 {
return None;
}
let param_name = parts[1];
let default_value = self.get_input_default(param_name)?;
if parts.len() == 2 {
Some(default_value)
} else {
let remaining = parts[2..].join(".");
match jsonpath::resolve(&default_value, &remaining) {
Ok(v) => v,
Err(e) => {
tracing::debug!(path = %remaining, error = %e, "JSONPath resolution failed for input default");
None
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn insert_and_get_result() {
let store = RunContext::new();
store.insert(
Arc::from("task1"),
TaskResult::success(json!({"key": "value"}), Duration::from_secs(1)),
);
let result = store.get("task1").unwrap();
assert!(result.is_success());
assert_eq!(result.output["key"], "value");
}
#[test]
fn success_str_converts_to_value() {
let store = RunContext::new();
store.insert(
Arc::from("task1"),
TaskResult::success_str("hello", Duration::from_secs(1)),
);
let result = store.get("task1").unwrap();
assert_eq!(*result.output, Value::String("hello".to_string()));
assert_eq!(result.output_str(), "hello");
}
#[test]
fn failed_result() {
let store = RunContext::new();
store.insert(
Arc::from("task1"),
TaskResult::failed("oops", Duration::from_secs(1)),
);
let result = store.get("task1").unwrap();
assert!(!result.is_success());
assert_eq!(result.error(), Some("oops"));
}
#[test]
fn resolve_simple_path() {
let store = RunContext::new();
store.insert(
Arc::from("weather"),
TaskResult::success(json!({"summary": "Sunny"}), Duration::from_secs(1)),
);
let value = store.resolve_path("weather.summary").unwrap();
assert_eq!(value, "Sunny");
}
#[test]
fn resolve_nested_path() {
let store = RunContext::new();
store.insert(
Arc::from("flights"),
TaskResult::success(
json!({"cheapest": {"price": 89, "airline": "AF"}}),
Duration::from_secs(1),
),
);
assert_eq!(store.resolve_path("flights.cheapest.price").unwrap(), 89);
assert_eq!(
store.resolve_path("flights.cheapest.airline").unwrap(),
"AF"
);
}
#[test]
fn resolve_array_index() {
let store = RunContext::new();
store.insert(
Arc::from("data"),
TaskResult::success(
json!({"items": ["first", "second"]}),
Duration::from_secs(1),
),
);
assert_eq!(store.resolve_path("data.items.0").unwrap(), "first");
assert_eq!(store.resolve_path("data.items.1").unwrap(), "second");
}
#[test]
fn resolve_path_not_found() {
let store = RunContext::new();
store.insert(
Arc::from("task1"),
TaskResult::success(json!({"a": 1}), Duration::from_secs(1)),
);
assert!(store.resolve_path("task1.nonexistent").is_none());
assert!(store.resolve_path("unknown.field").is_none());
}
#[test]
fn concurrent_writes_all_stored() {
use std::thread;
let store = RunContext::new();
let store_arc = Arc::new(store);
let handles: Vec<_> = (0..100)
.map(|i| {
let store = Arc::clone(&store_arc);
thread::spawn(move || {
store.insert(
Arc::from(format!("task_{}", i)),
TaskResult::success(json!({"index": i}), Duration::from_millis(i)),
);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
for i in 0..100 {
assert!(
store_arc.contains(&format!("task_{}", i)),
"task_{} should exist",
i
);
}
}
#[test]
fn concurrent_reads_during_writes() {
use std::thread;
let store = Arc::new(RunContext::new());
for i in 0..50 {
store.insert(
Arc::from(format!("initial_{}", i)),
TaskResult::success(json!({"value": i}), Duration::from_millis(i)),
);
}
let store_writer = Arc::clone(&store);
let store_reader = Arc::clone(&store);
let writer = thread::spawn(move || {
for i in 0..100 {
store_writer.insert(
Arc::from(format!("new_{}", i)),
TaskResult::success(json!({"new": i}), Duration::from_millis(i)),
);
}
});
let reader = thread::spawn(move || {
let mut read_count = 0;
for i in 0..50 {
if store_reader.get(&format!("initial_{}", i)).is_some() {
read_count += 1;
}
}
read_count
});
writer.join().unwrap();
let reads = reader.join().unwrap();
assert_eq!(reads, 50, "Should read all 50 initial entries");
for i in 0..100 {
assert!(store.contains(&format!("new_{}", i)));
}
}
#[test]
fn overwrite_existing_task() {
let store = RunContext::new();
store.insert(
Arc::from("task1"),
TaskResult::success(json!({"version": 1}), Duration::from_secs(1)),
);
store.insert(
Arc::from("task1"),
TaskResult::success(json!({"version": 2}), Duration::from_secs(2)),
);
let result = store.get("task1").unwrap();
assert_eq!(result.output["version"], 2);
assert_eq!(result.duration, Duration::from_secs(2));
}
#[test]
fn contains_and_is_success() {
let store = RunContext::new();
assert!(!store.contains("nonexistent"));
assert!(!store.is_success("nonexistent"));
store.insert(
Arc::from("success"),
TaskResult::success(json!(1), Duration::from_secs(1)),
);
assert!(store.contains("success"));
assert!(store.is_success("success"));
store.insert(
Arc::from("failed"),
TaskResult::failed("error", Duration::from_secs(1)),
);
assert!(store.contains("failed"));
assert!(!store.is_success("failed"));
}
#[test]
fn get_output_returns_arc() {
let store = RunContext::new();
let big_json = json!({
"large": "data".repeat(1000),
"nested": {"deep": {"value": 42}}
});
store.insert(
Arc::from("big"),
TaskResult::success(big_json.clone(), Duration::from_secs(1)),
);
let output1 = store.get_output("big").unwrap();
let output2 = store.get_output("big").unwrap();
assert!(Arc::ptr_eq(&output1, &output2));
}
#[test]
fn resolve_task_only_returns_full_output() {
let store = RunContext::new();
store.insert(
Arc::from("task"),
TaskResult::success(json!({"a": 1, "b": 2}), Duration::from_secs(1)),
);
let full = store.resolve_path("task").unwrap();
assert_eq!(full, json!({"a": 1, "b": 2}));
}
#[test]
fn resolve_deeply_nested_path() {
let store = RunContext::new();
store.insert(
Arc::from("deep"),
TaskResult::success(
json!({"level1": {"level2": {"level3": {"level4": "found"}}}}),
Duration::from_secs(1),
),
);
let value = store
.resolve_path("deep.level1.level2.level3.level4")
.unwrap();
assert_eq!(value, "found");
}
#[test]
fn resolve_mixed_array_object_path() {
let store = RunContext::new();
store.insert(
Arc::from("mixed"),
TaskResult::success(
json!({
"users": [
{"name": "Alice", "scores": [90, 85, 92]},
{"name": "Bob", "scores": [78, 82]}
]
}),
Duration::from_secs(1),
),
);
assert_eq!(store.resolve_path("mixed.users.0.name").unwrap(), "Alice");
assert_eq!(store.resolve_path("mixed.users.1.name").unwrap(), "Bob");
assert_eq!(store.resolve_path("mixed.users.0.scores.2").unwrap(), 92);
}
#[test]
fn output_str_cow_borrowed_for_strings() {
let result = TaskResult::success_str("hello", Duration::from_secs(1));
let cow = result.output_str();
assert!(matches!(cow, std::borrow::Cow::Borrowed(_)));
assert_eq!(&*cow, "hello");
}
#[test]
fn output_str_cow_owned_for_non_strings() {
let result = TaskResult::success(json!({"num": 42}), Duration::from_secs(1));
let cow = result.output_str();
assert!(matches!(cow, std::borrow::Cow::Owned(_)));
assert!(cow.contains("42"));
}
#[test]
fn empty_task_id_resolves_nothing() {
let store = RunContext::new();
store.insert(
Arc::from("task"),
TaskResult::success(json!(1), Duration::from_secs(1)),
);
assert!(store.resolve_path("").is_none());
}
#[test]
fn clone_is_shallow() {
let store = RunContext::new();
store.insert(
Arc::from("task"),
TaskResult::success(json!({"value": 42}), Duration::from_secs(1)),
);
let cloned = store.clone();
assert_eq!(
store.get("task").unwrap().output,
cloned.get("task").unwrap().output
);
store.insert(
Arc::from("new"),
TaskResult::success(json!(1), Duration::from_secs(1)),
);
assert!(cloned.contains("new"));
}
#[test]
fn test_context_default_is_empty() {
let store = RunContext::new();
assert!(!store.has_context());
}
#[test]
fn test_set_and_get_context_file() {
let store = RunContext::new();
let mut context = LoadedContext::new();
context
.files
.insert("brand".to_string(), json!("# Brand Guide"));
store.set_context(context);
assert!(store.has_context());
assert_eq!(
store.get_context_file("brand"),
Some(json!("# Brand Guide"))
);
assert!(store.get_context_file("nonexistent").is_none());
}
#[test]
fn test_set_and_get_context_session() {
let store = RunContext::new();
let mut context = LoadedContext::new();
context.session = Some(json!({"focus_areas": ["rust", "ai"]}));
store.set_context(context);
assert!(store.has_context());
let session = store.get_context_session().unwrap();
assert!(session["focus_areas"].is_array());
}
#[test]
fn test_resolve_context_path_files() {
let store = RunContext::new();
let mut context = LoadedContext::new();
context.files.insert(
"persona".to_string(),
json!({"name": "Agent", "role": "assistant"}),
);
store.set_context(context);
assert_eq!(
store.resolve_context_path("context.files.persona"),
Some(json!({"name": "Agent", "role": "assistant"}))
);
assert_eq!(
store.resolve_context_path("context.files.persona.name"),
Some(json!("Agent"))
);
assert!(store
.resolve_context_path("context.files.missing")
.is_none());
}
#[test]
fn test_resolve_context_path_session() {
let store = RunContext::new();
let mut context = LoadedContext::new();
context.session = Some(json!({"focus": "rust", "level": 3}));
store.set_context(context);
assert_eq!(
store.resolve_context_path("context.session"),
Some(json!({"focus": "rust", "level": 3}))
);
assert_eq!(
store.resolve_context_path("context.session.focus"),
Some(json!("rust"))
);
assert_eq!(
store.resolve_context_path("context.session.level"),
Some(json!(3))
);
}
#[test]
fn test_resolve_context_path_invalid() {
let store = RunContext::new();
let mut context = LoadedContext::new();
context.files.insert("brand".to_string(), json!("content"));
store.set_context(context);
assert!(store.resolve_context_path("context").is_none());
assert!(store.resolve_context_path("context.invalid").is_none());
assert!(store.resolve_context_path("context.files").is_none());
assert!(store.resolve_context_path("other.path").is_none());
}
#[test]
fn test_inputs_default_is_empty() {
let store = RunContext::new();
assert!(!store.has_inputs());
}
#[test]
fn test_set_and_get_input_default() {
let store = RunContext::new();
let mut inputs = FxHashMap::default();
inputs.insert(
"topic".to_string(),
json!({
"type": "string",
"description": "Research topic",
"default": "AI QR code generation"
}),
);
store.set_inputs(inputs);
assert!(store.has_inputs());
assert_eq!(
store.get_input_default("topic"),
Some(json!("AI QR code generation"))
);
assert!(store.get_input_default("nonexistent").is_none());
}
#[test]
fn test_get_input_default_without_default() {
let store = RunContext::new();
let mut inputs = FxHashMap::default();
inputs.insert(
"required_param".to_string(),
json!({
"type": "string",
"description": "A required parameter"
}),
);
store.set_inputs(inputs);
assert!(store.get_input_default("required_param").is_none());
}
#[test]
fn test_resolve_input_path_simple() {
let store = RunContext::new();
let mut inputs = FxHashMap::default();
inputs.insert(
"topic".to_string(),
json!({
"type": "string",
"default": "AI trends 2025"
}),
);
inputs.insert(
"depth".to_string(),
json!({
"type": "string",
"default": "comprehensive"
}),
);
store.set_inputs(inputs);
assert_eq!(
store.resolve_input_path("inputs.topic"),
Some(json!("AI trends 2025"))
);
assert_eq!(
store.resolve_input_path("inputs.depth"),
Some(json!("comprehensive"))
);
assert!(store.resolve_input_path("inputs.missing").is_none());
}
#[test]
fn test_resolve_input_path_nested() {
let store = RunContext::new();
let mut inputs = FxHashMap::default();
inputs.insert(
"config".to_string(),
json!({
"type": "object",
"default": {
"theme": "dark",
"version": 2,
"nested": {
"deep": "value"
}
}
}),
);
store.set_inputs(inputs);
assert_eq!(
store.resolve_input_path("inputs.config.theme"),
Some(json!("dark"))
);
assert_eq!(
store.resolve_input_path("inputs.config.version"),
Some(json!(2))
);
assert_eq!(
store.resolve_input_path("inputs.config.nested.deep"),
Some(json!("value"))
);
}
#[test]
fn test_resolve_input_path_invalid() {
let store = RunContext::new();
let mut inputs = FxHashMap::default();
inputs.insert(
"topic".to_string(),
json!({
"type": "string",
"default": "test"
}),
);
store.set_inputs(inputs);
assert!(store.resolve_input_path("inputs").is_none());
assert!(store.resolve_input_path("other.path").is_none());
assert!(store.resolve_input_path("").is_none());
}
fn task_with_media() -> TaskResult {
use std::path::PathBuf;
let media = vec![
crate::media::MediaRef {
hash: "blake3:af1349b9".to_string(),
mime_type: "image/png".to_string(),
size_bytes: 4096,
path: PathBuf::from("/tmp/cas/af/1349b9"),
extension: "png".to_string(),
created_by: "gen_img".to_string(),
metadata: serde_json::Map::new(),
},
crate::media::MediaRef {
hash: "blake3:deadbeef".to_string(),
mime_type: "audio/wav".to_string(),
size_bytes: 8192,
path: PathBuf::from("/tmp/cas/de/adbeef"),
extension: "wav".to_string(),
created_by: "gen_img".to_string(),
metadata: serde_json::Map::new(),
},
];
TaskResult::success(json!({"prompt": "a cat"}), Duration::from_secs(1)).with_media(media)
}
#[test]
fn resolve_media_full_array() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
let value = store.resolve_path("gen_img.media").unwrap();
let arr = value.as_array().expect("media should be an array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["hash"], "blake3:af1349b9");
assert_eq!(arr[1]["hash"], "blake3:deadbeef");
}
#[test]
fn resolve_media_index_hash() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
let hash = store.resolve_path("gen_img.media[0].hash").unwrap();
assert_eq!(hash, "blake3:af1349b9");
let hash2 = store.resolve_path("gen_img.media[1].hash").unwrap();
assert_eq!(hash2, "blake3:deadbeef");
}
#[test]
fn resolve_media_index_mime_type() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
let mime = store.resolve_path("gen_img.media[0].mime_type").unwrap();
assert_eq!(mime, "image/png");
let mime2 = store.resolve_path("gen_img.media[1].mime_type").unwrap();
assert_eq!(mime2, "audio/wav");
}
#[test]
fn resolve_media_empty_returns_empty_array() {
let store = RunContext::new();
store.insert(
Arc::from("no_media"),
TaskResult::success(json!({"text": "hello"}), Duration::from_secs(1)),
);
let value = store.resolve_path("no_media.media").unwrap();
assert_eq!(value, json!([]));
}
#[test]
fn resolve_media_index_path() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
let path = store.resolve_path("gen_img.media[0].path").unwrap();
assert_eq!(path, "/tmp/cas/af/1349b9");
}
#[test]
fn resolve_media_index_size_bytes() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
let size = store.resolve_path("gen_img.media[0].size_bytes").unwrap();
assert_eq!(size, 4096);
}
#[test]
fn resolve_media_index_extension() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
let ext = store.resolve_path("gen_img.media[0].extension").unwrap();
assert_eq!(ext, "png");
}
#[test]
fn resolve_media_out_of_bounds() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
assert!(store.resolve_path("gen_img.media[99].hash").is_none());
}
#[test]
fn resolve_media_does_not_shadow_output() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
let prompt = store.resolve_path("gen_img.prompt").unwrap();
assert_eq!(prompt, "a cat");
}
#[test]
fn iter_results_returns_all_entries() {
let store = RunContext::new();
store.insert(
Arc::from("task1"),
TaskResult::success_str("out1", Duration::from_millis(10)),
);
store.insert(
Arc::from("task2"),
TaskResult::success_str("out2", Duration::from_millis(20)),
);
store.insert(
Arc::from("task3"),
TaskResult::failed("err", Duration::from_millis(5)),
);
let results = store.iter_results();
assert_eq!(results.len(), 3);
let ids: Vec<String> = results.iter().map(|(id, _)| id.to_string()).collect();
assert!(ids.contains(&"task1".to_string()));
assert!(ids.contains(&"task2".to_string()));
assert!(ids.contains(&"task3".to_string()));
}
#[test]
fn iter_results_includes_media_refs() {
let store = RunContext::new();
store.insert(Arc::from("gen_img"), task_with_media());
let results = store.iter_results();
let (_, result) = results
.iter()
.find(|(id, _)| id.as_ref() == "gen_img")
.unwrap();
assert_eq!(result.media.len(), 2);
assert_eq!(result.media[0].hash, "blake3:af1349b9");
}
#[test]
fn invoke_json_result_accessible_via_template_binding() {
let store = RunContext::new();
let invoke_output = r#"{"hash":"blake3:abc123","mime_type":"image/png","size_bytes":1234,"metadata":{"width":256,"height":192}}"#;
store.insert(
Arc::from("thumb"),
TaskResult::success_str(invoke_output, Duration::from_millis(100)),
);
let hash = store.resolve_path("thumb.hash").unwrap();
assert_eq!(
hash, "blake3:abc123",
"{{{{with.thumb.hash}}}} must resolve"
);
let mime = store.resolve_path("thumb.mime_type").unwrap();
assert_eq!(
mime, "image/png",
"{{{{with.thumb.mime_type}}}} must resolve"
);
let size = store.resolve_path("thumb.size_bytes").unwrap();
assert_eq!(size, 1234, "{{{{with.thumb.size_bytes}}}} must resolve");
let width = store.resolve_path("thumb.metadata.width").unwrap();
assert_eq!(width, 256, "{{{{with.thumb.metadata.width}}}} must resolve");
let height = store.resolve_path("thumb.metadata.height").unwrap();
assert_eq!(
height, 192,
"{{{{with.thumb.metadata.height}}}} must resolve"
);
}
#[test]
fn invoke_json_result_with_array_accessible() {
let store = RunContext::new();
let invoke_output = r##"{"colors":[{"r":255,"g":0,"b":0,"hex":"#ff0000"},{"r":0,"g":0,"b":255,"hex":"#0000ff"}],"count":2}"##;
store.insert(
Arc::from("colors"),
TaskResult::success_str(invoke_output, Duration::from_millis(50)),
);
let count = store.resolve_path("colors.count").unwrap();
assert_eq!(count, 2);
let first_hex = store.resolve_path("colors.colors[0].hex").unwrap();
assert_eq!(first_hex, "#ff0000");
let second_r = store.resolve_path("colors.colors[1].r").unwrap();
assert_eq!(second_r, 0);
}
#[test]
fn invoke_dimensions_result_accessible() {
let store = RunContext::new();
let invoke_output = r#"{"width":1024,"height":768,"orientation":"landscape"}"#;
store.insert(
Arc::from("dim"),
TaskResult::success_str(invoke_output, Duration::from_millis(10)),
);
assert_eq!(store.resolve_path("dim.width").unwrap(), 1024);
assert_eq!(store.resolve_path("dim.height").unwrap(), 768);
assert_eq!(store.resolve_path("dim.orientation").unwrap(), "landscape");
}
#[test]
fn enriched_media_ref_metadata_accessible() {
let store = RunContext::new();
let mut metadata = serde_json::Map::new();
metadata.insert("width".into(), json!(512));
metadata.insert("height".into(), json!(384));
metadata.insert("thumbhash".into(), json!("dGVzdA=="));
let media = vec![crate::media::MediaRef {
hash: "blake3:enriched123".to_string(),
mime_type: "image/png".to_string(),
size_bytes: 2048,
path: std::path::PathBuf::from("/cas/en/riched123"),
extension: "png".to_string(),
created_by: "gen".to_string(),
metadata,
}];
store.insert(
Arc::from("gen"),
TaskResult::success(json!("image generated"), Duration::from_secs(1)).with_media(media),
);
assert_eq!(
store.resolve_path("gen.media[0].hash").unwrap(),
"blake3:enriched123"
);
assert_eq!(
store.resolve_path("gen.media[0].metadata.width").unwrap(),
512
);
assert_eq!(
store.resolve_path("gen.media[0].metadata.height").unwrap(),
384
);
assert_eq!(
store
.resolve_path("gen.media[0].metadata.thumbhash")
.unwrap(),
"dGVzdA=="
);
}
#[test]
fn chained_invoke_bindings_work() {
let store = RunContext::new();
let media = vec![crate::media::MediaRef {
hash: "blake3:source_hash".to_string(),
mime_type: "image/png".to_string(),
size_bytes: 5000,
path: std::path::PathBuf::from("/cas/so/urce"),
extension: "png".to_string(),
created_by: "gen".to_string(),
metadata: serde_json::Map::new(),
}];
store.insert(
Arc::from("gen"),
TaskResult::success(json!("ok"), Duration::from_secs(1)).with_media(media),
);
store.insert(
Arc::from("thumb"),
TaskResult::success_str(
r#"{"hash":"blake3:thumb_hash","size_bytes":1500,"metadata":{"width":256}}"#,
Duration::from_millis(200),
),
);
store.insert(
Arc::from("dim"),
TaskResult::success_str(
r#"{"width":256,"height":192,"orientation":"landscape"}"#,
Duration::from_millis(10),
),
);
assert_eq!(
store.resolve_path("gen.media[0].hash").unwrap(),
"blake3:source_hash"
);
assert_eq!(
store.resolve_path("thumb.hash").unwrap(),
"blake3:thumb_hash"
);
assert_eq!(store.resolve_path("thumb.metadata.width").unwrap(), 256);
assert_eq!(store.resolve_path("dim.width").unwrap(), 256);
assert_eq!(store.resolve_path("dim.orientation").unwrap(), "landscape");
}
}