Merge pull request #1155 from mattjoyce/curly-brace-templates

Curly brace templates and plugins
This commit is contained in:
Eugen Eisler
2024-11-21 22:56:37 +01:00
committed by GitHub
23 changed files with 2268 additions and 12 deletions

View File

@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/danielmiessler/fabric/plugins/template"
)
type PatternsEntity struct {
@@ -21,7 +23,7 @@ type Pattern struct {
}
// main entry point for getting patterns from any source
func (o *PatternsEntity) GetApplyVariables(source string, variables map[string]string) (*Pattern, error) {
func (o *PatternsEntity) GetApplyVariables(source string, variables map[string]string, input string) (*Pattern, error) {
var pattern *Pattern
var err error
@@ -41,17 +43,29 @@ func (o *PatternsEntity) GetApplyVariables(source string, variables map[string]s
return nil, err
}
return o.applyVariables(pattern, variables), nil
pattern, err = o.applyVariables(pattern, variables, input)
if err != nil {
return nil, err // Return the error if applyVariables failed
}
return pattern, nil
}
// handles all variable substitution
func (o *PatternsEntity) applyVariables(pattern *Pattern, variables map[string]string) *Pattern {
if variables != nil && len(variables) > 0 {
for variableName, value := range variables {
pattern.Pattern = strings.ReplaceAll(pattern.Pattern, variableName, value)
func (o *PatternsEntity) applyVariables(pattern *Pattern, variables map[string]string, input string) (*Pattern, error) {
// If {{input}} isn't in pattern, append it on new line
if !strings.Contains(pattern.Pattern, "{{input}}") {
if !strings.HasSuffix(pattern.Pattern, "\n") {
pattern.Pattern += "\n"
}
pattern.Pattern += "{{input}}"
}
return pattern
result, err := template.ApplyTemplate(pattern.Pattern, variables, input)
if err != nil {
return nil, err
}
pattern.Pattern = result
return pattern, nil
}
// retrieves a pattern from the database by name
@@ -113,5 +127,5 @@ func (o *PatternsEntity) getFromFile(pathStr string) (*Pattern, error) {
// Get required for Storage interface
func (o *PatternsEntity) Get(name string) (*Pattern, error) {
// Use GetPattern with no variables
return o.GetApplyVariables(name, nil)
return o.GetApplyVariables(name, nil, "")
}

View File

@@ -1 +1,146 @@
package fsdb
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestPatternsEntity(t *testing.T) (*PatternsEntity, func()) {
// Create a temporary directory for test patterns
tmpDir, err := os.MkdirTemp("", "test-patterns-*")
require.NoError(t, err)
entity := &PatternsEntity{
StorageEntity: &StorageEntity{
Dir: tmpDir,
Label: "patterns",
ItemIsDir: true,
},
SystemPatternFile: "system.md",
}
// Return cleanup function
cleanup := func() {
os.RemoveAll(tmpDir)
}
return entity, cleanup
}
// Helper to create a test pattern file
func createTestPattern(t *testing.T, entity *PatternsEntity, name, content string) {
patternDir := filepath.Join(entity.Dir, name)
err := os.MkdirAll(patternDir, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(patternDir, entity.SystemPatternFile), []byte(content), 0644)
require.NoError(t, err)
}
func TestApplyVariables(t *testing.T) {
entity := &PatternsEntity{}
tests := []struct {
name string
pattern *Pattern
variables map[string]string
input string
want string
wantErr bool
}{
{
name: "pattern with explicit input placement",
pattern: &Pattern{
Pattern: "You are a {{role}}.\n{{input}}\nPlease analyze.",
},
variables: map[string]string{
"role": "security expert",
},
input: "Check this code",
want: "You are a security expert.\nCheck this code\nPlease analyze.",
},
{
name: "pattern without input variable gets input appended",
pattern: &Pattern{
Pattern: "You are a {{role}}.\nPlease analyze.",
},
variables: map[string]string{
"role": "code reviewer",
},
input: "Review this PR",
want: "You are a code reviewer.\nPlease analyze.\nReview this PR",
},
// ... previous test cases ...
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := entity.applyVariables(tt.pattern, tt.variables, tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.want, result.Pattern)
})
}
}
func TestGetApplyVariables(t *testing.T) {
entity, cleanup := setupTestPatternsEntity(t)
defer cleanup()
// Create a test pattern
createTestPattern(t, entity, "test-pattern", "You are a {{role}}.\n{{input}}")
tests := []struct {
name string
source string
variables map[string]string
input string
want string
wantErr bool
}{
{
name: "basic pattern with variables and input",
source: "test-pattern",
variables: map[string]string{
"role": "reviewer",
},
input: "check this code",
want: "You are a reviewer.\ncheck this code",
},
{
name: "pattern with missing variable",
source: "test-pattern",
variables: map[string]string{},
input: "test input",
wantErr: true,
},
{
name: "non-existent pattern",
source: "non-existent",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := entity.GetApplyVariables(tt.source, tt.variables, tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, result.Pattern)
})
}
}

418
plugins/template/README.md Normal file
View File

@@ -0,0 +1,418 @@
# Fabric Template System
## Quick Start
echo "Hello {{name}}!" | fabric -v=name:World
## Overview
The Fabric Template System provides a powerful and extensible way to handle variable substitution and dynamic content generation through a plugin architecture. It uses a double-brace syntax (`{{}}`) for variables and plugin operations, making it both readable and flexible.
## Basic Usage
### Variable Substitution
The template system supports basic variable substitution using double braces:
```markdown
Hello {{name}}!
Current role: {{role}}
```
Variables can be provided via:
- Command line arguments: `-v=name:John -v=role:admin`
- YAML front matter in input files
- Environment variables (when configured)
### Special Variables
- `{{input}}`: Represents the main input content
```markdown
Here is the analysis:
{{input}}
End of analysis.
```
## Nested Tokens and Resolution
### Basic Nesting
The template system supports nested tokens, where inner tokens are resolved before outer ones. This enables complex, dynamic template generation.
#### Simple Variable Nesting
```markdown
{{outer{{inner}}}}
Example:
Variables: {
"inner": "name",
"john": "John Doe"
}
{{{{inner}}}} -> {{name}} -> John Doe
```
#### Nested Plugin Calls
```markdown
{{plugin:text:upper:{{plugin:sys:env:USER}}}}
First resolves: {{plugin:sys:env:USER}} -> "john"
Then resolves: {{plugin:text:upper:john}} -> "JOHN"
```
### How Nested Resolution Works
1. **Iterative Processing**
- The engine processes the template in multiple passes
- Each pass identifies all `{{...}}` patterns
- Processing continues until no more replacements are needed
2. **Resolution Order**
```markdown
Original: {{plugin:text:upper:{{user}}}}
Step 1: Found {{user}} -> "john"
Step 2: Now have {{plugin:text:upper:john}}
Step 3: Final result -> "JOHN"
```
3. **Complex Nesting Example**
```markdown
{{plugin:text:{{case}}:{{plugin:sys:env:{{varname}}}}}}
With variables:
{
"case": "upper",
"varname": "USER"
}
Resolution steps:
1. {{varname}} -> "USER"
2. {{plugin:sys:env:USER}} -> "john"
3. {{case}} -> "upper"
4. {{plugin:text:upper:john}} -> "JOHN"
```
### Important Considerations
1. **Depth Limitations**
- While nesting is supported, avoid excessive nesting for clarity
- Complex nested structures can be hard to debug
- Consider breaking very complex templates into smaller parts
2. **Variable Resolution**
- Inner variables must resolve to valid values for outer operations
- Error messages will point to the innermost failed resolution
- Debug logs show the step-by-step resolution process
3. **Plugin Nesting**
```markdown
# Valid:
{{plugin:text:upper:{{plugin:sys:env:USER}}}}
# Also Valid:
{{plugin:text:{{operation}}:{{value}}}}
# Invalid (plugin namespace cannot be dynamic):
{{plugin:{{namespace}}:operation:value}}
```
4. **Debugging Nested Templates**
```go
Debug = true // Enable debug logging
Template: {{plugin:text:upper:{{user}}}}
Debug output:
> Processing variable: user
> Replacing {{user}} with john
> Plugin call:
> Namespace: text
> Operation: upper
> Value: john
> Plugin result: JOHN
```
### Examples
1. **Dynamic Operation Selection**
```markdown
{{plugin:text:{{operation}}:hello}}
With variables:
{
"operation": "upper"
}
Result: HELLO
```
2. **Dynamic Environment Variable Lookup**
```markdown
{{plugin:sys:env:{{env_var}}}}
With variables:
{
"env_var": "HOME"
}
Result: /home/user
```
3. **Nested Date Formatting**
```markdown
{{plugin:datetime:{{format}}:{{plugin:datetime:now}}}}
With variables:
{
"format": "full"
}
Result: Wednesday, November 20, 2024
```
## Plugin System
### Plugin Syntax
Plugins use the following syntax:
```
{{plugin:namespace:operation:value}}
```
- `namespace`: The plugin category (e.g., text, datetime, sys)
- `operation`: The specific operation to perform
- `value`: Optional value for the operation
### Built-in Plugins
#### Text Plugin
Text manipulation operations:
```markdown
{{plugin:text:upper:hello}} -> HELLO
{{plugin:text:lower:HELLO}} -> hello
{{plugin:text:title:hello world}} -> Hello World
```
#### DateTime Plugin
Time and date operations:
```markdown
{{plugin:datetime:now}} -> 2024-11-20T15:04:05Z
{{plugin:datetime:today}} -> 2024-11-20
{{plugin:datetime:rel:-1d}} -> 2024-11-19
{{plugin:datetime:month}} -> November
```
#### System Plugin
System information:
```markdown
{{plugin:sys:hostname}} -> server1
{{plugin:sys:user}} -> currentuser
{{plugin:sys:os}} -> linux
{{plugin:sys:env:HOME}} -> /home/user
```
## Developing Plugins
### Plugin Interface
To create a new plugin, implement the following interface:
```go
type Plugin interface {
Apply(operation string, value string) (string, error)
}
```
### Example Plugin Implementation
Here's a simple plugin that performs basic math operations:
```go
package template
type MathPlugin struct{}
func (p *MathPlugin) Apply(operation string, value string) (string, error) {
switch operation {
case "add":
// Parse value as "a,b" and return a+b
nums := strings.Split(value, ",")
if len(nums) != 2 {
return "", fmt.Errorf("add requires two numbers")
}
a, err := strconv.Atoi(nums[0])
if err != nil {
return "", err
}
b, err := strconv.Atoi(nums[1])
if err != nil {
return "", err
}
return fmt.Sprintf("%d", a+b), nil
default:
return "", fmt.Errorf("unknown math operation: %s", operation)
}
}
```
### Registering a New Plugin
1. Add your plugin struct to the template package
2. Register it in template.go:
```go
var (
// Existing plugins
textPlugin = &TextPlugin{}
datetimePlugin = &DateTimePlugin{}
// Add your new plugin
mathPlugin = &MathPlugin{}
)
// Update the plugin handler in ApplyTemplate
switch namespace {
case "text":
result, err = textPlugin.Apply(operation, value)
case "datetime":
result, err = datetimePlugin.Apply(operation, value)
// Add your namespace
case "math":
result, err = mathPlugin.Apply(operation, value)
default:
return "", fmt.Errorf("unknown plugin namespace: %s", namespace)
}
```
### Plugin Development Guidelines
1. **Error Handling**
- Return clear error messages
- Validate all inputs
- Handle edge cases gracefully
2. **Debugging**
- Use the `debugf` function for logging
- Log entry and exit points
- Log intermediate calculations
```go
func (p *MyPlugin) Apply(operation string, value string) (string, error) {
debugf("MyPlugin operation: %s value: %s\n", operation, value)
// ... plugin logic ...
debugf("MyPlugin result: %s\n", result)
return result, nil
}
```
3. **Security Considerations**
- Validate and sanitize inputs
- Avoid shell execution
- Be careful with file operations
- Limit resource usage
4. **Performance**
- Cache expensive computations
- Minimize allocations
- Consider concurrent access
### Testing Plugins
Create tests for your plugin in `plugin_test.go`:
```go
func TestMathPlugin(t *testing.T) {
plugin := &MathPlugin{}
tests := []struct {
operation string
value string
expected string
wantErr bool
}{
{"add", "5,3", "8", false},
{"add", "bad,input", "", true},
{"unknown", "value", "", true},
}
for _, tt := range tests {
result, err := plugin.Apply(tt.operation, tt.value)
if (err != nil) != tt.wantErr {
t.Errorf("MathPlugin.Apply(%s, %s) error = %v, wantErr %v",
tt.operation, tt.value, err, tt.wantErr)
continue
}
if result != tt.expected {
t.Errorf("MathPlugin.Apply(%s, %s) = %v, want %v",
tt.operation, tt.value, result, tt.expected)
}
}
}
```
## Best Practices
1. **Namespace Selection**
- Choose clear, descriptive names
- Avoid conflicts with existing plugins
- Group related operations together
2. **Operation Names**
- Use lowercase names
- Keep names concise but clear
- Be consistent with similar operations
3. **Value Format**
- Document expected formats
- Use common separators consistently
- Provide examples in comments
4. **Error Messages**
- Be specific about what went wrong
- Include valid operation examples
- Help users fix the problem
## Common Issues and Solutions
1. **Missing Variables**
```
Error: missing required variables: [name]
Solution: Provide all required variables using -v=name:value
```
2. **Invalid Plugin Operations**
```
Error: unknown operation 'invalid' for plugin 'text'
Solution: Check plugin documentation for supported operations
```
3. **Plugin Value Format**
```
Error: invalid format for datetime:rel, expected -1d, -2w, etc.
Solution: Follow the required format for plugin values
```
## Contributing
1. Fork the repository
2. Create your plugin branch
3. Implement your plugin following the guidelines
4. Add comprehensive tests
5. Submit a pull request
## Support
For issues and questions:
1. Check the debugging output (enable with Debug=true)
2. Review the plugin documentation
3. Open an issue with:
- Template content
- Variables used
- Expected vs actual output
- Debug logs

View File

@@ -0,0 +1,144 @@
// Package template provides datetime operations for the template system
package template
import (
"fmt"
"strconv"
"time"
)
// DateTimePlugin handles time and date operations
type DateTimePlugin struct{}
// Apply executes datetime operations with the following formats:
// Time: now (RFC3339), time (HH:MM:SS), unix (timestamp)
// Hour: startofhour, endofhour
// Date: today (YYYY-MM-DD), full (Monday, January 2, 2006)
// Period: startofweek, endofweek, startofmonth, endofmonth
// Relative: rel:-1h, rel:-2d, rel:1w, rel:3m, rel:1y
func (p *DateTimePlugin) Apply(operation string, value string) (string, error) {
debugf("DateTime: operation=%q value=%q", operation, value)
now := time.Now()
debugf("DateTime: reference time=%v", now)
switch operation {
// Time operations
case "now":
result := now.Format(time.RFC3339)
debugf("DateTime: now=%q", result)
return result, nil
case "time":
result := now.Format("15:04:05")
debugf("DateTime: time=%q", result)
return result, nil
case "unix":
result := fmt.Sprintf("%d", now.Unix())
debugf("DateTime: unix=%q", result)
return result, nil
case "startofhour":
result := now.Truncate(time.Hour).Format(time.RFC3339)
debugf("DateTime: startofhour=%q", result)
return result, nil
case "endofhour":
result := now.Truncate(time.Hour).Add(time.Hour - time.Second).Format(time.RFC3339)
debugf("DateTime: endofhour=%q", result)
return result, nil
// Date operations
case "today":
result := now.Format("2006-01-02")
debugf("DateTime: today=%q", result)
return result, nil
case "full":
result := now.Format("Monday, January 2, 2006")
debugf("DateTime: full=%q", result)
return result, nil
case "month":
result := now.Format("January")
debugf("DateTime: month=%q", result)
return result, nil
case "year":
result := now.Format("2006")
debugf("DateTime: year=%q", result)
return result, nil
case "startofweek":
result := now.AddDate(0, 0, -int(now.Weekday())).Format("2006-01-02")
debugf("DateTime: startofweek=%q", result)
return result, nil
case "endofweek":
result := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
debugf("DateTime: endofweek=%q", result)
return result, nil
case "startofmonth":
result := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
debugf("DateTime: startofmonth=%q", result)
return result, nil
case "endofmonth":
result := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
debugf("DateTime: endofmonth=%q", result)
return result, nil
case "rel":
return p.handleRelative(now, value)
default:
return "", fmt.Errorf("datetime: unknown operation %q (see plugin documentation for supported operations)", operation)
}
}
func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, error) {
debugf("DateTime: handling relative time value=%q", value)
if value == "" {
return "", fmt.Errorf("datetime: relative time requires a value (e.g., -1h, -1d, -1w)")
}
// Try standard duration first (hours, minutes)
if duration, err := time.ParseDuration(value); err == nil {
result := now.Add(duration).Format(time.RFC3339)
debugf("DateTime: relative duration=%q result=%q", duration, result)
return result, nil
}
// Handle date units
if len(value) < 2 {
return "", fmt.Errorf("datetime: invalid relative format (use: -1h, 2d, -3w, 1m, -1y)")
}
unit := value[len(value)-1:]
numStr := value[:len(value)-1]
num, err := strconv.Atoi(numStr)
if err != nil {
return "", fmt.Errorf("datetime: invalid number in relative time: %q", value)
}
var result string
switch unit {
case "d":
result = now.AddDate(0, 0, num).Format("2006-01-02")
case "w":
result = now.AddDate(0, 0, num*7).Format("2006-01-02")
case "m":
result = now.AddDate(0, num, 0).Format("2006-01-02")
case "y":
result = now.AddDate(num, 0, 0).Format("2006-01-02")
default:
return "", fmt.Errorf("datetime: invalid unit %q (use: h,m for time or d,w,m,y for date)", unit)
}
debugf("DateTime: relative unit=%q num=%d result=%q", unit, num, result)
return result, nil
}

View File

@@ -0,0 +1,41 @@
# DateTime Plugin Tests
Simple test file for validating datetime plugin functionality.
## Basic Time Operations
```
Current Time: {{plugin:datetime:now}}
Time Only: {{plugin:datetime:time}}
Unix Timestamp: {{plugin:datetime:unix}}
Hour Start: {{plugin:datetime:startofhour}}
Hour End: {{plugin:datetime:endofhour}}
```
## Date Operations
```
Today: {{plugin:datetime:today}}
Full Date: {{plugin:datetime:full}}
Current Month: {{plugin:datetime:month}}
Current Year: {{plugin:datetime:year}}
```
## Period Operations
```
Week Start: {{plugin:datetime:startofweek}}
Week End: {{plugin:datetime:endofweek}}
Month Start: {{plugin:datetime:startofmonth}}
Month End: {{plugin:datetime:endofmonth}}
```
## Relative Time/Date
```
2 Hours Ahead: {{plugin:datetime:rel:2h}}
1 Day Ago: {{plugin:datetime:rel:-1d}}
Next Week: {{plugin:datetime:rel:1w}}
Last Month: {{plugin:datetime:rel:-1m}}
Next Year: {{plugin:datetime:rel:1y}}
```

View File

@@ -0,0 +1,138 @@
package template
import (
"fmt"
"strconv"
"strings"
"testing"
"time"
)
func TestDateTimePlugin(t *testing.T) {
plugin := &DateTimePlugin{}
now := time.Now()
tests := []struct {
name string
operation string
value string
validate func(string) error
wantErr bool
}{
{
name: "now returns RFC3339",
operation: "now",
validate: func(got string) error {
if _, err := time.Parse(time.RFC3339, got); err != nil {
return err
}
return nil
},
},
{
name: "time returns HH:MM:SS",
operation: "time",
validate: func(got string) error {
if _, err := time.Parse("15:04:05", got); err != nil {
return err
}
return nil
},
},
{
name: "unix returns timestamp",
operation: "unix",
validate: func(got string) error {
if _, err := strconv.ParseInt(got, 10, 64); err != nil {
return err
}
return nil
},
},
{
name: "today returns YYYY-MM-DD",
operation: "today",
validate: func(got string) error {
if _, err := time.Parse("2006-01-02", got); err != nil {
return err
}
return nil
},
},
{
name: "full returns long date",
operation: "full",
validate: func(got string) error {
if !strings.Contains(got, now.Month().String()) {
return fmt.Errorf("full date missing month name")
}
return nil
},
},
{
name: "relative positive hours",
operation: "rel",
value: "2h",
validate: func(got string) error {
t, err := time.Parse(time.RFC3339, got)
if err != nil {
return err
}
expected := now.Add(2 * time.Hour)
if t.Hour() != expected.Hour() {
return fmt.Errorf("expected hour %d, got %d", expected.Hour(), t.Hour())
}
return nil
},
},
{
name: "relative negative days",
operation: "rel",
value: "-2d",
validate: func(got string) error {
t, err := time.Parse("2006-01-02", got)
if err != nil {
return err
}
expected := now.AddDate(0, 0, -2)
if t.Day() != expected.Day() {
return fmt.Errorf("expected day %d, got %d", expected.Day(), t.Day())
}
return nil
},
},
// Error cases
{
name: "invalid operation",
operation: "invalid",
wantErr: true,
},
{
name: "empty relative value",
operation: "rel",
value: "",
wantErr: true,
},
{
name: "invalid relative format",
operation: "rel",
value: "2x",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := plugin.Apply(tt.operation, tt.value)
if (err != nil) != tt.wantErr {
t.Errorf("DateTimePlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil && tt.validate != nil {
if err := tt.validate(got); err != nil {
t.Errorf("DateTimePlugin.Apply() validation failed: %v", err)
}
}
})
}
}

BIN
plugins/template/fabric Normal file

Binary file not shown.

134
plugins/template/fetch.go Normal file
View File

@@ -0,0 +1,134 @@
// Package template provides URL fetching operations for the template system.
// Security Note: This plugin makes outbound HTTP requests. Use with caution
// and consider implementing URL allowlists in production.
package template
import (
"bytes"
"fmt"
"io"
"mime"
"net/http"
"strings"
"unicode/utf8"
)
const (
// MaxContentSize limits response size to 1MB to prevent memory issues
MaxContentSize = 1024 * 1024
// UserAgent identifies the client in HTTP requests
UserAgent = "Fabric-Fetch/1.0"
)
// FetchPlugin provides HTTP fetching capabilities with safety constraints:
// - Only text content types allowed
// - Size limited to MaxContentSize
// - UTF-8 validation
// - Null byte checking
type FetchPlugin struct{}
// Apply executes fetch operations:
// - get:URL: Fetches content from URL, returns text content
func (p *FetchPlugin) Apply(operation string, value string) (string, error) {
debugf("Fetch: operation=%q value=%q", operation, value)
switch operation {
case "get":
return p.fetch(value)
default:
return "", fmt.Errorf("fetch: unknown operation %q (supported: get)", operation)
}
}
// isTextContent checks if the content type is text-based
func (p *FetchPlugin) isTextContent(contentType string) bool {
debugf("Fetch: checking content type %q", contentType)
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
debugf("Fetch: error parsing media type: %v", err)
return false
}
isText := strings.HasPrefix(mediaType, "text/") ||
mediaType == "application/json" ||
mediaType == "application/xml" ||
mediaType == "application/yaml" ||
mediaType == "application/x-yaml" ||
strings.HasSuffix(mediaType, "+json") ||
strings.HasSuffix(mediaType, "+xml") ||
strings.HasSuffix(mediaType, "+yaml")
debugf("Fetch: content type %q is text: %v", mediaType, isText)
return isText
}
// validateTextContent ensures content is valid UTF-8 without null bytes
func (p *FetchPlugin) validateTextContent(content []byte) error {
debugf("Fetch: validating content length=%d bytes", len(content))
if !utf8.Valid(content) {
return fmt.Errorf("fetch: content is not valid UTF-8 text")
}
if bytes.Contains(content, []byte{0}) {
return fmt.Errorf("fetch: content contains null bytes")
}
debugf("Fetch: content validation successful")
return nil
}
// fetch retrieves content from a URL with safety checks
func (p *FetchPlugin) fetch(urlStr string) (string, error) {
debugf("Fetch: requesting URL %q", urlStr)
client := &http.Client{}
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("fetch: error creating request: %v", err)
}
req.Header.Set("User-Agent", UserAgent)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetch: error fetching URL: %v", err)
}
defer resp.Body.Close()
debugf("Fetch: got response status=%q", resp.Status)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("fetch: HTTP error: %d - %s", resp.StatusCode, resp.Status)
}
if contentLength := resp.ContentLength; contentLength > MaxContentSize {
return "", fmt.Errorf("fetch: content too large: %d bytes (max %d bytes)",
contentLength, MaxContentSize)
}
contentType := resp.Header.Get("Content-Type")
debugf("Fetch: content-type=%q", contentType)
if !p.isTextContent(contentType) {
return "", fmt.Errorf("fetch: unsupported content type %q - only text content allowed",
contentType)
}
debugf("Fetch: reading response body")
limitReader := io.LimitReader(resp.Body, MaxContentSize+1)
content, err := io.ReadAll(limitReader)
if err != nil {
return "", fmt.Errorf("fetch: error reading response: %v", err)
}
if len(content) > MaxContentSize {
return "", fmt.Errorf("fetch: content too large: exceeds %d bytes", MaxContentSize)
}
if err := p.validateTextContent(content); err != nil {
return "", err
}
debugf("Fetch: operation completed successfully, read %d bytes", len(content))
return string(content), nil
}

