Merge pull request #1802 from nickarino/input-extension-bug-fix

fix: improve template extension handling for {{input}} and add examples
This commit is contained in:
Kayvan Sylvan
2025-11-11 17:21:13 -08:00
committed by GitHub
13 changed files with 614 additions and 179 deletions

View File

@@ -11,8 +11,6 @@ import (
"github.com/danielmiessler/fabric/internal/util"
)
const inputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"
type PatternsEntity struct {
*StorageEntity
SystemPatternFile string
@@ -96,18 +94,18 @@ func (o *PatternsEntity) applyVariables(
// Temporarily replace {{input}} with a sentinel token to protect it
// from recursive variable resolution
withSentinel := strings.ReplaceAll(pattern.Pattern, "{{input}}", inputSentinel)
withSentinel := strings.ReplaceAll(pattern.Pattern, "{{input}}", template.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
}
// Finally, replace our sentinel with the actual user input
// The input has already been processed for variables if InputHasVars was true
pattern.Pattern = strings.ReplaceAll(processed, inputSentinel, input)
pattern.Pattern = strings.ReplaceAll(processed, template.InputSentinel, input)
return
}

View File

@@ -1,9 +1,24 @@
# Fabric Extensions: Complete Guide
## Important: Extensions Only Work in Patterns
**Extensions are ONLY processed when used within pattern files, not via direct piping to fabric.**
```bash
# ❌ This DOES NOT WORK - extensions are not processed in stdin
echo "{{ext:word-generator:generate:3}}" | fabric
# ✅ This WORKS - extensions are processed within patterns
fabric -p my-pattern-with-extensions.md
```
When you pipe directly to fabric without a pattern, the input goes straight to the LLM without template processing. Extensions are only evaluated during pattern template processing via `ApplyTemplate()`.
## Understanding Extension Architecture
### Registry Structure
The extension registry is stored at `~/.config/fabric/extensions/extensions.yaml` and tracks registered extensions:
```yaml
@@ -17,6 +32,7 @@ extensions:
The registry maintains security through hash verification of both configs and executables.
### Extension Configuration
Each extension requires a YAML configuration file with the following structure:
```yaml
@@ -42,8 +58,10 @@ config: # Output configuration
```
### Directory Structure
Recommended organization:
```
```text
~/.config/fabric/extensions/
├── bin/ # Extension executables
├── configs/ # Extension YAML configs
@@ -51,9 +69,11 @@ Recommended organization:
```
## Example 1: Python Wrapper (Word Generator)
A simple example wrapping a Python script.
### 1. Position Files
```bash
# Create directories
mkdir -p ~/.config/fabric/extensions/{bin,configs}
@@ -64,7 +84,9 @@ chmod +x ~/.config/fabric/extensions/bin/word-generator.py
```
### 2. Configure
Create `~/.config/fabric/extensions/configs/word-generator.yaml`:
```yaml
name: word-generator
executable: "~/.config/fabric/extensions/bin/word-generator.py"
@@ -83,22 +105,26 @@ config:
```
### 3. Register & Run
```bash
# Register
fabric --addextension ~/.config/fabric/extensions/configs/word-generator.yaml
# Run (generate 3 random words)
echo "{{ext:word-generator:generate:3}}" | fabric
# Extensions must be used within patterns (see "Extensions in patterns" section below)
# Direct piping to fabric will NOT process extension syntax
```
## Example 2: Direct Executable (SQLite3)
Using a system executable directly.
copy the memories to your home directory
~/memories.db
### 1. Configure
Create `~/.config/fabric/extensions/configs/memory-query.yaml`:
```yaml
name: memory-query
executable: "/usr/bin/sqlite3"
@@ -123,19 +149,19 @@ config:
```
### 2. Register & Run
```bash
# Register
fabric --addextension ~/.config/fabric/extensions/configs/memory-query.yaml
# Run queries
echo "{{ext:memory-query:all}}" | fabric
echo "{{ext:memory-query:byid:3}}" | fabric
# Extensions must be used within patterns (see "Extensions in patterns" section below)
# Direct piping to fabric will NOT process extension syntax
```
## Extension Management Commands
### Add Extension
```bash
fabric --addextension ~/.config/fabric/extensions/configs/memory-query.yaml
```
@@ -143,25 +169,29 @@ fabric --addextension ~/.config/fabric/extensions/configs/memory-query.yaml
Note : if the executable or config file changes, you must re-add the extension.
This will recompute the hash for the extension.
### List Extensions
```bash
fabric --listextensions
```
Shows all registered extensions with their status and configuration details.
### Remove Extension
```bash
fabric --rmextension <extension-name>
```
Removes an extension from the registry.
Removes an extension from the registry.
## Extensions in patterns
```
Create a pattern that use multiple extensions.
**IMPORTANT**: Extensions are ONLY processed when used within pattern files, not via direct piping to fabric.
Create a pattern file (e.g., `test_pattern.md`):
```markdown
These are my favorite
{{ext:word-generator:generate:3}}
@@ -171,8 +201,30 @@ These are my least favorite
what does this say about me?
```
Run the pattern:
```bash
./fabric -p ./plugins/template/Examples/test_pattern.md
fabric -p ./internal/plugins/template/Examples/test_pattern.md
```
## Passing {{input}} to extensions inside patterns
```text
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
@@ -197,6 +249,7 @@ what does this say about me?
## Troubleshooting
### Common Issues
1. **Registration Failures**
- Verify file permissions
- Check executable paths
@@ -214,10 +267,10 @@ what does this say about me?
- Monitor disk space for file operations
### Debug Tips
1. Enable verbose logging when available
2. Check system logs for execution errors
3. Verify extension dependencies
4. Test extensions with minimal configurations first
Would you like me to expand on any particular section or add more examples?
Would you like me to expand on any particular section or add more examples?

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: "/path/to/your/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

@@ -0,0 +1,5 @@
package template
// InputSentinel is used to temporarily replace {{input}} during template processing
// to prevent recursive variable resolution
const InputSentinel = "__FABRIC_INPUT_SENTINEL_TOKEN__"

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

@@ -37,152 +37,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 +116,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,275 @@
package template
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// withTestExtension creates a temporary test extension and runs the test function
func withTestExtension(t *testing.T, name string, scriptContent string, testFunc func(*ExtensionManager, string)) {
t.Helper()
// 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, name+".sh")
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, name+".yaml")
configContent := fmt.Sprintf(`name: %s
executable: %s
type: executable
timeout: "5s"
description: "Test extension"
version: "1.0.0"
operations:
echo:
cmd_template: "{{executable}} {{value}}"
config:
output:
method: stdout
`, name, scriptPath)
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
mgr := NewExtensionManager(configDir)
// Register the test extension
err = mgr.RegisterExtension(configPath)
if err != nil {
t.Fatalf("Failed to register extension: %v", err)
}
// Run the test
testFunc(mgr, name)
}
// 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) {
scriptContent := `#!/bin/bash
echo "RECEIVED: $@"
`
withTestExtension(t, "echo-test", scriptContent, func(mgr *ExtensionManager, name string) {
// Save and restore global extension manager
oldManager := extensionManager
defer func() { extensionManager = oldManager }()
extensionManager = mgr
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) {
scriptContent := `#!/bin/bash
# Output each argument on a separate line
for arg in "$@"; do
echo "ARG: $arg"
done
`
withTestExtension(t, "arg-test", scriptContent, func(mgr *ExtensionManager, name string) {
// Save and restore global extension manager
oldManager := extensionManager
defer func() { extensionManager = oldManager }()
extensionManager = mgr
// Test that sentinel token in extension value gets replaced
template := "{{ext:arg-test:echo: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) {
scriptContent := `#!/bin/bash
echo "NESTED_TEST: $*"
`
withTestExtension(t, "nested-test", scriptContent, func(mgr *ExtensionManager, name string) {
// Save and restore global extension manager
oldManager := extensionManager
defer func() { extensionManager = oldManager }()
extensionManager = mgr
// 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:echo:{{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)
}
})
}