1use std::borrow::Cow;
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::Duration;
13
14use dashmap::DashMap;
15use parking_lot::RwLock;
16use rustc_hash::{FxBuildHasher, FxHashMap};
17use serde_json::Value;
18
19use super::context::LoadedContext;
20use crate::binding::jsonpath;
21
22#[derive(Debug, Clone)]
24pub enum TaskOutcome {
25 Success,
26 Failed(String),
27 DependencyFailed {
29 dependency: String,
31 },
32 Skipped {
34 reason: String,
36 },
37}
38
39#[derive(Debug, Clone)]
41pub struct TaskResult {
42 pub output: Arc<Value>,
44 pub duration: Duration,
46 pub status: TaskOutcome,
48 pub media: Vec<crate::media::MediaRef>,
50}
51
52impl TaskResult {
53 pub fn success(output: impl Into<Value>, duration: Duration) -> Self {
55 Self {
56 output: Arc::new(output.into()),
57 duration,
58 status: TaskOutcome::Success,
59 media: Vec::new(),
60 }
61 }
62
63 pub fn success_str(output: impl Into<String>, duration: Duration) -> Self {
65 Self {
66 output: Arc::new(Value::String(output.into())),
67 duration,
68 status: TaskOutcome::Success,
69 media: Vec::new(),
70 }
71 }
72
73 pub fn failed(error: impl Into<String>, duration: Duration) -> Self {
75 Self {
76 output: Arc::new(Value::Null),
77 duration,
78 status: TaskOutcome::Failed(error.into()),
79 media: Vec::new(),
80 }
81 }
82
83 pub fn dependency_failed(dependency: impl Into<String>) -> Self {
88 Self {
89 output: Arc::new(Value::Null),
90 duration: Duration::ZERO,
91 status: TaskOutcome::DependencyFailed {
92 dependency: dependency.into(),
93 },
94 media: Vec::new(),
95 }
96 }
97
98 pub fn skipped(reason: impl Into<String>) -> Self {
102 Self {
103 output: Arc::new(Value::Null),
104 duration: Duration::ZERO,
105 status: TaskOutcome::Skipped {
106 reason: reason.into(),
107 },
108 media: Vec::new(),
109 }
110 }
111
112 pub fn with_media(mut self, media: Vec<crate::media::MediaRef>) -> Self {
114 self.media = media;
115 self
116 }
117
118 pub fn is_success(&self) -> bool {
120 matches!(self.status, TaskOutcome::Success)
121 }
122
123 pub fn is_dependency_failed(&self) -> bool {
125 matches!(self.status, TaskOutcome::DependencyFailed { .. })
126 }
127
128 pub fn is_skipped(&self) -> bool {
130 matches!(self.status, TaskOutcome::Skipped { .. })
131 }
132
133 pub fn failed_dependency(&self) -> Option<&str> {
135 match &self.status {
136 TaskOutcome::DependencyFailed { dependency } => Some(dependency),
137 _ => None,
138 }
139 }
140
141 pub fn error(&self) -> Option<&str> {
143 match &self.status {
144 TaskOutcome::Failed(e) => Some(e),
145 TaskOutcome::DependencyFailed { dependency } => Some(dependency),
146 TaskOutcome::Skipped { reason } => Some(reason),
147 TaskOutcome::Success => None,
148 }
149 }
150
151 pub fn output_str(&self) -> Cow<'_, str> {
153 match &*self.output {
154 Value::String(s) => Cow::Borrowed(s),
155 other => Cow::Owned(other.to_string()),
156 }
157 }
158}
159
160#[derive(Clone)]
167pub struct RunContext {
168 results: Arc<DashMap<Arc<str>, TaskResult, FxBuildHasher>>,
170
171 context: Arc<RwLock<LoadedContext>>,
176
177 inputs: Arc<RwLock<FxHashMap<String, Value>>>,
182
183 media_staging: Arc<DashMap<Arc<str>, Vec<crate::media::MediaRef>, FxBuildHasher>>,
187
188 media_budget: Arc<crate::media::MediaBudget>,
191
192 workspace_root: Arc<RwLock<PathBuf>>,
195}
196
197impl Default for RunContext {
198 fn default() -> Self {
199 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
200 Self {
201 results: Arc::new(DashMap::with_hasher(FxBuildHasher)),
202 context: Arc::default(),
203 inputs: Arc::default(),
204 media_staging: Arc::new(DashMap::with_hasher(FxBuildHasher)),
205 media_budget: Arc::new({
206 let max = std::env::var("NIKA_MEDIA_BUDGET")
207 .ok()
208 .and_then(|v| v.parse::<u64>().ok())
209 .unwrap_or(crate::media::MediaBudget::DEFAULT_MAX_PER_RUN);
210 crate::media::MediaBudget::with_max_per_run(max)
211 }),
212 workspace_root: Arc::new(RwLock::new(workspace_root)),
213 }
214 }
215}
216
217impl RunContext {
218 pub fn new() -> Self {
219 Self::default()
220 }
221
222 pub fn insert(&self, task_id: Arc<str>, result: TaskResult) {
224 self.results.insert(task_id, result);
225 }
226
227 pub fn get(&self, task_id: &str) -> Option<TaskResult> {
229 self.results.get(task_id).map(|r| r.value().clone())
230 }
231
232 pub fn contains(&self, task_id: &str) -> bool {
234 self.results.contains_key(task_id)
235 }
236
237 pub fn is_completed_successfully(&self, task_id: &str) -> Option<bool> {
242 self.results.get(task_id).map(|r| r.value().is_success())
243 }
244
245 pub fn iter_results(&self) -> Vec<(Arc<str>, TaskResult)> {
254 self.results
255 .iter()
256 .map(|entry| (entry.key().clone(), entry.value().clone()))
257 .collect()
258 }
259
260 pub fn is_success(&self, task_id: &str) -> bool {
262 self.get(task_id).is_some_and(|r| r.is_success())
263 }
264
265 pub fn is_failed(&self, task_id: &str) -> bool {
267 self.get(task_id).is_some_and(|r| {
268 matches!(
269 r.status,
270 TaskOutcome::Failed(_) | TaskOutcome::DependencyFailed { .. }
271 )
272 })
273 }
274
275 pub fn is_dependency_failed(&self, task_id: &str) -> bool {
277 self.get(task_id).is_some_and(|r| r.is_dependency_failed())
278 }
279
280 pub fn get_failed_dependency(&self, task_id: &str) -> Option<String> {
282 self.get(task_id)
283 .and_then(|r| r.failed_dependency().map(String::from))
284 }
285
286 pub fn get_output(&self, task_id: &str) -> Option<Arc<Value>> {
289 self.results.get(task_id).map(|r| Arc::clone(&r.output))
290 }
291
292 pub fn set_media(&self, task_id: &Arc<str>, media: Vec<crate::media::MediaRef>) {
298 if !media.is_empty() {
299 self.media_staging.insert(Arc::clone(task_id), media);
300 }
301 }
302
303 pub fn take_media(&self, task_id: &Arc<str>) -> Vec<crate::media::MediaRef> {
306 self.media_staging
307 .remove(task_id)
308 .map(|(_, v)| v)
309 .unwrap_or_default()
310 }
311
312 pub fn media_budget(&self) -> &Arc<crate::media::MediaBudget> {
314 &self.media_budget
315 }
316
317 pub fn set_workspace_root(&self, root: PathBuf) {
319 *self.workspace_root.write() = root;
320 }
321
322 pub fn workspace_root(&self) -> PathBuf {
324 self.workspace_root.read().clone()
325 }
326
327 pub fn resolve_path(&self, path: &str) -> Option<Value> {
337 let mut parts = path.splitn(2, '.');
338 let task_id = parts.next()?;
339
340 let Some(remaining) = parts.next() else {
342 let output = self.get_output(task_id)?;
343 return Some((*output).clone());
344 };
345
346 if remaining == "media"
348 || remaining.starts_with("media.")
349 || remaining.starts_with("media[")
350 {
351 let result = self.results.get(task_id)?.value().clone();
352 if result.media.is_empty() {
353 return Some(Value::Array(vec![]));
354 }
355 let media_json = serde_json::to_value(&result.media).ok()?;
356 if remaining == "media" {
357 return Some(media_json);
358 }
359 let media_remaining = &remaining[5..]; if let Some(dot_rest) = media_remaining.strip_prefix('.') {
361 return jsonpath::resolve(&media_json, dot_rest).ok().flatten();
362 }
363 if media_remaining.starts_with('[') {
364 return jsonpath::resolve(&media_json, media_remaining)
365 .ok()
366 .flatten();
367 }
368 return Some(media_json);
369 }
370
371 let output = self.get_output(task_id)?;
372
373 match jsonpath::resolve(&output, remaining) {
376 Ok(v) => v,
377 Err(e) => {
378 tracing::debug!(path = %remaining, error = %e, "JSONPath resolution failed for task output");
379 None
380 }
381 }
382 }
383
384 pub fn set_context(&self, context: LoadedContext) {
392 *self.context.write() = context;
393 }
394
395 pub fn get_context_file(&self, alias: &str) -> Option<Value> {
399 self.context.read().get_file(alias).cloned()
400 }
401
402 pub fn get_context_session(&self) -> Option<Value> {
406 self.context.read().get_session().cloned()
407 }
408
409 pub fn has_context(&self) -> bool {
411 !self.context.read().is_empty()
412 }
413
414 pub fn resolve_context_path(&self, path: &str) -> Option<Value> {
422 let parts: Vec<&str> = path.split('.').collect();
423 if parts.len() < 2 {
424 return None;
425 }
426
427 let context = self.context.read();
428
429 match parts[1] {
430 "files" => {
431 if parts.len() < 3 {
432 return None;
433 }
434 let alias = parts[2];
435 let value = context.get_file(alias)?;
436
437 if parts.len() == 3 {
438 Some(value.clone())
440 } else {
441 let remaining = parts[3..].join(".");
443 match jsonpath::resolve(value, &remaining) {
444 Ok(v) => v,
445 Err(e) => {
446 tracing::debug!(path = %remaining, error = %e, "JSONPath resolution failed for context file");
447 None
448 }
449 }
450 }
451 }
452 "session" => {
453 let session = context.get_session()?;
454
455 if parts.len() == 2 {
456 Some(session.clone())
458 } else {
459 let remaining = parts[2..].join(".");
461 match jsonpath::resolve(session, &remaining) {
462 Ok(v) => v,
463 Err(e) => {
464 tracing::debug!(path = %remaining, error = %e, "JSONPath resolution failed for session");
465 None
466 }
467 }
468 }
469 }
470 _ => None,
471 }
472 }
473
474 pub fn set_inputs(&self, inputs: FxHashMap<String, Value>) {
483 *self.inputs.write() = inputs;
484 }
485
486 pub fn get_input_default(&self, name: &str) -> Option<Value> {
494 let inputs = self.inputs.read();
495 let definition = inputs.get(name)?;
496
497 if let Some(obj) = definition.as_object() {
500 if let Some(default_val) = obj.get("default").or_else(|| obj.get("value")) {
503 return Some(default_val.clone());
504 }
505 if obj.contains_key("type") || obj.contains_key("description") {
507 return None;
508 }
509 }
510
511 Some(definition.clone())
514 }
515
516 pub fn has_inputs(&self) -> bool {
518 !self.inputs.read().is_empty()
519 }
520
521 pub fn resolve_input_path(&self, path: &str) -> Option<Value> {
527 let parts: Vec<&str> = path.split('.').collect();
528 if parts.is_empty() || parts[0] != "inputs" {
529 return None;
530 }
531 if parts.len() < 2 {
532 return None;
533 }
534
535 let param_name = parts[1];
536 let default_value = self.get_input_default(param_name)?;
537
538 if parts.len() == 2 {
539 Some(default_value)
541 } else {
542 let remaining = parts[2..].join(".");
544 match jsonpath::resolve(&default_value, &remaining) {
545 Ok(v) => v,
546 Err(e) => {
547 tracing::debug!(path = %remaining, error = %e, "JSONPath resolution failed for input default");
548 None
549 }
550 }
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use serde_json::json;
559
560 #[test]
561 fn insert_and_get_result() {
562 let store = RunContext::new();
563 store.insert(
564 Arc::from("task1"),
565 TaskResult::success(json!({"key": "value"}), Duration::from_secs(1)),
566 );
567
568 let result = store.get("task1").unwrap();
569 assert!(result.is_success());
570 assert_eq!(result.output["key"], "value");
571 }
572
573 #[test]
574 fn success_str_converts_to_value() {
575 let store = RunContext::new();
576 store.insert(
577 Arc::from("task1"),
578 TaskResult::success_str("hello", Duration::from_secs(1)),
579 );
580
581 let result = store.get("task1").unwrap();
582 assert_eq!(*result.output, Value::String("hello".to_string()));
583 assert_eq!(result.output_str(), "hello");
584 }
585
586 #[test]
587 fn failed_result() {
588 let store = RunContext::new();
589 store.insert(
590 Arc::from("task1"),
591 TaskResult::failed("oops", Duration::from_secs(1)),
592 );
593
594 let result = store.get("task1").unwrap();
595 assert!(!result.is_success());
596 assert_eq!(result.error(), Some("oops"));
597 }
598
599 #[test]
600 fn resolve_simple_path() {
601 let store = RunContext::new();
602 store.insert(
603 Arc::from("weather"),
604 TaskResult::success(json!({"summary": "Sunny"}), Duration::from_secs(1)),
605 );
606
607 let value = store.resolve_path("weather.summary").unwrap();
608 assert_eq!(value, "Sunny");
609 }
610
611 #[test]
612 fn resolve_nested_path() {
613 let store = RunContext::new();
614 store.insert(
615 Arc::from("flights"),
616 TaskResult::success(
617 json!({"cheapest": {"price": 89, "airline": "AF"}}),
618 Duration::from_secs(1),
619 ),
620 );
621
622 assert_eq!(store.resolve_path("flights.cheapest.price").unwrap(), 89);
623 assert_eq!(
624 store.resolve_path("flights.cheapest.airline").unwrap(),
625 "AF"
626 );
627 }
628
629 #[test]
630 fn resolve_array_index() {
631 let store = RunContext::new();
632 store.insert(
633 Arc::from("data"),
634 TaskResult::success(
635 json!({"items": ["first", "second"]}),
636 Duration::from_secs(1),
637 ),
638 );
639
640 assert_eq!(store.resolve_path("data.items.0").unwrap(), "first");
641 assert_eq!(store.resolve_path("data.items.1").unwrap(), "second");
642 }
643
644 #[test]
645 fn resolve_path_not_found() {
646 let store = RunContext::new();
647 store.insert(
648 Arc::from("task1"),
649 TaskResult::success(json!({"a": 1}), Duration::from_secs(1)),
650 );
651
652 assert!(store.resolve_path("task1.nonexistent").is_none());
653 assert!(store.resolve_path("unknown.field").is_none());
654 }
655
656 #[test]
661 fn concurrent_writes_all_stored() {
662 use std::thread;
663
664 let store = RunContext::new();
665 let store_arc = Arc::new(store);
666
667 let handles: Vec<_> = (0..100)
668 .map(|i| {
669 let store = Arc::clone(&store_arc);
670 thread::spawn(move || {
671 store.insert(
672 Arc::from(format!("task_{}", i)),
673 TaskResult::success(json!({"index": i}), Duration::from_millis(i)),
674 );
675 })
676 })
677 .collect();
678
679 for h in handles {
680 h.join().unwrap();
681 }
682
683 for i in 0..100 {
685 assert!(
686 store_arc.contains(&format!("task_{}", i)),
687 "task_{} should exist",
688 i
689 );
690 }
691 }
692
693 #[test]
694 fn concurrent_reads_during_writes() {
695 use std::thread;
696
697 let store = Arc::new(RunContext::new());
698
699 for i in 0..50 {
701 store.insert(
702 Arc::from(format!("initial_{}", i)),
703 TaskResult::success(json!({"value": i}), Duration::from_millis(i)),
704 );
705 }
706
707 let store_writer = Arc::clone(&store);
708 let store_reader = Arc::clone(&store);
709
710 let writer = thread::spawn(move || {
712 for i in 0..100 {
713 store_writer.insert(
714 Arc::from(format!("new_{}", i)),
715 TaskResult::success(json!({"new": i}), Duration::from_millis(i)),
716 );
717 }
718 });
719
720 let reader = thread::spawn(move || {
722 let mut read_count = 0;
723 for i in 0..50 {
724 if store_reader.get(&format!("initial_{}", i)).is_some() {
725 read_count += 1;
726 }
727 }
728 read_count
729 });
730
731 writer.join().unwrap();
732 let reads = reader.join().unwrap();
733
734 assert_eq!(reads, 50, "Should read all 50 initial entries");
736
737 for i in 0..100 {
739 assert!(store.contains(&format!("new_{}", i)));
740 }
741 }
742
743 #[test]
744 fn overwrite_existing_task() {
745 let store = RunContext::new();
746
747 store.insert(
749 Arc::from("task1"),
750 TaskResult::success(json!({"version": 1}), Duration::from_secs(1)),
751 );
752
753 store.insert(
755 Arc::from("task1"),
756 TaskResult::success(json!({"version": 2}), Duration::from_secs(2)),
757 );
758
759 let result = store.get("task1").unwrap();
760 assert_eq!(result.output["version"], 2);
761 assert_eq!(result.duration, Duration::from_secs(2));
762 }
763
764 #[test]
769 fn contains_and_is_success() {
770 let store = RunContext::new();
771
772 assert!(!store.contains("nonexistent"));
774 assert!(!store.is_success("nonexistent"));
775
776 store.insert(
778 Arc::from("success"),
779 TaskResult::success(json!(1), Duration::from_secs(1)),
780 );
781 assert!(store.contains("success"));
782 assert!(store.is_success("success"));
783
784 store.insert(
786 Arc::from("failed"),
787 TaskResult::failed("error", Duration::from_secs(1)),
788 );
789 assert!(store.contains("failed"));
790 assert!(!store.is_success("failed"));
791 }
792
793 #[test]
794 fn get_output_returns_arc() {
795 let store = RunContext::new();
796
797 let big_json = json!({
798 "large": "data".repeat(1000),
799 "nested": {"deep": {"value": 42}}
800 });
801
802 store.insert(
803 Arc::from("big"),
804 TaskResult::success(big_json.clone(), Duration::from_secs(1)),
805 );
806
807 let output1 = store.get_output("big").unwrap();
809 let output2 = store.get_output("big").unwrap();
810
811 assert!(Arc::ptr_eq(&output1, &output2));
813 }
814
815 #[test]
816 fn resolve_task_only_returns_full_output() {
817 let store = RunContext::new();
818 store.insert(
819 Arc::from("task"),
820 TaskResult::success(json!({"a": 1, "b": 2}), Duration::from_secs(1)),
821 );
822
823 let full = store.resolve_path("task").unwrap();
825 assert_eq!(full, json!({"a": 1, "b": 2}));
826 }
827
828 #[test]
829 fn resolve_deeply_nested_path() {
830 let store = RunContext::new();
831 store.insert(
832 Arc::from("deep"),
833 TaskResult::success(
834 json!({"level1": {"level2": {"level3": {"level4": "found"}}}}),
835 Duration::from_secs(1),
836 ),
837 );
838
839 let value = store
840 .resolve_path("deep.level1.level2.level3.level4")
841 .unwrap();
842 assert_eq!(value, "found");
843 }
844
845 #[test]
846 fn resolve_mixed_array_object_path() {
847 let store = RunContext::new();
848 store.insert(
849 Arc::from("mixed"),
850 TaskResult::success(
851 json!({
852 "users": [
853 {"name": "Alice", "scores": [90, 85, 92]},
854 {"name": "Bob", "scores": [78, 82]}
855 ]
856 }),
857 Duration::from_secs(1),
858 ),
859 );
860
861 assert_eq!(store.resolve_path("mixed.users.0.name").unwrap(), "Alice");
862 assert_eq!(store.resolve_path("mixed.users.1.name").unwrap(), "Bob");
863 assert_eq!(store.resolve_path("mixed.users.0.scores.2").unwrap(), 92);
864 }
865
866 #[test]
867 fn output_str_cow_borrowed_for_strings() {
868 let result = TaskResult::success_str("hello", Duration::from_secs(1));
869
870 let cow = result.output_str();
871 assert!(matches!(cow, std::borrow::Cow::Borrowed(_)));
873 assert_eq!(&*cow, "hello");
874 }
875
876 #[test]
877 fn output_str_cow_owned_for_non_strings() {
878 let result = TaskResult::success(json!({"num": 42}), Duration::from_secs(1));
879
880 let cow = result.output_str();
881 assert!(matches!(cow, std::borrow::Cow::Owned(_)));
883 assert!(cow.contains("42"));
884 }
885
886 #[test]
887 fn empty_task_id_resolves_nothing() {
888 let store = RunContext::new();
889 store.insert(
890 Arc::from("task"),
891 TaskResult::success(json!(1), Duration::from_secs(1)),
892 );
893
894 assert!(store.resolve_path("").is_none());
896 }
897
898 #[test]
899 fn clone_is_shallow() {
900 let store = RunContext::new();
901 store.insert(
902 Arc::from("task"),
903 TaskResult::success(json!({"value": 42}), Duration::from_secs(1)),
904 );
905
906 let cloned = store.clone();
908
909 assert_eq!(
911 store.get("task").unwrap().output,
912 cloned.get("task").unwrap().output
913 );
914
915 store.insert(
917 Arc::from("new"),
918 TaskResult::success(json!(1), Duration::from_secs(1)),
919 );
920
921 assert!(cloned.contains("new"));
923 }
924
925 #[test]
930 fn test_context_default_is_empty() {
931 let store = RunContext::new();
932 assert!(!store.has_context());
933 }
934
935 #[test]
936 fn test_set_and_get_context_file() {
937 let store = RunContext::new();
938
939 let mut context = LoadedContext::new();
940 context
941 .files
942 .insert("brand".to_string(), json!("# Brand Guide"));
943
944 store.set_context(context);
945
946 assert!(store.has_context());
947 assert_eq!(
948 store.get_context_file("brand"),
949 Some(json!("# Brand Guide"))
950 );
951 assert!(store.get_context_file("nonexistent").is_none());
952 }
953
954 #[test]
955 fn test_set_and_get_context_session() {
956 let store = RunContext::new();
957
958 let mut context = LoadedContext::new();
959 context.session = Some(json!({"focus_areas": ["rust", "ai"]}));
960
961 store.set_context(context);
962
963 assert!(store.has_context());
964 let session = store.get_context_session().unwrap();
965 assert!(session["focus_areas"].is_array());
966 }
967
968 #[test]
969 fn test_resolve_context_path_files() {
970 let store = RunContext::new();
971
972 let mut context = LoadedContext::new();
973 context.files.insert(
974 "persona".to_string(),
975 json!({"name": "Agent", "role": "assistant"}),
976 );
977
978 store.set_context(context);
979
980 assert_eq!(
982 store.resolve_context_path("context.files.persona"),
983 Some(json!({"name": "Agent", "role": "assistant"}))
984 );
985
986 assert_eq!(
988 store.resolve_context_path("context.files.persona.name"),
989 Some(json!("Agent"))
990 );
991
992 assert!(store
994 .resolve_context_path("context.files.missing")
995 .is_none());
996 }
997
998 #[test]
999 fn test_resolve_context_path_session() {
1000 let store = RunContext::new();
1001
1002 let mut context = LoadedContext::new();
1003 context.session = Some(json!({"focus": "rust", "level": 3}));
1004
1005 store.set_context(context);
1006
1007 assert_eq!(
1009 store.resolve_context_path("context.session"),
1010 Some(json!({"focus": "rust", "level": 3}))
1011 );
1012
1013 assert_eq!(
1015 store.resolve_context_path("context.session.focus"),
1016 Some(json!("rust"))
1017 );
1018 assert_eq!(
1019 store.resolve_context_path("context.session.level"),
1020 Some(json!(3))
1021 );
1022 }
1023
1024 #[test]
1025 fn test_resolve_context_path_invalid() {
1026 let store = RunContext::new();
1027
1028 let mut context = LoadedContext::new();
1029 context.files.insert("brand".to_string(), json!("content"));
1030
1031 store.set_context(context);
1032
1033 assert!(store.resolve_context_path("context").is_none());
1035 assert!(store.resolve_context_path("context.invalid").is_none());
1036 assert!(store.resolve_context_path("context.files").is_none());
1037 assert!(store.resolve_context_path("other.path").is_none());
1038 }
1039
1040 #[test]
1045 fn test_inputs_default_is_empty() {
1046 let store = RunContext::new();
1047 assert!(!store.has_inputs());
1048 }
1049
1050 #[test]
1051 fn test_set_and_get_input_default() {
1052 let store = RunContext::new();
1053
1054 let mut inputs = FxHashMap::default();
1055 inputs.insert(
1056 "topic".to_string(),
1057 json!({
1058 "type": "string",
1059 "description": "Research topic",
1060 "default": "AI QR code generation"
1061 }),
1062 );
1063
1064 store.set_inputs(inputs);
1065
1066 assert!(store.has_inputs());
1067 assert_eq!(
1068 store.get_input_default("topic"),
1069 Some(json!("AI QR code generation"))
1070 );
1071 assert!(store.get_input_default("nonexistent").is_none());
1072 }
1073
1074 #[test]
1075 fn test_get_input_default_without_default() {
1076 let store = RunContext::new();
1077
1078 let mut inputs = FxHashMap::default();
1079 inputs.insert(
1081 "required_param".to_string(),
1082 json!({
1083 "type": "string",
1084 "description": "A required parameter"
1085 }),
1086 );
1087
1088 store.set_inputs(inputs);
1089
1090 assert!(store.get_input_default("required_param").is_none());
1092 }
1093
1094 #[test]
1095 fn test_resolve_input_path_simple() {
1096 let store = RunContext::new();
1097
1098 let mut inputs = FxHashMap::default();
1099 inputs.insert(
1100 "topic".to_string(),
1101 json!({
1102 "type": "string",
1103 "default": "AI trends 2025"
1104 }),
1105 );
1106 inputs.insert(
1107 "depth".to_string(),
1108 json!({
1109 "type": "string",
1110 "default": "comprehensive"
1111 }),
1112 );
1113
1114 store.set_inputs(inputs);
1115
1116 assert_eq!(
1118 store.resolve_input_path("inputs.topic"),
1119 Some(json!("AI trends 2025"))
1120 );
1121
1122 assert_eq!(
1124 store.resolve_input_path("inputs.depth"),
1125 Some(json!("comprehensive"))
1126 );
1127
1128 assert!(store.resolve_input_path("inputs.missing").is_none());
1130 }
1131
1132 #[test]
1133 fn test_resolve_input_path_nested() {
1134 let store = RunContext::new();
1135
1136 let mut inputs = FxHashMap::default();
1137 inputs.insert(
1138 "config".to_string(),
1139 json!({
1140 "type": "object",
1141 "default": {
1142 "theme": "dark",
1143 "version": 2,
1144 "nested": {
1145 "deep": "value"
1146 }
1147 }
1148 }),
1149 );
1150
1151 store.set_inputs(inputs);
1152
1153 assert_eq!(
1155 store.resolve_input_path("inputs.config.theme"),
1156 Some(json!("dark"))
1157 );
1158 assert_eq!(
1159 store.resolve_input_path("inputs.config.version"),
1160 Some(json!(2))
1161 );
1162 assert_eq!(
1163 store.resolve_input_path("inputs.config.nested.deep"),
1164 Some(json!("value"))
1165 );
1166 }
1167
1168 #[test]
1169 fn test_resolve_input_path_invalid() {
1170 let store = RunContext::new();
1171
1172 let mut inputs = FxHashMap::default();
1173 inputs.insert(
1174 "topic".to_string(),
1175 json!({
1176 "type": "string",
1177 "default": "test"
1178 }),
1179 );
1180
1181 store.set_inputs(inputs);
1182
1183 assert!(store.resolve_input_path("inputs").is_none());
1185 assert!(store.resolve_input_path("other.path").is_none());
1186 assert!(store.resolve_input_path("").is_none());
1187 }
1188
1189 fn task_with_media() -> TaskResult {
1195 use std::path::PathBuf;
1196
1197 let media = vec![
1198 crate::media::MediaRef {
1199 hash: "blake3:af1349b9".to_string(),
1200 mime_type: "image/png".to_string(),
1201 size_bytes: 4096,
1202 path: PathBuf::from("/tmp/cas/af/1349b9"),
1203 extension: "png".to_string(),
1204 created_by: "gen_img".to_string(),
1205 metadata: serde_json::Map::new(),
1206 },
1207 crate::media::MediaRef {
1208 hash: "blake3:deadbeef".to_string(),
1209 mime_type: "audio/wav".to_string(),
1210 size_bytes: 8192,
1211 path: PathBuf::from("/tmp/cas/de/adbeef"),
1212 extension: "wav".to_string(),
1213 created_by: "gen_img".to_string(),
1214 metadata: serde_json::Map::new(),
1215 },
1216 ];
1217 TaskResult::success(json!({"prompt": "a cat"}), Duration::from_secs(1)).with_media(media)
1218 }
1219
1220 #[test]
1221 fn resolve_media_full_array() {
1222 let store = RunContext::new();
1223 store.insert(Arc::from("gen_img"), task_with_media());
1224
1225 let value = store.resolve_path("gen_img.media").unwrap();
1226 let arr = value.as_array().expect("media should be an array");
1227 assert_eq!(arr.len(), 2);
1228 assert_eq!(arr[0]["hash"], "blake3:af1349b9");
1229 assert_eq!(arr[1]["hash"], "blake3:deadbeef");
1230 }
1231
1232 #[test]
1233 fn resolve_media_index_hash() {
1234 let store = RunContext::new();
1235 store.insert(Arc::from("gen_img"), task_with_media());
1236
1237 let hash = store.resolve_path("gen_img.media[0].hash").unwrap();
1238 assert_eq!(hash, "blake3:af1349b9");
1239
1240 let hash2 = store.resolve_path("gen_img.media[1].hash").unwrap();
1241 assert_eq!(hash2, "blake3:deadbeef");
1242 }
1243
1244 #[test]
1245 fn resolve_media_index_mime_type() {
1246 let store = RunContext::new();
1247 store.insert(Arc::from("gen_img"), task_with_media());
1248
1249 let mime = store.resolve_path("gen_img.media[0].mime_type").unwrap();
1250 assert_eq!(mime, "image/png");
1251
1252 let mime2 = store.resolve_path("gen_img.media[1].mime_type").unwrap();
1253 assert_eq!(mime2, "audio/wav");
1254 }
1255
1256 #[test]
1257 fn resolve_media_empty_returns_empty_array() {
1258 let store = RunContext::new();
1259 store.insert(
1261 Arc::from("no_media"),
1262 TaskResult::success(json!({"text": "hello"}), Duration::from_secs(1)),
1263 );
1264
1265 let value = store.resolve_path("no_media.media").unwrap();
1266 assert_eq!(value, json!([]));
1267 }
1268
1269 #[test]
1270 fn resolve_media_index_path() {
1271 let store = RunContext::new();
1272 store.insert(Arc::from("gen_img"), task_with_media());
1273
1274 let path = store.resolve_path("gen_img.media[0].path").unwrap();
1275 assert_eq!(path, "/tmp/cas/af/1349b9");
1276 }
1277
1278 #[test]
1279 fn resolve_media_index_size_bytes() {
1280 let store = RunContext::new();
1281 store.insert(Arc::from("gen_img"), task_with_media());
1282
1283 let size = store.resolve_path("gen_img.media[0].size_bytes").unwrap();
1284 assert_eq!(size, 4096);
1285 }
1286
1287 #[test]
1288 fn resolve_media_index_extension() {
1289 let store = RunContext::new();
1290 store.insert(Arc::from("gen_img"), task_with_media());
1291
1292 let ext = store.resolve_path("gen_img.media[0].extension").unwrap();
1293 assert_eq!(ext, "png");
1294 }
1295
1296 #[test]
1297 fn resolve_media_out_of_bounds() {
1298 let store = RunContext::new();
1299 store.insert(Arc::from("gen_img"), task_with_media());
1300
1301 assert!(store.resolve_path("gen_img.media[99].hash").is_none());
1303 }
1304
1305 #[test]
1306 fn resolve_media_does_not_shadow_output() {
1307 let store = RunContext::new();
1308 store.insert(Arc::from("gen_img"), task_with_media());
1309
1310 let prompt = store.resolve_path("gen_img.prompt").unwrap();
1312 assert_eq!(prompt, "a cat");
1313 }
1314
1315 #[test]
1316 fn iter_results_returns_all_entries() {
1317 let store = RunContext::new();
1318 store.insert(
1319 Arc::from("task1"),
1320 TaskResult::success_str("out1", Duration::from_millis(10)),
1321 );
1322 store.insert(
1323 Arc::from("task2"),
1324 TaskResult::success_str("out2", Duration::from_millis(20)),
1325 );
1326 store.insert(
1327 Arc::from("task3"),
1328 TaskResult::failed("err", Duration::from_millis(5)),
1329 );
1330
1331 let results = store.iter_results();
1332 assert_eq!(results.len(), 3);
1333
1334 let ids: Vec<String> = results.iter().map(|(id, _)| id.to_string()).collect();
1336 assert!(ids.contains(&"task1".to_string()));
1337 assert!(ids.contains(&"task2".to_string()));
1338 assert!(ids.contains(&"task3".to_string()));
1339 }
1340
1341 #[test]
1342 fn iter_results_includes_media_refs() {
1343 let store = RunContext::new();
1344 store.insert(Arc::from("gen_img"), task_with_media());
1345
1346 let results = store.iter_results();
1347 let (_, result) = results
1348 .iter()
1349 .find(|(id, _)| id.as_ref() == "gen_img")
1350 .unwrap();
1351 assert_eq!(result.media.len(), 2);
1352 assert_eq!(result.media[0].hash, "blake3:af1349b9");
1353 }
1354
1355 #[test]
1360 fn invoke_json_result_accessible_via_template_binding() {
1361 let store = RunContext::new();
1367 let invoke_output = r#"{"hash":"blake3:abc123","mime_type":"image/png","size_bytes":1234,"metadata":{"width":256,"height":192}}"#;
1369 store.insert(
1370 Arc::from("thumb"),
1371 TaskResult::success_str(invoke_output, Duration::from_millis(100)),
1372 );
1373
1374 let hash = store.resolve_path("thumb.hash").unwrap();
1376 assert_eq!(
1377 hash, "blake3:abc123",
1378 "{{{{with.thumb.hash}}}} must resolve"
1379 );
1380
1381 let mime = store.resolve_path("thumb.mime_type").unwrap();
1382 assert_eq!(
1383 mime, "image/png",
1384 "{{{{with.thumb.mime_type}}}} must resolve"
1385 );
1386
1387 let size = store.resolve_path("thumb.size_bytes").unwrap();
1388 assert_eq!(size, 1234, "{{{{with.thumb.size_bytes}}}} must resolve");
1389
1390 let width = store.resolve_path("thumb.metadata.width").unwrap();
1392 assert_eq!(width, 256, "{{{{with.thumb.metadata.width}}}} must resolve");
1393
1394 let height = store.resolve_path("thumb.metadata.height").unwrap();
1395 assert_eq!(
1396 height, 192,
1397 "{{{{with.thumb.metadata.height}}}} must resolve"
1398 );
1399 }
1400
1401 #[test]
1402 fn invoke_json_result_with_array_accessible() {
1403 let store = RunContext::new();
1405 let invoke_output = r##"{"colors":[{"r":255,"g":0,"b":0,"hex":"#ff0000"},{"r":0,"g":0,"b":255,"hex":"#0000ff"}],"count":2}"##;
1406 store.insert(
1407 Arc::from("colors"),
1408 TaskResult::success_str(invoke_output, Duration::from_millis(50)),
1409 );
1410
1411 let count = store.resolve_path("colors.count").unwrap();
1412 assert_eq!(count, 2);
1413
1414 let first_hex = store.resolve_path("colors.colors[0].hex").unwrap();
1415 assert_eq!(first_hex, "#ff0000");
1416
1417 let second_r = store.resolve_path("colors.colors[1].r").unwrap();
1418 assert_eq!(second_r, 0);
1419 }
1420
1421 #[test]
1422 fn invoke_dimensions_result_accessible() {
1423 let store = RunContext::new();
1425 let invoke_output = r#"{"width":1024,"height":768,"orientation":"landscape"}"#;
1426 store.insert(
1427 Arc::from("dim"),
1428 TaskResult::success_str(invoke_output, Duration::from_millis(10)),
1429 );
1430
1431 assert_eq!(store.resolve_path("dim.width").unwrap(), 1024);
1432 assert_eq!(store.resolve_path("dim.height").unwrap(), 768);
1433 assert_eq!(store.resolve_path("dim.orientation").unwrap(), "landscape");
1434 }
1435
1436 #[test]
1437 fn enriched_media_ref_metadata_accessible() {
1438 let store = RunContext::new();
1440 let mut metadata = serde_json::Map::new();
1441 metadata.insert("width".into(), json!(512));
1442 metadata.insert("height".into(), json!(384));
1443 metadata.insert("thumbhash".into(), json!("dGVzdA=="));
1444
1445 let media = vec![crate::media::MediaRef {
1446 hash: "blake3:enriched123".to_string(),
1447 mime_type: "image/png".to_string(),
1448 size_bytes: 2048,
1449 path: std::path::PathBuf::from("/cas/en/riched123"),
1450 extension: "png".to_string(),
1451 created_by: "gen".to_string(),
1452 metadata,
1453 }];
1454
1455 store.insert(
1456 Arc::from("gen"),
1457 TaskResult::success(json!("image generated"), Duration::from_secs(1)).with_media(media),
1458 );
1459
1460 assert_eq!(
1462 store.resolve_path("gen.media[0].hash").unwrap(),
1463 "blake3:enriched123"
1464 );
1465 assert_eq!(
1467 store.resolve_path("gen.media[0].metadata.width").unwrap(),
1468 512
1469 );
1470 assert_eq!(
1471 store.resolve_path("gen.media[0].metadata.height").unwrap(),
1472 384
1473 );
1474 assert_eq!(
1475 store
1476 .resolve_path("gen.media[0].metadata.thumbhash")
1477 .unwrap(),
1478 "dGVzdA=="
1479 );
1480 }
1481
1482 #[test]
1483 fn chained_invoke_bindings_work() {
1484 let store = RunContext::new();
1486
1487 let media = vec![crate::media::MediaRef {
1489 hash: "blake3:source_hash".to_string(),
1490 mime_type: "image/png".to_string(),
1491 size_bytes: 5000,
1492 path: std::path::PathBuf::from("/cas/so/urce"),
1493 extension: "png".to_string(),
1494 created_by: "gen".to_string(),
1495 metadata: serde_json::Map::new(),
1496 }];
1497 store.insert(
1498 Arc::from("gen"),
1499 TaskResult::success(json!("ok"), Duration::from_secs(1)).with_media(media),
1500 );
1501
1502 store.insert(
1504 Arc::from("thumb"),
1505 TaskResult::success_str(
1506 r#"{"hash":"blake3:thumb_hash","size_bytes":1500,"metadata":{"width":256}}"#,
1507 Duration::from_millis(200),
1508 ),
1509 );
1510
1511 store.insert(
1513 Arc::from("dim"),
1514 TaskResult::success_str(
1515 r#"{"width":256,"height":192,"orientation":"landscape"}"#,
1516 Duration::from_millis(10),
1517 ),
1518 );
1519
1520 assert_eq!(
1522 store.resolve_path("gen.media[0].hash").unwrap(),
1523 "blake3:source_hash"
1524 );
1525 assert_eq!(
1526 store.resolve_path("thumb.hash").unwrap(),
1527 "blake3:thumb_hash"
1528 );
1529 assert_eq!(store.resolve_path("thumb.metadata.width").unwrap(), 256);
1530 assert_eq!(store.resolve_path("dim.width").unwrap(), 256);
1531 assert_eq!(store.resolve_path("dim.orientation").unwrap(), "landscape");
1532 }
1533}