39
plugins/template/fetch.md Normal file
View File

@@ -0,0 +1,39 @@
# Fetch Plugin Tests
Simple test file for validating fetch plugin functionality.
## Basic Fetch Operations
```
Raw Content:
{{plugin:fetch:get:https://raw.githubusercontent.com/user/repo/main/README.md}}
JSON API:
{{plugin:fetch:get:https://api.example.com/data.json}}
```
## Error Cases
These should produce appropriate error messages:
```
Invalid Operation:
{{plugin:fetch:invalid:https://example.com}}
Invalid URL:
{{plugin:fetch:get:not-a-url}}
Non-text Content:
{{plugin:fetch:get:https://example.com/image.jpg}}
Server Error:
{{plugin:fetch:get:https://httpstat.us/500}}
```
## Security Considerations
- Only use trusted URLs
- Be aware of rate limits
- Content is limited to 1MB
- Only text content types are allowed
- Consider URL allow listing in production
- Validate and sanitize fetched content before use

View File

@@ -0,0 +1,71 @@
package template
import (
"net/http/httptest"
"strings"
"testing"
)
func TestFetchPlugin(t *testing.T) {
plugin := &FetchPlugin{}
tests := []struct {
name string
operation string
value string
server func() *httptest.Server
wantErr bool
errContains string
}{
// ... keep existing valid test cases ...
{
name: "invalid URL",
operation: "get",
value: "not-a-url",
wantErr: true,
errContains: "unsupported protocol", // Updated to match actual error
},
{
name: "malformed URL",
operation: "get",
value: "http://[::1]:namedport",
wantErr: true,
errContains: "error creating request",
},
// ... keep other test cases ...
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var url string
if tt.server != nil {
server := tt.server()
defer server.Close()
url = server.URL
} else {
url = tt.value
}
got, err := plugin.Apply(tt.operation, url)
// Check error cases
if (err != nil) != tt.wantErr {
t.Errorf("FetchPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errContains != "" {
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
t.Logf("Full error: %v", err) // Added for better debugging
}
return
}
// For successful cases, verify we got some content
if err == nil && got == "" {
t.Error("FetchPlugin.Apply() returned empty content on success")
}
})
}
}

