Skip to main content

nika_engine/store/
run_context.rs

1//! RunContext - task output storage with DashMap
2//!
3//! Single HashMap design with lock-free concurrent access.
4//! Path resolution unified with jsonpath module.
5//!
6//! Added context storage for workflow `context:` block.
7//! Added inputs storage for workflow `inputs:` block.
8
9use 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/// Task execution status
23#[derive(Debug, Clone)]
24pub enum TaskOutcome {
25    Success,
26    Failed(String),
27    /// Task cannot run because a dependency failed
28    DependencyFailed {
29        /// ID of the failed dependency
30        dependency: String,
31    },
32    /// Task was skipped (not executed)
33    Skipped {
34        /// Reason for skipping
35        reason: String,
36    },
37}
38
39/// Task execution result (unified storage)
40#[derive(Debug, Clone)]
41pub struct TaskResult {
42    /// Output as JSON Value (Arc for O(1) cloning of large JSON structures)
43    pub output: Arc<Value>,
44    /// Execution duration
45    pub duration: Duration,
46    /// Success or failure status
47    pub status: TaskOutcome,
48    /// Media files produced by this task (empty for non-media tasks)
49    pub media: Vec<crate::media::MediaRef>,
50}
51
52impl TaskResult {
53    /// Create a successful result
54    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    /// Create a successful result from string (converts to Value::String)
64    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    /// Create a failed result
74    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    /// Create a result for a task that cannot run because its dependency failed
84    ///
85    /// This is distinct from `failed()` because the task itself didn't fail -
86    /// it simply cannot run because an upstream dependency failed.
87    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    /// Create a skipped result
99    ///
100    /// Used when a task is skipped due to cancellation or other reasons.
101    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    /// Attach media references to this result.
113    pub fn with_media(mut self, media: Vec<crate::media::MediaRef>) -> Self {
114        self.media = media;
115        self
116    }
117
118    /// Check if task succeeded
119    pub fn is_success(&self) -> bool {
120        matches!(self.status, TaskOutcome::Success)
121    }
122
123    /// Check if task failed due to a dependency failure
124    pub fn is_dependency_failed(&self) -> bool {
125        matches!(self.status, TaskOutcome::DependencyFailed { .. })
126    }
127
128    /// Check if task was skipped
129    pub fn is_skipped(&self) -> bool {
130        matches!(self.status, TaskOutcome::Skipped { .. })
131    }
132
133    /// Get the failed dependency name if this is a DependencyFailed result
134    pub fn failed_dependency(&self) -> Option<&str> {
135        match &self.status {
136            TaskOutcome::DependencyFailed { dependency } => Some(dependency),
137            _ => None,
138        }
139    }
140
141    /// Get error message if failed
142    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    /// Get output as string (zero-copy for String values)
152    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/// Thread-safe storage for task results (lock-free)
161///
162/// Uses `Arc<str>` keys for zero-cost cloning with same Arc used in events.
163///
164/// Added context storage for workflow `context:` block.
165/// Added inputs storage for workflow `inputs:` block.
166#[derive(Clone)]
167pub struct RunContext {
168    /// Task results: task_id → TaskResult
169    results: Arc<DashMap<Arc<str>, TaskResult, FxBuildHasher>>,
170
171    /// Context loaded at workflow start
172    ///
173    /// Contains files loaded from the `context:` block.
174    /// Accessible via `{{context.files.alias}}` bindings.
175    context: Arc<RwLock<LoadedContext>>,
176
177    /// Input parameters with defaults
178    ///
179    /// Contains input definitions from the `inputs:` block.
180    /// Accessible via `{{inputs.param}}` bindings.
181    inputs: Arc<RwLock<FxHashMap<String, Value>>>,
182
183    /// Side-channel for media refs produced by invoke tasks.
184    /// Written by run_invoke() after MediaProcessor completes.
185    /// Read (and drained) by the runner after building TaskResult.
186    media_staging: Arc<DashMap<Arc<str>, Vec<crate::media::MediaRef>, FxBuildHasher>>,
187
188    /// Shared per-run media budget (500MB default).
189    /// Lives here so all invoke tasks in a single run share one budget.
190    media_budget: Arc<crate::media::MediaBudget>,
191
192    /// Workspace root for CAS store path resolution.
193    /// Set by Runner at workflow start. Defaults to current_dir().
194    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    /// Insert a task result (accepts `Arc<str>` for zero-cost key reuse)
223    pub fn insert(&self, task_id: Arc<str>, result: TaskResult) {
224        self.results.insert(task_id, result);
225    }
226
227    /// Get a task result
228    pub fn get(&self, task_id: &str) -> Option<TaskResult> {
229        self.results.get(task_id).map(|r| r.value().clone())
230    }
231
232    /// Check if task exists
233    pub fn contains(&self, task_id: &str) -> bool {
234        self.results.contains_key(task_id)
235    }
236
237    /// Check if a task completed successfully without cloning the full TaskResult.
238    ///
239    /// Returns None if the task doesn't exist, Some(true) if succeeded, Some(false) if failed.
240    /// Avoids O(T*D) TaskResult cloning in get_ready_tasks() hot path.
241    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    /// Iterate over all task results (cloned).
246    ///
247    /// Returns (task_id, TaskResult) pairs for all stored results.
248    /// Used by integrity checks at workflow end.
249    ///
250    /// Note: for_each tasks store both individual iteration entries (task[0], task[1], ...)
251    /// and an aggregated parent entry (task). Media refs appear in both, so callers
252    /// doing per-file checks may see duplicates. This is acceptable for warn-only checks.
253    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    /// Check if task succeeded
261    pub fn is_success(&self, task_id: &str) -> bool {
262        self.get(task_id).is_some_and(|r| r.is_success())
263    }
264
265    /// Check if task failed (either directly or due to dependency failure)
266    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    /// Check if task failed due to a dependency failure
276    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    /// Get the failed dependency name if task has DependencyFailed status
281    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    /// Get just the output Value for a task (for JSONPath resolution)
287    /// Returns `Arc<Value>` for O(1) cloning instead of deep copy
288    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    // ═══════════════════════════════════════════════════════════════════════════
293    // MEDIA STAGING
294    // ═══════════════════════════════════════════════════════════════════════════
295
296    /// Stage media refs for a task (called from run_invoke).
297    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    /// Take staged media refs for a task (called from runner after building TaskResult).
304    /// Returns empty vec if no media was staged.
305    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    /// Get the shared per-run media budget.
313    pub fn media_budget(&self) -> &Arc<crate::media::MediaBudget> {
314        &self.media_budget
315    }
316
317    /// Set the workspace root (called by Runner at workflow start).
318    pub fn set_workspace_root(&self, root: PathBuf) {
319        *self.workspace_root.write() = root;
320    }
321
322    /// Get the workspace root path (cloned).
323    pub fn workspace_root(&self) -> PathBuf {
324        self.workspace_root.read().clone()
325    }
326
327    /// Resolve a dot-separated path (e.g., "weather.summary")
328    ///
329    /// Uses jsonpath module internally for unified path resolution.
330    /// Supports both simple dot notation and array indices.
331    ///
332    /// Media paths are intercepted before standard output resolution:
333    /// - `"task_id.media"` → full media array as JSON
334    /// - `"task_id.media[0].hash"` → specific media ref field
335    /// - `"task_id.media[0].path"` → specific media ref field
336    pub fn resolve_path(&self, path: &str) -> Option<Value> {
337        let mut parts = path.splitn(2, '.');
338        let task_id = parts.next()?;
339
340        // If no remaining path, return the whole output (clone from Arc)
341        let Some(remaining) = parts.next() else {
342            let output = self.get_output(task_id)?;
343            return Some((*output).clone());
344        };
345
346        // Intercept media paths: task_id.media, task_id.media[0].hash, etc.
347        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..]; // skip "media"
360            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        // Use jsonpath for path resolution (handles both dots and array indices)
374        // Arc<Value> derefs to &Value, so this works without changes
375        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    // ═══════════════════════════════════════════════════════════════════════════
385    // CONTEXT STORAGE
386    // ═══════════════════════════════════════════════════════════════════════════
387
388    /// Set workflow context
389    ///
390    /// Called by Runner at workflow start after loading context files.
391    pub fn set_context(&self, context: LoadedContext) {
392        *self.context.write() = context;
393    }
394
395    /// Get a context file by alias
396    ///
397    /// Returns the loaded value for `{{context.files.alias}}` bindings.
398    pub fn get_context_file(&self, alias: &str) -> Option<Value> {
399        self.context.read().get_file(alias).cloned()
400    }
401
402    /// Get session data
403    ///
404    /// Returns the loaded session for `{{context.session.key}}` bindings.
405    pub fn get_context_session(&self) -> Option<Value> {
406        self.context.read().get_session().cloned()
407    }
408
409    /// Check if context is loaded
410    pub fn has_context(&self) -> bool {
411        !self.context.read().is_empty()
412    }
413
414    /// Resolve a context path
415    ///
416    /// Supports:
417    /// - `context.files.alias` → file content
418    /// - `context.files.alias.field` → nested field
419    /// - `context.session` → session data
420    /// - `context.session.field` → session field
421    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                    // context.files.alias → full file content
439                    Some(value.clone())
440                } else {
441                    // context.files.alias.field → nested path
442                    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                    // context.session → full session
457                    Some(session.clone())
458                } else {
459                    // context.session.field → nested path
460                    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    // ═══════════════════════════════════════════════════════════════════════════
475    // INPUTS STORAGE
476    // ═══════════════════════════════════════════════════════════════════════════
477
478    /// Set workflow inputs
479    ///
480    /// Called by Runner at workflow start with input definitions.
481    /// Each input is a JSON object with `type`, `default`, `description`, etc.
482    pub fn set_inputs(&self, inputs: FxHashMap<String, Value>) {
483        *self.inputs.write() = inputs;
484    }
485
486    /// Get an input's value by name
487    ///
488    /// Supports two formats:
489    /// - Full form: `{ type: string, default: "value" }` → extracts `default` field
490    /// - Shorthand: `"value"` or `123` or `true` → uses value directly
491    ///
492    /// Returns `None` if input doesn't exist.
493    pub fn get_input_default(&self, name: &str) -> Option<Value> {
494        let inputs = self.inputs.read();
495        let definition = inputs.get(name)?;
496
497        // Check if this is a full input definition with a `default` field
498        // or a shorthand value (string, number, bool, array)
499        if let Some(obj) = definition.as_object() {
500            // Full form: { type, default, description, ... }
501            // Check for 'default' field, or 'value' as alternative
502            if let Some(default_val) = obj.get("default").or_else(|| obj.get("value")) {
503                return Some(default_val.clone());
504            }
505            // If object has type/description but no default, return None
506            if obj.contains_key("type") || obj.contains_key("description") {
507                return None;
508            }
509        }
510
511        // Shorthand: the value itself is the default
512        // e.g., `name: "TestUser"` or `count: 5`
513        Some(definition.clone())
514    }
515
516    /// Check if inputs are loaded
517    pub fn has_inputs(&self) -> bool {
518        !self.inputs.read().is_empty()
519    }
520
521    /// Resolve an input path
522    ///
523    /// Supports:
524    /// - `inputs.param` → default value of parameter
525    /// - `inputs.param.field` → nested field in default value (if object)
526    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            // inputs.param → full default value
540            Some(default_value)
541        } else {
542            // inputs.param.field → nested path in default value
543            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    // =========================================================================
657    // Concurrent Access Tests
658    // =========================================================================
659
660    #[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        // All 100 keys should exist
684        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        // Pre-populate some data
700        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        // Spawn writer thread
711        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        // Spawn reader thread - should not block
721        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        // Reader should have been able to read existing data
735        assert_eq!(reads, 50, "Should read all 50 initial entries");
736
737        // Verify writer completed
738        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        // Insert initial value
748        store.insert(
749            Arc::from("task1"),
750            TaskResult::success(json!({"version": 1}), Duration::from_secs(1)),
751        );
752
753        // Overwrite with new value
754        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    // =========================================================================
765    // Edge Case Tests
766    // =========================================================================
767
768    #[test]
769    fn contains_and_is_success() {
770        let store = RunContext::new();
771
772        // Non-existent task
773        assert!(!store.contains("nonexistent"));
774        assert!(!store.is_success("nonexistent"));
775
776        // Successful task
777        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        // Failed task
785        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        // get_output should return Arc (cheap clone)
808        let output1 = store.get_output("big").unwrap();
809        let output2 = store.get_output("big").unwrap();
810
811        // Both should point to same data (Arc comparison)
812        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        // Just task name should return full output
824        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        // Should be borrowed (no allocation for string values)
872        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        // Should be owned (converted to string)
882        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        // Empty path should return None
895        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        // Clone the store
907        let cloned = store.clone();
908
909        // Both should see the same data (shared Arc<DashMap>)
910        assert_eq!(
911            store.get("task").unwrap().output,
912            cloned.get("task").unwrap().output
913        );
914
915        // Insert into original
916        store.insert(
917            Arc::from("new"),
918            TaskResult::success(json!(1), Duration::from_secs(1)),
919        );
920
921        // Clone should also see it (same underlying DashMap)
922        assert!(cloned.contains("new"));
923    }
924
925    // =========================================================================
926    // Context Storage Tests
927    // =========================================================================
928
929    #[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        // Full file
981        assert_eq!(
982            store.resolve_context_path("context.files.persona"),
983            Some(json!({"name": "Agent", "role": "assistant"}))
984        );
985
986        // Nested field
987        assert_eq!(
988            store.resolve_context_path("context.files.persona.name"),
989            Some(json!("Agent"))
990        );
991
992        // Missing file
993        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        // Full session
1008        assert_eq!(
1009            store.resolve_context_path("context.session"),
1010            Some(json!({"focus": "rust", "level": 3}))
1011        );
1012
1013        // Nested field
1014        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        // Invalid paths
1034        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    // =========================================================================
1041    // Inputs Storage Tests
1042    // =========================================================================
1043
1044    #[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        // Input without default field
1080        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        // Should return None for input without default
1091        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        // Resolve inputs.topic
1117        assert_eq!(
1118            store.resolve_input_path("inputs.topic"),
1119            Some(json!("AI trends 2025"))
1120        );
1121
1122        // Resolve inputs.depth
1123        assert_eq!(
1124            store.resolve_input_path("inputs.depth"),
1125            Some(json!("comprehensive"))
1126        );
1127
1128        // Missing input
1129        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        // Resolve nested fields
1154        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        // Invalid paths
1184        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    // =========================================================================
1190    // Media Path Resolution Tests
1191    // =========================================================================
1192
1193    /// Helper: create a TaskResult with media refs for testing.
1194    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        // Task with no media
1260        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        // Index beyond array length should return None
1302        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        // Standard output field should still resolve normally
1311        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        // All task IDs should be present
1335        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    // ═══════════════════════════════════════════════════════════════
1356    // MEDIA TOOL INVOKE RESULT — Template binding integration
1357    // ═══════════════════════════════════════════════════════════════
1358
1359    #[test]
1360    fn invoke_json_result_accessible_via_template_binding() {
1361        // When invoke: nika:thumbnail returns a JSON string like:
1362        // {"hash":"blake3:abc","mime_type":"image/png","size_bytes":1234,"metadata":{"width":256}}
1363        // The result is stored as Value::String(json_str).
1364        // Downstream tasks must be able to access {{with.thumb.hash}} etc.
1365
1366        let store = RunContext::new();
1367        // Simulate what run_invoke + make_task_result does: stores JSON as Value::String
1368        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        // These must resolve correctly via auto-parse of JSON strings
1375        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        // Nested metadata access
1391        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        // nika:dominant_color returns {"colors":[{"r":255,"g":0,"b":0,"hex":"#ff0000"}],"count":1}
1404        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        // nika:dimensions returns {"width":1024,"height":768,"orientation":"landscape"}
1424        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        // MediaRef with enriched metadata must be accessible
1439        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        // Media ref fields
1461        assert_eq!(
1462            store.resolve_path("gen.media[0].hash").unwrap(),
1463            "blake3:enriched123"
1464        );
1465        // Enriched metadata
1466        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        // Simulate: gen → media[0].hash → thumb (invoke) → dim (invoke)
1485        let store = RunContext::new();
1486
1487        // Task "gen" has media
1488        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        // Task "thumb" returns invoke result (JSON string)
1503        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        // Task "dim" returns dimensions (JSON string)
1512        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        // Verify full chain is accessible
1521        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}