diff --git a/core/chatter.go b/core/chatter.go index d4cab48f..4fab37a0 100644 --- a/core/chatter.go +++ b/core/chatter.go @@ -100,9 +100,16 @@ func (o *Chatter) BuildSession(request *common.ChatRequest, raw bool) (session * } + //if there is no input from stdin + var messageContent string + if request.Message != nil { + messageContent = request.Message.Content + } + var patternContent string if request.PatternName != "" { - pattern, err := o.db.Patterns.GetApplyVariables(request.PatternName, request.PatternVariables) + pattern, err := o.db.Patterns.GetApplyVariables(request.PatternName, request.PatternVariables, messageContent) + if err != nil { return nil, fmt.Errorf("could not get pattern %s: %v", request.PatternName, err) } diff --git a/plugins/db/fsdb/patterns.go b/plugins/db/fsdb/patterns.go index 00e7656d..d64547a0 100644 --- a/plugins/db/fsdb/patterns.go +++ b/plugins/db/fsdb/patterns.go @@ -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, "") } \ No newline at end of file diff --git a/plugins/db/fsdb/patterns_test.go b/plugins/db/fsdb/patterns_test.go index e6e477ce..adf8b936 100644 --- a/plugins/db/fsdb/patterns_test.go +++ b/plugins/db/fsdb/patterns_test.go @@ -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) + }) + } +} \ No newline at end of file diff --git a/plugins/template/README.md b/plugins/template/README.md new file mode 100644 index 00000000..2bb885e4 --- /dev/null +++ b/plugins/template/README.md @@ -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 \ No newline at end of file diff --git a/plugins/template/datetime.go b/plugins/template/datetime.go new file mode 100644 index 00000000..31340e59 --- /dev/null +++ b/plugins/template/datetime.go @@ -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 +} \ No newline at end of file diff --git a/plugins/template/datetime.md b/plugins/template/datetime.md new file mode 100644 index 00000000..96cb5d0a --- /dev/null +++ b/plugins/template/datetime.md @@ -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}} +``` \ No newline at end of file diff --git a/plugins/template/datetime_test.go b/plugins/template/datetime_test.go new file mode 100644 index 00000000..0e737ffd --- /dev/null +++ b/plugins/template/datetime_test.go @@ -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) + } + } + }) + } +} \ No newline at end of file diff --git a/plugins/template/fabric b/plugins/template/fabric new file mode 100644 index 00000000..0bea184d Binary files /dev/null and b/plugins/template/fabric differ diff --git a/plugins/template/fetch.go b/plugins/template/fetch.go new file mode 100644 index 00000000..a8663464 --- /dev/null +++ b/plugins/template/fetch.go @@ -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 +} \ No newline at end of file diff --git a/plugins/template/fetch.md b/plugins/template/fetch.md new file mode 100644 index 00000000..cde6471a --- /dev/null +++ b/plugins/template/fetch.md @@ -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 \ No newline at end of file diff --git a/plugins/template/fetch_test.go b/plugins/template/fetch_test.go new file mode 100644 index 00000000..5f3fd6f3 --- /dev/null +++ b/plugins/template/fetch_test.go @@ -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") + } + }) + } +} \ No newline at end of file diff --git a/plugins/template/file.go b/plugins/template/file.go new file mode 100644 index 00000000..fb2f85d7 --- /dev/null +++ b/plugins/template/file.go @@ -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 +} diff --git a/plugins/template/file.md b/plugins/template/file.md new file mode 100644 index 00000000..93224ae4 --- /dev/null +++ b/plugins/template/file.md @@ -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 \ No newline at end of file diff --git a/plugins/template/file_test.go b/plugins/template/file_test.go new file mode 100644 index 00000000..f4c24736 --- /dev/null +++ b/plugins/template/file_test.go @@ -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) + } + } + }) + } +} \ No newline at end of file diff --git a/plugins/template/sys.go b/plugins/template/sys.go new file mode 100644 index 00000000..64059099 --- /dev/null +++ b/plugins/template/sys.go @@ -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) + } +} \ No newline at end of file diff --git a/plugins/template/sys.md b/plugins/template/sys.md new file mode 100644 index 00000000..fbd6b8dc --- /dev/null +++ b/plugins/template/sys.md @@ -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 \ No newline at end of file diff --git a/plugins/template/sys_test.go b/plugins/template/sys_test.go new file mode 100644 index 00000000..31544f9f --- /dev/null +++ b/plugins/template/sys_test.go @@ -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) + } + } + }) + } +} \ No newline at end of file diff --git a/plugins/template/template.go b/plugins/template/template.go new file mode 100644 index 00000000..6ff7cd4e --- /dev/null +++ b/plugins/template/template.go @@ -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 +} \ No newline at end of file diff --git a/plugins/template/template_test.go b/plugins/template/template_test.go new file mode 100644 index 00000000..d3617b6f --- /dev/null +++ b/plugins/template/template_test.go @@ -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) + } + }) + } +} + diff --git a/plugins/template/text.go b/plugins/template/text.go new file mode 100644 index 00000000..e3d3f76e --- /dev/null +++ b/plugins/template/text.go @@ -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) + } +} \ No newline at end of file diff --git a/plugins/template/text.md b/plugins/template/text.md new file mode 100644 index 00000000..e69de29b diff --git a/plugins/template/text_test.go b/plugins/template/text_test.go new file mode 100644 index 00000000..4a85f838 --- /dev/null +++ b/plugins/template/text_test.go @@ -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) + } + }) + } +} \ No newline at end of file diff --git a/restapi/patterns.go b/restapi/patterns.go index cfb78d09..92949b8e 100644 --- a/restapi/patterns.go +++ b/restapi/patterns.go @@ -1,9 +1,10 @@ package restapi import ( + "net/http" + "github.com/danielmiessler/fabric/plugins/db/fsdb" "github.com/gin-gonic/gin" - "net/http" ) // PatternsHandler defines the handler for patterns-related operations @@ -26,7 +27,8 @@ func NewPatternsHandler(r *gin.Engine, patterns *fsdb.PatternsEntity) (ret *Patt func (h *PatternsHandler) Get(c *gin.Context) { name := c.Param("name") variables := make(map[string]string) // Assuming variables are passed somehow - pattern, err := h.patterns.GetApplyVariables(name, variables) + input := "" // Assuming input is passed somehow + pattern, err := h.patterns.GetApplyVariables(name, variables, input) if err != nil { c.JSON(http.StatusInternalServerError, err.Error()) return