197
plugins/template/file.go Normal file
View File

@@ -0,0 +1,197 @@
// Package template provides file system operations for the template system.
// Security Note: This plugin provides access to the local filesystem.
// Consider carefully which paths to allow access to in production.
package template
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// MaxFileSize defines the maximum file size that can be read (1MB)
const MaxFileSize = 1 * 1024 * 1024
// FilePlugin provides filesystem operations with safety constraints:
// - No directory traversal
// - Size limits
// - Path sanitization
type FilePlugin struct{}
// safePath validates and normalizes file paths
func (p *FilePlugin) safePath(path string) (string, error) {
debugf("File: validating path %q", path)
// Basic security check - no path traversal
if strings.Contains(path, "..") {
return "", fmt.Errorf("file: path cannot contain '..'")
}
// Expand home directory if needed
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("file: could not expand home directory: %v", err)
}
path = filepath.Join(home, path[2:])
}
// Clean the path
cleaned := filepath.Clean(path)
debugf("File: cleaned path %q", cleaned)
return cleaned, nil
}
// Apply executes file operations:
// - read:PATH - Read entire file content
// - tail:PATH|N - Read last N lines
// - exists:PATH - Check if file exists
// - size:PATH - Get file size in bytes
// - modified:PATH - Get last modified time
func (p *FilePlugin) Apply(operation string, value string) (string, error) {
debugf("File: operation=%q value=%q", operation, value)
switch operation {
case "tail":
parts := strings.Split(value, "|")
if len(parts) != 2 {
return "", fmt.Errorf("file: tail requires format path|lines")
}
path, err := p.safePath(parts[0])
if err != nil {
return "", err
}
n, err := strconv.Atoi(parts[1])
if err != nil {
return "", fmt.Errorf("file: invalid line count %q", parts[1])
}
if n < 1 {
return "", fmt.Errorf("file: line count must be positive")
}
lines, err := p.lastNLines(path, n)
if err != nil {
return "", err
}
result := strings.Join(lines, "\n")
debugf("File: tail returning %d lines", len(lines))
return result, nil
case "read":
path, err := p.safePath(value)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("file: could not stat file: %v", err)
}
if info.Size() > MaxFileSize {
return "", fmt.Errorf("file: size %d exceeds limit of %d bytes",
info.Size(), MaxFileSize)
}
content, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("file: could not read: %v", err)
}
debugf("File: read %d bytes", len(content))
return string(content), nil
case "exists":
path, err := p.safePath(value)
if err != nil {
return "", err
}
_, err = os.Stat(path)
exists := err == nil
debugf("File: exists=%v for path %q", exists, path)
return fmt.Sprintf("%t", exists), nil
case "size":
path, err := p.safePath(value)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("file: could not stat file: %v", err)
}
size := info.Size()
debugf("File: size=%d for path %q", size, path)
return fmt.Sprintf("%d", size), nil
case "modified":
path, err := p.safePath(value)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("file: could not stat file: %v", err)
}
mtime := info.ModTime().Format(time.RFC3339)
debugf("File: modified=%q for path %q", mtime, path)
return mtime, nil
default:
return "", fmt.Errorf("file: unknown operation %q (supported: read, tail, exists, size, modified)",
operation)
}
}
// lastNLines returns the last n lines from a file
func (p *FilePlugin) lastNLines(path string, n int) ([]string, error) {
debugf("File: reading last %d lines from %q", n, path)
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("file: could not open: %v", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("file: could not stat: %v", err)
}
if info.Size() > MaxFileSize {
return nil, fmt.Errorf("file: size %d exceeds limit of %d bytes",
info.Size(), MaxFileSize)
}
lines := make([]string, 0, n)
scanner := bufio.NewScanner(file)
lineCount := 0
for scanner.Scan() {
lineCount++
if len(lines) == n {
lines = lines[1:]
}
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("file: error reading: %v", err)
}
debugf("File: read %d lines total, returning last %d", lineCount, len(lines))
return lines, nil
}

