Skip to main content

nika_engine/runtime/
artifact_processor.rs

1//! Artifact Processor - Write task outputs to disk
2//!
3//! Integrates the artifact system with the task execution flow.
4//! Called after successful task completion when `artifact:` is configured.
5//!
6//! Supports template-based artifacts via the `template:` field
7//! which supports `{{with.*}}` bindings for dynamic content generation.
8
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use tracing::{debug, warn};
13
14use crate::ast::artifact::{
15    ArtifactFormat, ArtifactMode, ArtifactOutput, ArtifactSpec, ArtifactsConfig,
16};
17use crate::ast::OutputFormat;
18use crate::binding::{template_resolve, ResolvedBindings};
19use crate::error::NikaError;
20use crate::event::{EventKind, EventLog};
21use crate::io::atomic::{write_append, write_fail, write_unique};
22use crate::io::security::DEFAULT_ARTIFACT_DIR;
23use crate::io::writer::{
24    ArtifactWriter, BinarySource, BinaryWriteRequest, WriteRequest, WriteResult,
25};
26use crate::media::MediaRef;
27use crate::serde_yaml;
28use crate::store::RunContext;
29
30/// Result of processing artifacts for a task
31#[derive(Debug, Clone)]
32pub struct ArtifactProcessResult {
33    /// Number of artifacts successfully written
34    pub written: usize,
35    /// Paths of written artifacts
36    pub paths: Vec<PathBuf>,
37    /// Any errors that occurred (non-fatal)
38    pub errors: Vec<String>,
39}
40
41/// Process artifacts for a completed task
42///
43/// # Arguments
44///
45/// * `task_id` - The task ID
46/// * `output` - The task output as a string
47/// * `artifact_spec` - Task-level artifact configuration
48/// * `workflow_config` - Workflow-level artifact defaults
49/// * `base_path` - Base path for artifact resolution (workflow directory)
50/// * `event_log` - Optional event log for emitting artifact events
51/// * `bindings` - Resolved bindings for template resolution
52/// * `datastore` - Data store for lazy binding resolution
53/// * `media_refs` - Media files produced by the task (for binary artifact source resolution)
54///
55/// # Returns
56///
57/// `ArtifactProcessResult` with write status and any errors
58#[allow(clippy::too_many_arguments)]
59pub async fn process_task_artifacts(
60    task_id: &str,
61    output: &str,
62    artifact_spec: &ArtifactSpec,
63    workflow_config: Option<&ArtifactsConfig>,
64    base_path: &std::path::Path,
65    event_log: Option<&EventLog>,
66    bindings: &ResolvedBindings,
67    datastore: &RunContext,
68    media_refs: &[MediaRef],
69) -> ArtifactProcessResult {
70    let mut result = ArtifactProcessResult {
71        written: 0,
72        paths: Vec::new(),
73        errors: Vec::new(),
74    };
75
76    // Get the artifact outputs to write based on spec type
77    let outputs = match artifact_spec {
78        ArtifactSpec::Enabled(false) => {
79            // Artifacts disabled for this task
80            return result;
81        }
82        ArtifactSpec::Enabled(true) => {
83            // Use defaults - generate single output with task_id as filename
84            let format = workflow_config
85                .map(|c| &c.format)
86                .unwrap_or(&ArtifactFormat::Text);
87            vec![ArtifactOutput {
88                path: format!("{}.{}", task_id, format.extension()),
89                source: None,
90                template: None,
91                format: Some(*format),
92                mode: workflow_config.map(|c| c.mode),
93            }]
94        }
95        ArtifactSpec::Single(output_spec) => {
96            vec![output_spec.clone()]
97        }
98        ArtifactSpec::Multiple(outputs) => outputs.clone(),
99    };
100
101    // Resolve artifact directory
102    let artifact_dir = resolve_artifact_dir(workflow_config, base_path).await;
103
104    // Get max size from workflow config
105    let max_size = workflow_config
106        .map(|c| c.max_size)
107        .unwrap_or(crate::ast::artifact::DEFAULT_MAX_ARTIFACT_SIZE);
108
109    // Create artifact writer
110    let writer = ArtifactWriter::new(&artifact_dir, task_id).with_max_size(max_size);
111
112    // Process each output
113    for output_spec in outputs {
114        match write_single_artifact(
115            task_id,
116            output,
117            &output_spec,
118            workflow_config,
119            &writer,
120            bindings,
121            datastore,
122            media_refs,
123        )
124        .await
125        {
126            Ok(write_result) => {
127                debug!(
128                    task_id = %task_id,
129                    path = %write_result.path.display(),
130                    size = write_result.size,
131                    "Artifact written"
132                );
133
134                // Emit ArtifactWritten event if event_log provided
135                if let Some(log) = event_log {
136                    let checksum = if write_result.format == OutputFormat::Binary {
137                        resolve_binary_checksum(&output_spec, media_refs)
138                    } else {
139                        None
140                    };
141                    log.emit(EventKind::ArtifactWritten {
142                        task_id: Arc::from(task_id),
143                        path: write_result.path.display().to_string(),
144                        size: write_result.size,
145                        format: format!("{:?}", write_result.format).to_lowercase(),
146                        checksum,
147                    });
148                }
149
150                result.written += 1;
151                result.paths.push(write_result.path);
152            }
153            Err(e) => {
154                warn!(
155                    task_id = %task_id,
156                    path = %output_spec.path,
157                    error = %e,
158                    "Failed to write artifact"
159                );
160
161                // Emit ArtifactFailed event if event_log provided
162                if let Some(log) = event_log {
163                    log.emit(EventKind::ArtifactFailed {
164                        task_id: Arc::from(task_id),
165                        path: output_spec.path.clone(),
166                        reason: e.to_string(),
167                    });
168                }
169
170                result.errors.push(format!("{}: {}", output_spec.path, e));
171            }
172        }
173    }
174
175    result
176}
177
178/// Write a single artifact output
179///
180/// Supports `template:` field - if set, resolves template with bindings
181/// instead of using task output directly.
182#[allow(clippy::too_many_arguments)]
183async fn write_single_artifact(
184    task_id: &str,
185    output: &str,
186    output_spec: &ArtifactOutput,
187    workflow_config: Option<&ArtifactsConfig>,
188    writer: &ArtifactWriter,
189    bindings: &ResolvedBindings,
190    datastore: &RunContext,
191    media_refs: &[MediaRef],
192) -> Result<WriteResult, NikaError> {
193    // Determine format (task spec > workflow default)
194    let format = output_spec
195        .format
196        .or(workflow_config.map(|c| c.format))
197        .unwrap_or(ArtifactFormat::Text);
198
199    // Determine mode (task spec > workflow default) — computed before binary
200    // branch so that binary artifacts can validate and reject unsupported modes.
201    let mode = output_spec
202        .mode
203        .or(workflow_config.map(|c| c.mode))
204        .unwrap_or(ArtifactMode::Overwrite);
205
206    // Binary format: resolve source to CAS path and copy
207    if format == ArtifactFormat::Binary {
208        // Defense-in-depth: if media_refs is empty, try to construct a MediaRef
209        // from the task output JSON. This handles cases where set_media() was not
210        // called (e.g., older code paths) but the output contains CAS hash/path.
211        let fallback_refs;
212        let effective_media_refs = if media_refs.is_empty() {
213            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(output) {
214                if let (Some(hash), Some(path_str)) = (
215                    parsed.get("hash").and_then(|v| v.as_str()),
216                    parsed.get("path").and_then(|v| v.as_str()),
217                ) {
218                    debug!(
219                        task_id = %task_id,
220                        hash = %hash,
221                        "Binary artifact fallback: constructing MediaRef from output JSON"
222                    );
223                    fallback_refs = vec![MediaRef {
224                        hash: hash.to_string(),
225                        mime_type: parsed
226                            .get("mime_type")
227                            .and_then(|v| v.as_str())
228                            .unwrap_or("application/octet-stream")
229                            .to_string(),
230                        size_bytes: parsed
231                            .get("size_bytes")
232                            .and_then(|v| v.as_u64())
233                            .unwrap_or(0),
234                        path: std::path::PathBuf::from(path_str),
235                        extension: parsed
236                            .get("extension")
237                            .and_then(|v| v.as_str())
238                            .map(String::from)
239                            .unwrap_or_default(),
240                        created_by: task_id.to_string(),
241                        metadata: serde_json::Map::new(),
242                    }];
243                    &fallback_refs
244                } else {
245                    media_refs
246                }
247            } else {
248                media_refs
249            }
250        } else {
251            media_refs
252        };
253
254        return write_binary_artifact(
255            task_id,
256            output_spec,
257            mode,
258            writer,
259            bindings,
260            datastore,
261            effective_media_refs,
262        )
263        .await;
264    }
265
266    // Determine content source: source binding > template > task output
267    let raw_content: String = if let Some(ref source_alias) = output_spec.source {
268        // Resolve from bindings (with: block or upstream task output)
269        debug!(
270            task_id = %task_id,
271            source = %source_alias,
272            "Resolving artifact source binding"
273        );
274        if let Some(value) = bindings.get(source_alias) {
275            match value {
276                serde_json::Value::String(s) => s.clone(),
277                other => other.to_string(),
278            }
279        } else {
280            // Try datastore (task outputs stored by task ID)
281            match datastore.get_output(source_alias) {
282                Some(arc_value) => match arc_value.as_ref() {
283                    serde_json::Value::String(s) => s.clone(),
284                    other => other.to_string(),
285                },
286                None => {
287                    warn!(
288                        task_id = %task_id,
289                        source = %source_alias,
290                        "Artifact source binding not found, falling back to task output"
291                    );
292                    output.to_string()
293                }
294            }
295        }
296    } else if let Some(ref tpl) = output_spec.template {
297        // Replace {{output}} with actual task output before template resolution
298        let tpl_with_output = tpl.replace("{{output}}", output);
299
300        // Resolve template with bindings (handles {{with.*}}, {{inputs.*}}, etc.)
301        debug!(
302            task_id = %task_id,
303            template = %tpl,
304            "Resolving artifact template"
305        );
306        match template_resolve(&tpl_with_output, bindings, datastore) {
307            Ok(resolved) => resolved.into_owned(),
308            Err(e) => {
309                warn!(
310                    task_id = %task_id,
311                    template = %tpl,
312                    error = %e,
313                    "Failed to resolve artifact template, using raw template"
314                );
315                // On template resolution failure, use the pre-resolved template
316                tpl_with_output
317            }
318        }
319    } else {
320        // No source or template - use task output directly
321        output.to_string()
322    };
323
324    // Convert content based on format
325    let content = format_output(&raw_content, format)?;
326
327    // Convert ArtifactFormat to OutputFormat for the writer
328    let output_format = match format {
329        ArtifactFormat::Text => OutputFormat::Text,
330        ArtifactFormat::Json => OutputFormat::Json,
331        ArtifactFormat::Yaml => OutputFormat::Text, // YAML treated as text for validation
332        ArtifactFormat::Binary => OutputFormat::Text, // Binary bypasses format_output entirely
333    };
334
335    // Pre-resolve {{with.*}} and {{output}} binding references in the path
336    // before the TemplateResolver handles {{task_id}}, {{date}}, etc.
337    let resolved_path =
338        resolve_artifact_path_bindings(&output_spec.path, output, bindings, datastore);
339
340    // Normalize the artifact path to prevent doubled paths when user specifies
341    // full path like ./artifacts/custom.txt instead of just custom.txt
342    let artifact_dir_str = workflow_config
343        .and_then(|c| c.dir.as_deref())
344        .unwrap_or(DEFAULT_ARTIFACT_DIR);
345    let normalized_path = normalize_artifact_path(&resolved_path, artifact_dir_str);
346
347    // Build write request - we need to keep output_format for WriteResult
348    let request = WriteRequest::new(task_id, &normalized_path)
349        .with_content(content)
350        .with_format(output_format.clone());
351
352    // Handle different write modes
353    match mode {
354        ArtifactMode::Overwrite => writer.write(request).await,
355        ArtifactMode::Append => {
356            // For append mode, we need to use atomic append
357            let resolved_path = writer.validate_path(task_id, &normalized_path)?;
358            write_append(&resolved_path, request.content.as_bytes())
359                .await
360                .map_err(|e| NikaError::ArtifactWriteError {
361                    path: resolved_path.display().to_string(),
362                    reason: format!("Append failed: {}", e),
363                })?;
364            Ok(WriteResult {
365                path: resolved_path,
366                size: request.content.len() as u64,
367                format: output_format.clone(),
368            })
369        }
370        ArtifactMode::Unique => {
371            // For unique mode, generate unique filename
372            let resolved_path = writer.validate_path(task_id, &normalized_path)?;
373            let unique_path = write_unique(&resolved_path, request.content.as_bytes())
374                .await
375                .map_err(|e| NikaError::ArtifactWriteError {
376                    path: resolved_path.display().to_string(),
377                    reason: format!("Unique write failed: {}", e),
378                })?;
379            Ok(WriteResult {
380                path: unique_path,
381                size: request.content.len() as u64,
382                format: output_format.clone(),
383            })
384        }
385        ArtifactMode::Fail => {
386            // For fail mode, error if file exists
387            let resolved_path = writer.validate_path(task_id, &normalized_path)?;
388            write_fail(&resolved_path, request.content.as_bytes())
389                .await
390                .map_err(|e| NikaError::ArtifactWriteError {
391                    path: resolved_path.display().to_string(),
392                    reason: format!("Write failed (file may exist): {}", e),
393                })?;
394            Ok(WriteResult {
395                path: resolved_path,
396                size: request.content.len() as u64,
397                format: output_format.clone(),
398            })
399        }
400    }
401}
402
403/// Write a binary artifact from a media reference.
404///
405/// Resolves the `source` binding to a media hash or path, then copies from CAS store.
406/// Falls back to the first media ref if no explicit source is specified.
407///
408/// # Mode support
409///
410/// Binary artifacts only support `Overwrite` (default) and `Fail` modes.
411/// `Append` and `Unique` are rejected with NIKA-281 because binary data
412/// cannot be meaningfully appended or deduplicated by filename suffix.
413async fn write_binary_artifact(
414    task_id: &str,
415    output_spec: &ArtifactOutput,
416    mode: ArtifactMode,
417    writer: &ArtifactWriter,
418    bindings: &ResolvedBindings,
419    datastore: &RunContext,
420    media_refs: &[MediaRef],
421) -> Result<WriteResult, NikaError> {
422    // Reject unsupported modes for binary artifacts
423    match mode {
424        ArtifactMode::Append => {
425            return Err(NikaError::ArtifactWriteError {
426                path: output_spec.path.clone(),
427                reason: "Binary artifacts do not support append mode".to_string(),
428            });
429        }
430        ArtifactMode::Unique => {
431            return Err(NikaError::ArtifactWriteError {
432                path: output_spec.path.clone(),
433                reason: "Binary artifacts do not support unique mode".to_string(),
434            });
435        }
436        ArtifactMode::Overwrite | ArtifactMode::Fail => {
437            // Supported -- continue
438        }
439    }
440
441    // Resolve source to a MediaRef:
442    // 1. If source is specified, look it up in bindings/media_refs
443    // 2. Otherwise, use first media ref from the task
444    //
445    // Resolution order for `source: alias`:
446    //   a) Direct match: media_refs.created_by == alias || media_refs.hash == alias
447    //   b) Binding indirection: resolve alias -> source task ID -> media_refs.created_by
448    //   c) Hash indirection: binding value is a hash string -> media_refs.hash == hash
449    let media_ref = if let Some(ref source_alias) = output_spec.source {
450        // Try to find media by source alias (could be a task_id or hash)
451        // First check if any media ref was created by a task matching the source alias
452        let from_media = media_refs
453            .iter()
454            .find(|m| m.created_by == *source_alias || m.hash == *source_alias);
455        if let Some(mr) = from_media {
456            mr.clone()
457        } else {
458            // Try binding indirection: source_alias is a with-binding alias
459            // that maps to a task (e.g., `source: img` where `with: { img: $gen_img }`)
460            // Resolve the source task ID and find media by created_by.
461            let from_binding_source = bindings
462                .source_task_id(source_alias)
463                .and_then(|task_id| media_refs.iter().find(|m| m.created_by == task_id).cloned());
464
465            if let Some(mr) = from_binding_source {
466                mr
467            } else {
468                // Try resolving from bindings — the value might contain a media hash
469                let hash_value = if let Some(value) = bindings.get(source_alias) {
470                    match value {
471                        serde_json::Value::String(s) => Some(s.clone()),
472                        _ => None,
473                    }
474                } else {
475                    datastore
476                        .get_output(source_alias)
477                        .and_then(|v| match v.as_ref() {
478                            serde_json::Value::String(s) => Some(s.clone()),
479                            _ => None,
480                        })
481                };
482
483                if let Some(hash) = hash_value {
484                    // Find media ref by hash
485                    media_refs
486                        .iter()
487                        .find(|m| m.hash == hash)
488                        .cloned()
489                        .ok_or_else(|| NikaError::ArtifactWriteError {
490                            path: output_spec.path.clone(),
491                            reason: format!(
492                                "Binary artifact source '{}' resolved to hash '{}' but no media ref matches",
493                                source_alias, hash
494                            ),
495                        })?
496                } else {
497                    return Err(NikaError::ArtifactWriteError {
498                        path: output_spec.path.clone(),
499                        reason: format!(
500                            "Binary artifact source '{}' not found in media refs or bindings",
501                            source_alias
502                        ),
503                    });
504                }
505            }
506        }
507    } else {
508        // No explicit source — use first media ref
509        media_refs
510            .first()
511            .cloned()
512            .ok_or_else(|| NikaError::ArtifactWriteError {
513                path: output_spec.path.clone(),
514                reason: "Binary artifact requires media content but task produced no media"
515                    .to_string(),
516            })?
517    };
518
519    debug!(
520        task_id = %task_id,
521        hash = %media_ref.hash,
522        path = %media_ref.path.display(),
523        "Writing binary artifact from CAS"
524    );
525
526    // Pre-resolve binding references in the path
527    let resolved_path = resolve_artifact_path_bindings(&output_spec.path, "", bindings, datastore);
528
529    // Normalize the artifact path
530    let artifact_dir_str = ""; // Binary artifacts use the raw path
531    let normalized_path = normalize_artifact_path(&resolved_path, artifact_dir_str);
532
533    // For Fail mode, check that the target does not already exist
534    if mode == ArtifactMode::Fail {
535        let resolved = writer.validate_path(task_id, &normalized_path)?;
536        if resolved.exists() {
537            return Err(NikaError::ArtifactWriteError {
538                path: resolved.display().to_string(),
539                reason: "File already exists and mode is 'fail'".to_string(),
540            });
541        }
542    }
543
544    let request = BinaryWriteRequest {
545        task_id: task_id.to_string(),
546        output_path: normalized_path,
547        source: BinarySource::CasPath(media_ref.path.clone()),
548        expected_size: media_ref.size_bytes,
549    };
550
551    writer.write_binary(request).await
552}
553
554/// Resolve the blake3 checksum for a binary artifact from media refs.
555///
556/// Looks up the matching MediaRef by source alias or falls back to the first ref.
557/// Returns `Some("blake3:...")` if found, `None` otherwise.
558fn resolve_binary_checksum(
559    output_spec: &ArtifactOutput,
560    media_refs: &[MediaRef],
561) -> Option<String> {
562    if let Some(ref source_alias) = output_spec.source {
563        // Match by creator task_id or by hash
564        media_refs
565            .iter()
566            .find(|m| m.created_by == *source_alias || m.hash == *source_alias)
567            .map(|m| m.hash.clone())
568    } else {
569        // No explicit source -- use first media ref (same logic as write_binary_artifact)
570        media_refs.first().map(|m| m.hash.clone())
571    }
572}
573
574/// Format output content based on artifact format
575fn format_output(output: &str, format: ArtifactFormat) -> Result<String, NikaError> {
576    match format {
577        ArtifactFormat::Text => Ok(output.to_string()),
578        ArtifactFormat::Json => {
579            // Try to parse as JSON and pretty-print
580            match serde_json::from_str::<serde_json::Value>(output) {
581                Ok(value) => serde_json::to_string_pretty(&value).map_err(|e| {
582                    NikaError::ArtifactWriteError {
583                        path: "".to_string(),
584                        reason: format!("Failed to format JSON: {}", e),
585                    }
586                }),
587                Err(_) => {
588                    // If not valid JSON, wrap as string
589                    Ok(serde_json::to_string_pretty(&output)
590                        .unwrap_or_else(|_| format!("\"{}\"", output)))
591                }
592            }
593        }
594        ArtifactFormat::Yaml => {
595            // Try to parse as JSON first, then convert to YAML
596            match serde_json::from_str::<serde_json::Value>(output) {
597                Ok(value) => {
598                    serde_yaml::to_string(&value).map_err(|e| NikaError::ArtifactWriteError {
599                        path: "".to_string(),
600                        reason: format!("Failed to format YAML: {}", e),
601                    })
602                }
603                Err(_) => {
604                    // If not valid JSON, just use as-is
605                    Ok(output.to_string())
606                }
607            }
608        }
609        ArtifactFormat::Binary => {
610            // Binary artifacts are handled separately via write_binary()
611            // This path should not be reached for binary format
612            Err(NikaError::ArtifactWriteError {
613                path: "".to_string(),
614                reason: "Binary format must be written via write_binary(), not format_output()"
615                    .to_string(),
616            })
617        }
618    }
619}
620
621/// Resolve artifact directory from workflow config
622///
623/// Creates the directory if it doesn't exist and canonicalizes the path
624/// to avoid macOS symlink issues (e.g., /var -> /private/var).
625async fn resolve_artifact_dir(
626    workflow_config: Option<&ArtifactsConfig>,
627    base_path: &std::path::Path,
628) -> PathBuf {
629    let dir_str = workflow_config
630        .and_then(|c| c.dir.as_deref())
631        .unwrap_or(DEFAULT_ARTIFACT_DIR);
632
633    let artifact_dir = base_path.join(dir_str);
634
635    // Create directory if it doesn't exist (non-blocking)
636    if !artifact_dir.exists() {
637        if let Err(e) = tokio::fs::create_dir_all(&artifact_dir).await {
638            tracing::warn!(
639                path = %artifact_dir.display(),
640                error = %e,
641                "Failed to create artifact directory"
642            );
643            return artifact_dir;
644        }
645    }
646
647    // Canonicalize to resolve symlinks (important for macOS /var -> /private/var)
648    artifact_dir.canonicalize().unwrap_or(artifact_dir)
649}
650
651/// Sanitize a value for safe use in file paths.
652///
653/// Replaces path-dangerous characters with underscores and truncates
654/// to prevent excessively long paths. This is the security boundary
655/// where user-controlled binding values enter the filesystem path context.
656fn sanitize_for_path(value: &str) -> String {
657    value
658        .replace(['/', '\\', ':'], "_")
659        .replace('\0', "")
660        .replace("..", "_")
661        .replace('~', "_")
662        .chars()
663        .take(200)
664        .collect::<String>()
665        .trim()
666        .to_string()
667}
668
669/// Pre-resolve `{{with.*}}` and `{{output}}` binding references in an artifact path.
670///
671/// This is a targeted pre-pass that resolves binding-based templates in artifact
672/// paths before they reach the `TemplateResolver` (which handles `{{task_id}}`,
673/// `{{date}}`, etc.). The two template systems remain independent.
674///
675/// Supported patterns:
676/// - `{{with.alias}}` — Resolves from the task's `with:` bindings
677/// - `{{output}}` — Resolves to the current task's output (sanitized)
678///
679/// Values are sanitized via `sanitize_for_path()` to prevent path traversal.
680/// Unresolved `{{with.*}}` references are left as-is (will error in TemplateResolver).
681fn resolve_artifact_path_bindings(
682    path: &str,
683    output: &str,
684    bindings: &ResolvedBindings,
685    datastore: &RunContext,
686) -> String {
687    let mut result = path.to_string();
688    let mut pos = 0;
689
690    while let Some(start) = result[pos..].find("{{") {
691        let start = pos + start;
692        let Some(end) = result[start..].find("}}") else {
693            break;
694        };
695        let end = start + end + 2;
696
697        let var_name = result[start + 2..end - 2].trim();
698
699        if var_name == "output" {
700            let sanitized = sanitize_for_path(output.trim());
701            result.replace_range(start..end, &sanitized);
702            pos = start + sanitized.len();
703        } else if let Some(alias) = var_name.strip_prefix("with.") {
704            // Extract top-level alias (e.g., "with.timestamp" → "timestamp")
705            let top_alias = alias.split('.').next().unwrap_or(alias);
706
707            // Check for media paths: {{with.alias.media[N].field}}
708            // Media refs live in the TaskResult side-channel, not in the task
709            // output value, so we must resolve via datastore.resolve_path()
710            // using the original source task ID.
711            let nested_path = alias.split_once('.').map(|x| x.1).unwrap_or("");
712            let is_media_path = nested_path == "media"
713                || nested_path.starts_with("media.")
714                || nested_path.starts_with("media[");
715
716            if is_media_path {
717                // Resolve media path via datastore using source task ID
718                if let Some(source_task_id) = bindings.source_task_id(top_alias) {
719                    let full_path = format!("{}.{}", source_task_id, nested_path);
720                    if let Some(value) = datastore.resolve_path(&full_path) {
721                        let raw_value = match &value {
722                            serde_json::Value::String(s) => s.clone(),
723                            other => other.to_string(),
724                        };
725                        let sanitized = sanitize_for_path(&raw_value);
726                        result.replace_range(start..end, &sanitized);
727                        pos = start + sanitized.len();
728                    } else {
729                        pos = end;
730                    }
731                } else {
732                    pos = end;
733                }
734            } else if let Some(value) = bindings.get(top_alias) {
735                // For nested paths like "with.data.name", do JSONPath-like access
736                let raw_value = if alias.contains('.') {
737                    // Navigate into the JSON value
738                    let parts: Vec<&str> = alias.splitn(2, '.').collect();
739                    if parts.len() == 2 {
740                        json_path_value(value, parts[1])
741                    } else {
742                        value_to_string(value)
743                    }
744                } else {
745                    value_to_string(value)
746                };
747                let sanitized = sanitize_for_path(&raw_value);
748                result.replace_range(start..end, &sanitized);
749                pos = start + sanitized.len();
750            } else {
751                // Unknown alias — leave as-is for TemplateResolver to handle/error
752                pos = end;
753            }
754        } else if let Some(input_path) = var_name.strip_prefix("inputs.") {
755            // Resolve {{inputs.param}} from datastore
756            let full_path = format!("inputs.{}", input_path);
757            if let Some(value) = datastore.resolve_input_path(&full_path) {
758                let raw_value = match &value {
759                    serde_json::Value::String(s) => s.clone(),
760                    other => other.to_string(),
761                };
762                let sanitized = sanitize_for_path(&raw_value);
763                result.replace_range(start..end, &sanitized);
764                pos = start + sanitized.len();
765            } else {
766                pos = end;
767            }
768        } else {
769            // Not a binding reference (e.g., {{task_id}}, {{date}}) — skip
770            pos = end;
771        }
772    }
773
774    result
775}
776
777/// Convert a serde_json::Value to a path-friendly string
778fn value_to_string(value: &serde_json::Value) -> String {
779    match value {
780        serde_json::Value::String(s) => s.clone(),
781        serde_json::Value::Number(n) => n.to_string(),
782        serde_json::Value::Bool(b) => b.to_string(),
783        serde_json::Value::Null => "null".to_string(),
784        // Arrays and objects get compact JSON representation
785        other => other.to_string(),
786    }
787}
788
789/// Simple dot-path navigation into a serde_json::Value
790fn json_path_value(value: &serde_json::Value, path: &str) -> String {
791    let mut current = value;
792    for part in path.split('.') {
793        match current {
794            serde_json::Value::Object(map) => {
795                if let Some(next) = map.get(part) {
796                    current = next;
797                } else {
798                    return format!("{{{{with.{}}}}}", path);
799                }
800            }
801            serde_json::Value::Array(arr) => {
802                if let Ok(idx) = part.parse::<usize>() {
803                    if let Some(next) = arr.get(idx) {
804                        current = next;
805                    } else {
806                        return format!("{{{{with.{}}}}}", path);
807                    }
808                } else {
809                    return format!("{{{{with.{}}}}}", path);
810                }
811            }
812            _ => return format!("{{{{with.{}}}}}", path),
813        }
814    }
815    value_to_string(current)
816}
817
818/// Normalize artifact output path to prevent doubled paths
819///
820/// If the artifact path starts with `./` and contains the artifact_dir path,
821/// strip the redundant prefix to prevent paths like:
822/// `artifacts/./artifacts/custom.txt` → `artifacts/custom.txt`
823///
824/// This handles the common user mistake of specifying full paths in artifact spec.
825fn normalize_artifact_path(path: &str, artifact_dir_str: &str) -> String {
826    let path = path.trim();
827    let artifact_dir = artifact_dir_str
828        .trim_start_matches("./")
829        .trim_end_matches('/');
830
831    // Check if path starts with ./ and contains the artifact_dir
832    if path.starts_with("./") {
833        let path_without_dot = path.trim_start_matches("./");
834        // If path starts with artifact_dir, strip it to get the relative part
835        if path_without_dot.starts_with(artifact_dir) {
836            let relative = path_without_dot
837                .trim_start_matches(artifact_dir)
838                .trim_start_matches('/');
839            if !relative.is_empty() {
840                debug!(
841                    original = %path,
842                    normalized = %relative,
843                    "Normalized artifact path (removed redundant prefix)"
844                );
845                return relative.to_string();
846            }
847        }
848    }
849
850    path.to_string()
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use tempfile::tempdir;
857
858    #[test]
859    fn test_format_output_text() {
860        let result = format_output("hello world", ArtifactFormat::Text);
861        assert!(result.is_ok());
862        assert_eq!(result.unwrap(), "hello world");
863    }
864
865    #[test]
866    fn test_format_output_json_valid() {
867        let result = format_output(r#"{"key":"value"}"#, ArtifactFormat::Json);
868        assert!(result.is_ok());
869        let formatted = result.unwrap();
870        assert!(formatted.contains("key"));
871        assert!(formatted.contains("value"));
872    }
873
874    #[test]
875    fn test_format_output_json_invalid() {
876        let result = format_output("not json", ArtifactFormat::Json);
877        assert!(result.is_ok());
878        // Should be wrapped as JSON string
879        let formatted = result.unwrap();
880        assert!(formatted.contains("not json"));
881    }
882
883    #[test]
884    fn test_format_output_yaml() {
885        let result = format_output(r#"{"key":"value"}"#, ArtifactFormat::Yaml);
886        assert!(result.is_ok());
887        let formatted = result.unwrap();
888        assert!(formatted.contains("key"));
889    }
890
891    #[tokio::test]
892    async fn test_resolve_artifact_dir_default() {
893        let base = PathBuf::from("/project");
894        let dir = resolve_artifact_dir(None, &base).await;
895        assert_eq!(dir, PathBuf::from("/project/.nika/artifacts"));
896    }
897
898    #[tokio::test]
899    async fn test_resolve_artifact_dir_custom() {
900        let base = PathBuf::from("/project");
901        let config = ArtifactsConfig {
902            dir: Some("output".to_string()),
903            ..Default::default()
904        };
905        let dir = resolve_artifact_dir(Some(&config), &base).await;
906        assert_eq!(dir, PathBuf::from("/project/output"));
907    }
908
909    #[tokio::test]
910    async fn test_process_task_artifacts_disabled() {
911        let base = tempdir().unwrap();
912        let bindings = ResolvedBindings::default();
913        let datastore = RunContext::new();
914        let result = process_task_artifacts(
915            "task1",
916            "output",
917            &ArtifactSpec::Enabled(false),
918            None,
919            base.path(),
920            None, // No event log for tests
921            &bindings,
922            &datastore,
923            &[],
924        )
925        .await;
926
927        assert_eq!(result.written, 0);
928        assert!(result.paths.is_empty());
929        assert!(result.errors.is_empty());
930    }
931
932    #[tokio::test]
933    async fn test_process_task_artifacts_enabled() {
934        let base = tempdir().unwrap();
935        let artifact_dir = base.path().join(".nika/artifacts");
936        std::fs::create_dir_all(&artifact_dir).unwrap();
937        let bindings = ResolvedBindings::default();
938        let datastore = RunContext::new();
939
940        let result = process_task_artifacts(
941            "task1",
942            "test output",
943            &ArtifactSpec::Enabled(true),
944            None,
945            base.path(),
946            None, // No event log for tests
947            &bindings,
948            &datastore,
949            &[],
950        )
951        .await;
952
953        // Print errors for debugging
954        if !result.errors.is_empty() {
955            eprintln!("Artifact errors: {:?}", result.errors);
956        }
957
958        assert_eq!(
959            result.written, 1,
960            "Expected 1 artifact written, errors: {:?}",
961            result.errors
962        );
963        assert!(!result.paths.is_empty());
964        assert!(
965            result.errors.is_empty(),
966            "Unexpected errors: {:?}",
967            result.errors
968        );
969    }
970
971    #[tokio::test]
972    async fn test_process_task_artifacts_single() {
973        let base = tempdir().unwrap();
974        let artifact_dir = base.path().join(".nika/artifacts");
975        std::fs::create_dir_all(&artifact_dir).unwrap();
976        let bindings = ResolvedBindings::default();
977        let datastore = RunContext::new();
978
979        let spec = ArtifactSpec::Single(ArtifactOutput {
980            path: "output.json".to_string(),
981            source: None,
982            template: None,
983            format: Some(ArtifactFormat::Json),
984            mode: None,
985        });
986
987        let result = process_task_artifacts(
988            "task1",
989            r#"{"result": "success"}"#,
990            &spec,
991            None,
992            base.path(),
993            None, // No event log for tests
994            &bindings,
995            &datastore,
996            &[],
997        )
998        .await;
999
1000        assert_eq!(result.written, 1);
1001        assert!(result.paths[0].ends_with("output.json"));
1002    }
1003
1004    #[tokio::test]
1005    async fn test_process_task_artifacts_multiple() {
1006        let base = tempdir().unwrap();
1007        let artifact_dir = base.path().join(".nika/artifacts");
1008        std::fs::create_dir_all(&artifact_dir).unwrap();
1009        let bindings = ResolvedBindings::default();
1010        let datastore = RunContext::new();
1011
1012        let spec = ArtifactSpec::Multiple(vec![
1013            ArtifactOutput {
1014                path: "raw.txt".to_string(),
1015                source: None,
1016                template: None,
1017                format: Some(ArtifactFormat::Text),
1018                mode: None,
1019            },
1020            ArtifactOutput {
1021                path: "processed.json".to_string(),
1022                source: None,
1023                template: None,
1024                format: Some(ArtifactFormat::Json),
1025                mode: None,
1026            },
1027        ]);
1028
1029        let result = process_task_artifacts(
1030            "task1",
1031            "test data",
1032            &spec,
1033            None,
1034            base.path(),
1035            None, // No event log for tests
1036            &bindings,
1037            &datastore,
1038            &[],
1039        )
1040        .await;
1041
1042        assert_eq!(result.written, 2);
1043        assert_eq!(result.paths.len(), 2);
1044    }
1045
1046    // ========== BUG-3: artifact source resolution ==========
1047
1048    #[tokio::test]
1049    async fn test_artifact_source_from_binding() {
1050        let base = tempdir().unwrap();
1051        let artifact_dir = base.path().join(".nika/artifacts");
1052        std::fs::create_dir_all(&artifact_dir).unwrap();
1053
1054        // Set up bindings with a "report_data" alias
1055        let mut bindings = ResolvedBindings::new();
1056        bindings.set(
1057            "report_data".to_string(),
1058            serde_json::Value::String("Content from binding source".to_string()),
1059        );
1060        let datastore = RunContext::new();
1061
1062        let spec = ArtifactSpec::Single(ArtifactOutput {
1063            path: "report.txt".to_string(),
1064            source: Some("report_data".to_string()),
1065            template: None,
1066            format: Some(ArtifactFormat::Text),
1067            mode: None,
1068        });
1069
1070        let result = process_task_artifacts(
1071            "task1",
1072            "this is the task output (should NOT be written)",
1073            &spec,
1074            None,
1075            base.path(),
1076            None,
1077            &bindings,
1078            &datastore,
1079            &[],
1080        )
1081        .await;
1082
1083        assert_eq!(result.written, 1, "artifact should be written");
1084        assert!(result.errors.is_empty(), "no errors expected");
1085
1086        // Verify file content comes from source binding, not task output
1087        let content = std::fs::read_to_string(&result.paths[0]).unwrap();
1088        assert_eq!(content, "Content from binding source");
1089        assert!(!content.contains("should NOT be written"));
1090    }
1091
1092    #[tokio::test]
1093    async fn test_artifact_source_fallback_to_task_output() {
1094        let base = tempdir().unwrap();
1095        let artifact_dir = base.path().join(".nika/artifacts");
1096        std::fs::create_dir_all(&artifact_dir).unwrap();
1097        let bindings = ResolvedBindings::new();
1098        let datastore = RunContext::new();
1099
1100        // source points to a non-existent binding → should fall back to task output
1101        let spec = ArtifactSpec::Single(ArtifactOutput {
1102            path: "fallback.txt".to_string(),
1103            source: Some("nonexistent".to_string()),
1104            template: None,
1105            format: Some(ArtifactFormat::Text),
1106            mode: None,
1107        });
1108
1109        let result = process_task_artifacts(
1110            "task1",
1111            "task output fallback",
1112            &spec,
1113            None,
1114            base.path(),
1115            None,
1116            &bindings,
1117            &datastore,
1118            &[],
1119        )
1120        .await;
1121
1122        assert_eq!(result.written, 1);
1123        let content = std::fs::read_to_string(&result.paths[0]).unwrap();
1124        assert_eq!(content, "task output fallback");
1125    }
1126
1127    // ========== normalize_artifact_path tests ==========
1128
1129    #[test]
1130    fn test_normalize_artifact_path_simple_filename() {
1131        // Simple filename should not be modified
1132        let result = normalize_artifact_path("custom.txt", "./examples/.test-output/artifacts");
1133        assert_eq!(result, "custom.txt");
1134    }
1135
1136    #[test]
1137    fn test_normalize_artifact_path_doubled_path() {
1138        // Doubled path should be normalized
1139        let result = normalize_artifact_path(
1140            "./examples/.test-output/artifacts/custom.txt",
1141            "./examples/.test-output/artifacts",
1142        );
1143        assert_eq!(result, "custom.txt");
1144    }
1145
1146    #[test]
1147    fn test_normalize_artifact_path_nested_doubled() {
1148        // Nested doubled path should be normalized
1149        let result =
1150            normalize_artifact_path("./output/artifacts/subdir/file.json", "./output/artifacts");
1151        assert_eq!(result, "subdir/file.json");
1152    }
1153
1154    #[test]
1155    fn test_normalize_artifact_path_no_leading_dot() {
1156        // Path without leading ./ should not be modified
1157        let result = normalize_artifact_path("subdir/file.txt", "./artifacts");
1158        assert_eq!(result, "subdir/file.txt");
1159    }
1160
1161    #[test]
1162    fn test_normalize_artifact_path_different_prefix() {
1163        // Path that doesn't match artifact_dir should not be modified
1164        let result = normalize_artifact_path("./other/path/file.txt", "./artifacts");
1165        assert_eq!(result, "./other/path/file.txt");
1166    }
1167
1168    #[test]
1169    fn test_normalize_artifact_path_default_dir() {
1170        // Works with default artifact directory
1171        let result = normalize_artifact_path("./.nika/artifacts/output.json", ".nika/artifacts");
1172        assert_eq!(result, "output.json");
1173    }
1174
1175    // ========== Template resolution tests ==========
1176
1177    #[tokio::test]
1178    async fn test_artifact_template_resolution() {
1179        use crate::store::TaskResult;
1180        use std::sync::Arc;
1181        use std::time::Duration;
1182
1183        let base = tempdir().unwrap();
1184        let artifact_dir = base.path().join(".nika/artifacts");
1185        std::fs::create_dir_all(&artifact_dir).unwrap();
1186
1187        // Create datastore with task result that has JSON data
1188        let datastore = RunContext::new();
1189        let task_result = TaskResult::success_str(
1190            r#"{"name": "Alice", "age": 30}"#.to_string(),
1191            Duration::from_millis(100),
1192        );
1193        datastore.insert(Arc::from("generate_data"), task_result);
1194
1195        // Create bindings that reference the upstream task
1196        let mut bindings = ResolvedBindings::default();
1197        bindings.set("data", serde_json::json!({"name": "Alice", "age": 30}));
1198
1199        // Create artifact spec with template
1200        let spec = ArtifactSpec::Single(ArtifactOutput {
1201            path: "report.md".to_string(),
1202            source: None,
1203            template: Some(
1204                "# Report\n\nUser: {{with.data.name}}, Age: {{with.data.age}}".to_string(),
1205            ),
1206            format: Some(ArtifactFormat::Text),
1207            mode: None,
1208        });
1209
1210        let result = process_task_artifacts(
1211            "generate_report",
1212            "task output (ignored when template is set)",
1213            &spec,
1214            None,
1215            base.path(),
1216            None,
1217            &bindings,
1218            &datastore,
1219            &[],
1220        )
1221        .await;
1222
1223        assert_eq!(
1224            result.written, 1,
1225            "Expected 1 artifact written, errors: {:?}",
1226            result.errors
1227        );
1228        assert!(
1229            result.errors.is_empty(),
1230            "Unexpected errors: {:?}",
1231            result.errors
1232        );
1233
1234        // Read the artifact content and verify template was resolved
1235        let artifact_content = std::fs::read_to_string(&result.paths[0]).unwrap();
1236        assert_eq!(artifact_content, "# Report\n\nUser: Alice, Age: 30");
1237    }
1238
1239    #[tokio::test]
1240    async fn test_artifact_without_template_uses_output() {
1241        let base = tempdir().unwrap();
1242        let artifact_dir = base.path().join(".nika/artifacts");
1243        std::fs::create_dir_all(&artifact_dir).unwrap();
1244        let bindings = ResolvedBindings::default();
1245        let datastore = RunContext::new();
1246
1247        // Create artifact spec WITHOUT template
1248        let spec = ArtifactSpec::Single(ArtifactOutput {
1249            path: "output.txt".to_string(),
1250            source: None,
1251            template: None, // No template - should use task output
1252            format: Some(ArtifactFormat::Text),
1253            mode: None,
1254        });
1255
1256        let result = process_task_artifacts(
1257            "task1",
1258            "This is the task output",
1259            &spec,
1260            None,
1261            base.path(),
1262            None,
1263            &bindings,
1264            &datastore,
1265            &[],
1266        )
1267        .await;
1268
1269        assert_eq!(result.written, 1);
1270
1271        // Read the artifact content and verify it's the task output
1272        let artifact_content = std::fs::read_to_string(&result.paths[0]).unwrap();
1273        assert_eq!(artifact_content, "This is the task output");
1274    }
1275
1276    #[tokio::test]
1277    async fn test_artifact_template_with_missing_binding() {
1278        let base = tempdir().unwrap();
1279        let artifact_dir = base.path().join(".nika/artifacts");
1280        std::fs::create_dir_all(&artifact_dir).unwrap();
1281        let bindings = ResolvedBindings::default(); // Empty bindings
1282        let datastore = RunContext::new();
1283
1284        // Create artifact spec with template that references missing binding
1285        let spec = ArtifactSpec::Single(ArtifactOutput {
1286            path: "report.md".to_string(),
1287            source: None,
1288            template: Some("Hello {{with.missing}}!".to_string()),
1289            format: Some(ArtifactFormat::Text),
1290            mode: None,
1291        });
1292
1293        let result = process_task_artifacts(
1294            "task1",
1295            "fallback output",
1296            &spec,
1297            None,
1298            base.path(),
1299            None,
1300            &bindings,
1301            &datastore,
1302            &[],
1303        )
1304        .await;
1305
1306        // Should still write, but with raw template (on resolution error)
1307        assert_eq!(result.written, 1);
1308
1309        let artifact_content = std::fs::read_to_string(&result.paths[0]).unwrap();
1310        // On template resolution failure, it uses the raw template
1311        assert_eq!(artifact_content, "Hello {{with.missing}}!");
1312    }
1313
1314    // ========== resolve_artifact_path_bindings tests ==========
1315
1316    #[test]
1317    fn test_path_bindings_with_alias() {
1318        let mut bindings = ResolvedBindings::default();
1319        bindings.set("timestamp", serde_json::json!("2024-01-15_14-30-00"));
1320
1321        let result = resolve_artifact_path_bindings(
1322            "./outputs/result-{{with.timestamp}}.json",
1323            "task output",
1324            &bindings,
1325            &RunContext::new(),
1326        );
1327        assert_eq!(result, "./outputs/result-2024-01-15_14-30-00.json");
1328    }
1329
1330    #[test]
1331    fn test_path_bindings_output() {
1332        let bindings = ResolvedBindings::default();
1333
1334        let result = resolve_artifact_path_bindings(
1335            "./outputs/{{output}}.json",
1336            "my-report",
1337            &bindings,
1338            &RunContext::new(),
1339        );
1340        assert_eq!(result, "./outputs/my-report.json");
1341    }
1342
1343    #[test]
1344    fn test_path_bindings_mixed_with_builtins() {
1345        let mut bindings = ResolvedBindings::default();
1346        bindings.set("locale", serde_json::json!("fr-FR"));
1347
1348        // {{with.locale}} should resolve, {{task_id}} should be left for TemplateResolver
1349        let result = resolve_artifact_path_bindings(
1350            "{{task_id}}/{{with.locale}}/output.json",
1351            "",
1352            &bindings,
1353            &RunContext::new(),
1354        );
1355        assert_eq!(result, "{{task_id}}/fr-FR/output.json");
1356    }
1357
1358    #[test]
1359    fn test_path_bindings_nested_json() {
1360        let mut bindings = ResolvedBindings::default();
1361        bindings.set("meta", serde_json::json!({"slug": "qr-code", "version": 2}));
1362
1363        let result = resolve_artifact_path_bindings(
1364            "./outputs/{{with.meta.slug}}-v{{with.meta.version}}.json",
1365            "",
1366            &bindings,
1367            &RunContext::new(),
1368        );
1369        assert_eq!(result, "./outputs/qr-code-v2.json");
1370    }
1371
1372    #[test]
1373    fn test_path_bindings_sanitizes_slashes() {
1374        let mut bindings = ResolvedBindings::default();
1375        bindings.set("name", serde_json::json!("../../etc/passwd"));
1376
1377        let result = resolve_artifact_path_bindings(
1378            "./outputs/{{with.name}}.txt",
1379            "",
1380            &bindings,
1381            &RunContext::new(),
1382        );
1383        // Path traversal characters should be sanitized
1384        assert!(!result.contains(".."));
1385        assert!(!result.contains("etc/passwd"));
1386    }
1387
1388    #[test]
1389    fn test_path_bindings_sanitizes_output() {
1390        let bindings = ResolvedBindings::default();
1391
1392        let result = resolve_artifact_path_bindings(
1393            "./outputs/{{output}}.txt",
1394            "../../../etc/passwd",
1395            &bindings,
1396            &RunContext::new(),
1397        );
1398        assert!(!result.contains("../"));
1399        assert!(!result.contains("etc/passwd"));
1400    }
1401
1402    #[test]
1403    fn test_path_bindings_unknown_alias_preserved() {
1404        let bindings = ResolvedBindings::default();
1405
1406        // Unknown binding should be left as-is
1407        let result = resolve_artifact_path_bindings(
1408            "./outputs/{{with.unknown}}.json",
1409            "",
1410            &bindings,
1411            &RunContext::new(),
1412        );
1413        assert_eq!(result, "./outputs/{{with.unknown}}.json");
1414    }
1415
1416    #[test]
1417    fn test_path_bindings_no_bindings_passthrough() {
1418        let bindings = ResolvedBindings::default();
1419
1420        // Path with no binding references should pass through unchanged
1421        let result = resolve_artifact_path_bindings(
1422            "{{task_id}}/{{date}}/output.json",
1423            "",
1424            &bindings,
1425            &RunContext::new(),
1426        );
1427        assert_eq!(result, "{{task_id}}/{{date}}/output.json");
1428    }
1429
1430    #[test]
1431    fn test_path_bindings_truncates_long_values() {
1432        let mut bindings = ResolvedBindings::default();
1433        let long_value = "a".repeat(300);
1434        bindings.set("name", serde_json::json!(long_value));
1435
1436        let result =
1437            resolve_artifact_path_bindings("{{with.name}}.txt", "", &bindings, &RunContext::new());
1438        // sanitize_for_path truncates to 200 chars
1439        assert!(result.len() <= 204); // 200 + ".txt"
1440    }
1441
1442    #[tokio::test]
1443    async fn test_e2e_artifact_path_with_bindings() {
1444        let base = tempdir().unwrap();
1445        let artifact_dir = base.path().join(".nika/artifacts");
1446        std::fs::create_dir_all(&artifact_dir).unwrap();
1447
1448        let mut bindings = ResolvedBindings::default();
1449        bindings.set("timestamp", serde_json::json!("2024-01-15_14-30-00"));
1450
1451        let datastore = RunContext::new();
1452
1453        let spec = ArtifactSpec::Single(ArtifactOutput {
1454            path: "result-{{with.timestamp}}.json".to_string(),
1455            source: None,
1456            template: None,
1457            format: Some(ArtifactFormat::Json),
1458            mode: None,
1459        });
1460
1461        let result = process_task_artifacts(
1462            "save_result",
1463            r#"{"status": "ok"}"#,
1464            &spec,
1465            None,
1466            base.path(),
1467            None,
1468            &bindings,
1469            &datastore,
1470            &[],
1471        )
1472        .await;
1473
1474        assert_eq!(
1475            result.written, 1,
1476            "Expected 1 artifact written, errors: {:?}",
1477            result.errors
1478        );
1479        assert!(
1480            result.paths[0]
1481                .display()
1482                .to_string()
1483                .contains("result-2024-01-15_14-30-00.json"),
1484            "Expected resolved path, got: {}",
1485            result.paths[0].display()
1486        );
1487    }
1488
1489    // ========== sanitize_for_path tests ==========
1490
1491    #[test]
1492    fn test_sanitize_for_path_clean() {
1493        assert_eq!(sanitize_for_path("hello-world"), "hello-world");
1494    }
1495
1496    #[test]
1497    fn test_sanitize_for_path_slashes() {
1498        assert_eq!(sanitize_for_path("a/b/c"), "a_b_c");
1499    }
1500
1501    #[test]
1502    fn test_sanitize_for_path_backslashes() {
1503        assert_eq!(sanitize_for_path("a\\b\\c"), "a_b_c");
1504    }
1505
1506    #[test]
1507    fn test_sanitize_for_path_dotdot() {
1508        assert_eq!(sanitize_for_path("../escape"), "__escape");
1509    }
1510
1511    #[test]
1512    fn test_sanitize_for_path_null() {
1513        assert_eq!(sanitize_for_path("a\0b"), "ab");
1514    }
1515
1516    #[test]
1517    fn test_sanitize_for_path_tilde() {
1518        assert_eq!(sanitize_for_path("~/home"), "__home");
1519    }
1520
1521    #[test]
1522    fn test_sanitize_for_path_truncation() {
1523        let long = "x".repeat(300);
1524        assert_eq!(sanitize_for_path(&long).len(), 200);
1525    }
1526
1527    // ========== Binary artifact tests ==========
1528
1529    #[tokio::test]
1530    async fn test_process_binary_artifact_from_media_ref() {
1531        use crate::media::MediaRef;
1532
1533        let base = tempdir().unwrap();
1534        let artifact_dir = base.path().join(".nika/artifacts");
1535        std::fs::create_dir_all(&artifact_dir).unwrap();
1536
1537        // Create a fake CAS file
1538        let cas_dir = base.path().join(".nika/media/store/ab");
1539        std::fs::create_dir_all(&cas_dir).unwrap();
1540        let cas_file = cas_dir.join("cdef1234");
1541        let binary_data = b"\x89PNG\r\n\x1a\n fake image data";
1542        std::fs::write(&cas_file, binary_data).unwrap();
1543
1544        let bindings = ResolvedBindings::default();
1545        let datastore = RunContext::new();
1546
1547        let media_refs = vec![MediaRef {
1548            hash: "blake3:abcdef1234".to_string(),
1549            mime_type: "image/png".to_string(),
1550            size_bytes: binary_data.len() as u64,
1551            path: cas_file.clone(),
1552            extension: "png".to_string(),
1553            created_by: "gen_img".to_string(),
1554            metadata: serde_json::Map::new(),
1555        }];
1556
1557        let spec = ArtifactSpec::Single(ArtifactOutput {
1558            path: "output/image.bin".to_string(),
1559            source: None, // Use first media ref
1560            template: None,
1561            format: Some(ArtifactFormat::Binary),
1562            mode: None,
1563        });
1564
1565        let result = process_task_artifacts(
1566            "gen_img",
1567            "text output (ignored for binary)",
1568            &spec,
1569            None,
1570            base.path(),
1571            None,
1572            &bindings,
1573            &datastore,
1574            &media_refs,
1575        )
1576        .await;
1577
1578        assert_eq!(
1579            result.written, 1,
1580            "Expected 1 binary artifact, errors: {:?}",
1581            result.errors
1582        );
1583        assert!(
1584            result.errors.is_empty(),
1585            "Unexpected errors: {:?}",
1586            result.errors
1587        );
1588
1589        // Verify file was copied correctly
1590        let written = std::fs::read(&result.paths[0]).unwrap();
1591        assert_eq!(written, binary_data);
1592    }
1593
1594    #[tokio::test]
1595    async fn test_process_binary_artifact_with_source() {
1596        use crate::media::MediaRef;
1597
1598        let base = tempdir().unwrap();
1599        let artifact_dir = base.path().join(".nika/artifacts");
1600        std::fs::create_dir_all(&artifact_dir).unwrap();
1601
1602        // Create two fake CAS files
1603        let cas_dir = base.path().join(".nika/media/store/ab");
1604        std::fs::create_dir_all(&cas_dir).unwrap();
1605        let cas_file1 = cas_dir.join("file1");
1606        let cas_file2 = cas_dir.join("file2");
1607        std::fs::write(&cas_file1, b"image data 1").unwrap();
1608        std::fs::write(&cas_file2, b"image data 2").unwrap();
1609
1610        let bindings = ResolvedBindings::default();
1611        let datastore = RunContext::new();
1612
1613        let media_refs = vec![
1614            MediaRef {
1615                hash: "blake3:hash1".to_string(),
1616                mime_type: "image/png".to_string(),
1617                size_bytes: 12,
1618                path: cas_file1,
1619                extension: "png".to_string(),
1620                created_by: "gen_img".to_string(),
1621                metadata: serde_json::Map::new(),
1622            },
1623            MediaRef {
1624                hash: "blake3:hash2".to_string(),
1625                mime_type: "image/jpeg".to_string(),
1626                size_bytes: 12,
1627                path: cas_file2.clone(),
1628                extension: "jpg".to_string(),
1629                created_by: "gen_thumb".to_string(),
1630                metadata: serde_json::Map::new(),
1631            },
1632        ];
1633
1634        // Specify source by creator task_id
1635        let spec = ArtifactSpec::Single(ArtifactOutput {
1636            path: "output/thumb.bin".to_string(),
1637            source: Some("gen_thumb".to_string()),
1638            template: None,
1639            format: Some(ArtifactFormat::Binary),
1640            mode: None,
1641        });
1642
1643        let result = process_task_artifacts(
1644            "save_thumb",
1645            "",
1646            &spec,
1647            None,
1648            base.path(),
1649            None,
1650            &bindings,
1651            &datastore,
1652            &media_refs,
1653        )
1654        .await;
1655
1656        assert_eq!(result.written, 1, "errors: {:?}", result.errors);
1657        let written = std::fs::read(&result.paths[0]).unwrap();
1658        assert_eq!(written, b"image data 2");
1659    }
1660
1661    // ========== Binary artifact edge cases ==========
1662
1663    #[tokio::test]
1664    async fn test_binary_artifact_missing_source_binding_error() {
1665        let base = tempdir().unwrap();
1666        let artifact_dir = base.path().join(".nika/artifacts");
1667        std::fs::create_dir_all(&artifact_dir).unwrap();
1668        let bindings = ResolvedBindings::default();
1669        let datastore = RunContext::new();
1670
1671        let spec = ArtifactSpec::Single(ArtifactOutput {
1672            path: "output.bin".to_string(),
1673            source: Some("nonexistent_source".to_string()),
1674            template: None,
1675            format: Some(ArtifactFormat::Binary),
1676            mode: None,
1677        });
1678
1679        let result = process_task_artifacts(
1680            "task1",
1681            "",
1682            &spec,
1683            None,
1684            base.path(),
1685            None,
1686            &bindings,
1687            &datastore,
1688            &[], // No media refs
1689        )
1690        .await;
1691
1692        assert_eq!(result.written, 0);
1693        assert_eq!(result.errors.len(), 1);
1694        assert!(
1695            result.errors[0].contains("not found"),
1696            "Error should mention source not found: {}",
1697            result.errors[0]
1698        );
1699    }
1700
1701    #[tokio::test]
1702    async fn test_binary_artifact_no_media_no_source_error() {
1703        let base = tempdir().unwrap();
1704        let artifact_dir = base.path().join(".nika/artifacts");
1705        std::fs::create_dir_all(&artifact_dir).unwrap();
1706        let bindings = ResolvedBindings::default();
1707        let datastore = RunContext::new();
1708
1709        let spec = ArtifactSpec::Single(ArtifactOutput {
1710            path: "output.bin".to_string(),
1711            source: None, // No source specified
1712            template: None,
1713            format: Some(ArtifactFormat::Binary),
1714            mode: None,
1715        });
1716
1717        let result = process_task_artifacts(
1718            "task1",
1719            "text output",
1720            &spec,
1721            None,
1722            base.path(),
1723            None,
1724            &bindings,
1725            &datastore,
1726            &[], // No media refs either
1727        )
1728        .await;
1729
1730        assert_eq!(result.written, 0);
1731        assert_eq!(result.errors.len(), 1);
1732        assert!(
1733            result.errors[0].contains("no media"),
1734            "Error should mention no media: {}",
1735            result.errors[0]
1736        );
1737    }
1738
1739    // ═══════════════════════════════════════════════════
1740    // Binary artifact fallback from output JSON
1741    // ═══════════════════════════════════════════════════
1742
1743    /// Defense-in-depth: when media_refs is empty but the task output
1744    /// contains JSON with hash/path fields (e.g., from fetch binary or
1745    /// builtin media tools before the set_media fix), the artifact
1746    /// processor should construct a MediaRef from the output and succeed.
1747    #[tokio::test]
1748    async fn test_binary_artifact_fallback_from_output_json() {
1749        let base = tempdir().unwrap();
1750        let artifact_dir = base.path().join(".nika/artifacts");
1751        std::fs::create_dir_all(&artifact_dir).unwrap();
1752
1753        // Create a CAS file that the fallback MediaRef will point to
1754        let cas_dir = base.path().join(".nika/media/store/ab");
1755        std::fs::create_dir_all(&cas_dir).unwrap();
1756        let cas_file = cas_dir.join("fallback_cas");
1757        std::fs::write(&cas_file, b"fake png data").unwrap();
1758
1759        let bindings = ResolvedBindings::default();
1760        let datastore = RunContext::new();
1761
1762        let spec = ArtifactSpec::Single(ArtifactOutput {
1763            path: "output.png".to_string(),
1764            source: None,
1765            template: None,
1766            format: Some(ArtifactFormat::Binary),
1767            mode: None,
1768        });
1769
1770        // Task output is JSON string with hash/path (like fetch binary returns)
1771        let output_json = serde_json::json!({
1772            "hash": "blake3:fallback_cas",
1773            "mime_type": "image/png",
1774            "size_bytes": 13,
1775            "path": cas_file.to_string_lossy(),
1776        });
1777
1778        let result = process_task_artifacts(
1779            "task_fallback",
1780            &output_json.to_string(),
1781            &spec,
1782            None,
1783            base.path(),
1784            None,
1785            &bindings,
1786            &datastore,
1787            &[], // Empty media_refs — simulates pre-fix state
1788        )
1789        .await;
1790
1791        assert_eq!(
1792            result.written, 1,
1793            "Fallback should write 1 artifact, errors: {:?}",
1794            result.errors
1795        );
1796        assert!(
1797            result.errors.is_empty(),
1798            "No errors expected: {:?}",
1799            result.errors
1800        );
1801    }
1802
1803    // ═══════════════════════════════════════════════════
1804    // Binary artifact mode validation tests
1805    // ═══════════════════════════════════════════════════
1806
1807    fn setup_binary_mode_fixtures() -> (
1808        tempfile::TempDir,
1809        Vec<crate::media::MediaRef>,
1810        ResolvedBindings,
1811        RunContext,
1812    ) {
1813        use crate::media::MediaRef;
1814        let base = tempdir().unwrap();
1815        std::fs::create_dir_all(base.path().join(".nika/artifacts")).unwrap();
1816        let cas_dir = base.path().join(".nika/media/store/ab");
1817        std::fs::create_dir_all(&cas_dir).unwrap();
1818        let cas_file = cas_dir.join("testbin");
1819        std::fs::write(&cas_file, b"binary payload").unwrap();
1820        let media_refs = vec![MediaRef {
1821            hash: "blake3:testbin".to_string(),
1822            mime_type: "application/octet-stream".to_string(),
1823            size_bytes: 14,
1824            path: cas_file,
1825            extension: "bin".to_string(),
1826            created_by: "producer".to_string(),
1827            metadata: serde_json::Map::new(),
1828        }];
1829        (
1830            base,
1831            media_refs,
1832            ResolvedBindings::default(),
1833            RunContext::new(),
1834        )
1835    }
1836
1837    #[tokio::test]
1838    async fn test_binary_mode_append_is_rejected() {
1839        let (base, media_refs, bindings, datastore) = setup_binary_mode_fixtures();
1840        let spec = ArtifactSpec::Single(ArtifactOutput {
1841            path: "output.bin".to_string(),
1842            source: None,
1843            template: None,
1844            format: Some(ArtifactFormat::Binary),
1845            mode: Some(ArtifactMode::Append),
1846        });
1847        let result = process_task_artifacts(
1848            "producer",
1849            "",
1850            &spec,
1851            None,
1852            base.path(),
1853            None,
1854            &bindings,
1855            &datastore,
1856            &media_refs,
1857        )
1858        .await;
1859        assert_eq!(result.written, 0, "Append mode must be rejected for binary");
1860        assert_eq!(result.errors.len(), 1);
1861        assert!(
1862            result.errors[0].contains("Binary artifacts do not support append mode"),
1863            "got: {}",
1864            result.errors[0]
1865        );
1866    }
1867
1868    #[tokio::test]
1869    async fn test_binary_mode_unique_is_rejected() {
1870        let (base, media_refs, bindings, datastore) = setup_binary_mode_fixtures();
1871        let spec = ArtifactSpec::Single(ArtifactOutput {
1872            path: "output.bin".to_string(),
1873            source: None,
1874            template: None,
1875            format: Some(ArtifactFormat::Binary),
1876            mode: Some(ArtifactMode::Unique),
1877        });
1878        let result = process_task_artifacts(
1879            "producer",
1880            "",
1881            &spec,
1882            None,
1883            base.path(),
1884            None,
1885            &bindings,
1886            &datastore,
1887            &media_refs,
1888        )
1889        .await;
1890        assert_eq!(result.written, 0, "Unique mode must be rejected for binary");
1891        assert_eq!(result.errors.len(), 1);
1892        assert!(
1893            result.errors[0].contains("Binary artifacts do not support unique mode"),
1894            "got: {}",
1895            result.errors[0]
1896        );
1897    }
1898
1899    #[tokio::test]
1900    async fn test_binary_mode_overwrite_succeeds() {
1901        let (base, media_refs, bindings, datastore) = setup_binary_mode_fixtures();
1902        let spec = ArtifactSpec::Single(ArtifactOutput {
1903            path: "output.bin".to_string(),
1904            source: None,
1905            template: None,
1906            format: Some(ArtifactFormat::Binary),
1907            mode: Some(ArtifactMode::Overwrite),
1908        });
1909        let result = process_task_artifacts(
1910            "producer",
1911            "",
1912            &spec,
1913            None,
1914            base.path(),
1915            None,
1916            &bindings,
1917            &datastore,
1918            &media_refs,
1919        )
1920        .await;
1921        assert_eq!(
1922            result.written, 1,
1923            "Overwrite should work, errors: {:?}",
1924            result.errors
1925        );
1926        assert!(result.errors.is_empty());
1927        assert_eq!(std::fs::read(&result.paths[0]).unwrap(), b"binary payload");
1928    }
1929
1930    #[tokio::test]
1931    async fn test_binary_mode_fail_rejects_existing_file() {
1932        let (base, media_refs, bindings, datastore) = setup_binary_mode_fixtures();
1933        // Pre-create the target so fail mode triggers
1934        let target = base.path().join(".nika/artifacts/output.bin");
1935        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1936        std::fs::write(&target, b"existing data").unwrap();
1937        let spec = ArtifactSpec::Single(ArtifactOutput {
1938            path: "output.bin".to_string(),
1939            source: None,
1940            template: None,
1941            format: Some(ArtifactFormat::Binary),
1942            mode: Some(ArtifactMode::Fail),
1943        });
1944        let result = process_task_artifacts(
1945            "producer",
1946            "",
1947            &spec,
1948            None,
1949            base.path(),
1950            None,
1951            &bindings,
1952            &datastore,
1953            &media_refs,
1954        )
1955        .await;
1956        assert_eq!(result.written, 0, "Fail mode should reject existing file");
1957        assert_eq!(result.errors.len(), 1);
1958        assert!(
1959            result.errors[0].contains("already exists"),
1960            "got: {}",
1961            result.errors[0]
1962        );
1963    }
1964
1965    #[tokio::test]
1966    async fn test_binary_mode_fail_succeeds_for_new_file() {
1967        let (base, media_refs, bindings, datastore) = setup_binary_mode_fixtures();
1968        let spec = ArtifactSpec::Single(ArtifactOutput {
1969            path: "fresh_output.bin".to_string(),
1970            source: None,
1971            template: None,
1972            format: Some(ArtifactFormat::Binary),
1973            mode: Some(ArtifactMode::Fail),
1974        });
1975        let result = process_task_artifacts(
1976            "producer",
1977            "",
1978            &spec,
1979            None,
1980            base.path(),
1981            None,
1982            &bindings,
1983            &datastore,
1984            &media_refs,
1985        )
1986        .await;
1987        assert_eq!(
1988            result.written, 1,
1989            "Fail mode should succeed for new file, errors: {:?}",
1990            result.errors
1991        );
1992        assert!(result.errors.is_empty());
1993        assert_eq!(std::fs::read(&result.paths[0]).unwrap(), b"binary payload");
1994    }
1995
1996    // ========== Media binding template tests (source_task_id tracking) ==========
1997
1998    #[test]
1999    fn test_path_bindings_media_hash_via_source_task() {
2000        use crate::media::MediaRef;
2001        use crate::store::TaskResult;
2002        use std::sync::Arc;
2003        use std::time::Duration;
2004
2005        let datastore = RunContext::new();
2006        let mut task_result =
2007            TaskResult::success_str("LLM text output".to_string(), Duration::from_millis(100));
2008        task_result.media = vec![MediaRef {
2009            hash: "blake3:af1349b9".to_string(),
2010            mime_type: "image/png".to_string(),
2011            size_bytes: 4096,
2012            path: std::path::PathBuf::from("/tmp/cas/af/1349b9"),
2013            extension: "png".to_string(),
2014            created_by: "gen_img".to_string(),
2015            metadata: serde_json::Map::new(),
2016        }];
2017        datastore.insert(Arc::from("gen_img"), task_result);
2018
2019        let mut bindings = ResolvedBindings::new();
2020        bindings.set_with_source("img", serde_json::json!("LLM text output"), "gen_img");
2021
2022        let result = resolve_artifact_path_bindings(
2023            "output/{{with.img.media[0].hash}}.bin",
2024            "",
2025            &bindings,
2026            &datastore,
2027        );
2028        assert_eq!(
2029            result, "output/blake3_af1349b9.bin",
2030            "Media hash should resolve via source task ID, with : sanitized to _"
2031        );
2032    }
2033
2034    #[test]
2035    fn test_path_bindings_media_extension_via_source_task() {
2036        use crate::media::MediaRef;
2037        use crate::store::TaskResult;
2038        use std::sync::Arc;
2039        use std::time::Duration;
2040
2041        let datastore = RunContext::new();
2042        let mut task_result =
2043            TaskResult::success_str("output".to_string(), Duration::from_millis(50));
2044        task_result.media = vec![MediaRef {
2045            hash: "blake3:deadbeef".to_string(),
2046            mime_type: "image/png".to_string(),
2047            size_bytes: 1024,
2048            path: std::path::PathBuf::from("/tmp/cas/de/adbeef"),
2049            extension: "png".to_string(),
2050            created_by: "gen_img".to_string(),
2051            metadata: serde_json::Map::new(),
2052        }];
2053        datastore.insert(Arc::from("gen_img"), task_result);
2054
2055        let mut bindings = ResolvedBindings::new();
2056        bindings.set_with_source("img", serde_json::json!("output"), "gen_img");
2057
2058        let result = resolve_artifact_path_bindings(
2059            "output/{{with.img.media[0].extension}}/result.bin",
2060            "",
2061            &bindings,
2062            &datastore,
2063        );
2064        assert_eq!(
2065            result, "output/png/result.bin",
2066            "Media extension should resolve via source task ID"
2067        );
2068    }
2069
2070    #[test]
2071    fn test_path_bindings_media_without_source_task_unresolved() {
2072        let bindings = ResolvedBindings::new();
2073        let datastore = RunContext::new();
2074
2075        let result = resolve_artifact_path_bindings(
2076            "output/{{with.img.media[0].hash}}.bin",
2077            "",
2078            &bindings,
2079            &datastore,
2080        );
2081        assert_eq!(
2082            result, "output/{{with.img.media[0].hash}}.bin",
2083            "Without source task tracking, media path should remain unresolved"
2084        );
2085    }
2086
2087    #[tokio::test]
2088    async fn test_binary_artifact_source_via_binding_alias() {
2089        use crate::media::MediaRef;
2090        use crate::store::TaskResult;
2091        use std::sync::Arc;
2092        use std::time::Duration;
2093
2094        let base = tempdir().unwrap();
2095        let artifact_dir = base.path().join(".nika/artifacts");
2096        std::fs::create_dir_all(&artifact_dir).unwrap();
2097
2098        let cas_dir = base.path().join(".nika/media/store/ab");
2099        std::fs::create_dir_all(&cas_dir).unwrap();
2100        let cas_file = cas_dir.join("cdef1234");
2101        let binary_data = b"\x89PNG fake image";
2102        std::fs::write(&cas_file, binary_data).unwrap();
2103
2104        let datastore = RunContext::new();
2105        let mut task_result =
2106            TaskResult::success_str("generated image".to_string(), Duration::from_millis(100));
2107        task_result.media = vec![MediaRef {
2108            hash: "blake3:abcdef1234".to_string(),
2109            mime_type: "image/png".to_string(),
2110            size_bytes: binary_data.len() as u64,
2111            path: cas_file.clone(),
2112            extension: "png".to_string(),
2113            created_by: "gen_img".to_string(),
2114            metadata: serde_json::Map::new(),
2115        }];
2116        datastore.insert(Arc::from("gen_img"), task_result);
2117
2118        let mut bindings = ResolvedBindings::new();
2119        bindings.set_with_source("img", serde_json::json!("generated image"), "gen_img");
2120
2121        let media_refs = vec![MediaRef {
2122            hash: "blake3:abcdef1234".to_string(),
2123            mime_type: "image/png".to_string(),
2124            size_bytes: binary_data.len() as u64,
2125            path: cas_file,
2126            extension: "png".to_string(),
2127            created_by: "gen_img".to_string(),
2128            metadata: serde_json::Map::new(),
2129        }];
2130
2131        let spec = ArtifactSpec::Single(ArtifactOutput {
2132            path: "output/image.bin".to_string(),
2133            source: Some("img".to_string()),
2134            template: None,
2135            format: Some(ArtifactFormat::Binary),
2136            mode: None,
2137        });
2138
2139        let result = process_task_artifacts(
2140            "save_img",
2141            "",
2142            &spec,
2143            None,
2144            base.path(),
2145            None,
2146            &bindings,
2147            &datastore,
2148            &media_refs,
2149        )
2150        .await;
2151
2152        assert_eq!(
2153            result.written, 1,
2154            "Binary artifact should resolve via binding alias indirection, errors: {:?}",
2155            result.errors
2156        );
2157        assert!(
2158            result.errors.is_empty(),
2159            "No errors expected: {:?}",
2160            result.errors
2161        );
2162
2163        let written = std::fs::read(&result.paths[0]).unwrap();
2164        assert_eq!(
2165            written, binary_data,
2166            "Binary content should match CAS source"
2167        );
2168    }
2169
2170    #[tokio::test]
2171    async fn test_binary_artifact_path_with_media_extension_template() {
2172        use crate::media::MediaRef;
2173        use crate::store::TaskResult;
2174        use std::sync::Arc;
2175        use std::time::Duration;
2176
2177        let base = tempdir().unwrap();
2178        let artifact_dir = base.path().join(".nika/artifacts");
2179        std::fs::create_dir_all(&artifact_dir).unwrap();
2180
2181        let cas_dir = base.path().join(".nika/media/store/xx");
2182        std::fs::create_dir_all(&cas_dir).unwrap();
2183        let cas_file = cas_dir.join("yy1234");
2184        let binary_data = b"image bytes";
2185        std::fs::write(&cas_file, binary_data).unwrap();
2186
2187        let datastore = RunContext::new();
2188        let mut task_result =
2189            TaskResult::success_str("done".to_string(), Duration::from_millis(50));
2190        task_result.media = vec![MediaRef {
2191            hash: "blake3:xxyy1234".to_string(),
2192            mime_type: "image/jpeg".to_string(),
2193            size_bytes: binary_data.len() as u64,
2194            path: cas_file.clone(),
2195            extension: "jpg".to_string(),
2196            created_by: "gen_img".to_string(),
2197            metadata: serde_json::Map::new(),
2198        }];
2199        datastore.insert(Arc::from("gen_img"), task_result);
2200
2201        let mut bindings = ResolvedBindings::new();
2202        bindings.set_with_source("img", serde_json::json!("done"), "gen_img");
2203
2204        let media_refs = vec![MediaRef {
2205            hash: "blake3:xxyy1234".to_string(),
2206            mime_type: "image/jpeg".to_string(),
2207            size_bytes: binary_data.len() as u64,
2208            path: cas_file,
2209            extension: "jpg".to_string(),
2210            created_by: "gen_img".to_string(),
2211            metadata: serde_json::Map::new(),
2212        }];
2213
2214        let spec = ArtifactSpec::Single(ArtifactOutput {
2215            path: "output/result.{{with.img.media[0].extension}}".to_string(),
2216            source: None,
2217            template: None,
2218            format: Some(ArtifactFormat::Binary),
2219            mode: None,
2220        });
2221
2222        let result = process_task_artifacts(
2223            "gen_img",
2224            "",
2225            &spec,
2226            None,
2227            base.path(),
2228            None,
2229            &bindings,
2230            &datastore,
2231            &media_refs,
2232        )
2233        .await;
2234
2235        assert_eq!(result.written, 1, "errors: {:?}", result.errors);
2236
2237        let path_str = result.paths[0].display().to_string();
2238        assert!(
2239            path_str.ends_with("result.jpg"),
2240            "Path should end with resolved extension 'result.jpg', got: {}",
2241            path_str
2242        );
2243
2244        let written = std::fs::read(&result.paths[0]).unwrap();
2245        assert_eq!(written, binary_data);
2246    }
2247}