1use 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#[derive(Debug, Clone)]
32pub struct ArtifactProcessResult {
33 pub written: usize,
35 pub paths: Vec<PathBuf>,
37 pub errors: Vec<String>,
39}
40
41#[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 let outputs = match artifact_spec {
78 ArtifactSpec::Enabled(false) => {
79 return result;
81 }
82 ArtifactSpec::Enabled(true) => {
83 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 let artifact_dir = resolve_artifact_dir(workflow_config, base_path).await;
103
104 let max_size = workflow_config
106 .map(|c| c.max_size)
107 .unwrap_or(crate::ast::artifact::DEFAULT_MAX_ARTIFACT_SIZE);
108
109 let writer = ArtifactWriter::new(&artifact_dir, task_id).with_max_size(max_size);
111
112 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 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 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#[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 let format = output_spec
195 .format
196 .or(workflow_config.map(|c| c.format))
197 .unwrap_or(ArtifactFormat::Text);
198
199 let mode = output_spec
202 .mode
203 .or(workflow_config.map(|c| c.mode))
204 .unwrap_or(ArtifactMode::Overwrite);
205
206 if format == ArtifactFormat::Binary {
208 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 let raw_content: String = if let Some(ref source_alias) = output_spec.source {
268 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 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 let tpl_with_output = tpl.replace("{{output}}", output);
299
300 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 tpl_with_output
317 }
318 }
319 } else {
320 output.to_string()
322 };
323
324 let content = format_output(&raw_content, format)?;
326
327 let output_format = match format {
329 ArtifactFormat::Text => OutputFormat::Text,
330 ArtifactFormat::Json => OutputFormat::Json,
331 ArtifactFormat::Yaml => OutputFormat::Text, ArtifactFormat::Binary => OutputFormat::Text, };
334
335 let resolved_path =
338 resolve_artifact_path_bindings(&output_spec.path, output, bindings, datastore);
339
340 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 let request = WriteRequest::new(task_id, &normalized_path)
349 .with_content(content)
350 .with_format(output_format.clone());
351
352 match mode {
354 ArtifactMode::Overwrite => writer.write(request).await,
355 ArtifactMode::Append => {
356 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 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 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
403async 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 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 }
439 }
440
441 let media_ref = if let Some(ref source_alias) = output_spec.source {
450 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 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 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 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 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 let resolved_path = resolve_artifact_path_bindings(&output_spec.path, "", bindings, datastore);
528
529 let artifact_dir_str = ""; let normalized_path = normalize_artifact_path(&resolved_path, artifact_dir_str);
532
533 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
554fn 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 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 media_refs.first().map(|m| m.hash.clone())
571 }
572}
573
574fn format_output(output: &str, format: ArtifactFormat) -> Result<String, NikaError> {
576 match format {
577 ArtifactFormat::Text => Ok(output.to_string()),
578 ArtifactFormat::Json => {
579 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 Ok(serde_json::to_string_pretty(&output)
590 .unwrap_or_else(|_| format!("\"{}\"", output)))
591 }
592 }
593 }
594 ArtifactFormat::Yaml => {
595 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 Ok(output.to_string())
606 }
607 }
608 }
609 ArtifactFormat::Binary => {
610 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
621async 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 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 artifact_dir.canonicalize().unwrap_or(artifact_dir)
649}
650
651fn 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
669fn 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 let top_alias = alias.split('.').next().unwrap_or(alias);
706
707 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 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 let raw_value = if alias.contains('.') {
737 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 pos = end;
753 }
754 } else if let Some(input_path) = var_name.strip_prefix("inputs.") {
755 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 pos = end;
771 }
772 }
773
774 result
775}
776
777fn 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 other => other.to_string(),
786 }
787}
788
789fn 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
818fn 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 if path.starts_with("./") {
833 let path_without_dot = path.trim_start_matches("./");
834 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 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, &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, &bindings,
948 &datastore,
949 &[],
950 )
951 .await;
952
953 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, &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, &bindings,
1037 &datastore,
1038 &[],
1039 )
1040 .await;
1041
1042 assert_eq!(result.written, 2);
1043 assert_eq!(result.paths.len(), 2);
1044 }
1045
1046 #[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 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 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 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 #[test]
1130 fn test_normalize_artifact_path_simple_filename() {
1131 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 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 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 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 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 let result = normalize_artifact_path("./.nika/artifacts/output.json", ".nika/artifacts");
1172 assert_eq!(result, "output.json");
1173 }
1174
1175 #[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 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 let mut bindings = ResolvedBindings::default();
1197 bindings.set("data", serde_json::json!({"name": "Alice", "age": 30}));
1198
1199 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 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 let spec = ArtifactSpec::Single(ArtifactOutput {
1249 path: "output.txt".to_string(),
1250 source: None,
1251 template: None, 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 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(); let datastore = RunContext::new();
1283
1284 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 assert_eq!(result.written, 1);
1308
1309 let artifact_content = std::fs::read_to_string(&result.paths[0]).unwrap();
1310 assert_eq!(artifact_content, "Hello {{with.missing}}!");
1312 }
1313
1314 #[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 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 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 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 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 assert!(result.len() <= 204); }
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 #[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 #[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 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, 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 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 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 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 #[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 &[], )
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, 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 &[], )
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 #[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 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 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 &[], )
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 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 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 #[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}