51
plugins/template/file.md Normal file
View File

@@ -0,0 +1,51 @@
# File Plugin Tests
Simple test file for validating file plugin functionality.
## Basic File Operations
```
Read File:
{{plugin:file:read:/path/to/file.txt}}
Last 5 Lines:
{{plugin:file:tail:/path/to/log.txt|5}}
Check Existence:
{{plugin:file:exists:/path/to/file.txt}}
Get Size:
{{plugin:file:size:/path/to/file.txt}}
Last Modified:
{{plugin:file:modified:/path/to/file.txt}}
```
## Error Cases
These should produce appropriate error messages:
```
Invalid Operation:
{{plugin:file:invalid:/path/to/file.txt}}
Non-existent File:
{{plugin:file:read:/path/to/nonexistent.txt}}
Path Traversal Attempt:
{{plugin:file:read:../../../etc/passwd}}
Invalid Tail Format:
{{plugin:file:tail:/path/to/file.txt}}
Large File:
{{plugin:file:read:/path/to/huge.iso}}
```
## Security Considerations
- Carefully control which paths are accessible
- Consider using path allow lists in production
- Be aware of file size limits (1MB max)
- No directory traversal is allowed
- Home directory (~/) expansion is supported
- All paths are cleaned and normalized

View File

@@ -0,0 +1,152 @@
package template
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestFilePlugin(t *testing.T) {
plugin := &FilePlugin{}
// Create temp test files
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
content := "line1\nline2\nline3\nline4\nline5\n"
err := os.WriteFile(testFile, []byte(content), 0644)
if err != nil {
t.Fatal(err)
}
bigFile := filepath.Join(tmpDir, "big.txt")
err = os.WriteFile(bigFile, []byte(strings.Repeat("x", MaxFileSize+1)), 0644)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
operation string
value string
want string
wantErr bool
errContains string
validate func(string) bool
}{
{
name: "read file",
operation: "read",
value: testFile,
want: content,
},
{
name: "tail file",
operation: "tail",
value: testFile + "|3",
want: "line3\nline4\nline5",
},
{
name: "exists true",
operation: "exists",
value: testFile,
want: "true",
},
{
name: "exists false",
operation: "exists",
value: filepath.Join(tmpDir, "nonexistent.txt"),
want: "false",
},
{
name: "size",
operation: "size",
value: testFile,
want: "30",
},
{
name: "modified",
operation: "modified",
value: testFile,
validate: func(got string) bool {
_, err := time.Parse(time.RFC3339, got)
return err == nil
},
},
// Error cases
{
name: "read non-existent",
operation: "read",
value: filepath.Join(tmpDir, "nonexistent.txt"),
wantErr: true,
errContains: "could not stat file",
},
{
name: "invalid operation",
operation: "invalid",
value: testFile,
wantErr: true,
errContains: "unknown operation",
},
{
name: "path traversal attempt",
operation: "read",
value: "../../../etc/passwd",
wantErr: true,
errContains: "cannot contain '..'",
},
{
name: "file too large",
operation: "read",
value: bigFile,
wantErr: true,
errContains: "exceeds limit",
},
{
name: "invalid tail format",
operation: "tail",
value: testFile,
wantErr: true,
errContains: "requires format path|lines",
},
{
name: "invalid tail count",
operation: "tail",
value: testFile + "|invalid",
wantErr: true,
errContains: "invalid line count",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := plugin.Apply(tt.operation, tt.value)
// Check error cases
if (err != nil) != tt.wantErr {
t.Errorf("FilePlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errContains != "" {
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
}
return
}
// Check success cases
if err == nil {
if tt.validate != nil {
if !tt.validate(got) {
t.Errorf("FilePlugin.Apply() returned invalid result: %q", got)
}
} else if tt.want != "" && got != tt.want {
t.Errorf("FilePlugin.Apply() = %v, want %v", got, tt.want)
}
}
})
}
}

