1use std::borrow::Cow;
70use std::sync::LazyLock;
71
72use regex::Regex;
73use rustc_hash::{FxHashMap, FxHashSet};
74use serde_json::Value;
75use smallvec::SmallVec;
76
77use crate::error::NikaError;
78use crate::store::RunContext;
79
80use super::resolve::ResolvedBindings;
81use super::transform::TransformExpr;
82
83const MAX_TEMPLATE_VARS: usize = 256;
88
89const MAX_PATH_DEPTH: usize = 32;
94
95static USE_RE: LazyLock<Regex> = LazyLock::new(|| {
99 Regex::new(r"\{\{\s*with\.(\w+(?:\.\w+)*)(\s*(?:\|\s*\w+)+)?\s*\}\}").unwrap()
100});
101
102static BRACKET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[(\d+)\]").unwrap());
105
106static TEMPLATE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{(.*?)\}\}").unwrap());
113
114#[derive(Debug, Clone, PartialEq)]
119pub enum TemplateExpr {
120 Alias {
123 path: String,
124 transforms: Vec<String>,
125 },
126 Context {
128 path: String,
129 transforms: Vec<String>,
130 },
131 Input {
133 path: String,
134 transforms: Vec<String>,
135 },
136}
137
138pub fn parse_template_expr(content: &str) -> Result<TemplateExpr, NikaError> {
154 let trimmed = content.trim();
155
156 if trimmed.is_empty() {
157 return Err(NikaError::TemplateParse {
158 position: 0,
159 details: format!("Empty template expression in '{}'", content),
160 });
161 }
162
163 if let Some(rest) = trimmed.strip_prefix("context.") {
167 if rest.is_empty() {
168 return Err(NikaError::TemplateParse {
169 position: 0,
170 details: format!("Empty context path after 'context.' in '{}'", content),
171 });
172 }
173 let parts: Vec<&str> = rest.split('|').map(str::trim).collect();
174 let path = parts[0].to_string();
175 let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
176 if path.is_empty() {
177 return Err(NikaError::TemplateParse {
178 position: 0,
179 details: format!("Empty context path after 'context.' in '{}'", content),
180 });
181 }
182 return Ok(TemplateExpr::Context { path, transforms });
183 }
184 if let Some(rest) = trimmed.strip_prefix("inputs.") {
185 if rest.is_empty() {
186 return Err(NikaError::TemplateParse {
187 position: 0,
188 details: format!("Empty input path after 'inputs.' in '{}'", content),
189 });
190 }
191 let parts: Vec<&str> = rest.split('|').map(str::trim).collect();
192 let path = parts[0].to_string();
193 let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
194 if path.is_empty() {
195 return Err(NikaError::TemplateParse {
196 position: 0,
197 details: format!("Empty input path after 'inputs.' in '{}'", content),
198 });
199 }
200 return Ok(TemplateExpr::Input { path, transforms });
201 }
202
203 let effective = trimmed.strip_prefix("with.").unwrap_or(trimmed);
206
207 let parts: Vec<&str> = effective.split('|').map(str::trim).collect();
210 let path = parts[0].to_string();
211 let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
212
213 if path.is_empty() {
214 return Err(NikaError::TemplateParse {
215 position: 0,
216 details: format!("Empty alias path in '{}'", content),
217 });
218 }
219
220 Ok(TemplateExpr::Alias { path, transforms })
221}
222
223fn value_to_display(value: &Value) -> Cow<'_, str> {
232 match value {
233 Value::String(s) => Cow::Borrowed(s.as_str()),
234 Value::Null => Cow::Borrowed(""),
235 Value::Bool(b) => Cow::Owned(b.to_string()),
236 Value::Number(n) => Cow::Owned(n.to_string()),
237 other => Cow::Owned(other.to_string()), }
239}
240
241fn resolve_alias_path(
245 path: &str,
246 with_values: &FxHashMap<String, Value>,
247) -> Result<Value, NikaError> {
248 let segment_count = path.split('.').count();
250 if segment_count > MAX_PATH_DEPTH {
251 return Err(NikaError::TemplateError {
252 template: path.to_string(),
253 reason: format!(
254 "Path depth {} exceeds maximum of {} segments",
255 segment_count, MAX_PATH_DEPTH
256 ),
257 });
258 }
259
260 let mut segments = path.split('.');
261 let alias = segments.next().ok_or_else(|| NikaError::TemplateError {
262 template: path.to_string(),
263 reason: "Empty alias path (no segments)".to_string(),
264 })?;
265
266 let base = with_values
267 .get(alias)
268 .ok_or_else(|| NikaError::TemplateError {
269 template: alias.to_string(),
270 reason: format!(
271 "Alias '{}' not found in 'with:' block. Available: [{}]",
272 alias,
273 with_values.keys().cloned().collect::<Vec<_>>().join(", ")
274 ),
275 })?;
276
277 let effective_base =
282 crate::binding::jsonpath::try_parse_json_str(base).unwrap_or_else(|| base.clone());
283 let mut current = &effective_base;
284 let mut traversed: SmallVec<[&str; 8]> = SmallVec::new();
285 traversed.push(alias);
286
287 for segment in segments {
288 let next = if let Ok(idx) = segment.parse::<usize>() {
289 current.get(idx)
290 } else {
291 current.get(segment)
292 };
293
294 match next {
295 Some(v) => {
296 traversed.push(segment);
297 current = v;
298 }
299 None => {
300 if matches!(current, Value::Object(_) | Value::Array(_)) {
301 let traversed_path = traversed.join(".");
302 return Err(NikaError::PathNotFound {
303 path: format!("{}.{}", traversed_path, segment),
304 });
305 } else {
306 let value_type = match current {
307 Value::Null => "null",
308 Value::Bool(_) => "bool",
309 Value::Number(_) => "number",
310 Value::String(_) => "string",
311 _ => unreachable!(),
312 };
313 return Err(NikaError::InvalidTraversal {
314 segment: segment.to_string(),
315 value_type: value_type.to_string(),
316 full_path: path.to_string(),
317 });
318 }
319 }
320 }
321 }
322
323 Ok(current.clone())
324}
325
326pub fn resolve_with<'a>(
340 template: &'a str,
341 with_values: &FxHashMap<String, Value>,
342 datastore: &RunContext,
343) -> Result<Cow<'a, str>, NikaError> {
344 if !template.contains("{{") {
346 return Ok(Cow::Borrowed(template));
347 }
348
349 let var_count = template.matches("{{").count();
351 if var_count > MAX_TEMPLATE_VARS {
352 return Err(NikaError::TemplateError {
353 template: format!("(template with {} variables)", var_count),
354 reason: format!(
355 "Template contains {} variable references, exceeding the maximum of {}",
356 var_count, MAX_TEMPLATE_VARS
357 ),
358 });
359 }
360
361 let normalized = normalize_bracket_notation(template);
364 let template_str: &str = normalized.as_ref();
365
366 let mut result = String::with_capacity(template_str.len() + 64);
368 let mut last_end = 0;
369 let mut errors: SmallVec<[String; 4]> = SmallVec::new();
370
371 for cap in TEMPLATE_RE.captures_iter(template_str) {
372 let m = cap.get(0).unwrap();
373 let content = &cap[1];
374
375 result.push_str(&template_str[last_end..m.start()]);
377
378 match parse_template_expr(content) {
379 Ok(TemplateExpr::Alias {
380 ref path,
381 ref transforms,
382 }) => {
383 match resolve_alias_path(path, with_values) {
384 Ok(value) => {
385 let has_shell = transforms.iter().any(|t| t == "shell");
386
387 let display = if has_shell {
393 let non_shell: Vec<String> = transforms
394 .iter()
395 .filter(|t| *t != "shell")
396 .cloned()
397 .collect();
398 if non_shell.is_empty() {
399 escape_for_shell(&value_to_display(&value))
400 } else {
401 let transform_str = non_shell.join(" | ");
402 let expr = TransformExpr::parse(&transform_str).map_err(|e| {
403 NikaError::TemplateParse {
404 position: m.start(),
405 details: format!(
406 "Transform parse error in '{{{{{}}}}}': {}",
407 content, e
408 ),
409 }
410 })?;
411 let transformed =
412 expr.apply(&value).map_err(|e| NikaError::TemplateParse {
413 position: m.start(),
414 details: format!(
415 "Transform apply error in '{{{{{}}}}}': {}",
416 content, e
417 ),
418 })?;
419 escape_for_shell(&value_to_display(&transformed))
420 }
421 } else if transforms.is_empty() {
422 if is_in_json_context(template_str, m.start()) {
424 escape_for_json(&value_to_display(&value)).into_owned()
425 } else {
426 value_to_display(&value).into_owned()
427 }
428 } else {
429 let transform_str = transforms.join(" | ");
431 let expr = TransformExpr::parse(&transform_str).map_err(|e| {
432 NikaError::TemplateParse {
433 position: m.start(),
434 details: format!(
435 "Transform parse error in '{{{{{}}}}}': {}",
436 content, e
437 ),
438 }
439 })?;
440 let transformed =
441 expr.apply(&value).map_err(|e| NikaError::TemplateParse {
442 position: m.start(),
443 details: format!(
444 "Transform apply error in '{{{{{}}}}}': {}",
445 content, e
446 ),
447 })?;
448 if is_in_json_context(template_str, m.start()) {
449 escape_for_json(&value_to_display(&transformed)).into_owned()
450 } else {
451 value_to_display(&transformed).into_owned()
452 }
453 };
454 result.push_str(&display);
455 }
456 Err(e) => {
457 let msg = format!("{}", e);
460 if msg.contains("exceeds maximum") || msg.contains("Empty alias path") {
461 return Err(e);
462 }
463 errors.push(path.clone());
464 }
465 }
466 }
467 Ok(TemplateExpr::Context { .. } | TemplateExpr::Input { .. }) => {
468 result.push_str(&format!("{{{{{}}}}}", content.trim()));
470 }
471 Err(_) => {
472 result.push_str(m.as_str());
474 }
475 }
476
477 last_end = m.end();
478 }
479
480 if !errors.is_empty() {
481 return Err(NikaError::TemplateError {
482 template: errors.join(", "),
483 reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
484 });
485 }
486
487 result.push_str(&template_str[last_end..]);
489
490 let has_context = template.contains("context.");
495 let has_inputs = template.contains("inputs.");
496
497 if !has_context && !has_inputs {
498 return Ok(Cow::Owned(result));
499 }
500
501 if has_context && result.contains("{{") {
502 let intermediate = std::mem::take(&mut result);
503 result = String::with_capacity(intermediate.len() + 64);
504 let mut last_end = 0;
505 let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
506
507 for cap in TEMPLATE_RE.captures_iter(&intermediate) {
508 let m = cap.get(0).unwrap();
509 let inner = cap[1].trim();
510 let (path, transforms) = match parse_template_expr(inner) {
511 Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
512 _ => continue,
513 };
514 result.push_str(&intermediate[last_end..m.start()]);
515 let full_path = format!("context.{}", path);
516 match datastore.resolve_context_path(&full_path) {
517 Some(value) => {
518 let replacement = if !transforms.is_empty() {
519 let transform_str = transforms.join(" | ");
520 let expr = TransformExpr::parse(&transform_str).map_err(|e| {
521 NikaError::TemplateParse {
522 position: m.start(),
523 details: format!("Transform parse error: {}", e),
524 }
525 })?;
526 let transformed =
527 expr.apply(&value).map_err(|e| NikaError::TemplateParse {
528 position: m.start(),
529 details: format!("Transform apply error: {}", e),
530 })?;
531 if is_in_json_context(&intermediate, m.start()) {
532 escape_for_json(&value_to_display(&transformed)).into_owned()
533 } else {
534 value_to_display(&transformed).into_owned()
535 }
536 } else {
537 let s = context_value_to_string(&value, &full_path)?;
538 if is_in_json_context(&intermediate, m.start()) {
539 escape_for_json(&s).into_owned()
540 } else {
541 s.into_owned()
542 }
543 };
544 result.push_str(&replacement);
545 }
546 None => {
547 context_errors.push(full_path);
548 }
549 }
550
551 last_end = m.end();
552 }
553
554 if !context_errors.is_empty() {
555 return Err(NikaError::TemplateError {
556 template: context_errors.join(", "),
557 reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
558 .to_string(),
559 });
560 }
561
562 result.push_str(&intermediate[last_end..]);
563 }
564
565 if has_inputs && result.contains("{{") {
566 let intermediate = std::mem::take(&mut result);
567 result = String::with_capacity(intermediate.len() + 64);
568 let mut last_end = 0;
569 let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
570
571 for cap in TEMPLATE_RE.captures_iter(&intermediate) {
572 let m = cap.get(0).unwrap();
573 let inner = cap[1].trim();
574 let (path, transforms) = match parse_template_expr(inner) {
575 Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
576 _ => continue,
577 };
578 result.push_str(&intermediate[last_end..m.start()]);
579 let full_path = format!("inputs.{}", path);
580 match datastore.resolve_input_path(&full_path) {
581 Some(value) => {
582 let replacement = if !transforms.is_empty() {
583 let transform_str = transforms.join(" | ");
584 let expr = TransformExpr::parse(&transform_str).map_err(|e| {
585 NikaError::TemplateParse {
586 position: m.start(),
587 details: format!("Transform parse error: {}", e),
588 }
589 })?;
590 let transformed =
591 expr.apply(&value).map_err(|e| NikaError::TemplateParse {
592 position: m.start(),
593 details: format!("Transform apply error: {}", e),
594 })?;
595 if is_in_json_context(&intermediate, m.start()) {
596 escape_for_json(&value_to_display(&transformed)).into_owned()
597 } else {
598 value_to_display(&transformed).into_owned()
599 }
600 } else {
601 let s = input_value_to_string(&value, &full_path)?;
602 if is_in_json_context(&intermediate, m.start()) {
603 escape_for_json(&s).into_owned()
604 } else {
605 s.into_owned()
606 }
607 };
608 result.push_str(&replacement);
609 }
610 None => {
611 input_errors.push(full_path);
612 }
613 }
614
615 last_end = m.end();
616 }
617
618 if !input_errors.is_empty() {
619 return Err(NikaError::TemplateError {
620 template: input_errors.join(", "),
621 reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
622 });
623 }
624
625 result.push_str(&intermediate[last_end..]);
626 }
627
628 Ok(Cow::Owned(result))
629}
630
631pub fn extract_with_refs(template: &str) -> Vec<String> {
636 if !template.contains("{{") {
637 return Vec::new();
638 }
639 let mut aliases = Vec::new();
640 for cap in TEMPLATE_RE.captures_iter(template) {
641 let content = &cap[1];
642 if let Ok(TemplateExpr::Alias { path, .. }) = parse_template_expr(content) {
643 let alias = path.split('.').next().unwrap().to_string();
644 aliases.push(alias);
645 }
646 }
647 aliases
648}
649
650pub fn validate_with_refs(
652 template: &str,
653 declared_aliases: &FxHashSet<String>,
654 task_id: &str,
655) -> Result<(), NikaError> {
656 for alias in extract_with_refs(template) {
657 if !declared_aliases.contains(&alias) {
658 return Err(NikaError::UnknownAlias {
659 alias,
660 task_id: task_id.to_string(),
661 });
662 }
663 }
664 Ok(())
665}
666
667fn escape_for_json(s: &str) -> Cow<'_, str> {
675 let needs_escape = s
677 .chars()
678 .any(|c| matches!(c, '"' | '\\' | '\n' | '\r' | '\t') || c.is_control());
679 if !needs_escape {
680 return Cow::Borrowed(s);
681 }
682
683 let mut result = String::with_capacity(s.len());
684 for ch in s.chars() {
685 match ch {
686 '"' => result.push_str("\\\""),
687 '\\' => result.push_str("\\\\"),
688 '\n' => result.push_str("\\n"),
689 '\r' => result.push_str("\\r"),
690 '\t' => result.push_str("\\t"),
691 c if c.is_control() => {
692 result.push_str(&format!("\\u{:04x}", c as u32));
693 }
694 c => result.push(c),
695 }
696 }
697 Cow::Owned(result)
698}
699
700pub fn escape_for_shell(s: &str) -> String {
707 if s.is_empty() {
711 return "''".to_string();
712 }
713
714 let mut result = String::with_capacity(s.len() + 10);
715 result.push('\'');
716
717 for ch in s.chars() {
718 if ch == '\'' {
719 result.push_str("'\\''");
721 } else {
722 result.push(ch);
723 }
724 }
725
726 result.push('\'');
727 result
728}
729
730fn normalize_bracket_notation(template: &str) -> Cow<'_, str> {
738 if !template.contains('[') {
739 return Cow::Borrowed(template);
740 }
741
742 let mut has_bracket_in_template = false;
744 let mut search_start = 0;
745 while let Some(open) = template[search_start..].find("{{") {
746 let abs_open = search_start + open;
747 if let Some(close) = template[abs_open..].find("}}") {
748 let block = &template[abs_open..abs_open + close + 2];
749 if block.contains('[') {
750 has_bracket_in_template = true;
751 break;
752 }
753 search_start = abs_open + close + 2;
754 } else {
755 break;
756 }
757 }
758
759 if !has_bracket_in_template {
760 return Cow::Borrowed(template);
761 }
762
763 let mut result = String::with_capacity(template.len());
765 let mut pos = 0;
766
767 while pos < template.len() {
768 if let Some(open) = template[pos..].find("{{") {
769 let abs_open = pos + open;
770 result.push_str(&template[pos..abs_open]);
772
773 if let Some(close) = template[abs_open..].find("}}") {
774 let abs_close = abs_open + close + 2;
775 let block = &template[abs_open..abs_close];
776 let normalized_block = BRACKET_RE.replace_all(block, ".$1");
778 result.push_str(&normalized_block);
779 pos = abs_close;
780 } else {
781 result.push_str(&template[abs_open..]);
783 pos = template.len();
784 }
785 } else {
786 result.push_str(&template[pos..]);
788 break;
789 }
790 }
791
792 Cow::Owned(result)
793}
794
795pub fn resolve<'a>(
812 template: &'a str,
813 bindings: &ResolvedBindings,
814 datastore: &RunContext,
815) -> Result<Cow<'a, str>, NikaError> {
816 if !template.contains("{{") {
820 return Ok(Cow::Borrowed(template));
821 }
822 let has_with = template.contains("with.");
823 let has_context = template.contains("context.");
824 let has_inputs = template.contains("inputs.");
825 if !has_with && !has_context && !has_inputs {
826 return Ok(Cow::Borrowed(template));
827 }
828
829 let var_count = template.matches("{{").count();
831 if var_count > MAX_TEMPLATE_VARS {
832 return Err(NikaError::TemplateError {
833 template: format!("(template with {} variables)", var_count),
834 reason: format!(
835 "Template contains {} variable references, exceeding the maximum of {}",
836 var_count, MAX_TEMPLATE_VARS
837 ),
838 });
839 }
840
841 let normalized = normalize_bracket_notation(template);
844 let template_str: &str = normalized.as_ref();
845
846 let mut result = String::with_capacity(template_str.len() + 64);
849 let mut last_end = 0;
850 let mut errors: SmallVec<[String; 4]> = SmallVec::new();
851
852 for cap in TEMPLATE_RE.captures_iter(template_str) {
853 let m = cap.get(0).unwrap();
854 let content = &cap[1];
855
856 result.push_str(&template_str[last_end..m.start()]);
858
859 match parse_template_expr(content) {
860 Ok(TemplateExpr::Alias {
861 ref path,
862 ref transforms,
863 }) => {
864 let segment_count = path.split('.').count();
866 if segment_count > MAX_PATH_DEPTH {
867 return Err(NikaError::TemplateError {
868 template: path.to_string(),
869 reason: format!(
870 "Path depth {} exceeds maximum of {} segments",
871 segment_count, MAX_PATH_DEPTH
872 ),
873 });
874 }
875
876 let mut parts = path.split('.');
878 let alias = parts.next().unwrap();
879
880 match bindings.get_resolved(alias, datastore) {
882 Ok(base_value) => {
883 let effective_base =
887 crate::binding::jsonpath::try_parse_json_str(&base_value)
888 .unwrap_or(base_value);
889 let mut value_ref: &Value = &effective_base;
890 let mut traversed_segments: SmallVec<[&str; 8]> = SmallVec::new();
891 traversed_segments.push(alias);
892
893 for segment in parts {
895 let next = if let Ok(idx) = segment.parse::<usize>() {
896 value_ref.get(idx)
897 } else {
898 value_ref.get(segment)
899 };
900
901 match next {
902 Some(v) => {
903 traversed_segments.push(segment);
904 value_ref = v;
905 }
906 None => {
907 let value_type = match value_ref {
908 Value::Null => "null",
909 Value::Bool(_) => "bool",
910 Value::Number(_) => "number",
911 Value::String(_) => "string",
912 Value::Array(_) => "array",
913 Value::Object(_) => "object",
914 };
915
916 if matches!(value_ref, Value::Object(_) | Value::Array(_)) {
917 let traversed_path = traversed_segments.join(".");
918 return Err(NikaError::PathNotFound {
919 path: format!("{}.{}", traversed_path, segment),
920 });
921 } else {
922 return Err(NikaError::InvalidTraversal {
923 segment: segment.to_string(),
924 value_type: value_type.to_string(),
925 full_path: path.to_string(),
926 });
927 }
928 }
929 }
930 }
931
932 let has_shell = transforms.iter().any(|t| t == "shell");
934
935 let display = if has_shell {
936 let non_shell: Vec<&String> =
938 transforms.iter().filter(|t| *t != "shell").collect();
939 let pre_shell_value = if non_shell.is_empty() {
940 value_ref.clone()
941 } else {
942 let transform_str = non_shell
943 .iter()
944 .map(|s| s.as_str())
945 .collect::<Vec<_>>()
946 .join(" | ");
947 let expr =
948 crate::binding::transform::TransformExpr::parse(&transform_str)
949 .map_err(|e| NikaError::TemplateParse {
950 position: m.start(),
951 details: format!("Transform parse error: {}", e),
952 })?;
953 expr.apply(value_ref)
954 .map_err(|e| NikaError::TemplateParse {
955 position: m.start(),
956 details: format!("Transform apply error: {}", e),
957 })?
958 };
959 escape_for_shell(&value_to_display(&pre_shell_value))
960 } else if !transforms.is_empty() {
961 let transform_str = transforms.join(" | ");
963 let expr =
964 crate::binding::transform::TransformExpr::parse(&transform_str)
965 .map_err(|e| NikaError::TemplateParse {
966 position: m.start(),
967 details: format!("Transform parse error: {}", e),
968 })?;
969 let final_value =
970 expr.apply(value_ref)
971 .map_err(|e| NikaError::TemplateParse {
972 position: m.start(),
973 details: format!("Transform apply error: {}", e),
974 })?;
975 if is_in_json_context(template_str, m.start()) {
976 escape_for_json(&value_to_display(&final_value)).into_owned()
977 } else {
978 value_to_display(&final_value).into_owned()
979 }
980 } else {
981 let replacement = value_to_string(value_ref, path, alias)?;
983 if is_in_json_context(template_str, m.start()) {
984 escape_for_json(&replacement).into_owned()
985 } else {
986 replacement.into_owned()
987 }
988 };
989
990 result.push_str(&display);
991 }
992 Err(_) => {
993 errors.push(alias.to_string());
994 }
995 }
996 }
997 Ok(TemplateExpr::Context { .. } | TemplateExpr::Input { .. }) => {
998 result.push_str(&format!("{{{{{}}}}}", content.trim()));
1000 }
1001 Err(_) => {
1002 result.push_str(m.as_str());
1004 }
1005 }
1006
1007 last_end = m.end();
1008 }
1009
1010 if !errors.is_empty() {
1011 return Err(NikaError::TemplateError {
1012 template: errors.join(", "),
1013 reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
1014 });
1015 }
1016
1017 result.push_str(&template_str[last_end..]);
1019
1020 if has_context && result.contains("context.") {
1024 let intermediate = std::mem::take(&mut result);
1025 result = String::with_capacity(intermediate.len() + 64);
1026 let mut last_end = 0;
1027 let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
1028
1029 for cap in TEMPLATE_RE.captures_iter(&intermediate) {
1030 let m = cap.get(0).unwrap();
1031 let inner = cap[1].trim();
1032 let (path, transforms) = match parse_template_expr(inner) {
1033 Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
1034 _ => continue,
1035 };
1036 result.push_str(&intermediate[last_end..m.start()]);
1037 let full_path = format!("context.{}", path);
1038 match datastore.resolve_context_path(&full_path) {
1039 Some(value) => {
1040 let replacement = if !transforms.is_empty() {
1041 let transform_str = transforms.join(" | ");
1042 let expr = TransformExpr::parse(&transform_str).map_err(|e| {
1043 NikaError::TemplateParse {
1044 position: m.start(),
1045 details: format!("Transform parse error: {}", e),
1046 }
1047 })?;
1048 let transformed =
1049 expr.apply(&value).map_err(|e| NikaError::TemplateParse {
1050 position: m.start(),
1051 details: format!("Transform apply error: {}", e),
1052 })?;
1053 if is_in_json_context(&intermediate, m.start()) {
1054 escape_for_json(&value_to_display(&transformed)).into_owned()
1055 } else {
1056 value_to_display(&transformed).into_owned()
1057 }
1058 } else {
1059 let s = context_value_to_string(&value, &full_path)?;
1060 if is_in_json_context(&intermediate, m.start()) {
1061 escape_for_json(&s).into_owned()
1062 } else {
1063 s.into_owned()
1064 }
1065 };
1066 result.push_str(&replacement);
1067 }
1068 None => {
1069 context_errors.push(full_path);
1070 }
1071 }
1072
1073 last_end = m.end();
1074 }
1075
1076 if !context_errors.is_empty() {
1077 return Err(NikaError::TemplateError {
1078 template: context_errors.join(", "),
1079 reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
1080 .to_string(),
1081 });
1082 }
1083
1084 result.push_str(&intermediate[last_end..]);
1086
1087 if !has_inputs || !result.contains("inputs.") {
1089 return Ok(Cow::Owned(result));
1090 }
1091 }
1093
1094 if has_inputs && result.contains("inputs.") {
1098 let intermediate = std::mem::take(&mut result);
1099 result = String::with_capacity(intermediate.len() + 64);
1100 let mut last_end = 0;
1101 let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
1102
1103 for cap in TEMPLATE_RE.captures_iter(&intermediate) {
1104 let m = cap.get(0).unwrap();
1105 let inner = cap[1].trim();
1106 let (path, transforms) = match parse_template_expr(inner) {
1107 Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
1108 _ => continue,
1109 };
1110 result.push_str(&intermediate[last_end..m.start()]);
1111 let full_path = format!("inputs.{}", path);
1112 match datastore.resolve_input_path(&full_path) {
1113 Some(value) => {
1114 let replacement = if !transforms.is_empty() {
1115 let transform_str = transforms.join(" | ");
1116 let expr = TransformExpr::parse(&transform_str).map_err(|e| {
1117 NikaError::TemplateParse {
1118 position: m.start(),
1119 details: format!("Transform parse error: {}", e),
1120 }
1121 })?;
1122 let transformed =
1123 expr.apply(&value).map_err(|e| NikaError::TemplateParse {
1124 position: m.start(),
1125 details: format!("Transform apply error: {}", e),
1126 })?;
1127 if is_in_json_context(&intermediate, m.start()) {
1128 escape_for_json(&value_to_display(&transformed)).into_owned()
1129 } else {
1130 value_to_display(&transformed).into_owned()
1131 }
1132 } else {
1133 let s = input_value_to_string(&value, &full_path)?;
1134 if is_in_json_context(&intermediate, m.start()) {
1135 escape_for_json(&s).into_owned()
1136 } else {
1137 s.into_owned()
1138 }
1139 };
1140 result.push_str(&replacement);
1141 }
1142 None => {
1143 input_errors.push(full_path);
1144 }
1145 }
1146
1147 last_end = m.end();
1148 }
1149
1150 if !input_errors.is_empty() {
1151 return Err(NikaError::TemplateError {
1152 template: input_errors.join(", "),
1153 reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
1154 });
1155 }
1156
1157 result.push_str(&intermediate[last_end..]);
1159
1160 return Ok(Cow::Owned(result));
1161 }
1162
1163 Ok(Cow::Owned(result))
1164}
1165
1166pub fn resolve_for_shell<'a>(
1174 template: &'a str,
1175 bindings: &ResolvedBindings,
1176 datastore: &RunContext,
1177) -> Result<Cow<'a, str>, NikaError> {
1178 if !template.contains("{{") {
1180 return Ok(Cow::Borrowed(template));
1181 }
1182 let has_with = template.contains("with.");
1183 let has_context = template.contains("context.");
1184 let has_inputs = template.contains("inputs.");
1185 if !has_with && !has_context && !has_inputs {
1186 return Ok(Cow::Borrowed(template));
1187 }
1188
1189 let normalized = normalize_bracket_notation(template);
1191 let template_str: &str = normalized.as_ref();
1192
1193 let mut result = String::with_capacity(template_str.len() + 64);
1196 let mut last_end = 0;
1197 let mut errors: SmallVec<[String; 4]> = SmallVec::new();
1198
1199 for cap in TEMPLATE_RE.captures_iter(template_str) {
1200 let m = cap.get(0).unwrap();
1201 let content = &cap[1];
1202
1203 let (path, transforms) = match parse_template_expr(content) {
1204 Ok(TemplateExpr::Alias { path, transforms }) => (path, transforms),
1205 _ => continue,
1206 };
1207
1208 result.push_str(&template_str[last_end..m.start()]);
1209
1210 let mut parts = path.split('.');
1211 let alias = parts.next().unwrap();
1212
1213 match bindings.get_resolved(alias, datastore) {
1214 Ok(base_value) => {
1215 let effective_base =
1217 crate::binding::jsonpath::try_parse_json_str(&base_value).unwrap_or(base_value);
1218 let mut value_ref: &Value = &effective_base;
1219 let mut traversed_segments: SmallVec<[&str; 8]> = SmallVec::new();
1220 traversed_segments.push(alias);
1221
1222 for segment in parts {
1223 let next = if let Ok(idx) = segment.parse::<usize>() {
1224 value_ref.get(idx)
1225 } else {
1226 value_ref.get(segment)
1227 };
1228
1229 match next {
1230 Some(v) => {
1231 traversed_segments.push(segment);
1232 value_ref = v;
1233 }
1234 None => {
1235 let value_type = match value_ref {
1236 Value::Null => "null",
1237 Value::Bool(_) => "bool",
1238 Value::Number(_) => "number",
1239 Value::String(_) => "string",
1240 Value::Array(_) => "array",
1241 Value::Object(_) => "object",
1242 };
1243
1244 if matches!(value_ref, Value::Object(_) | Value::Array(_)) {
1245 let traversed_path = traversed_segments.join(".");
1246 return Err(NikaError::PathNotFound {
1247 path: format!("{}.{}", traversed_path, segment),
1248 });
1249 } else {
1250 return Err(NikaError::InvalidTraversal {
1251 segment: segment.to_string(),
1252 value_type: value_type.to_string(),
1253 full_path: path.to_string(),
1254 });
1255 }
1256 }
1257 }
1258 }
1259
1260 let has_shell = transforms.iter().any(|t| t == "shell");
1262 let non_shell: Vec<&String> = transforms.iter().filter(|t| *t != "shell").collect();
1263
1264 let raw_value = if !non_shell.is_empty() {
1265 let transform_str = non_shell
1266 .iter()
1267 .map(|s| s.as_str())
1268 .collect::<Vec<_>>()
1269 .join(" | ");
1270 let expr = crate::binding::transform::TransformExpr::parse(&transform_str)
1271 .map_err(|e| NikaError::TemplateParse {
1272 position: m.start(),
1273 details: format!("Transform parse error: {}", e),
1274 })?;
1275 let transformed =
1276 expr.apply(value_ref)
1277 .map_err(|e| NikaError::TemplateParse {
1278 position: m.start(),
1279 details: format!("Transform apply error: {}", e),
1280 })?;
1281 value_to_display(&transformed).into_owned()
1282 } else if has_shell {
1283 value_to_string(value_ref, &path, alias)?.into_owned()
1285 } else {
1286 value_to_string(value_ref, &path, alias)?.into_owned()
1288 };
1289
1290 let escaped = escape_for_shell(&raw_value);
1292 result.push_str(&escaped);
1293 }
1294 Err(_) => {
1295 errors.push(alias.to_string());
1296 }
1297 }
1298
1299 last_end = m.end();
1300 }
1301
1302 if !errors.is_empty() {
1303 return Err(NikaError::TemplateError {
1304 template: errors.join(", "),
1305 reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
1306 });
1307 }
1308
1309 result.push_str(&template_str[last_end..]);
1310
1311 if has_context && result.contains("context.") {
1313 let intermediate = std::mem::take(&mut result);
1314 result = String::with_capacity(intermediate.len() + 64);
1315 let mut last_end = 0;
1316 let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
1317
1318 for cap in TEMPLATE_RE.captures_iter(&intermediate) {
1319 let m = cap.get(0).unwrap();
1320 let inner = cap[1].trim();
1321 let (path, transforms) = match parse_template_expr(inner) {
1322 Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
1323 _ => continue,
1324 };
1325 result.push_str(&intermediate[last_end..m.start()]);
1326 let full_path = format!("context.{}", path);
1327 match datastore.resolve_context_path(&full_path) {
1328 Some(value) => {
1329 let raw_value = if !transforms.is_empty() {
1330 let transform_str = transforms.join(" | ");
1331 let expr = TransformExpr::parse(&transform_str).map_err(|e| {
1332 NikaError::TemplateParse {
1333 position: m.start(),
1334 details: format!("Transform parse error: {}", e),
1335 }
1336 })?;
1337 let transformed =
1338 expr.apply(&value).map_err(|e| NikaError::TemplateParse {
1339 position: m.start(),
1340 details: format!("Transform apply error: {}", e),
1341 })?;
1342 value_to_display(&transformed).into_owned()
1343 } else {
1344 context_value_to_string(&value, &full_path)?.into_owned()
1345 };
1346 let escaped = escape_for_shell(&raw_value);
1347 result.push_str(&escaped);
1348 }
1349 None => {
1350 context_errors.push(full_path);
1351 }
1352 }
1353
1354 last_end = m.end();
1355 }
1356
1357 if !context_errors.is_empty() {
1358 return Err(NikaError::TemplateError {
1359 template: context_errors.join(", "),
1360 reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
1361 .to_string(),
1362 });
1363 }
1364
1365 result.push_str(&intermediate[last_end..]);
1366 }
1367
1368 if has_inputs && result.contains("inputs.") {
1370 let intermediate = std::mem::take(&mut result);
1371 result = String::with_capacity(intermediate.len() + 64);
1372 let mut last_end = 0;
1373 let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
1374
1375 for cap in TEMPLATE_RE.captures_iter(&intermediate) {
1376 let m = cap.get(0).unwrap();
1377 let inner = cap[1].trim();
1378 let (path, transforms) = match parse_template_expr(inner) {
1379 Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
1380 _ => continue,
1381 };
1382 result.push_str(&intermediate[last_end..m.start()]);
1383 let full_path = format!("inputs.{}", path);
1384 match datastore.resolve_input_path(&full_path) {
1385 Some(value) => {
1386 let raw_value = if !transforms.is_empty() {
1387 let transform_str = transforms.join(" | ");
1388 let expr = TransformExpr::parse(&transform_str).map_err(|e| {
1389 NikaError::TemplateParse {
1390 position: m.start(),
1391 details: format!("Transform parse error: {}", e),
1392 }
1393 })?;
1394 let transformed =
1395 expr.apply(&value).map_err(|e| NikaError::TemplateParse {
1396 position: m.start(),
1397 details: format!("Transform apply error: {}", e),
1398 })?;
1399 value_to_display(&transformed).into_owned()
1400 } else {
1401 input_value_to_string(&value, &full_path)?.into_owned()
1402 };
1403 let escaped = escape_for_shell(&raw_value);
1404 result.push_str(&escaped);
1405 }
1406 None => {
1407 input_errors.push(full_path);
1408 }
1409 }
1410
1411 last_end = m.end();
1412 }
1413
1414 if !input_errors.is_empty() {
1415 return Err(NikaError::TemplateError {
1416 template: input_errors.join(", "),
1417 reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
1418 });
1419 }
1420
1421 result.push_str(&intermediate[last_end..]);
1422 }
1423
1424 Ok(Cow::Owned(result))
1425}
1426
1427fn value_to_string<'a>(
1432 value: &'a Value,
1433 path: &str,
1434 alias: &str,
1435) -> Result<Cow<'a, str>, NikaError> {
1436 match value {
1437 Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
1438 Value::Null => Err(NikaError::NullValue {
1439 path: path.to_string(),
1440 alias: alias.to_string(),
1441 }),
1442 Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
1443 Value::Number(n) => Ok(Cow::Owned(n.to_string())),
1444 other => Ok(Cow::Owned(other.to_string())),
1446 }
1447}
1448
1449fn context_value_to_string<'a>(value: &'a Value, path: &str) -> Result<Cow<'a, str>, NikaError> {
1453 match value {
1454 Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
1455 Value::Null => Err(NikaError::TemplateError {
1456 template: path.to_string(),
1457 reason: "Context binding resolved to null".to_string(),
1458 }),
1459 Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
1460 Value::Number(n) => Ok(Cow::Owned(n.to_string())),
1461 other => Ok(Cow::Owned(other.to_string())),
1463 }
1464}
1465
1466fn input_value_to_string<'a>(value: &'a Value, path: &str) -> Result<Cow<'a, str>, NikaError> {
1470 match value {
1471 Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
1472 Value::Null => Err(NikaError::TemplateError {
1473 template: path.to_string(),
1474 reason: "Input binding resolved to null. Provide a 'default' value in your inputs definition.".to_string(),
1475 }),
1476 Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
1477 Value::Number(n) => Ok(Cow::Owned(n.to_string())),
1478 other => Ok(Cow::Owned(other.to_string())),
1480 }
1481}
1482
1483fn is_in_json_context(template: &str, pos: usize) -> bool {
1485 let trimmed = template.trim_start();
1490 let looks_like_json = trimmed.starts_with('{') || trimmed.starts_with('[');
1491 if !looks_like_json {
1492 return false;
1493 }
1494
1495 let before = &template[..pos];
1497 let mut in_string = false;
1498 let mut escaped = false;
1499
1500 for ch in before.chars() {
1501 if escaped {
1502 escaped = false;
1503 continue;
1504 }
1505 match ch {
1506 '\\' => escaped = true,
1507 '"' => in_string = !in_string,
1508 _ => {}
1509 }
1510 }
1511
1512 in_string
1513}
1514
1515pub fn extract_refs(template: &str) -> Vec<(String, String)> {
1520 USE_RE
1521 .captures_iter(template)
1522 .map(|cap| {
1523 let full_path = cap[1].to_string();
1524 let alias = full_path.split('.').next().unwrap().to_string();
1525 (alias, full_path)
1526 })
1527 .collect()
1528}
1529
1530pub fn validate_refs(
1535 template: &str,
1536 declared_aliases: &FxHashSet<String>,
1537 task_id: &str,
1538) -> Result<(), NikaError> {
1539 for (alias, _full_path) in extract_refs(template) {
1540 if !declared_aliases.contains(&alias) {
1541 return Err(NikaError::UnknownAlias {
1542 alias,
1543 task_id: task_id.to_string(),
1544 });
1545 }
1546 }
1547 Ok(())
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552 use super::*;
1553 use serde_json::json;
1554 use std::borrow::Cow;
1555
1556 fn empty_datastore() -> RunContext {
1558 RunContext::new()
1559 }
1560
1561 #[test]
1562 fn resolve_simple() {
1563 let mut bindings = ResolvedBindings::new();
1564 bindings.set("forecast", json!("Sunny 25C"));
1565 let ds = empty_datastore();
1566
1567 let result = resolve("Weather: {{with.forecast}}", &bindings, &ds).unwrap();
1568 assert_eq!(result, "Weather: Sunny 25C");
1569 }
1570
1571 #[test]
1572 fn resolve_number() {
1573 let mut bindings = ResolvedBindings::new();
1574 bindings.set("price", json!(89));
1575 let ds = empty_datastore();
1576
1577 let result = resolve("Price: ${{with.price}}", &bindings, &ds).unwrap();
1578 assert_eq!(result, "Price: $89");
1579 }
1580
1581 #[test]
1582 fn resolve_nested() {
1583 let mut bindings = ResolvedBindings::new();
1584 bindings.set("flight_info", json!({"departure": "10:30", "gate": "A12"}));
1585 let ds = empty_datastore();
1586
1587 let result = resolve("Depart at {{with.flight_info.departure}}", &bindings, &ds).unwrap();
1588 assert_eq!(result, "Depart at 10:30");
1589 }
1590
1591 #[test]
1592 fn resolve_multiple() {
1593 let mut bindings = ResolvedBindings::new();
1594 bindings.set("a", json!("first"));
1595 bindings.set("b", json!("second"));
1596 let ds = empty_datastore();
1597
1598 let result = resolve("{{with.a}} and {{with.b}}", &bindings, &ds).unwrap();
1599 assert_eq!(result, "first and second");
1600 }
1601
1602 #[test]
1603 fn resolve_object() {
1604 let mut bindings = ResolvedBindings::new();
1605 bindings.set("data", json!({"x": 1, "y": 2}));
1606 let ds = empty_datastore();
1607
1608 let result = resolve("Full: {{with.data}}", &bindings, &ds).unwrap();
1609 assert!(result.contains("\"x\":1") || result.contains("\"x\": 1"));
1611 }
1612
1613 #[test]
1614 fn resolve_alias_not_found() {
1615 let mut bindings = ResolvedBindings::new();
1616 bindings.set("known", json!("value"));
1617 let ds = empty_datastore();
1618
1619 let result = resolve("{{with.unknown}}", &bindings, &ds);
1620 assert!(result.is_err());
1621 assert!(result.unwrap_err().to_string().contains("unknown"));
1622 }
1623
1624 #[test]
1625 fn resolve_path_not_found() {
1626 let mut bindings = ResolvedBindings::new();
1627 bindings.set("data", json!({"a": 1}));
1628 let ds = empty_datastore();
1629
1630 let result = resolve("{{with.data.nonexistent}}", &bindings, &ds);
1631 assert!(result.is_err());
1632 }
1633
1634 #[test]
1635 fn resolve_no_templates() {
1636 let bindings = ResolvedBindings::new();
1637 let ds = empty_datastore();
1638 let result = resolve("No templates here", &bindings, &ds).unwrap();
1639 assert_eq!(result, "No templates here");
1640 assert!(matches!(result, Cow::Borrowed(_)));
1642 }
1643
1644 #[test]
1645 fn resolve_with_templates_is_owned() {
1646 let mut bindings = ResolvedBindings::new();
1647 bindings.set("x", json!("value"));
1648 let ds = empty_datastore();
1649 let result = resolve("Has {{with.x}} template", &bindings, &ds).unwrap();
1650 assert_eq!(result, "Has value template");
1651 assert!(matches!(result, Cow::Owned(_)));
1653 }
1654
1655 #[test]
1656 fn resolve_array_index() {
1657 let mut bindings = ResolvedBindings::new();
1658 bindings.set("items", json!(["first", "second", "third"]));
1659 let ds = empty_datastore();
1660
1661 let result = resolve("Item: {{with.items.0}}", &bindings, &ds).unwrap();
1662 assert_eq!(result, "Item: first");
1663 }
1664
1665 #[test]
1670 fn resolve_bracket_notation_simple() {
1671 let mut bindings = ResolvedBindings::new();
1672 bindings.set("items", json!(["first", "second", "third"]));
1673 let ds = empty_datastore();
1674
1675 let result = resolve("Item: {{with.items[0]}}", &bindings, &ds).unwrap();
1677 assert_eq!(result, "Item: first");
1678 }
1679
1680 #[test]
1681 fn resolve_bracket_notation_second_element() {
1682 let mut bindings = ResolvedBindings::new();
1683 bindings.set("items", json!(["first", "second", "third"]));
1684 let ds = empty_datastore();
1685
1686 let result = resolve("Item: {{with.items[1]}}", &bindings, &ds).unwrap();
1687 assert_eq!(result, "Item: second");
1688 }
1689
1690 #[test]
1691 fn resolve_bracket_notation_nested() {
1692 let mut bindings = ResolvedBindings::new();
1693 bindings.set(
1694 "data",
1695 json!({
1696 "user": {"name": "Alice", "address": {"city": "Paris"}},
1697 "items": ["one", "two", "three"]
1698 }),
1699 );
1700 let ds = empty_datastore();
1701
1702 let result = resolve("First item: {{with.data.items[0]}}", &bindings, &ds).unwrap();
1704 assert_eq!(result, "First item: one");
1705 }
1706
1707 #[test]
1708 fn resolve_bracket_notation_mixed_syntax() {
1709 let mut bindings = ResolvedBindings::new();
1710 bindings.set(
1711 "data",
1712 json!({"users": [{"name": "Alice"}, {"name": "Bob"}]}),
1713 );
1714 let ds = empty_datastore();
1715
1716 let result = resolve("User: {{with.data.users[0].name}}", &bindings, &ds).unwrap();
1718 assert_eq!(result, "User: Alice");
1719 }
1720
1721 #[test]
1722 fn resolve_bracket_notation_multiple() {
1723 let mut bindings = ResolvedBindings::new();
1724 bindings.set("items", json!(["a", "b", "c"]));
1725 let ds = empty_datastore();
1726
1727 let result = resolve("{{with.items[0]}} and {{with.items[2]}}", &bindings, &ds).unwrap();
1729 assert_eq!(result, "a and c");
1730 }
1731
1732 #[test]
1733 fn normalize_bracket_notation_unit() {
1734 assert_eq!(
1736 normalize_bracket_notation("{{with.items[0]}}"),
1737 "{{with.items.0}}"
1738 );
1739 assert_eq!(
1740 normalize_bracket_notation("{{with.data.items[1].name}}"),
1741 "{{with.data.items.1.name}}"
1742 );
1743 assert_eq!(
1744 normalize_bracket_notation("no brackets here"),
1745 "no brackets here"
1746 );
1747 assert_eq!(
1749 normalize_bracket_notation("{{with.a[0]}} and {{with.b[2]}}"),
1750 "{{with.a.0}} and {{with.b.2}}"
1751 );
1752 }
1753
1754 #[test]
1759 fn resolve_null_is_error() {
1760 let mut bindings = ResolvedBindings::new();
1761 bindings.set("data", json!(null));
1762 let ds = empty_datastore();
1763
1764 let result = resolve("Value: {{with.data}}", &bindings, &ds);
1765 assert!(result.is_err());
1766 let err = result.unwrap_err();
1767 assert!(err.to_string().contains("NIKA-072"));
1768 assert!(err.to_string().contains("Null value"));
1769 }
1770
1771 #[test]
1772 fn resolve_nested_null_is_error() {
1773 let mut bindings = ResolvedBindings::new();
1774 bindings.set("data", json!({"value": null}));
1775 let ds = empty_datastore();
1776
1777 let result = resolve("Value: {{with.data.value}}", &bindings, &ds);
1778 assert!(result.is_err());
1779 assert!(result.unwrap_err().to_string().contains("NIKA-072"));
1780 }
1781
1782 #[test]
1783 fn resolve_invalid_traversal_on_string() {
1784 let mut bindings = ResolvedBindings::new();
1785 bindings.set("data", json!("just a string"));
1786 let ds = empty_datastore();
1787
1788 let result = resolve("{{with.data.field}}", &bindings, &ds);
1789 assert!(result.is_err());
1790 let err = result.unwrap_err();
1791 assert!(err.to_string().contains("NIKA-073"));
1792 assert!(err.to_string().contains("string"));
1793 }
1794
1795 #[test]
1796 fn resolve_invalid_traversal_on_number() {
1797 let mut bindings = ResolvedBindings::new();
1798 bindings.set("price", json!(42));
1799 let ds = empty_datastore();
1800
1801 let result = resolve("{{with.price.currency}}", &bindings, &ds);
1802 assert!(result.is_err());
1803 let err = result.unwrap_err();
1804 assert!(err.to_string().contains("NIKA-073"));
1805 assert!(err.to_string().contains("number"));
1806 }
1807
1808 #[test]
1813 fn extract_refs_simple() {
1814 let refs = extract_refs("Hello {{with.weather}}!");
1815 assert_eq!(refs.len(), 1);
1816 assert_eq!(refs[0], ("weather".to_string(), "weather".to_string()));
1817 }
1818
1819 #[test]
1820 fn extract_refs_nested() {
1821 let refs = extract_refs("{{with.data.field.sub}}");
1822 assert_eq!(refs.len(), 1);
1823 assert_eq!(refs[0], ("data".to_string(), "data.field.sub".to_string()));
1824 }
1825
1826 #[test]
1827 fn extract_refs_multiple() {
1828 let refs = extract_refs("{{with.a}} and {{with.b.c}}");
1829 assert_eq!(refs.len(), 2);
1830 assert_eq!(refs[0].0, "a");
1831 assert_eq!(refs[1].0, "b");
1832 }
1833
1834 #[test]
1835 fn extract_refs_none() {
1836 let refs = extract_refs("No templates here");
1837 assert!(refs.is_empty());
1838 }
1839
1840 #[test]
1841 fn validate_refs_success() {
1842 let declared: FxHashSet<String> =
1843 ["weather", "price"].iter().map(|s| s.to_string()).collect();
1844 let result = validate_refs("{{with.weather}} costs {{with.price}}", &declared, "task1");
1845 assert!(result.is_ok());
1846 }
1847
1848 #[test]
1849 fn validate_refs_unknown_alias() {
1850 let declared: FxHashSet<String> = ["weather"].iter().map(|s| s.to_string()).collect();
1851 let result = validate_refs("{{with.weather}} and {{with.unknown}}", &declared, "task1");
1852 assert!(result.is_err());
1853 let err = result.unwrap_err();
1854 assert!(err.to_string().contains("NIKA-071"));
1855 assert!(err.to_string().contains("unknown"));
1856 }
1857
1858 use crate::store::LoadedContext;
1863
1864 fn datastore_with_context() -> RunContext {
1866 let store = RunContext::new();
1867 let mut context = LoadedContext::new();
1868 context.files.insert(
1869 "brand".to_string(),
1870 json!("# QR Code AI\nTagline: Scan smarter"),
1871 );
1872 context
1873 .files
1874 .insert("config".to_string(), json!({"theme": "dark", "version": 2}));
1875 context.session = Some(json!({"focus": "rust", "level": 3}));
1876 store.set_context(context);
1877 store
1878 }
1879
1880 #[test]
1881 fn resolve_context_files_simple() {
1882 let bindings = ResolvedBindings::new();
1883 let ds = datastore_with_context();
1884
1885 let result = resolve("Brand: {{context.files.brand}}", &bindings, &ds).unwrap();
1886 assert_eq!(result, "Brand: # QR Code AI\nTagline: Scan smarter");
1887 }
1888
1889 #[test]
1890 fn resolve_context_files_nested() {
1891 let bindings = ResolvedBindings::new();
1892 let ds = datastore_with_context();
1893
1894 let result = resolve("Theme: {{context.files.config.theme}}", &bindings, &ds).unwrap();
1895 assert_eq!(result, "Theme: dark");
1896 }
1897
1898 #[test]
1899 fn resolve_context_session() {
1900 let bindings = ResolvedBindings::new();
1901 let ds = datastore_with_context();
1902
1903 let result = resolve("Focus: {{context.session.focus}}", &bindings, &ds).unwrap();
1904 assert_eq!(result, "Focus: rust");
1905 }
1906
1907 #[test]
1908 fn resolve_context_session_number() {
1909 let bindings = ResolvedBindings::new();
1910 let ds = datastore_with_context();
1911
1912 let result = resolve("Level: {{context.session.level}}", &bindings, &ds).unwrap();
1913 assert_eq!(result, "Level: 3");
1914 }
1915
1916 #[test]
1917 fn resolve_context_with_use_bindings() {
1918 let mut bindings = ResolvedBindings::new();
1919 bindings.set("greeting", json!("Hello"));
1920 let ds = datastore_with_context();
1921
1922 let result = resolve(
1923 "{{with.greeting}}! Brand: {{context.files.brand}}",
1924 &bindings,
1925 &ds,
1926 )
1927 .unwrap();
1928 assert_eq!(result, "Hello! Brand: # QR Code AI\nTagline: Scan smarter");
1929 }
1930
1931 #[test]
1932 fn resolve_context_not_found() {
1933 let bindings = ResolvedBindings::new();
1934 let ds = datastore_with_context();
1935
1936 let result = resolve("{{context.files.nonexistent}}", &bindings, &ds);
1937 assert!(result.is_err());
1938 let err = result.unwrap_err();
1939 assert!(err.to_string().contains("Context binding"));
1940 assert!(err.to_string().contains("nonexistent"));
1941 }
1942
1943 #[test]
1944 fn resolve_context_no_context_loaded() {
1945 let bindings = ResolvedBindings::new();
1946 let ds = empty_datastore(); let result = resolve("{{context.files.brand}}", &bindings, &ds);
1949 assert!(result.is_err());
1950 }
1951
1952 #[test]
1953 fn resolve_only_context_no_use() {
1954 let bindings = ResolvedBindings::new();
1955 let ds = datastore_with_context();
1956
1957 let result = resolve("Theme is {{context.files.config.theme}}", &bindings, &ds).unwrap();
1959 assert_eq!(result, "Theme is dark");
1960 }
1961
1962 #[test]
1963 fn resolve_context_preserves_no_template() {
1964 let bindings = ResolvedBindings::new();
1965 let ds = datastore_with_context();
1966
1967 let result = resolve("Plain text without templates", &bindings, &ds).unwrap();
1969 assert_eq!(result, "Plain text without templates");
1970 assert!(matches!(result, Cow::Borrowed(_)));
1972 }
1973
1974 #[test]
1979 fn escape_for_shell_simple() {
1980 assert_eq!(escape_for_shell("hello"), "'hello'");
1981 }
1982
1983 #[test]
1984 fn escape_for_shell_empty() {
1985 assert_eq!(escape_for_shell(""), "''");
1986 }
1987
1988 #[test]
1989 fn escape_for_shell_with_single_quote() {
1990 assert_eq!(escape_for_shell("Nika's"), "'Nika'\\''s'");
1992 }
1993
1994 #[test]
1995 fn escape_for_shell_with_multiple_quotes() {
1996 assert_eq!(escape_for_shell("don't won't"), "'don'\\''t won'\\''t'");
1998 }
1999
2000 #[test]
2001 fn escape_for_shell_with_special_chars() {
2002 assert_eq!(escape_for_shell("$HOME;rm -rf /"), "'$HOME;rm -rf /'");
2004 }
2005
2006 #[test]
2007 fn escape_for_shell_with_backticks() {
2008 assert_eq!(escape_for_shell("`whoami`"), "'`whoami`'");
2010 }
2011
2012 #[test]
2013 fn escape_for_shell_with_newlines() {
2014 assert_eq!(escape_for_shell("line1\nline2"), "'line1\nline2'");
2016 }
2017
2018 #[test]
2023 fn resolve_shell_modifier_simple() {
2024 let mut bindings = ResolvedBindings::new();
2025 bindings.set("msg", json!("hello world"));
2026 let ds = empty_datastore();
2027
2028 let result = resolve("echo {{with.msg|shell}}", &bindings, &ds).unwrap();
2030 assert_eq!(result, "echo 'hello world'");
2031 }
2032
2033 #[test]
2034 fn resolve_shell_modifier_with_quote() {
2035 let mut bindings = ResolvedBindings::new();
2036 bindings.set("response", json!("Hello from Nika's v0.5.1!"));
2037 let ds = empty_datastore();
2038
2039 let result = resolve("echo {{with.response|shell}}", &bindings, &ds).unwrap();
2041 assert_eq!(result, "echo 'Hello from Nika'\\''s v0.5.1!'");
2042 }
2043
2044 #[test]
2045 fn resolve_shell_modifier_with_special_chars() {
2046 let mut bindings = ResolvedBindings::new();
2047 bindings.set("content", json!("Hello; echo pwned"));
2048 let ds = empty_datastore();
2049
2050 let result = resolve("echo {{with.content|shell}}", &bindings, &ds).unwrap();
2052 assert_eq!(result, "echo 'Hello; echo pwned'");
2053 }
2054
2055 #[test]
2056 fn resolve_without_modifier_no_escape() {
2057 let mut bindings = ResolvedBindings::new();
2058 bindings.set("msg", json!("hello world"));
2059 let ds = empty_datastore();
2060
2061 let result = resolve("echo {{with.msg}}", &bindings, &ds).unwrap();
2063 assert_eq!(result, "echo hello world");
2064 }
2065
2066 #[test]
2067 fn resolve_shell_modifier_multiple() {
2068 let mut bindings = ResolvedBindings::new();
2069 bindings.set("file", json!("test.txt"));
2070 bindings.set("content", json!("Hello 'world'"));
2071 let ds = empty_datastore();
2072
2073 let result = resolve(
2075 "cat {{with.file|shell}} && echo {{with.content|shell}}",
2076 &bindings,
2077 &ds,
2078 )
2079 .unwrap();
2080 assert_eq!(result, "cat 'test.txt' && echo 'Hello '\\''world'\\'''");
2081 }
2082
2083 #[test]
2084 fn resolve_for_shell_simple() {
2085 let mut bindings = ResolvedBindings::new();
2086 bindings.set("msg", json!("hello world"));
2087 let ds = empty_datastore();
2088
2089 let result = resolve_for_shell("echo {{with.msg}}", &bindings, &ds).unwrap();
2090 assert_eq!(result, "echo 'hello world'");
2091 }
2092
2093 #[test]
2094 fn resolve_for_shell_with_quote() {
2095 let mut bindings = ResolvedBindings::new();
2096 bindings.set("response", json!("Hello from Nika's v0.5.1!"));
2097 let ds = empty_datastore();
2098
2099 let result =
2101 resolve_for_shell("echo 'Claude said: {{with.response}}'", &bindings, &ds).unwrap();
2102 assert_eq!(
2104 result,
2105 "echo 'Claude said: 'Hello from Nika'\\''s v0.5.1!''"
2106 );
2107 }
2108
2109 #[test]
2110 fn resolve_for_shell_no_templates() {
2111 let bindings = ResolvedBindings::new();
2112 let ds = empty_datastore();
2113
2114 let result = resolve_for_shell("echo hello", &bindings, &ds).unwrap();
2116 assert_eq!(result, "echo hello");
2117 assert!(matches!(result, Cow::Borrowed(_)));
2118 }
2119
2120 #[test]
2121 fn resolve_for_shell_preserves_command_structure() {
2122 let mut bindings = ResolvedBindings::new();
2123 bindings.set("file", json!("test.txt"));
2124 bindings.set("content", json!("Hello; echo pwned"));
2125 let ds = empty_datastore();
2126
2127 let result =
2129 resolve_for_shell("cat {{with.file}} && echo {{with.content}}", &bindings, &ds)
2130 .unwrap();
2131 assert_eq!(result, "cat 'test.txt' && echo 'Hello; echo pwned'");
2132 }
2133
2134 use rustc_hash::FxHashMap;
2139
2140 fn datastore_with_inputs() -> RunContext {
2142 let store = RunContext::new();
2143 let mut inputs = FxHashMap::default();
2144 inputs.insert(
2145 "topic".to_string(),
2146 json!({
2147 "type": "string",
2148 "default": "AI QR code generation"
2149 }),
2150 );
2151 inputs.insert(
2152 "depth".to_string(),
2153 json!({
2154 "type": "string",
2155 "default": "comprehensive"
2156 }),
2157 );
2158 inputs.insert(
2159 "config".to_string(),
2160 json!({
2161 "type": "object",
2162 "default": {
2163 "theme": "dark",
2164 "count": 5
2165 }
2166 }),
2167 );
2168 store.set_inputs(inputs);
2169 store
2170 }
2171
2172 #[test]
2173 fn resolve_inputs_simple() {
2174 let bindings = ResolvedBindings::new();
2175 let ds = datastore_with_inputs();
2176
2177 let result = resolve("Topic: {{inputs.topic}}", &bindings, &ds).unwrap();
2178 assert_eq!(result, "Topic: AI QR code generation");
2179 }
2180
2181 #[test]
2182 fn resolve_inputs_multiple() {
2183 let bindings = ResolvedBindings::new();
2184 let ds = datastore_with_inputs();
2185
2186 let result = resolve(
2187 "Research {{inputs.topic}} at {{inputs.depth}} depth",
2188 &bindings,
2189 &ds,
2190 )
2191 .unwrap();
2192 assert_eq!(
2193 result,
2194 "Research AI QR code generation at comprehensive depth"
2195 );
2196 }
2197
2198 #[test]
2199 fn resolve_inputs_nested() {
2200 let bindings = ResolvedBindings::new();
2201 let ds = datastore_with_inputs();
2202
2203 let result = resolve("Theme: {{inputs.config.theme}}", &bindings, &ds).unwrap();
2204 assert_eq!(result, "Theme: dark");
2205 }
2206
2207 #[test]
2208 fn resolve_inputs_with_use_bindings() {
2209 let mut bindings = ResolvedBindings::new();
2210 bindings.set("greeting", json!("Hello"));
2211 let ds = datastore_with_inputs();
2212
2213 let result = resolve(
2214 "{{with.greeting}}! Research {{inputs.topic}}",
2215 &bindings,
2216 &ds,
2217 )
2218 .unwrap();
2219 assert_eq!(result, "Hello! Research AI QR code generation");
2220 }
2221
2222 #[test]
2223 fn resolve_inputs_with_context() {
2224 let mut bindings = ResolvedBindings::new();
2225 bindings.set("msg", json!("Test"));
2226 let store = RunContext::new();
2227
2228 let mut context = LoadedContext::new();
2230 context
2231 .files
2232 .insert("brand".to_string(), json!("QR Code AI"));
2233 store.set_context(context);
2234
2235 let mut inputs = FxHashMap::default();
2236 inputs.insert(
2237 "topic".to_string(),
2238 json!({
2239 "type": "string",
2240 "default": "AI trends"
2241 }),
2242 );
2243 store.set_inputs(inputs);
2244
2245 let result = resolve(
2246 "{{with.msg}}: {{context.files.brand}} - {{inputs.topic}}",
2247 &bindings,
2248 &store,
2249 )
2250 .unwrap();
2251 assert_eq!(result, "Test: QR Code AI - AI trends");
2252 }
2253
2254 #[test]
2255 fn resolve_inputs_not_found() {
2256 let bindings = ResolvedBindings::new();
2257 let ds = datastore_with_inputs();
2258
2259 let result = resolve("{{inputs.nonexistent}}", &bindings, &ds);
2260 assert!(result.is_err());
2261 let err = result.unwrap_err();
2262 assert!(err.to_string().contains("Input binding"));
2263 assert!(err.to_string().contains("nonexistent"));
2264 }
2265
2266 #[test]
2267 fn resolve_inputs_no_inputs_loaded() {
2268 let bindings = ResolvedBindings::new();
2269 let ds = empty_datastore(); let result = resolve("{{inputs.topic}}", &bindings, &ds);
2272 assert!(result.is_err());
2273 }
2274
2275 #[test]
2276 fn resolve_only_inputs_no_use() {
2277 let bindings = ResolvedBindings::new();
2278 let ds = datastore_with_inputs();
2279
2280 let result = resolve("Topic is {{inputs.topic}}", &bindings, &ds).unwrap();
2282 assert_eq!(result, "Topic is AI QR code generation");
2283 }
2284
2285 #[test]
2286 fn resolve_inputs_preserves_no_template() {
2287 let bindings = ResolvedBindings::new();
2288 let ds = datastore_with_inputs();
2289
2290 let result = resolve("Plain text without templates", &bindings, &ds).unwrap();
2292 assert_eq!(result, "Plain text without templates");
2293 assert!(matches!(result, Cow::Borrowed(_)));
2295 }
2296
2297 #[test]
2310 fn injection_template_syntax_not_reevaluated() {
2311 let mut bindings = ResolvedBindings::new();
2313 bindings.set("user_input", json!("{{with.secret}}"));
2314 bindings.set("secret", json!("TOP_SECRET"));
2315 let ds = empty_datastore();
2316
2317 let result = resolve("User said: {{with.user_input}}", &bindings, &ds).unwrap();
2318 assert_eq!(result, "User said: {{with.secret}}");
2320 assert!(!result.contains("TOP_SECRET"));
2321 }
2322
2323 #[test]
2324 fn injection_nested_template_attack() {
2325 let mut bindings = ResolvedBindings::new();
2327 bindings.set("left", json!("{{with."));
2328 bindings.set("right", json!("secret}}"));
2329 bindings.set("secret", json!("LEAKED"));
2330 let ds = empty_datastore();
2331
2332 let result = resolve("{{with.left}}{{with.right}}", &bindings, &ds).unwrap();
2334 assert_eq!(result, "{{with.secret}}");
2335 assert!(!result.contains("LEAKED"));
2336 }
2337
2338 #[test]
2339 fn injection_json_context_quotes_escaped() {
2340 let mut bindings = ResolvedBindings::new();
2342 bindings.set("name", json!(r#"Alice", "admin": true, "x": "#));
2343 let ds = empty_datastore();
2344
2345 let template = r#"{"user": "{{with.name}}"}"#;
2347 let result = resolve(template, &bindings, &ds).unwrap();
2348
2349 assert!(
2351 result.contains(r#"\""#),
2352 "Quotes should be escaped: {}",
2353 result
2354 );
2355 assert_eq!(
2358 result, r#"{"user": "Alice\", \"admin\": true, \"x\": "}"#,
2359 "Quotes should be escaped to prevent JSON structure injection"
2360 );
2361 assert!(result.contains(r#"\"admin\""#), "admin should be escaped");
2364 }
2365
2366 #[test]
2367 fn injection_json_context_backslash_escaped() {
2368 let mut bindings = ResolvedBindings::new();
2370 bindings.set("path", json!(r#"C:\Users\admin"#));
2371 let ds = empty_datastore();
2372
2373 let template = r#"{"path": "{{with.path}}"}"#;
2374 let result = resolve(template, &bindings, &ds).unwrap();
2375
2376 assert!(
2378 result.contains(r#"\\"#),
2379 "Backslashes should be escaped: {}",
2380 result
2381 );
2382 }
2383
2384 #[test]
2385 fn injection_json_context_newline_escaped() {
2386 let mut bindings = ResolvedBindings::new();
2388 bindings.set("text", json!("line1\nline2"));
2389 let ds = empty_datastore();
2390
2391 let template = r#"{"text": "{{with.text}}"}"#;
2392 let result = resolve(template, &bindings, &ds).unwrap();
2393
2394 assert!(
2396 result.contains(r#"\n"#),
2397 "Newlines should be escaped: {}",
2398 result
2399 );
2400 assert!(
2401 !result.contains('\n') || result.matches('\n').count() == 0 || result.contains("\\n"),
2402 "Raw newlines should be escaped"
2403 );
2404 }
2405
2406 #[test]
2407 fn injection_shell_modifier_escapes_semicolon() {
2408 let mut bindings = ResolvedBindings::new();
2410 bindings.set("filename", json!("file.txt; rm -rf /"));
2411 let ds = empty_datastore();
2412
2413 let result = resolve("cat {{with.filename|shell}}", &bindings, &ds).unwrap();
2414 assert_eq!(result, "cat 'file.txt; rm -rf /'");
2417 assert!(result.starts_with("cat '") && result.ends_with("'"));
2419 }
2420
2421 #[test]
2422 fn injection_shell_modifier_escapes_backticks() {
2423 let mut bindings = ResolvedBindings::new();
2425 bindings.set("input", json!("`whoami`"));
2426 let ds = empty_datastore();
2427
2428 let result = resolve("echo {{with.input|shell}}", &bindings, &ds).unwrap();
2429 assert_eq!(result, "echo '`whoami`'");
2431 }
2432
2433 #[test]
2434 fn injection_shell_modifier_escapes_dollar_parens() {
2435 let mut bindings = ResolvedBindings::new();
2437 bindings.set("input", json!("$(cat /etc/passwd)"));
2438 let ds = empty_datastore();
2439
2440 let result = resolve("echo {{with.input|shell}}", &bindings, &ds).unwrap();
2441 assert_eq!(result, "echo '$(cat /etc/passwd)'");
2443 }
2444
2445 #[test]
2446 fn injection_shell_modifier_escapes_env_vars() {
2447 let mut bindings = ResolvedBindings::new();
2449 bindings.set("input", json!("$HOME/.ssh/id_rsa"));
2450 let ds = empty_datastore();
2451
2452 let result = resolve("cat {{with.input|shell}}", &bindings, &ds).unwrap();
2453 assert_eq!(result, "cat '$HOME/.ssh/id_rsa'");
2455 }
2456
2457 #[test]
2458 fn injection_resolve_for_shell_escapes_all() {
2459 let mut bindings = ResolvedBindings::new();
2461 bindings.set("cmd", json!("echo 'pwned'; rm -rf /"));
2462 let ds = empty_datastore();
2463
2464 let result = resolve_for_shell("{{with.cmd}}", &bindings, &ds).unwrap();
2465 assert_eq!(result, "'echo '\\''pwned'\\''; rm -rf /'");
2468 }
2471
2472 #[test]
2473 fn injection_control_characters_json() {
2474 let mut bindings = ResolvedBindings::new();
2476 bindings.set("data", json!("a\tb\rc\x0c"));
2478 let ds = empty_datastore();
2479
2480 let template = r#"{"data": "{{with.data}}"}"#;
2481 let result = resolve(template, &bindings, &ds).unwrap();
2482
2483 assert!(result.contains(r#"\t"#) || !result.contains('\t'));
2485 assert!(result.contains(r#"\r"#) || !result.contains('\r'));
2486 }
2487
2488 #[test]
2489 fn injection_unicode_escape_sequences() {
2490 let mut bindings = ResolvedBindings::new();
2492 bindings.set("text", json!(r#"\u0000"#)); let ds = empty_datastore();
2494
2495 let result = resolve("Text: {{with.text}}", &bindings, &ds).unwrap();
2496 assert_eq!(result, r#"Text: \u0000"#);
2498 }
2499
2500 #[test]
2501 fn injection_null_byte_in_value() {
2502 let mut bindings = ResolvedBindings::new();
2505 bindings.set("normal", json!("safe"));
2508 let ds = empty_datastore();
2509
2510 let result = resolve("{{with.normal}}", &bindings, &ds).unwrap();
2511 assert_eq!(result, "safe");
2512 }
2513
2514 #[test]
2515 fn injection_very_long_value() {
2516 let mut bindings = ResolvedBindings::new();
2518 let long_string = "A".repeat(100_000);
2519 bindings.set("big", json!(long_string.clone()));
2520 let ds = empty_datastore();
2521
2522 let result = resolve("Data: {{with.big}}", &bindings, &ds).unwrap();
2523 assert!(result.starts_with("Data: AAAA"));
2524 assert_eq!(result.len(), 6 + 100_000); }
2526
2527 #[test]
2528 fn injection_deeply_nested_json_value() {
2529 let mut bindings = ResolvedBindings::new();
2531 bindings.set("nested", json!({"a": {"b": {"c": {"d": "deep"}}}}));
2532 let ds = empty_datastore();
2533
2534 let result = resolve("{{with.nested}}", &bindings, &ds).unwrap();
2535 assert!(result.contains("deep"));
2537 }
2538
2539 #[test]
2540 fn injection_template_markers_in_context_path() {
2541 let bindings = ResolvedBindings::new();
2543 let store = RunContext::new();
2544
2545 let mut context = LoadedContext::new();
2546 context
2548 .files
2549 .insert("normal".to_string(), json!("safe content"));
2550 store.set_context(context);
2551
2552 let result = resolve("{{context.files.normal}}", &bindings, &store).unwrap();
2553 assert_eq!(result, "safe content");
2554 }
2555
2556 #[test]
2557 fn injection_context_value_with_template_syntax() {
2558 let bindings = ResolvedBindings::new();
2560 let store = RunContext::new();
2561
2562 let mut context = LoadedContext::new();
2563 context
2564 .files
2565 .insert("brand".to_string(), json!("Brand: {{with.secret}}"));
2566 store.set_context(context);
2567
2568 let result = resolve("{{context.files.brand}}", &bindings, &store).unwrap();
2569 assert_eq!(result, "Brand: {{with.secret}}");
2571 }
2572
2573 #[test]
2574 fn injection_input_value_with_template_syntax() {
2575 let bindings = ResolvedBindings::new();
2577 let store = RunContext::new();
2578
2579 let mut inputs = FxHashMap::default();
2580 inputs.insert(
2581 "topic".to_string(),
2582 json!({
2583 "type": "string",
2584 "default": "Learn about {{with.secret}}"
2585 }),
2586 );
2587 store.set_inputs(inputs);
2588
2589 let result = resolve("{{inputs.topic}}", &bindings, &store).unwrap();
2590 assert_eq!(result, "Learn about {{with.secret}}");
2592 }
2593
2594 #[test]
2595 fn injection_3pass_no_cross_contamination() {
2596 let mut bindings = ResolvedBindings::new();
2598 bindings.set("data", json!("{{context.files.secret}}"));
2600 let store = RunContext::new();
2601
2602 let mut context = LoadedContext::new();
2603 context
2604 .files
2605 .insert("secret".to_string(), json!("CONFIDENTIAL"));
2606 store.set_context(context);
2607
2608 let result = resolve("Result: {{with.data}}", &bindings, &store).unwrap();
2610 assert_eq!(result, "Result: {{context.files.secret}}");
2612 assert!(!result.contains("CONFIDENTIAL"));
2613 }
2614
2615 #[test]
2616 fn injection_html_script_tags() {
2617 let mut bindings = ResolvedBindings::new();
2619 bindings.set("content", json!("<script>alert('xss')</script>"));
2620 let ds = empty_datastore();
2621
2622 let result = resolve("{{with.content}}", &bindings, &ds).unwrap();
2623 assert_eq!(result, "<script>alert('xss')</script>");
2626 }
2627
2628 #[test]
2629 fn injection_sql_like_content() {
2630 let mut bindings = ResolvedBindings::new();
2632 bindings.set("query", json!("'; DROP TABLE users; --"));
2633 let ds = empty_datastore();
2634
2635 let result = resolve(
2636 "SELECT * FROM x WHERE name='{{with.query}}'",
2637 &bindings,
2638 &ds,
2639 )
2640 .unwrap();
2641 assert!(result.contains("DROP TABLE"));
2644 }
2645}
2646
2647#[cfg(test)]
2652mod v028_template_tests {
2653 use super::*;
2654 use crate::store::{LoadedContext, RunContext};
2655 use serde_json::json;
2656
2657 fn empty_datastore() -> RunContext {
2658 RunContext::new()
2659 }
2660
2661 fn make_with(entries: &[(&str, Value)]) -> FxHashMap<String, Value> {
2662 entries
2663 .iter()
2664 .map(|(k, v)| (k.to_string(), v.clone()))
2665 .collect()
2666 }
2667
2668 #[test]
2671 fn parse_expr_simple_alias() {
2672 let result = parse_template_expr("title").unwrap();
2673 assert_eq!(
2674 result,
2675 TemplateExpr::Alias {
2676 path: "title".to_string(),
2677 transforms: vec![],
2678 }
2679 );
2680 }
2681
2682 #[test]
2683 fn parse_expr_alias_with_path() {
2684 let result = parse_template_expr("data.items").unwrap();
2685 assert_eq!(
2686 result,
2687 TemplateExpr::Alias {
2688 path: "data.items".to_string(),
2689 transforms: vec![],
2690 }
2691 );
2692 }
2693
2694 #[test]
2695 fn parse_expr_alias_single_transform() {
2696 let result = parse_template_expr("title | upper").unwrap();
2697 assert_eq!(
2698 result,
2699 TemplateExpr::Alias {
2700 path: "title".to_string(),
2701 transforms: vec!["upper".to_string()],
2702 }
2703 );
2704 }
2705
2706 #[test]
2707 fn parse_expr_alias_multi_transform() {
2708 let result = parse_template_expr("x | sort | unique | first(3)").unwrap();
2709 assert_eq!(
2710 result,
2711 TemplateExpr::Alias {
2712 path: "x".to_string(),
2713 transforms: vec![
2714 "sort".to_string(),
2715 "unique".to_string(),
2716 "first(3)".to_string(),
2717 ],
2718 }
2719 );
2720 }
2721
2722 #[test]
2723 fn parse_expr_context_files() {
2724 let result = parse_template_expr("context.files.brand").unwrap();
2725 assert_eq!(
2726 result,
2727 TemplateExpr::Context {
2728 path: "files.brand".to_string(),
2729 transforms: vec![]
2730 }
2731 );
2732 }
2733
2734 #[test]
2735 fn parse_expr_context_session() {
2736 let result = parse_template_expr("context.session.key").unwrap();
2737 assert_eq!(
2738 result,
2739 TemplateExpr::Context {
2740 path: "session.key".to_string(),
2741 transforms: vec![]
2742 }
2743 );
2744 }
2745
2746 #[test]
2747 fn parse_expr_inputs() {
2748 let result = parse_template_expr("inputs.locale").unwrap();
2749 assert_eq!(
2750 result,
2751 TemplateExpr::Input {
2752 path: "locale".to_string(),
2753 transforms: vec![]
2754 }
2755 );
2756 }
2757
2758 #[test]
2759 fn parse_expr_inputs_nested() {
2760 let result = parse_template_expr("inputs.config.theme").unwrap();
2761 assert_eq!(
2762 result,
2763 TemplateExpr::Input {
2764 path: "config.theme".to_string(),
2765 transforms: vec![]
2766 }
2767 );
2768 }
2769
2770 #[test]
2771 fn parse_expr_context_with_transforms() {
2772 let result = parse_template_expr("context.files.brand | upper").unwrap();
2773 assert_eq!(
2774 result,
2775 TemplateExpr::Context {
2776 path: "files.brand".to_string(),
2777 transforms: vec!["upper".to_string()]
2778 }
2779 );
2780 }
2781
2782 #[test]
2783 fn parse_expr_inputs_with_transforms() {
2784 let result = parse_template_expr("inputs.topic | lower | trim").unwrap();
2785 assert_eq!(
2786 result,
2787 TemplateExpr::Input {
2788 path: "topic".to_string(),
2789 transforms: vec!["lower".to_string(), "trim".to_string()]
2790 }
2791 );
2792 }
2793
2794 #[test]
2795 fn parse_expr_contextual_is_alias() {
2796 let result = parse_template_expr("contextual").unwrap();
2798 assert_eq!(
2799 result,
2800 TemplateExpr::Alias {
2801 path: "contextual".to_string(),
2802 transforms: vec![],
2803 }
2804 );
2805 }
2806
2807 #[test]
2808 fn parse_expr_inputstream_is_alias() {
2809 let result = parse_template_expr("inputstream").unwrap();
2811 assert_eq!(
2812 result,
2813 TemplateExpr::Alias {
2814 path: "inputstream".to_string(),
2815 transforms: vec![],
2816 }
2817 );
2818 }
2819
2820 #[test]
2821 fn parse_expr_empty_is_error() {
2822 let result = parse_template_expr("");
2823 assert!(result.is_err());
2824 }
2825
2826 #[test]
2827 fn parse_expr_whitespace_is_error() {
2828 let result = parse_template_expr(" ");
2829 assert!(result.is_err());
2830 }
2831
2832 #[test]
2833 fn parse_expr_context_dot_only_is_error() {
2834 let result = parse_template_expr("context.");
2835 assert!(result.is_err());
2836 }
2837
2838 #[test]
2839 fn parse_expr_inputs_dot_only_is_error() {
2840 let result = parse_template_expr("inputs.");
2841 assert!(result.is_err());
2842 }
2843
2844 #[test]
2845 fn parse_expr_whitespace_trimmed() {
2846 let result = parse_template_expr(" title ").unwrap();
2847 assert_eq!(
2848 result,
2849 TemplateExpr::Alias {
2850 path: "title".to_string(),
2851 transforms: vec![],
2852 }
2853 );
2854 }
2855
2856 #[test]
2857 fn parse_expr_transform_with_spaces() {
2858 let result = parse_template_expr(" name | upper | trim ").unwrap();
2859 assert_eq!(
2860 result,
2861 TemplateExpr::Alias {
2862 path: "name".to_string(),
2863 transforms: vec!["upper".to_string(), "trim".to_string()],
2864 }
2865 );
2866 }
2867
2868 #[test]
2871 fn display_string() {
2872 assert_eq!(value_to_display(&json!("hello")), "hello");
2873 }
2874
2875 #[test]
2876 fn display_number() {
2877 assert_eq!(value_to_display(&json!(42)), "42");
2878 assert_eq!(value_to_display(&json!(3.12)), "3.12");
2879 }
2880
2881 #[test]
2882 fn display_bool() {
2883 assert_eq!(value_to_display(&json!(true)), "true");
2884 assert_eq!(value_to_display(&json!(false)), "false");
2885 }
2886
2887 #[test]
2888 fn display_null_is_empty() {
2889 assert_eq!(value_to_display(&Value::Null), "");
2890 }
2891
2892 #[test]
2893 fn display_array() {
2894 assert_eq!(value_to_display(&json!([1, 2, 3])), "[1,2,3]");
2895 }
2896
2897 #[test]
2898 fn display_object() {
2899 let val = json!({"a": 1});
2900 let display = value_to_display(&val);
2901 assert!(display.contains("\"a\""));
2902 assert!(display.contains("1"));
2903 }
2904
2905 #[test]
2908 fn resolve_with_simple_alias() {
2909 let with = make_with(&[("name", json!("World"))]);
2910 let ds = empty_datastore();
2911 let result = resolve_with("Hello {{name}}", &with, &ds).unwrap();
2912 assert_eq!(result, "Hello World");
2913 }
2914
2915 #[test]
2916 fn resolve_with_deep_alias() {
2917 let with = make_with(&[("data", json!({"items": [1, 2, 3]}))]);
2918 let ds = empty_datastore();
2919 let result = resolve_with("Items: {{data.items}}", &with, &ds).unwrap();
2920 assert_eq!(result, "Items: [1,2,3]");
2921 }
2922
2923 #[test]
2924 fn resolve_with_transform() {
2925 let with = make_with(&[("title", json!("hello world"))]);
2926 let ds = empty_datastore();
2927 let result = resolve_with("{{title | upper}}", &with, &ds).unwrap();
2928 assert_eq!(result, "HELLO WORLD");
2929 }
2930
2931 #[test]
2932 fn resolve_with_array_json_serialization() {
2933 let with = make_with(&[("items", json!(["a", "b", "c"]))]);
2934 let ds = empty_datastore();
2935 let result = resolve_with("{{items}}", &with, &ds).unwrap();
2936 assert_eq!(result, "[\"a\",\"b\",\"c\"]");
2937 }
2938
2939 #[test]
2940 fn resolve_with_null_is_empty() {
2941 let with = make_with(&[("val", Value::Null)]);
2942 let ds = empty_datastore();
2943 let result = resolve_with("Got: {{val}}!", &with, &ds).unwrap();
2944 assert_eq!(result, "Got: !");
2945 }
2946
2947 #[test]
2948 fn resolve_with_multiple_aliases() {
2949 let with = make_with(&[("a", json!("hello")), ("b", json!("world"))]);
2950 let ds = empty_datastore();
2951 let result = resolve_with("{{a}} and {{b}}", &with, &ds).unwrap();
2952 assert_eq!(result, "hello and world");
2953 }
2954
2955 #[test]
2956 fn resolve_with_missing_alias_errors() {
2957 let with = make_with(&[("name", json!("Alice"))]);
2958 let ds = empty_datastore();
2959 let result = resolve_with("{{missing}}", &with, &ds);
2960 assert!(result.is_err());
2961 }
2962
2963 #[test]
2964 fn resolve_with_number() {
2965 let with = make_with(&[("count", json!(42))]);
2966 let ds = empty_datastore();
2967 let result = resolve_with("Count: {{count}}", &with, &ds).unwrap();
2968 assert_eq!(result, "Count: 42");
2969 }
2970
2971 #[test]
2972 fn resolve_with_bool() {
2973 let with = make_with(&[("flag", json!(true))]);
2974 let ds = empty_datastore();
2975 let result = resolve_with("Flag: {{flag}}", &with, &ds).unwrap();
2976 assert_eq!(result, "Flag: true");
2977 }
2978
2979 #[test]
2982 fn resolve_with_context_file() {
2983 let with = FxHashMap::default();
2984 let ds = empty_datastore();
2985 let mut context = LoadedContext::new();
2986 context
2987 .files
2988 .insert("brand".to_string(), json!("SuperNovae AI"));
2989 ds.set_context(context);
2990
2991 let result = resolve_with("Brand: {{context.files.brand}}", &with, &ds).unwrap();
2992 assert_eq!(result, "Brand: SuperNovae AI");
2993 }
2994
2995 #[test]
2996 fn resolve_with_context_session() {
2997 let with = FxHashMap::default();
2998 let ds = empty_datastore();
2999 let mut context = LoadedContext::new();
3000 context.session = Some(json!({"focus": "rust"}));
3001 ds.set_context(context);
3002
3003 let result = resolve_with("Focus: {{context.session.focus}}", &with, &ds).unwrap();
3004 assert_eq!(result, "Focus: rust");
3005 }
3006
3007 #[test]
3008 fn resolve_with_inputs() {
3009 let with = FxHashMap::default();
3010 let ds = empty_datastore();
3011 let mut inputs = FxHashMap::default();
3012 inputs.insert("locale".to_string(), json!("fr-FR"));
3013 ds.set_inputs(inputs);
3014
3015 let result = resolve_with("Locale: {{inputs.locale}}", &with, &ds).unwrap();
3016 assert_eq!(result, "Locale: fr-FR");
3017 }
3018
3019 #[test]
3020 fn resolve_with_inputs_nested() {
3021 let with = FxHashMap::default();
3022 let ds = empty_datastore();
3023 let mut inputs = FxHashMap::default();
3024 inputs.insert("config".to_string(), json!({"theme": "dark"}));
3025 ds.set_inputs(inputs);
3026
3027 let result = resolve_with("Theme: {{inputs.config.theme}}", &with, &ds).unwrap();
3028 assert_eq!(result, "Theme: dark");
3029 }
3030
3031 #[test]
3034 fn no_reevaluation_alias_containing_template() {
3035 let with = make_with(&[("val", json!("{{context.files.secret}}"))]);
3037 let ds = empty_datastore();
3038 let mut context = LoadedContext::new();
3039 context
3040 .files
3041 .insert("secret".to_string(), json!("TOP_SECRET"));
3042 ds.set_context(context);
3043
3044 let result = resolve_with("Got: {{val}}", &with, &ds).unwrap();
3045 assert_eq!(result, "Got: {{context.files.secret}}");
3050 assert!(!result.contains("TOP_SECRET"));
3051 }
3052
3053 #[test]
3054 fn no_reevaluation_alias_to_alias() {
3055 let with = make_with(&[("a", json!("{{b}}")), ("b", json!("secret"))]);
3058 let ds = empty_datastore();
3059
3060 let result = resolve_with("Got: {{a}}", &with, &ds).unwrap();
3061 assert_eq!(result, "Got: {{b}}");
3064 }
3065
3066 #[test]
3069 fn resolve_with_shell_escape() {
3070 let with = make_with(&[("val", json!("hello 'world'"))]);
3071 let ds = empty_datastore();
3072 let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
3073 assert_eq!(result, "'hello '\\''world'\\'''");
3074 }
3075
3076 #[test]
3077 fn resolve_with_shell_plus_transform() {
3078 let with = make_with(&[("val", json!("Hello World"))]);
3079 let ds = empty_datastore();
3080 let result = resolve_with("{{val | lower | shell}}", &with, &ds).unwrap();
3081 assert_eq!(result, "'hello world'");
3082 }
3083
3084 #[test]
3087 fn resolve_with_empty_template() {
3088 let with = FxHashMap::default();
3089 let ds = empty_datastore();
3090 let result = resolve_with("", &with, &ds).unwrap();
3091 assert_eq!(result, "");
3092 }
3093
3094 #[test]
3095 fn resolve_with_no_templates() {
3096 let with = FxHashMap::default();
3097 let ds = empty_datastore();
3098 let result = resolve_with("plain text", &with, &ds).unwrap();
3099 assert_eq!(result, "plain text");
3100 assert!(matches!(result, Cow::Borrowed(_)));
3102 }
3103
3104 #[test]
3105 fn resolve_with_unclosed_braces() {
3106 let with = FxHashMap::default();
3107 let ds = empty_datastore();
3108 let result = resolve_with("{{incomplete", &with, &ds).unwrap();
3110 assert_eq!(result, "{{incomplete");
3111 }
3112
3113 #[test]
3114 fn resolve_with_bracket_notation() {
3115 let with = make_with(&[("items", json!(["a", "b", "c"]))]);
3116 let ds = empty_datastore();
3117 let result = resolve_with("{{items[1]}}", &with, &ds).unwrap();
3118 assert_eq!(result, "b");
3119 }
3120
3121 #[test]
3122 fn resolve_with_nested_path() {
3123 let with = make_with(&[(
3124 "user",
3125 json!({"name": "Alice", "address": {"city": "Paris"}}),
3126 )]);
3127 let ds = empty_datastore();
3128 let result = resolve_with("{{user.address.city}}", &with, &ds).unwrap();
3129 assert_eq!(result, "Paris");
3130 }
3131
3132 #[test]
3133 fn resolve_with_mixed_aliases_and_context() {
3134 let with = make_with(&[("name", json!("Alice"))]);
3135 let ds = empty_datastore();
3136 let mut context = LoadedContext::new();
3137 context
3138 .files
3139 .insert("brand".to_string(), json!("SuperNovae"));
3140 ds.set_context(context);
3141
3142 let result =
3143 resolve_with("Hello {{name}} from {{context.files.brand}}", &with, &ds).unwrap();
3144 assert_eq!(result, "Hello Alice from SuperNovae");
3145 }
3146
3147 #[test]
3150 fn resolve_with_rejects_excessive_template_vars() {
3151 let with = make_with(&[("x", json!("v"))]);
3152 let ds = empty_datastore();
3153 let template: String = (0..=MAX_TEMPLATE_VARS)
3155 .map(|_| "{{x}}")
3156 .collect::<Vec<_>>()
3157 .join(" ");
3158 let result = resolve_with(&template, &with, &ds);
3159 assert!(result.is_err());
3160 let err = result.unwrap_err();
3161 assert!(
3162 format!("{}", err).contains("exceeding the maximum"),
3163 "Expected max vars error, got: {}",
3164 err
3165 );
3166 }
3167
3168 #[test]
3169 fn resolve_with_accepts_many_vars_under_limit() {
3170 let with = make_with(&[("x", json!("v"))]);
3171 let ds = empty_datastore();
3172 let template: String = (0..MAX_TEMPLATE_VARS)
3174 .map(|_| "{{x}}")
3175 .collect::<Vec<_>>()
3176 .join(" ");
3177 let result = resolve_with(&template, &with, &ds);
3178 assert!(result.is_ok());
3179 }
3180
3181 #[test]
3182 fn resolve_alias_rejects_excessive_path_depth() {
3183 let segments: Vec<String> = (0..=MAX_PATH_DEPTH).map(|i| format!("k{}", i)).collect();
3186 let deep_path = segments.join(".");
3187
3188 let mut value: Value = json!("leaf");
3190 for key in segments.iter().rev().skip(1) {
3191 let mut map = serde_json::Map::new();
3192 map.insert(key.clone(), value);
3193 value = Value::Object(map);
3194 }
3195 let with = make_with(&[(segments[0].as_str(), value)]);
3196 let ds = empty_datastore();
3197 let template = format!("{{{{{}}}}}", deep_path);
3198 let result = resolve_with(&template, &with, &ds);
3199 assert!(result.is_err());
3200 let err = result.unwrap_err();
3201 assert!(
3202 format!("{}", err).contains("exceeds maximum"),
3203 "Expected path depth error, got: {}",
3204 err
3205 );
3206 }
3207
3208 #[test]
3211 fn extract_refs_simple() {
3212 let refs = extract_with_refs("Hello {{name}}!");
3213 assert_eq!(refs, vec!["name".to_string()]);
3214 }
3215
3216 #[test]
3217 fn extract_refs_deep_path() {
3218 let refs = extract_with_refs("{{data.items.0}}");
3219 assert_eq!(refs, vec!["data".to_string()]);
3220 }
3221
3222 #[test]
3223 fn extract_refs_with_transforms() {
3224 let refs = extract_with_refs("{{title | upper | trim}}");
3225 assert_eq!(refs, vec!["title".to_string()]);
3226 }
3227
3228 #[test]
3229 fn extract_refs_skips_context_and_inputs() {
3230 let refs = extract_with_refs("{{name}} and {{context.files.brand}} and {{inputs.locale}}");
3231 assert_eq!(refs, vec!["name".to_string()]);
3232 }
3233
3234 #[test]
3235 fn extract_refs_empty() {
3236 let refs = extract_with_refs("no templates here");
3237 assert!(refs.is_empty());
3238 }
3239
3240 #[test]
3241 fn extract_refs_multiple() {
3242 let refs = extract_with_refs("{{a}} then {{b.field}} then {{c}}");
3243 assert_eq!(
3244 refs,
3245 vec!["a".to_string(), "b".to_string(), "c".to_string()]
3246 );
3247 }
3248
3249 #[test]
3252 fn validate_refs_all_declared() {
3253 let declared: FxHashSet<String> = ["name", "title"].iter().map(|s| s.to_string()).collect();
3254 let result = validate_with_refs("{{name}} and {{title}}", &declared, "task1");
3255 assert!(result.is_ok());
3256 }
3257
3258 #[test]
3259 fn validate_refs_unknown_alias() {
3260 let declared: FxHashSet<String> = ["name"].iter().map(|s| s.to_string()).collect();
3261 let result = validate_with_refs("{{name}} and {{missing}}", &declared, "task1");
3262 assert!(result.is_err());
3263 }
3264
3265 #[test]
3266 fn validate_refs_context_not_checked() {
3267 let declared: FxHashSet<String> = FxHashSet::default();
3269 let result = validate_with_refs("{{context.files.brand}}", &declared, "task1");
3270 assert!(result.is_ok());
3271 }
3272
3273 #[test]
3274 fn validate_refs_inputs_not_checked() {
3275 let declared: FxHashSet<String> = FxHashSet::default();
3277 let result = validate_with_refs("{{inputs.locale}}", &declared, "task1");
3278 assert!(result.is_ok());
3279 }
3280
3281 #[test]
3291 fn audit_is_in_json_context_false_positive_unbalanced() {
3292 let mut bindings = ResolvedBindings::new();
3293 bindings.set("msg", json!("line1\nline2"));
3294 let ds = empty_datastore();
3295
3296 let template = r#"He said "hello {{with.msg}}"#;
3299 let result = resolve(template, &bindings, &ds).unwrap();
3300
3301 let has_escaped_newline = result.contains("\\n");
3304 let has_raw_newline = result.contains('\n');
3305
3306 if has_escaped_newline && !has_raw_newline {
3307 panic!(
3318 "GAP CONFIRMED: is_in_json_context false positive! \
3319 Non-JSON template '{}' has value JSON-escaped. \
3320 Result: '{}'",
3321 template, result
3322 );
3323 }
3324 assert!(
3326 has_raw_newline,
3327 "Newline should be preserved (not JSON-escaped): '{}'",
3328 result
3329 );
3330 }
3331
3332 #[test]
3334 fn audit_is_in_json_context_correct_for_real_json() {
3335 let mut bindings = ResolvedBindings::new();
3336 bindings.set("name", json!("line1\nline2"));
3337 let ds = empty_datastore();
3338
3339 let template = r#"{"user": "{{with.name}}"}"#;
3340 let result = resolve(template, &bindings, &ds).unwrap();
3341
3342 assert!(
3344 result.contains("\\n"),
3345 "Newline should be JSON-escaped in JSON context: '{}'",
3346 result
3347 );
3348 assert!(
3349 !result.contains('\n'),
3350 "Raw newline must not appear in JSON context: '{}'",
3351 result
3352 );
3353 }
3354
3355 #[test]
3357 fn audit_is_in_json_context_balanced_quotes_outside() {
3358 let mut bindings = ResolvedBindings::new();
3359 bindings.set("val", json!("test\nvalue"));
3360 let ds = empty_datastore();
3361
3362 let template = r#"He said "hi" then {{with.val}}"#;
3364 let result = resolve(template, &bindings, &ds).unwrap();
3365
3366 assert!(
3369 result.contains('\n'),
3370 "Outside JSON context, newline should be raw: '{}'",
3371 result
3372 );
3373 }
3374
3375 #[test]
3385 fn audit_resolve_for_shell_missing_inputs_support() {
3386 let bindings = ResolvedBindings::new();
3387 let store = RunContext::new();
3388
3389 let mut inputs = FxHashMap::default();
3390 inputs.insert("topic".to_string(), json!("AI safety"));
3391 store.set_inputs(inputs);
3392
3393 let result = resolve_for_shell("echo {{inputs.topic}}", &bindings, &store).unwrap();
3394
3395 if result.contains("{{inputs.topic}}") {
3399 panic!(
3401 "GAP CONFIRMED: resolve_for_shell does not resolve \
3402 inputs templates. Result: '{}'. Fix: add has_inputs \
3403 check alongside has_with and has_context.",
3404 result
3405 );
3406 }
3407 assert!(result.contains("AI safety"));
3409 }
3410
3411 #[test]
3417 fn audit_bracket_notation_negative_index() {
3418 let mut bindings = ResolvedBindings::new();
3419 bindings.set("items", json!(["a", "b", "c"]));
3420 let ds = empty_datastore();
3421
3422 let result = resolve("{{with.items[-1]}}", &bindings, &ds);
3425 assert!(
3426 result.is_err(),
3427 "Negative index should produce an error, got: {:?}",
3428 result
3429 );
3430 }
3431
3432 #[test]
3436 fn audit_bracket_notation_non_numeric() {
3437 let mut bindings = ResolvedBindings::new();
3438 bindings.set("data", json!({"key": "value"}));
3439 let ds = empty_datastore();
3440
3441 let result = resolve("{{with.data[key]}}", &bindings, &ds);
3444 assert!(
3445 result.is_err(),
3446 "Non-numeric bracket access should produce an error, got: {:?}",
3447 result
3448 );
3449 }
3450
3451 #[test]
3453 fn audit_bracket_notation_root_array() {
3454 let mut bindings = ResolvedBindings::new();
3455 bindings.set("list", json!(["first", "second", "third"]));
3456 let ds = empty_datastore();
3457
3458 let result = resolve("{{with.list[2]}}", &bindings, &ds).unwrap();
3459 assert_eq!(result, "third");
3460 }
3461
3462 #[test]
3464 fn audit_bracket_notation_nested_arrays() {
3465 let mut bindings = ResolvedBindings::new();
3466 bindings.set("matrix", json!([[1, 2], [3, 4]]));
3467 let ds = empty_datastore();
3468
3469 let result = resolve("{{with.matrix[1][0]}}", &bindings, &ds).unwrap();
3470 assert_eq!(result, "3");
3471 }
3472
3473 #[test]
3485 fn audit_shell_transform_vs_escape_for_shell_consistency() {
3486 let test_cases = vec![
3487 "simple",
3488 "hello world",
3489 "it's a test",
3490 "double\"quote",
3491 "",
3492 "special;chars|here",
3493 "$(whoami)",
3494 "`uname`",
3495 "$HOME/.ssh",
3496 "line1\nline2",
3497 "tab\there",
3498 ];
3499
3500 for input in test_cases {
3501 let method1 = escape_for_shell(input);
3503
3504 use crate::binding::transform::TransformOp;
3506 let json_val = json!(input);
3507 let method2_val = TransformOp::Shell.apply(&json_val).unwrap();
3508 let method2 = method2_val.as_str().unwrap().to_string();
3509
3510 assert_eq!(
3511 method1, method2,
3512 "Shell escaping methods differ for input '{}': \
3513 escape_for_shell='{}' vs TransformOp::Shell='{}'",
3514 input, method1, method2
3515 );
3516 }
3517 }
3518
3519 fn media_template_fixtures() -> (RunContext, ResolvedBindings) {
3543 use crate::binding::{BindingEntry, BindingSpec};
3544
3545 let store = RunContext::new();
3546
3547 let gen_media = vec![crate::media::MediaRef {
3549 hash: "blake3:abc123".to_string(),
3550 mime_type: "image/png".to_string(),
3551 size_bytes: 524288,
3552 path: std::path::PathBuf::from("/tmp/cas/ab/c123"),
3553 extension: "png".to_string(),
3554 created_by: "gen".to_string(),
3555 metadata: {
3556 let mut m = serde_json::Map::new();
3557 m.insert("width".to_string(), json!(1024));
3558 m.insert("height".to_string(), json!(768));
3559 m
3560 },
3561 }];
3562 store.insert(
3563 std::sync::Arc::from("gen"),
3564 crate::store::TaskResult::success(
3565 json!({"prompt": "a sunset photo"}),
3566 std::time::Duration::from_secs(3),
3567 )
3568 .with_media(gen_media),
3569 );
3570
3571 store.insert(
3573 std::sync::Arc::from("thumb"),
3574 crate::store::TaskResult::success_str(
3575 r#"{"hash":"blake3:def456","mime_type":"image/png","size_bytes":2048,"metadata":{"width":256,"height":192}}"#,
3576 std::time::Duration::from_millis(100),
3577 ),
3578 );
3579
3580 let mut spec = BindingSpec::default();
3582 spec.insert(
3584 "source_hash".to_string(),
3585 BindingEntry::new("gen.media[0].hash"),
3586 );
3587 spec.insert(
3588 "source_width".to_string(),
3589 BindingEntry::new("gen.media[0].metadata.width"),
3590 );
3591 spec.insert("thumb".to_string(), BindingEntry::new("thumb"));
3593 spec.insert("thumb_hash".to_string(), BindingEntry::new("thumb.hash"));
3594 spec.insert(
3595 "thumb_width".to_string(),
3596 BindingEntry::new("thumb.metadata.width"),
3597 );
3598
3599 let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
3600 (store, bindings)
3601 }
3602
3603 #[test]
3604 fn media_template_resolve_source_hash() {
3605 let (store, bindings) = media_template_fixtures();
3606
3607 let result = resolve("Source image hash: {{with.source_hash}}", &bindings, &store).unwrap();
3608
3609 assert_eq!(result.as_ref(), "Source image hash: blake3:abc123");
3610 }
3611
3612 #[test]
3613 fn media_template_resolve_source_width() {
3614 let (store, bindings) = media_template_fixtures();
3615
3616 let result = resolve("Original width: {{with.source_width}}px", &bindings, &store).unwrap();
3617
3618 assert_eq!(result.as_ref(), "Original width: 1024px");
3619 }
3620
3621 #[test]
3622 fn media_template_resolve_thumb_hash() {
3623 let (store, bindings) = media_template_fixtures();
3624
3625 let result = resolve("Thumbnail hash: {{with.thumb_hash}}", &bindings, &store).unwrap();
3626
3627 assert_eq!(result.as_ref(), "Thumbnail hash: blake3:def456");
3628 }
3629
3630 #[test]
3631 fn media_template_resolve_thumb_nested_width() {
3632 let (store, bindings) = media_template_fixtures();
3633
3634 let result = resolve("Thumb is {{with.thumb_width}}px wide", &bindings, &store).unwrap();
3635
3636 assert_eq!(result.as_ref(), "Thumb is 256px wide");
3637 }
3638
3639 #[test]
3640 fn media_template_thumb_output_traversal_auto_parses_json_string() {
3641 let (store, bindings) = media_template_fixtures();
3642
3643 let result = resolve(
3645 "Hash via binding spec: {{with.thumb_hash}}",
3646 &bindings,
3647 &store,
3648 )
3649 .unwrap();
3650 assert_eq!(result.as_ref(), "Hash via binding spec: blake3:def456");
3651
3652 let result = resolve("Direct: {{with.thumb.hash}}", &bindings, &store).unwrap();
3656 assert_eq!(result.as_ref(), "Direct: blake3:def456");
3657 }
3658
3659 #[test]
3660 fn media_template_thumb_deep_traversal_via_binding_spec() {
3661 let (store, bindings) = media_template_fixtures();
3662
3663 let result = resolve("Width: {{with.thumb_width}}", &bindings, &store).unwrap();
3665 assert_eq!(result.as_ref(), "Width: 256");
3666 }
3667
3668 #[test]
3669 fn media_template_thumb_output_as_parsed_json_object() {
3670 let store = RunContext::new();
3674 store.insert(
3675 std::sync::Arc::from("thumb2"),
3676 crate::store::TaskResult::success(
3677 json!({
3678 "hash": "blake3:parsed_obj",
3679 "metadata": { "width": 128 }
3680 }),
3681 std::time::Duration::from_millis(50),
3682 ),
3683 );
3684
3685 let mut bindings = ResolvedBindings::new();
3686 bindings.set(
3687 "thumb2",
3688 json!({"hash": "blake3:parsed_obj", "metadata": {"width": 128}}),
3689 );
3690
3691 let result = resolve(
3692 "Hash: {{with.thumb2.hash}}, Width: {{with.thumb2.metadata.width}}",
3693 &bindings,
3694 &store,
3695 )
3696 .unwrap();
3697
3698 assert_eq!(result.as_ref(), "Hash: blake3:parsed_obj, Width: 128");
3699 }
3700
3701 #[test]
3702 fn media_template_chained_bindings_in_one_template() {
3703 let (store, bindings) = media_template_fixtures();
3704
3705 let result = resolve(
3707 "Source: {{with.source_hash}}, Thumb: {{with.thumb_hash}}, Width: {{with.thumb_width}}",
3708 &bindings,
3709 &store,
3710 )
3711 .unwrap();
3712
3713 assert_eq!(
3714 result.as_ref(),
3715 "Source: blake3:abc123, Thumb: blake3:def456, Width: 256"
3716 );
3717 }
3718
3719 #[test]
3720 fn media_template_chained_bindings_in_prompt() {
3721 let (store, bindings) = media_template_fixtures();
3722
3723 let result = resolve(
3725 "The image ({{with.source_hash}}) was resized to {{with.thumb_width}}px. \
3726 The thumbnail hash is {{with.thumb_hash}}.",
3727 &bindings,
3728 &store,
3729 )
3730 .unwrap();
3731
3732 assert_eq!(
3733 result.as_ref(),
3734 "The image (blake3:abc123) was resized to 256px. \
3735 The thumbnail hash is blake3:def456."
3736 );
3737 }
3738
3739 #[test]
3740 fn media_template_no_templates_returns_borrowed() {
3741 let (store, bindings) = media_template_fixtures();
3742
3743 let result = resolve("plain text without templates", &bindings, &store).unwrap();
3745 assert!(
3746 matches!(result, std::borrow::Cow::Borrowed(_)),
3747 "No-template strings should be zero-alloc Cow::Borrowed"
3748 );
3749 }
3750
3751 #[test]
3752 fn media_template_json_context_escaping() {
3753 let (store, bindings) = media_template_fixtures();
3754
3755 let result = resolve(
3758 r#"{"source": "{{with.source_hash}}", "thumb": "{{with.thumb_hash}}"}"#,
3759 &bindings,
3760 &store,
3761 )
3762 .unwrap();
3763
3764 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3766 assert_eq!(parsed["source"], "blake3:abc123");
3767 assert_eq!(parsed["thumb"], "blake3:def456");
3768 }
3769
3770 #[test]
3782 fn regression_nika253_chart_to_dimensions_json_string_traversal() {
3783 let store = RunContext::new();
3787 let chart_output_json = serde_json::json!({
3788 "hash": "blake3:af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",
3789 "path": "/tmp/cas/af/1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",
3790 "size_bytes": 12345,
3791 "mime_type": "image/png",
3792 "extension": "png",
3793 "deduplicated": false,
3794 "metadata": { "chart_type": "bar", "width": 800, "height": 500 }
3795 });
3796 store.insert(
3798 std::sync::Arc::from("gen_chart"),
3799 crate::store::TaskResult::success_str(
3800 chart_output_json.to_string(),
3801 std::time::Duration::from_millis(100),
3802 ),
3803 );
3804
3805 let mut spec: crate::binding::BindingSpec = FxHashMap::default();
3807 spec.insert(
3808 "chart_result".to_string(),
3809 crate::binding::BindingEntry::new("gen_chart"),
3810 );
3811 let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
3812
3813 let result = resolve("hash: {{with.chart_result.hash}}", &bindings, &store).unwrap();
3820 assert_eq!(
3821 result.as_ref(),
3822 "hash: blake3:af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
3823 );
3824
3825 let result = resolve(
3827 "type: {{with.chart_result.metadata.chart_type}}",
3828 &bindings,
3829 &store,
3830 )
3831 .unwrap();
3832 assert_eq!(result.as_ref(), "type: bar");
3833
3834 let result = resolve(
3836 "width: {{with.chart_result.metadata.width}}",
3837 &bindings,
3838 &store,
3839 )
3840 .unwrap();
3841 assert_eq!(result.as_ref(), "width: 800");
3842 }
3843
3844 #[test]
3847 fn regression_nika253_resolve_with_json_string_traversal() {
3848 let ds = empty_datastore();
3849 let mut with_values = FxHashMap::default();
3850 with_values.insert(
3851 "chart_out".to_string(),
3852 Value::String(r#"{"hash":"blake3:abc123","size_bytes":9999}"#.to_string()),
3853 );
3854
3855 let result = resolve_with("{{chart_out.hash}}", &with_values, &ds).unwrap();
3856 assert_eq!(result.as_ref(), "blake3:abc123");
3857 }
3858
3859 #[test]
3862 fn regression_bug29_bracket_notation_preserves_literal_text() {
3863 let mut bindings = ResolvedBindings::new();
3864 bindings.set("items", json!(["first", "second", "third"]));
3865 let ds = empty_datastore();
3866
3867 let result = resolve("data[0] is {{with.items[0]}}", &bindings, &ds).unwrap();
3869 assert_eq!(
3870 result, "data[0] is first",
3871 "Literal 'data[0]' outside {{}} must NOT be normalized to 'data.0'"
3872 );
3873 }
3874
3875 #[test]
3877 fn regression_bug29_multiple_literal_brackets() {
3878 let with = make_with(&[("items", json!(["a", "b"]))]);
3879 let ds = empty_datastore();
3880
3881 let result = resolve_with(
3882 "arr[0] and arr[1] are {{items[0]}} and {{items[1]}}",
3883 &with,
3884 &ds,
3885 )
3886 .unwrap();
3887 assert_eq!(
3888 result, "arr[0] and arr[1] are a and b",
3889 "Multiple literal brackets must be preserved"
3890 );
3891 }
3892
3893 #[test]
3895 fn regression_bug29_normalize_unit_test() {
3896 assert_eq!(
3898 normalize_bracket_notation("data[0] is {{with.items[0]}}"),
3899 "data[0] is {{with.items.0}}"
3900 );
3901 assert_eq!(
3903 normalize_bracket_notation("array[5] is cool"),
3904 "array[5] is cool"
3905 );
3906 assert_eq!(
3908 normalize_bracket_notation("{{a[0]}} and {{b[1]}}"),
3909 "{{a.0}} and {{b.1}}"
3910 );
3911 }
3912
3913 #[test]
3917 fn regression_bug45_no_cross_pass_contamination() {
3918 let with = make_with(&[("user_input", json!("{{context.files.secret}}"))]);
3919 let ds = empty_datastore();
3920 let mut context = LoadedContext::new();
3921 context
3922 .files
3923 .insert("secret".to_string(), json!("TOP_SECRET_VALUE"));
3924 ds.set_context(context);
3925
3926 let result = resolve_with("Result: {{user_input}}", &with, &ds).unwrap();
3927 assert_eq!(result, "Result: {{context.files.secret}}");
3930 assert!(
3931 !result.contains("TOP_SECRET_VALUE"),
3932 "with: value containing {{context.files.x}} must NOT be evaluated"
3933 );
3934 }
3935
3936 #[test]
3938 fn regression_bug45_no_inputs_injection() {
3939 let with = make_with(&[("val", json!("{{inputs.locale}}"))]);
3940 let ds = empty_datastore();
3941 let mut inputs = FxHashMap::default();
3942 inputs.insert("locale".to_string(), json!("fr-FR"));
3943 ds.set_inputs(inputs);
3944
3945 let result = resolve_with("Got: {{val}}", &with, &ds).unwrap();
3946 assert_eq!(result, "Got: {{inputs.locale}}");
3947 assert!(
3948 !result.contains("fr-FR"),
3949 "with: value containing {{inputs.x}} must NOT be evaluated"
3950 );
3951 }
3952
3953 #[test]
3955 fn regression_bug45_legitimate_context_still_resolves() {
3956 let with = make_with(&[("name", json!("Alice"))]);
3957 let ds = empty_datastore();
3958 let mut context = LoadedContext::new();
3959 context
3960 .files
3961 .insert("brand".to_string(), json!("SuperNovae"));
3962 ds.set_context(context);
3963
3964 let result =
3966 resolve_with("Hello {{name}} from {{context.files.brand}}", &with, &ds).unwrap();
3967 assert_eq!(result, "Hello Alice from SuperNovae");
3968 }
3969
3970 #[test]
3973 fn regression_bug47_shell_not_double_applied() {
3974 let with = make_with(&[("val", json!("hello world"))]);
3975 let ds = empty_datastore();
3976 let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
3977 assert_eq!(result, "'hello world'");
3979 }
3981
3982 #[test]
3984 fn regression_bug47_shell_with_chain_not_double_applied() {
3985 let with = make_with(&[("val", json!("Hello World"))]);
3986 let ds = empty_datastore();
3987 let result = resolve_with("{{val | lower | shell}}", &with, &ds).unwrap();
3988 assert_eq!(result, "'hello world'");
3990 }
3991
3992 #[test]
3994 fn regression_bug47_shell_with_quotes() {
3995 let with = make_with(&[("val", json!("it's a test"))]);
3996 let ds = empty_datastore();
3997 let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
3998 assert_eq!(result, "'it'\\''s a test'");
4000 }
4001}