fix: improve template extension handling for {{input}} and add examples

This commit is contained in:
Nick Skriloff
2025-10-20 19:49:33 -04:00
parent 7d80fd6d1d
commit 29c24c8387
10 changed files with 638 additions and 163 deletions

2
go.sum
View File

@@ -27,8 +27,6 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/anthropics/anthropic-sdk-go v1.12.0 h1:xPqlGnq7rWrTiHazIvCiumA0u7mGQnwDQtvA1M82h9U=
github.com/anthropics/anthropic-sdk-go v1.12.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/anthropics/anthropic-sdk-go v1.13.0 h1:Bhbe8sRoDPtipttg8bQYrMCKe2b79+q6rFW1vOKEUKI=
github.com/anthropics/anthropic-sdk-go v1.13.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=

View File

@@ -99,9 +99,9 @@ func (o *PatternsEntity) applyVariables(
withSentinel := strings.ReplaceAll(pattern.Pattern, "{{input}}", inputSentinel)
// Process all other template variables in the pattern
// At this point, our sentinel ensures {{input}} won't be affected
// Pass the actual input so extension calls can use {{input}} within their value parameter
var processed string
if processed, err = template.ApplyTemplate(withSentinel, variables, ""); err != nil {
if processed, err = template.ApplyTemplate(withSentinel, variables, input); err != nil {
return
}

View File

@@ -175,6 +175,26 @@ what does this say about me?
./fabric -p ./plugins/template/Examples/test_pattern.md
```
## Passing {{input}} to extensions inside patterns
```
Create a pattern called ai_summarize that uses extensions (see openai.yaml and copy for claude)
Summarize the responses from both AI models:
OpenAI Response:
{{ext:openai:chat:{{input}}}}
Claude Response:
{{ext:claude:chat:{{input}}}}
```
```bash
echo "What is Artificial Intelligence" | ../fabric-fix -p ai_summarize
```
## Security Considerations
1. **Hash Verification**

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(jq -R -s '.' <<< "$*")
RESPONSE=$(curl "$OPENAI_API_BASE_URL/chat/completions" \
-s -w "\n%{http_code}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":$INPUT}]}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [[ "$HTTP_CODE" -ne 200 ]]; then
echo "Error: HTTP $HTTP_CODE" >&2
echo "$BODY" | jq -r '.error.message // "Unknown error"' >&2
exit 1
fi
echo "$BODY" | jq -r '.choices[0].message.content'

View File

@@ -0,0 +1,14 @@
name: openai
executable: "/Users/ourdecisions/.config/fabric/extensions/bin/openai-chat.sh"
type: executable
timeout: "30s"
description: "Call OpenAI Chat Completions API"
version: "1.0.0"
operations:
chat:
cmd_template: "{{executable}} {{value}}"
config:
output:
method: stdout

View File

@@ -140,6 +140,11 @@ func (r *ExtensionRegistry) Register(configPath string) error {
return fmt.Errorf("failed to hash executable: %w", err)
}
// Validate full extension definition (ensures operations and cmd_template present)
if err := r.validateExtensionDefinition(&ext); err != nil {
return fmt.Errorf("invalid extension definition: %w", err)
}
// Store entry
r.registry.Extensions[ext.Name] = &RegistryEntry{
ConfigPath: absPath,

View File

@@ -10,6 +10,10 @@ import (
debuglog "github.com/danielmiessler/fabric/internal/log"
)
// inputSentinel is used to temporarily replace {{input}} during template processing
// to prevent recursive variable resolution
const inputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"
var (
textPlugin = &TextPlugin{}
datetimePlugin = &DateTimePlugin{}
@@ -37,152 +41,65 @@ func debugf(format string, a ...interface{}) {
debuglog.Debug(debuglog.Trace, format, a...)
}
func ApplyTemplate(content string, variables map[string]string, input string) (string, error) {
var missingVars []string
r := regexp.MustCompile(`\{\{([^{}]+)\}\}`)
debugf("Starting template processing\n")
for strings.Contains(content, "{{") {
matches := r.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
break
}
replaced := false
for _, match := range matches {
fullMatch := match[0]
varName := match[1]
// Check if this is a plugin call
if strings.HasPrefix(varName, "plugin:") {
pluginMatches := pluginPattern.FindStringSubmatch(fullMatch)
if len(pluginMatches) >= 3 {
namespace := pluginMatches[1]
operation := pluginMatches[2]
value := ""
if len(pluginMatches) == 4 {
value = pluginMatches[3]
}
debugf("\nPlugin call:\n")
debugf(" Namespace: %s\n", namespace)
debugf(" Operation: %s\n", operation)
debugf(" Value: %s\n", value)
var result string
var err error
switch namespace {
case "text":
debugf("Executing text plugin\n")
result, err = textPlugin.Apply(operation, value)
case "datetime":
debugf("Executing datetime plugin\n")
result, err = datetimePlugin.Apply(operation, value)
case "file":
debugf("Executing file plugin\n")
result, err = filePlugin.Apply(operation, value)
debugf("File plugin result: %#v\n", result)
case "fetch":
debugf("Executing fetch plugin\n")
result, err = fetchPlugin.Apply(operation, value)
case "sys":
debugf("Executing sys plugin\n")
result, err = sysPlugin.Apply(operation, value)
default:
return "", fmt.Errorf("unknown plugin namespace: %s", namespace)
}
if err != nil {
debugf("Plugin error: %v\n", err)
return "", fmt.Errorf("plugin %s error: %v", namespace, err)
}
debugf("Plugin result: %s\n", result)
content = strings.ReplaceAll(content, fullMatch, result)
debugf("Content after replacement: %s\n", content)
continue
}
}
if pluginMatches := extensionPattern.FindStringSubmatch(fullMatch); len(pluginMatches) >= 3 {
name := pluginMatches[1]
operation := pluginMatches[2]
value := ""
if len(pluginMatches) == 4 {
value = pluginMatches[3]
}
debugf("\nExtension call:\n")
debugf(" Name: %s\n", name)
debugf(" Operation: %s\n", operation)
debugf(" Value: %s\n", value)
result, err := extensionManager.ProcessExtension(name, operation, value)
if err != nil {
return "", fmt.Errorf("extension %s error: %v", name, err)
}
content = strings.ReplaceAll(content, fullMatch, result)
replaced = true
continue
}
// Handle regular variables and input
debugf("Processing variable: %s\n", varName)
if varName == "input" {
debugf("Replacing {{input}}\n")
replaced = true
content = strings.ReplaceAll(content, fullMatch, input)
} else {
if val, ok := variables[varName]; !ok {
debugf("Missing variable: %s\n", varName)
missingVars = append(missingVars, varName)
return "", fmt.Errorf("missing required variable: %s", varName)
} else {
debugf("Replacing variable %s with value: %s\n", varName, val)
content = strings.ReplaceAll(content, fullMatch, val)
replaced = true
}
}
if !replaced {
return "", fmt.Errorf("template processing stuck - potential infinite loop")
}
// matchTriple extracts the first two required and optional third value from a token
// pattern of the form {{type:part1:part2(:part3)?}} returning part1, part2, part3 (possibly empty)
func matchTriple(r *regexp.Regexp, full string) (string, string, string, bool) {
parts := r.FindStringSubmatch(full)
if len(parts) >= 3 {
v := ""
if len(parts) == 4 {
v = parts[3]
}
return parts[1], parts[2], v, true
}
return "", "", "", false
}
debugf("Starting template processing\n")
for strings.Contains(content, "{{") {
matches := r.FindAllStringSubmatch(content, -1)
func ApplyTemplate(content string, variables map[string]string, input string) (string, error) {
tokenPattern := regexp.MustCompile(`\{\{([^{}]+)\}\}`)
debugf("Starting template processing with input='%s'\n", input)
for {
if !strings.Contains(content, "{{") {
break
}
matches := tokenPattern.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
break
}
replaced := false
for _, match := range matches {
fullMatch := match[0]
varName := match[1]
progress := false
for _, m := range matches {
full := m[0]
raw := m[1]
// Check if this is a plugin call
if strings.HasPrefix(varName, "plugin:") {
pluginMatches := pluginPattern.FindStringSubmatch(fullMatch)
if len(pluginMatches) >= 3 {
namespace := pluginMatches[1]
operation := pluginMatches[2]
value := ""
if len(pluginMatches) == 4 {
value = pluginMatches[3]
// Extension call
if strings.HasPrefix(raw, "ext:") {
if name, operation, value, ok := matchTriple(extensionPattern, full); ok {
if strings.Contains(value, inputSentinel) {
value = strings.ReplaceAll(value, inputSentinel, input)
debugf("Replaced sentinel in extension value with input\n")
}
debugf("Extension call: name=%s operation=%s value=%s\n", name, operation, value)
result, err := extensionManager.ProcessExtension(name, operation, value)
if err != nil {
return "", fmt.Errorf("extension %s error: %v", name, err)
}
content = strings.ReplaceAll(content, full, result)
progress = true
continue
}
}
debugf("\nPlugin call:\n")
debugf(" Namespace: %s\n", namespace)
debugf(" Operation: %s\n", operation)
debugf(" Value: %s\n", value)
var result string
var err error
// Plugin call
if strings.HasPrefix(raw, "plugin:") {
if namespace, operation, value, ok := matchTriple(pluginPattern, full); ok {
debugf("Plugin call: namespace=%s operation=%s value=%s\n", namespace, operation, value)
var (
result string
err error
)
switch namespace {
case "text":
debugf("Executing text plugin\n")
@@ -203,39 +120,33 @@ func ApplyTemplate(content string, variables map[string]string, input string) (s
default:
return "", fmt.Errorf("unknown plugin namespace: %s", namespace)
}
if err != nil {
debugf("Plugin error: %v\n", err)
return "", fmt.Errorf("plugin %s error: %v", namespace, err)
}
debugf("Plugin result: %s\n", result)
content = strings.ReplaceAll(content, fullMatch, result)
debugf("Content after replacement: %s\n", content)
content = strings.ReplaceAll(content, full, result)
progress = true
continue
}
}
// Handle regular variables and input
debugf("Processing variable: %s\n", varName)
if varName == "input" {
debugf("Replacing {{input}}\n")
replaced = true
content = strings.ReplaceAll(content, fullMatch, input)
} else {
if val, ok := variables[varName]; !ok {
debugf("Missing variable: %s\n", varName)
missingVars = append(missingVars, varName)
return "", fmt.Errorf("missing required variable: %s", varName)
} else {
debugf("Replacing variable %s with value: %s\n", varName, val)
content = strings.ReplaceAll(content, fullMatch, val)
replaced = true
// Variables / input / sentinel
switch raw {
case "input", inputSentinel:
content = strings.ReplaceAll(content, full, input)
progress = true
default:
val, ok := variables[raw]
if !ok {
return "", fmt.Errorf("missing required variable: %s", raw)
}
content = strings.ReplaceAll(content, full, val)
progress = true
}
if !replaced {
return "", fmt.Errorf("template processing stuck - potential infinite loop")
}
}
if !progress {
return "", fmt.Errorf("template processing stuck - potential infinite loop")
}
}

View File

@@ -0,0 +1,77 @@
package template
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestExtensionValueMixedInputAndVariable ensures an extension value mixing {{input}} and another template variable is processed.
func TestExtensionValueMixedInputAndVariable(t *testing.T) {
input := "PRIMARY"
variables := map[string]string{
"suffix": "SUF",
}
// Build temp extension environment
tmp := t.TempDir()
configDir := filepath.Join(tmp, ".config", "fabric")
extsDir := filepath.Join(configDir, "extensions")
binDir := filepath.Join(extsDir, "bin")
configsDir := filepath.Join(extsDir, "configs")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("mkdir configs: %v", err)
}
scriptPath := filepath.Join(binDir, "mix-echo.sh")
// Simple echo script; avoid percent formatting complexities
script := "#!/bin/sh\necho VAL=$1\n"
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
configYAML := "" +
"name: mix-echo\n" +
"type: executable\n" +
"executable: " + scriptPath + "\n" +
"description: mixed input/variable test\n" +
"version: 1.0.0\n" +
"timeout: 5s\n" +
"operations:\n" +
" echo:\n" +
" cmd_template: '{{executable}} {{value}}'\n"
if err := os.WriteFile(filepath.Join(configsDir, "mix-echo.yaml"), []byte(configYAML), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
// Use a fresh extension manager isolated from global one
mgr := NewExtensionManager(configDir)
if err := mgr.RegisterExtension(filepath.Join(configsDir, "mix-echo.yaml")); err != nil {
// Some environments may not support execution; skip instead of fail hard
if strings.Contains(err.Error(), "operation not permitted") {
t.Skipf("skipping due to exec restriction: %v", err)
}
t.Fatalf("register: %v", err)
}
// Temporarily swap global extensionManager for this test
prevMgr := extensionManager
extensionManager = mgr
defer func() { extensionManager = prevMgr }()
// Template uses input plus a variable inside extension value
tmpl := "{{ext:mix-echo:echo:pre-{{input}}-mid-{{suffix}}-post}}"
out, err := ApplyTemplate(tmpl, variables, input)
if err != nil {
t.Fatalf("ApplyTemplate error: %v", err)
}
if !strings.Contains(out, "VAL=pre-PRIMARY-mid-SUF-post") {
t.Fatalf("unexpected output: %q", out)
}
}

View File

@@ -0,0 +1,71 @@
package template
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestMultipleExtensionsWithInput ensures multiple extension calls each using {{input}} get proper substitution.
func TestMultipleExtensionsWithInput(t *testing.T) {
input := "DATA"
variables := map[string]string{}
tmp := t.TempDir()
configDir := filepath.Join(tmp, ".config", "fabric")
extsDir := filepath.Join(configDir, "extensions")
binDir := filepath.Join(extsDir, "bin")
configsDir := filepath.Join(extsDir, "configs")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
if err := os.MkdirAll(configsDir, 0o755); err != nil {
t.Fatalf("mkdir configs: %v", err)
}
scriptPath := filepath.Join(binDir, "multi-echo.sh")
script := "#!/bin/sh\necho ECHO=$1\n"
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
configYAML := "" +
"name: multi-echo\n" +
"type: executable\n" +
"executable: " + scriptPath + "\n" +
"description: multi echo extension\n" +
"version: 1.0.0\n" +
"timeout: 5s\n" +
"operations:\n" +
" echo:\n" +
" cmd_template: '{{executable}} {{value}}'\n"
if err := os.WriteFile(filepath.Join(configsDir, "multi-echo.yaml"), []byte(configYAML), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
mgr := NewExtensionManager(configDir)
if err := mgr.RegisterExtension(filepath.Join(configsDir, "multi-echo.yaml")); err != nil {
t.Fatalf("register: %v", err)
}
prev := extensionManager
extensionManager = mgr
defer func() { extensionManager = prev }()
tmpl := strings.Join([]string{
"First: {{ext:multi-echo:echo:{{input}}}}",
"Second: {{ext:multi-echo:echo:{{input}}}}",
"Third: {{ext:multi-echo:echo:{{input}}}}",
}, " | ")
out, err := ApplyTemplate(tmpl, variables, input)
if err != nil {
t.Fatalf("ApplyTemplate error: %v", err)
}
wantCount := 3
occ := strings.Count(out, "ECHO=DATA")
if occ != wantCount {
t.Fatalf("expected %d occurrences of ECHO=DATA, got %d; output=%q", wantCount, occ, out)
}
}

View File

@@ -0,0 +1,359 @@
package template
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestSentinelTokenReplacement tests the fix for the {{input}} sentinel token bug
// This test verifies that when {{input}} is used inside an extension call,
// the actual input is passed to the extension, not the sentinel token.
func TestSentinelTokenReplacement(t *testing.T) {
// Create a temporary directory for test extension
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".config", "fabric")
extensionsDir := filepath.Join(configDir, "extensions")
binDir := filepath.Join(extensionsDir, "bin")
configsDir := filepath.Join(extensionsDir, "configs")
err := os.MkdirAll(binDir, 0755)
if err != nil {
t.Fatalf("Failed to create bin directory: %v", err)
}
err = os.MkdirAll(configsDir, 0755)
if err != nil {
t.Fatalf("Failed to create configs directory: %v", err)
}
// Create a test script that echoes what it receives
scriptPath := filepath.Join(binDir, "echo-test.sh")
scriptContent := `#!/bin/bash
echo "RECEIVED: $*"
`
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
if err != nil {
t.Fatalf("Failed to create test script: %v", err)
}
// Create extension config
configPath := filepath.Join(configsDir, "echo-test.yaml")
configContent := `name: echo-test
executable: ` + scriptPath + `
type: executable
timeout: "5s"
description: "Echo test extension"
version: "1.0.0"
operations:
echo:
cmd_template: "{{executable}} {{value}}"
config:
output:
method: stdout
`
err = os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to create extension config: %v", err)
}
// Initialize extension manager with test config directory
oldManager := extensionManager
defer func() { extensionManager = oldManager }()
extensionManager = NewExtensionManager(configDir)
// Register the test extension
err = extensionManager.RegisterExtension(configPath)
if err != nil {
t.Fatalf("Failed to register extension: %v", err)
}
tests := []struct {
name string
template string
input string
wantContain string
wantNotContain string
}{
{
name: "sentinel token with {{input}} in extension value",
template: "{{ext:echo-test:echo:__FABRIC_INPUT_SENTINEL_TOKEN__}}",
input: "test input data",
wantContain: "RECEIVED: test input data",
wantNotContain: "__FABRIC_INPUT_SENTINEL_TOKEN__",
},
{
name: "direct input variable replacement",
template: "{{ext:echo-test:echo:{{input}}}}",
input: "Hello World",
wantContain: "RECEIVED: Hello World",
wantNotContain: "{{input}}",
},
{
name: "sentinel with complex input",
template: "Result: {{ext:echo-test:echo:__FABRIC_INPUT_SENTINEL_TOKEN__}}",
input: "What is AI?",
wantContain: "RECEIVED: What is AI?",
wantNotContain: "__FABRIC_INPUT_SENTINEL_TOKEN__",
},
{
name: "multiple words in input",
template: "{{ext:echo-test:echo:{{input}}}}",
input: "Multiple word input string",
wantContain: "RECEIVED: Multiple word input string",
wantNotContain: "{{input}}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ApplyTemplate(tt.template, map[string]string{}, tt.input)
if err != nil {
t.Errorf("ApplyTemplate() error = %v", err)
return
}
// Check that result contains expected string
if !strings.Contains(got, tt.wantContain) {
t.Errorf("ApplyTemplate() = %q, should contain %q", got, tt.wantContain)
}
// Check that result does NOT contain unwanted string
if strings.Contains(got, tt.wantNotContain) {
t.Errorf("ApplyTemplate() = %q, should NOT contain %q", got, tt.wantNotContain)
}
})
}
}
// TestSentinelInVariableProcessing tests that the sentinel token is handled
// correctly in regular variable processing (not just extensions)
// Note: The sentinel is only replaced when it appears in extension values,
// not when used as a standalone variable (which would be a user error)
func TestSentinelInVariableProcessing(t *testing.T) {
tests := []struct {
name string
template string
vars map[string]string
input string
want string
}{
{
name: "input variable works normally",
template: "Value: {{input}}",
input: "actual input",
want: "Value: actual input",
},
{
name: "multiple input references",
template: "First: {{input}}, Second: {{input}}",
input: "test",
want: "First: test, Second: test",
},
{
name: "input with variables",
template: "Var: {{name}}, Input: {{input}}",
vars: map[string]string{"name": "TestVar"},
input: "input value",
want: "Var: TestVar, Input: input value",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ApplyTemplate(tt.template, tt.vars, tt.input)
if err != nil {
t.Errorf("ApplyTemplate() error = %v", err)
return
}
if got != tt.want {
t.Errorf("ApplyTemplate() = %q, want %q", got, tt.want)
}
})
}
}
// TestExtensionValueWithSentinel specifically tests the extension value
// sentinel replacement logic
func TestExtensionValueWithSentinel(t *testing.T) {
// Create a temporary directory for test extension
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".config", "fabric")
extensionsDir := filepath.Join(configDir, "extensions")
binDir := filepath.Join(extensionsDir, "bin")
configsDir := filepath.Join(extensionsDir, "configs")
err := os.MkdirAll(binDir, 0755)
if err != nil {
t.Fatalf("Failed to create bin directory: %v", err)
}
err = os.MkdirAll(configsDir, 0755)
if err != nil {
t.Fatalf("Failed to create configs directory: %v", err)
}
// Create a test script that outputs the exact arguments it receives
scriptPath := filepath.Join(binDir, "arg-test.sh")
scriptContent := `#!/bin/bash
# Output each argument on a separate line
for arg in "$@"; do
echo "ARG: $arg"
done
`
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
if err != nil {
t.Fatalf("Failed to create test script: %v", err)
}
// Create extension config
configPath := filepath.Join(configsDir, "arg-test.yaml")
configContent := `name: arg-test
executable: ` + scriptPath + `
type: executable
timeout: "5s"
description: "Argument test extension"
version: "1.0.0"
operations:
test:
cmd_template: "{{executable}} {{value}}"
config:
output:
method: stdout
`
err = os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to create extension config: %v", err)
}
// Initialize extension manager with test config directory
oldManager := extensionManager
defer func() { extensionManager = oldManager }()
extensionManager = NewExtensionManager(configDir)
// Register the test extension
err = extensionManager.RegisterExtension(configPath)
if err != nil {
t.Fatalf("Failed to register extension: %v", err)
}
// Test that sentinel token in extension value gets replaced
template := "{{ext:arg-test:test:prefix-__FABRIC_INPUT_SENTINEL_TOKEN__-suffix}}"
input := "MYINPUT"
got, err := ApplyTemplate(template, map[string]string{}, input)
if err != nil {
t.Fatalf("ApplyTemplate() error = %v", err)
}
// The sentinel should be replaced with actual input
expectedContain := "ARG: prefix-MYINPUT-suffix"
if !strings.Contains(got, expectedContain) {
t.Errorf("ApplyTemplate() = %q, should contain %q", got, expectedContain)
}
// The sentinel token should NOT appear in output
if strings.Contains(got, "__FABRIC_INPUT_SENTINEL_TOKEN__") {
t.Errorf("ApplyTemplate() = %q, should NOT contain sentinel token", got)
}
}
// TestNestedInputInExtension tests the original bug case:
// {{ext:name:op:{{input}}}} should pass the actual input, not the sentinel
func TestNestedInputInExtension(t *testing.T) {
// Create a temporary directory for test extension
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".config", "fabric")
extensionsDir := filepath.Join(configDir, "extensions")
binDir := filepath.Join(extensionsDir, "bin")
configsDir := filepath.Join(extensionsDir, "configs")
err := os.MkdirAll(binDir, 0755)
if err != nil {
t.Fatalf("Failed to create bin directory: %v", err)
}
err = os.MkdirAll(configsDir, 0755)
if err != nil {
t.Fatalf("Failed to create configs directory: %v", err)
}
// Create a test script
scriptPath := filepath.Join(binDir, "nested-test.sh")
scriptContent := `#!/bin/bash
echo "NESTED_TEST: $*"
`
err = os.WriteFile(scriptPath, []byte(scriptContent), 0755)
if err != nil {
t.Fatalf("Failed to create test script: %v", err)
}
// Create extension config
configPath := filepath.Join(configsDir, "nested-test.yaml")
configContent := `name: nested-test
executable: ` + scriptPath + `
type: executable
timeout: "5s"
description: "Nested input test extension"
version: "1.0.0"
operations:
process:
cmd_template: "{{executable}} {{value}}"
config:
output:
method: stdout
`
err = os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to create extension config: %v", err)
}
// Initialize extension manager with test config directory
oldManager := extensionManager
defer func() { extensionManager = oldManager }()
extensionManager = NewExtensionManager(configDir)
// Register the test extension
err = extensionManager.RegisterExtension(configPath)
if err != nil {
t.Fatalf("Failed to register extension: %v", err)
}
// This is the bug case: {{input}} nested inside extension call
// The template processing should:
// 1. Replace {{input}} with sentinel during variable protection
// 2. Process the extension, replacing sentinel with actual input
// 3. Execute extension with actual input, not sentinel
template := "{{ext:nested-test:process:{{input}}}}"
input := "What is Artificial Intelligence"
got, err := ApplyTemplate(template, map[string]string{}, input)
if err != nil {
t.Fatalf("ApplyTemplate() error = %v", err)
}
// Verify the actual input was passed, not the sentinel
expectedContain := "NESTED_TEST: What is Artificial Intelligence"
if !strings.Contains(got, expectedContain) {
t.Errorf("ApplyTemplate() = %q, should contain %q", got, expectedContain)
}
// Verify sentinel token does NOT appear
if strings.Contains(got, "__FABRIC_INPUT_SENTINEL_TOKEN__") {
t.Errorf("ApplyTemplate() output contains sentinel token (BUG NOT FIXED): %q", got)
}
// Verify {{input}} template tag does NOT appear
if strings.Contains(got, "{{input}}") {
t.Errorf("ApplyTemplate() output contains unresolved {{input}}: %q", got)
}
}