87
plugins/template/sys.go Normal file
View File

@@ -0,0 +1,87 @@
// Package template provides system information operations for the template system.
package template
import (
"fmt"
"os"
"os/user"
"runtime"
)
// SysPlugin provides access to system-level information.
// Security Note: This plugin provides access to system information and
// environment variables. Be cautious with exposed variables in templates.
type SysPlugin struct{}
// Apply executes system operations with the following options:
// - hostname: System hostname
// - user: Current username
// - os: Operating system (linux, darwin, windows)
// - arch: System architecture (amd64, arm64, etc)
// - env:VALUE: Environment variable lookup
// - pwd: Current working directory
// - home: User's home directory
func (p *SysPlugin) Apply(operation string, value string) (string, error) {
debugf("Sys: operation=%q value=%q", operation, value)
switch operation {
case "hostname":
hostname, err := os.Hostname()
if err != nil {
debugf("Sys: hostname error: %v", err)
return "", fmt.Errorf("sys: hostname error: %v", err)
}
debugf("Sys: hostname=%q", hostname)
return hostname, nil
case "user":
currentUser, err := user.Current()
if err != nil {
debugf("Sys: user error: %v", err)
return "", fmt.Errorf("sys: user error: %v", err)
}
debugf("Sys: user=%q", currentUser.Username)
return currentUser.Username, nil
case "os":
result := runtime.GOOS
debugf("Sys: os=%q", result)
return result, nil
case "arch":
result := runtime.GOARCH
debugf("Sys: arch=%q", result)
return result, nil
case "env":
if value == "" {
debugf("Sys: env error: missing variable name")
return "", fmt.Errorf("sys: env operation requires a variable name")
}
result := os.Getenv(value)
debugf("Sys: env %q=%q", value, result)
return result, nil
case "pwd":
dir, err := os.Getwd()
if err != nil {
debugf("Sys: pwd error: %v", err)
return "", fmt.Errorf("sys: pwd error: %v", err)
}
debugf("Sys: pwd=%q", dir)
return dir, nil
case "home":
homeDir, err := os.UserHomeDir()
if err != nil {
debugf("Sys: home error: %v", err)
return "", fmt.Errorf("sys: home error: %v", err)
}
debugf("Sys: home=%q", homeDir)
return homeDir, nil
default:
debugf("Sys: unknown operation %q", operation)
return "", fmt.Errorf("sys: unknown operation %q (supported: hostname, user, os, arch, env, pwd, home)", operation)
}
}

43
plugins/template/sys.md Normal file
View File

@@ -0,0 +1,43 @@
# System Plugin Tests
Simple test file for validating system plugin functionality.
## Basic System Information
```
Hostname: {{plugin:sys:hostname}}
Username: {{plugin:sys:user}}
Operating System: {{plugin:sys:os}}
Architecture: {{plugin:sys:arch}}
```
## Paths and Directories
```
Current Directory: {{plugin:sys:pwd}}
Home Directory: {{plugin:sys:home}}
```
## Environment Variables
```
Path: {{plugin:sys:env:PATH}}
Home: {{plugin:sys:env:HOME}}
Shell: {{plugin:sys:env:SHELL}}
```
## Error Cases
These should produce appropriate error messages:
```
Invalid Operation: {{plugin:sys:invalid}}
Missing Env Var: {{plugin:sys:env:}}
Non-existent Env Var: {{plugin:sys:env:NONEXISTENT_VAR_123456}}
```
## Security Note
Be careful when exposing system information in templates, especially:
- Environment variables that might contain sensitive data
- Full paths that reveal system structure
- Username/hostname information in public templates

View File

@@ -0,0 +1,140 @@
package template
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestSysPlugin(t *testing.T) {
plugin := &SysPlugin{}
// Set up test environment variable
const testEnvVar = "FABRIC_TEST_VAR"
const testEnvValue = "test_value"
os.Setenv(testEnvVar, testEnvValue)
defer os.Unsetenv(testEnvVar)
tests := []struct {
name string
operation string
value string
validate func(string) error
wantErr bool
}{
{
name: "hostname returns valid name",
operation: "hostname",
validate: func(got string) error {
if got == "" {
return fmt.Errorf("hostname is empty")
}
return nil
},
},
{
name: "user returns current user",
operation: "user",
validate: func(got string) error {
if got == "" {
return fmt.Errorf("username is empty")
}
return nil
},
},
{
name: "os returns valid OS",
operation: "os",
validate: func(got string) error {
if got != runtime.GOOS {
return fmt.Errorf("expected OS %s, got %s", runtime.GOOS, got)
}
return nil
},
},
{
name: "arch returns valid architecture",
operation: "arch",
validate: func(got string) error {
if got != runtime.GOARCH {
return fmt.Errorf("expected arch %s, got %s", runtime.GOARCH, got)
}
return nil
},
},
{
name: "env returns environment variable",
operation: "env",
value: testEnvVar,
validate: func(got string) error {
if got != testEnvValue {
return fmt.Errorf("expected env var %s, got %s", testEnvValue, got)
}
return nil
},
},
{
name: "pwd returns valid directory",
operation: "pwd",
validate: func(got string) error {
if !filepath.IsAbs(got) {
return fmt.Errorf("expected absolute path, got %s", got)
}
return nil
},
},
{
name: "home returns valid home directory",
operation: "home",
validate: func(got string) error {
if !filepath.IsAbs(got) {
return fmt.Errorf("expected absolute path, got %s", got)
}
if !strings.Contains(got, "home") && !strings.Contains(got, "Users") {
return fmt.Errorf("path %s doesn't look like a home directory", got)
}
return nil
},
},
// Error cases
{
name: "unknown operation",
operation: "invalid",
wantErr: true,
},
{
name: "env without variable",
operation: "env",
wantErr: true,
},
{
name: "env with non-existent variable",
operation: "env",
value: "NONEXISTENT_VAR_123456",
validate: func(got string) error {
if got != "" {
return fmt.Errorf("expected empty string for non-existent env var, got %s", got)
}
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := plugin.Apply(tt.operation, tt.value)
if (err != nil) != tt.wantErr {
t.Errorf("SysPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil && tt.validate != nil {
if err := tt.validate(got); err != nil {
t.Errorf("SysPlugin.Apply() validation failed: %v", err)
}
}
})
}
}

View File

@@ -0,0 +1,119 @@
package template
import (
"fmt"
"regexp"
"strings"
)
var (
textPlugin = &TextPlugin{}
datetimePlugin = &DateTimePlugin{}
filePlugin = &FilePlugin{}
fetchPlugin = &FetchPlugin{}
sysPlugin = &SysPlugin{}
Debug = false // Debug flag
)
var pluginPattern = regexp.MustCompile(`\{\{plugin:([^:]+):([^:]+)(?::([^}]+))?\}\}`)
func debugf(format string, a ...interface{}) {
if Debug {
fmt.Printf(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
}
}
// 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")
}
}
}
debugf("Template processing complete\n")
return content, nil
}

View File

@@ -0,0 +1,146 @@
package template
import (
"strings"
"testing"
)
func TestApplyTemplate(t *testing.T) {
tests := []struct {
name string
template string
vars map[string]string
input string
want string
wantErr bool
errContains string
}{
// Basic variable substitution
{
name: "simple variable",
template: "Hello {{name}}!",
vars: map[string]string{"name": "World"},
want: "Hello World!",
},
{
name: "multiple variables",
template: "{{greeting}} {{name}}!",
vars: map[string]string{
"greeting": "Hello",
"name": "World",
},
want: "Hello World!",
},
{
name: "special input variable",
template: "Content: {{input}}",
input: "test content",
want: "Content: test content",
},
// Nested variable substitution
{
name: "nested variables",
template: "{{outer{{inner}}}}",
vars: map[string]string{
"inner": "foo", // First resolution
"outerfoo": "result", // Second resolution
},
want: "result",
},
// Plugin operations
{
name: "simple text plugin",
template: "{{plugin:text:upper:hello}}",
want: "HELLO",
},
{
name: "text plugin with variable",
template: "{{plugin:text:upper:{{name}}}}",
vars: map[string]string{"name": "world"},
want: "WORLD",
},
{
name: "plugin with dynamic operation",
template: "{{plugin:text:{{operation}}:hello}}",
vars: map[string]string{"operation": "upper"},
want: "HELLO",
},
// Multiple operations
{
name: "multiple plugins",
template: "A:{{plugin:text:upper:hello}} B:{{plugin:text:lower:WORLD}}",
want: "A:HELLO B:world",
},
{
name: "nested plugins",
template: "{{plugin:text:upper:{{plugin:text:lower:HELLO}}}}",
want: "HELLO",
},
// Error cases
{
name: "missing variable",
template: "Hello {{name}}!",
wantErr: true,
errContains: "missing required variable",
},
{
name: "unknown plugin",
template: "{{plugin:invalid:op:value}}",
wantErr: true,
errContains: "unknown plugin namespace",
},
{
name: "unknown plugin operation",
template: "{{plugin:text:invalid:value}}",
wantErr: true,
errContains: "unknown text operation",
},
{
name: "nested plugin error",
template: "{{plugin:text:upper:{{plugin:invalid:op:value}}}}",
wantErr: true,
errContains: "unknown plugin namespace",
},
// Edge cases
{
name: "empty template",
template: "",
want: "",
},
{
name: "no substitutions needed",
template: "plain text",
want: "plain text",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ApplyTemplate(tt.template, tt.vars, tt.input)
// Check error cases
if (err != nil) != tt.wantErr {
t.Errorf("ApplyTemplate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errContains != "" {
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
}
return
}
// Check result
if got != tt.want {
t.Errorf("ApplyTemplate() = %q, want %q", got, tt.want)
}
})
}
}

64
plugins/template/text.go Normal file
View File

@@ -0,0 +1,64 @@
// Package template provides text transformation operations for the template system.
package template
import (
"fmt"
"strings"
"unicode"
)
// TextPlugin provides string manipulation operations
type TextPlugin struct{}
// toTitle capitalizes a letter if it follows a non-letter, unless next char is space
func toTitle(s string) string {
// First lowercase everything
lower := strings.ToLower(s)
runes := []rune(lower)
for i := 0; i < len(runes); i++ {
// Capitalize if previous char is non-letter AND
// (we're at the end OR next char is not space)
if (i == 0 || !unicode.IsLetter(runes[i-1])) {
if i == len(runes)-1 || !unicode.IsSpace(runes[i+1]) {
runes[i] = unicode.ToUpper(runes[i])
}
}
}
return string(runes)
}
// Apply executes the requested text operation on the provided value
func (p *TextPlugin) Apply(operation string, value string) (string, error) {
debugf("TextPlugin: operation=%s value=%q", operation, value)
if value == "" {
return "", fmt.Errorf("text: empty input for operation %q", operation)
}
switch operation {
case "upper":
result := strings.ToUpper(value)
debugf("TextPlugin: upper result=%q", result)
return result, nil
case "lower":
result := strings.ToLower(value)
debugf("TextPlugin: lower result=%q", result)
return result, nil
case "title":
result := toTitle(value)
debugf("TextPlugin: title result=%q", result)
return result, nil
case "trim":
result := strings.TrimSpace(value)
debugf("TextPlugin: trim result=%q", result)
return result, nil
default:
return "", fmt.Errorf("text: unknown text operation %q (supported: upper, lower, title, trim)", operation)
}
}

0
plugins/template/text.md Normal file
View File

View File

@@ -0,0 +1,104 @@
package template
import (
"testing"
)
func TestTextPlugin(t *testing.T) {
plugin := &TextPlugin{}
tests := []struct {
name string
operation string
value string
want string
wantErr bool
}{
// Upper tests
{
name: "upper basic",
operation: "upper",
value: "hello",
want: "HELLO",
},
{
name: "upper mixed case",
operation: "upper",
value: "hElLo",
want: "HELLO",
},
// Lower tests
{
name: "lower basic",
operation: "lower",
value: "HELLO",
want: "hello",
},
{
name: "lower mixed case",
operation: "lower",
value: "hElLo",
want: "hello",
},
// Title tests
{
name: "title basic",
operation: "title",
value: "hello world",
want: "Hello World",
},
{
name: "title with apostrophe",
operation: "title",
value: "o'reilly's book",
want: "O'Reilly's Book",
},
// Trim tests
{
name: "trim spaces",
operation: "trim",
value: " hello ",
want: "hello",
},
{
name: "trim newlines",
operation: "trim",
value: "\nhello\n",
want: "hello",
},
// Error cases
{
name: "empty value",
operation: "upper",
value: "",
wantErr: true,
},
{
name: "unknown operation",
operation: "invalid",
value: "test",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := plugin.Apply(tt.operation, tt.value)
// Check error cases
if (err != nil) != tt.wantErr {
t.Errorf("TextPlugin.Apply() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Check successful cases
if err == nil && got != tt.want {
t.Errorf("TextPlugin.Apply() = %q, want %q", got, tt.want)
}
})
}
}