Refactored, added comprehensive testing.
All checks were successful
Release / release (push) Successful in 3m17s
All checks were successful
Release / release (push) Successful in 3m17s
This commit is contained in:
32
input/aggregate.go
Normal file
32
input/aggregate.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package input
|
||||
|
||||
// AggregatePrompts combines prompt sources with stdin based on role.
|
||||
func AggregatePrompts(prompts []string, stdin string, role StdinRole) []string {
|
||||
switch role {
|
||||
case StdinAsPrompt:
|
||||
if stdin != "" {
|
||||
return []string{stdin}
|
||||
}
|
||||
return prompts
|
||||
|
||||
case StdinAsPrefixedContent:
|
||||
if stdin != "" {
|
||||
return append(prompts, stdin)
|
||||
}
|
||||
return prompts
|
||||
|
||||
case StdinAsFile:
|
||||
return prompts
|
||||
|
||||
default:
|
||||
return prompts
|
||||
}
|
||||
}
|
||||
|
||||
// AggregateFiles combines file sources with stdin based on role.
|
||||
func AggregateFiles(files []FileData, stdin string, role StdinRole) []FileData {
|
||||
if role == StdinAsFile && stdin != "" {
|
||||
return append([]FileData{{Path: "input", Content: stdin}}, files...)
|
||||
}
|
||||
return files
|
||||
}
|
||||
174
input/aggregate_test.go
Normal file
174
input/aggregate_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAggregatePrompts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prompts []string
|
||||
stdin string
|
||||
role StdinRole
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty inputs returns empty",
|
||||
prompts: []string{},
|
||||
stdin: "",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "stdin as prompt with no other prompts",
|
||||
prompts: []string{},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{"stdin content"},
|
||||
},
|
||||
{
|
||||
name: "stdin as prompt replaces existing prompts",
|
||||
prompts: []string{"prompt1", "prompt2"},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{"stdin content"},
|
||||
},
|
||||
{
|
||||
name: "no stdin with role prompt returns prompts unchanged",
|
||||
prompts: []string{"prompt1", "prompt2"},
|
||||
stdin: "",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{"prompt1", "prompt2"},
|
||||
},
|
||||
{
|
||||
name: "stdin as prefixed appends to prompts",
|
||||
prompts: []string{"prompt1", "prompt2"},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrefixedContent,
|
||||
want: []string{"prompt1", "prompt2", "stdin content"},
|
||||
},
|
||||
{
|
||||
name: "stdin as prefixed with no prompts",
|
||||
prompts: []string{},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrefixedContent,
|
||||
want: []string{"stdin content"},
|
||||
},
|
||||
{
|
||||
name: "no stdin with role prefixed returns prompts unchanged",
|
||||
prompts: []string{"prompt1"},
|
||||
stdin: "",
|
||||
role: StdinAsPrefixedContent,
|
||||
want: []string{"prompt1"},
|
||||
},
|
||||
{
|
||||
name: "stdin as file excludes stdin from prompts",
|
||||
prompts: []string{"prompt1"},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsFile,
|
||||
want: []string{"prompt1"},
|
||||
},
|
||||
{
|
||||
name: "no stdin with role file returns prompts unchanged",
|
||||
prompts: []string{"prompt1"},
|
||||
stdin: "",
|
||||
role: StdinAsFile,
|
||||
want: []string{"prompt1"},
|
||||
},
|
||||
{
|
||||
name: "empty string stdin with role prompt",
|
||||
prompts: []string{"prompt1"},
|
||||
stdin: "",
|
||||
role: StdinAsPrompt,
|
||||
want: []string{"prompt1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := AggregatePrompts(tt.prompts, tt.stdin, tt.role)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []FileData
|
||||
stdin string
|
||||
role StdinRole
|
||||
want []FileData
|
||||
}{
|
||||
{
|
||||
name: "empty inputs returns empty",
|
||||
files: []FileData{},
|
||||
stdin: "",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{},
|
||||
},
|
||||
{
|
||||
name: "stdin as file prepends to files",
|
||||
files: []FileData{{Path: "a.go", Content: "code"}},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
{Path: "a.go", Content: "code"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin as file with no other files",
|
||||
files: []FileData{},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no stdin with role file returns files unchanged",
|
||||
files: []FileData{{Path: "a.go", Content: "code"}},
|
||||
stdin: "",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{{Path: "a.go", Content: "code"}},
|
||||
},
|
||||
{
|
||||
name: "stdin as prompt excludes stdin from files",
|
||||
files: []FileData{{Path: "a.go", Content: "code"}},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrompt,
|
||||
want: []FileData{{Path: "a.go", Content: "code"}},
|
||||
},
|
||||
{
|
||||
name: "stdin as prefixed excludes stdin from files",
|
||||
files: []FileData{{Path: "a.go", Content: "code"}},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsPrefixedContent,
|
||||
want: []FileData{{Path: "a.go", Content: "code"}},
|
||||
},
|
||||
{
|
||||
name: "stdin as file with multiple files",
|
||||
files: []FileData{
|
||||
{Path: "a.go", Content: "code a"},
|
||||
{Path: "b.go", Content: "code b"},
|
||||
},
|
||||
stdin: "stdin content",
|
||||
role: StdinAsFile,
|
||||
want: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
{Path: "a.go", Content: "code a"},
|
||||
{Path: "b.go", Content: "code b"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := AggregateFiles(tt.files, tt.stdin, tt.role)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
39
input/input.go
Normal file
39
input/input.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
)
|
||||
|
||||
// ResolveInputs orchestrates the complete input resolution pipeline.
|
||||
// Returns aggregated prompts and files ready for query construction.
|
||||
func ResolveInputs(cfg config.ConfigData, stdinContent string, hasStdin bool) (InputData, error) {
|
||||
// Determine stdin role (CA -> CB)
|
||||
role := DetermineRole(cfg, hasStdin)
|
||||
|
||||
// Read all sources (CC, CD)
|
||||
prompts, err := ReadPromptSources(cfg)
|
||||
if err != nil {
|
||||
return InputData{}, err
|
||||
}
|
||||
|
||||
files, err := ReadFileSources(cfg)
|
||||
if err != nil {
|
||||
return InputData{}, err
|
||||
}
|
||||
|
||||
// Aggregate with stdin (CE, CF)
|
||||
finalPrompts := AggregatePrompts(prompts, stdinContent, role)
|
||||
finalFiles := AggregateFiles(files, stdinContent, role)
|
||||
|
||||
// Validate at least one input exists
|
||||
if len(finalPrompts) == 0 && len(finalFiles) == 0 {
|
||||
return InputData{}, fmt.Errorf("no input provided: supply stdin, --file, or --prompt")
|
||||
}
|
||||
|
||||
return InputData{
|
||||
Prompts: finalPrompts,
|
||||
Files: finalFiles,
|
||||
}, nil
|
||||
}
|
||||
206
input/input_test.go
Normal file
206
input/input_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
stdinContent string
|
||||
hasStdin bool
|
||||
want InputData
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "no input returns error",
|
||||
cfg: config.ConfigData{},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
wantErr: true,
|
||||
errContains: "no input provided",
|
||||
},
|
||||
{
|
||||
name: "stdin only as prompt",
|
||||
cfg: config.ConfigData{},
|
||||
stdinContent: "analyze this",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{"analyze this"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt flag only",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"test prompt"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
want: InputData{
|
||||
Prompts: []string{"test prompt"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file flag only",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
want: InputData{
|
||||
Prompts: []string{},
|
||||
Files: []FileData{
|
||||
{Path: "testdata/code.go", Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin as file with -F flag",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
},
|
||||
stdinContent: "stdin content",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{},
|
||||
Files: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin as file with -F and explicit files",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
FilePaths: []string{"testdata/code.go"},
|
||||
},
|
||||
stdinContent: "stdin content",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{},
|
||||
Files: []FileData{
|
||||
{Path: "input", Content: "stdin content"},
|
||||
{Path: "testdata/code.go", Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin prefixed with explicit prompt",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"analyze"},
|
||||
},
|
||||
stdinContent: "code to analyze",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{"analyze", "code to analyze"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt from file",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/prompt1.txt"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
want: InputData{
|
||||
Prompts: []string{"Analyze the following code.\n"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete scenario: prompts, files, stdin",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"review this"},
|
||||
FilePaths: []string{"testdata/code.go"},
|
||||
},
|
||||
stdinContent: "additional context",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{"review this", "additional context"},
|
||||
Files: []FileData{
|
||||
{Path: "testdata/code.go", Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file read error propagates",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/nonexistent.go"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
wantErr: true,
|
||||
errContains: "read file",
|
||||
},
|
||||
{
|
||||
name: "prompt file read error propagates",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/missing.txt"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
wantErr: true,
|
||||
errContains: "read prompt file",
|
||||
},
|
||||
{
|
||||
name: "empty file path error propagates",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{""},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
wantErr: true,
|
||||
errContains: "empty file path",
|
||||
},
|
||||
{
|
||||
name: "stdin replaces prompts when no explicit flags",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{},
|
||||
},
|
||||
stdinContent: "stdin prompt",
|
||||
hasStdin: true,
|
||||
want: InputData{
|
||||
Prompts: []string{"stdin prompt"},
|
||||
Files: []FileData{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple files in order",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go", "testdata/data.json"},
|
||||
},
|
||||
stdinContent: "",
|
||||
hasStdin: false,
|
||||
want: InputData{
|
||||
Prompts: []string{},
|
||||
Files: []FileData{
|
||||
{Path: "testdata/code.go", Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"},
|
||||
{Path: "testdata/data.json", Content: "{\n \"name\": \"test\",\n \"value\": 42\n}\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResolveInputs(tt.cfg, tt.stdinContent, tt.hasStdin)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
26
input/role.go
Normal file
26
input/role.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package input
|
||||
|
||||
import "git.wisehodl.dev/jay/aicli/config"
|
||||
|
||||
// DetermineRole decides how stdin content participates in the query based on
|
||||
// flags and stdin presence. Per spec §7 rules.
|
||||
func DetermineRole(cfg config.ConfigData, hasStdin bool) StdinRole {
|
||||
if !hasStdin {
|
||||
return StdinAsPrompt // unused, but set for consistency
|
||||
}
|
||||
|
||||
// Explicit -F flag forces stdin as file
|
||||
if cfg.StdinAsFile {
|
||||
return StdinAsFile
|
||||
}
|
||||
|
||||
// Any explicit prompt flag (-p or -pf) makes stdin prefixed content
|
||||
hasExplicitPrompt := len(cfg.PromptFlags) > 0 || len(cfg.PromptPaths) > 0
|
||||
|
||||
if hasExplicitPrompt {
|
||||
return StdinAsPrefixedContent
|
||||
}
|
||||
|
||||
// Default: stdin replaces any default prompt
|
||||
return StdinAsPrompt
|
||||
}
|
||||
95
input/role_test.go
Normal file
95
input/role_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDetermineRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
hasStdin bool
|
||||
want StdinRole
|
||||
}{
|
||||
{
|
||||
name: "no stdin returns StdinAsPrompt",
|
||||
cfg: config.ConfigData{},
|
||||
hasStdin: false,
|
||||
want: StdinAsPrompt,
|
||||
},
|
||||
{
|
||||
name: "stdin with no flags returns StdinAsPrompt",
|
||||
cfg: config.ConfigData{},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrompt,
|
||||
},
|
||||
{
|
||||
name: "stdin with -p flag returns StdinAsPrefixedContent",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"analyze this"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrefixedContent,
|
||||
},
|
||||
{
|
||||
name: "stdin with -pf flag returns StdinAsPrefixedContent",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"prompt.txt"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrefixedContent,
|
||||
},
|
||||
{
|
||||
name: "stdin with -F flag returns StdinAsFile",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsFile,
|
||||
},
|
||||
{
|
||||
name: "stdin with -F and -p returns StdinAsFile (explicit wins)",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
PromptFlags: []string{"analyze"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsFile,
|
||||
},
|
||||
{
|
||||
name: "stdin with file flags returns StdinAsPrompt",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"main.go"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrompt,
|
||||
},
|
||||
{
|
||||
name: "no stdin with -F returns StdinAsFile (role set but unused)",
|
||||
cfg: config.ConfigData{
|
||||
StdinAsFile: true,
|
||||
},
|
||||
hasStdin: false,
|
||||
want: StdinAsPrompt,
|
||||
},
|
||||
{
|
||||
name: "stdin with both -p and -pf returns StdinAsPrefixedContent",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"first"},
|
||||
PromptPaths: []string{"prompt.txt"},
|
||||
},
|
||||
hasStdin: true,
|
||||
want: StdinAsPrefixedContent,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DetermineRole(tt.cfg, tt.hasStdin)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
52
input/sources.go
Normal file
52
input/sources.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
)
|
||||
|
||||
// ReadPromptSources reads all prompt content from flags and files.
|
||||
// Returns arrays of prompt strings in source order.
|
||||
func ReadPromptSources(cfg config.ConfigData) ([]string, error) {
|
||||
prompts := []string{}
|
||||
|
||||
// Add flag prompts first
|
||||
prompts = append(prompts, cfg.PromptFlags...)
|
||||
|
||||
// Add prompt file contents
|
||||
for _, path := range cfg.PromptPaths {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read prompt file %s: %w", path, err)
|
||||
}
|
||||
prompts = append(prompts, string(content))
|
||||
}
|
||||
|
||||
return prompts, nil
|
||||
}
|
||||
|
||||
// ReadFileSources reads all input files specified in config.
|
||||
// Returns FileData array in source order.
|
||||
func ReadFileSources(cfg config.ConfigData) ([]FileData, error) {
|
||||
files := []FileData{}
|
||||
|
||||
for _, path := range cfg.FilePaths {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("empty file path provided")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %s: %w", path, err)
|
||||
}
|
||||
|
||||
files = append(files, FileData{
|
||||
Path: path,
|
||||
Content: string(content),
|
||||
})
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
183
input/sources_test.go
Normal file
183
input/sources_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wisehodl.dev/jay/aicli/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReadPromptSources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no prompts returns empty",
|
||||
cfg: config.ConfigData{},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "single flag prompt",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"analyze this"},
|
||||
},
|
||||
want: []string{"analyze this"},
|
||||
},
|
||||
{
|
||||
name: "multiple flag prompts",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"first", "second", "third"},
|
||||
},
|
||||
want: []string{"first", "second", "third"},
|
||||
},
|
||||
{
|
||||
name: "single prompt file",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/prompt1.txt"},
|
||||
},
|
||||
want: []string{"Analyze the following code.\n"},
|
||||
},
|
||||
{
|
||||
name: "multiple prompt files",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/prompt1.txt", "testdata/prompt2.txt"},
|
||||
},
|
||||
want: []string{
|
||||
"Analyze the following code.\n",
|
||||
"Focus on:\n- Performance\n- Security\n- Readability\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty prompt file",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/prompt_empty.txt"},
|
||||
},
|
||||
want: []string{""},
|
||||
},
|
||||
{
|
||||
name: "flags and files combined",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"first flag", "second flag"},
|
||||
PromptPaths: []string{"testdata/prompt1.txt"},
|
||||
},
|
||||
want: []string{
|
||||
"first flag",
|
||||
"second flag",
|
||||
"Analyze the following code.\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file not found",
|
||||
cfg: config.ConfigData{
|
||||
PromptPaths: []string{"testdata/nonexistent.txt"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid",
|
||||
cfg: config.ConfigData{
|
||||
PromptFlags: []string{"valid flag"},
|
||||
PromptPaths: []string{"testdata/nonexistent.txt"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ReadPromptSources(tt.cfg)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileSources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ConfigData
|
||||
want []FileData
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no files returns empty",
|
||||
cfg: config.ConfigData{},
|
||||
want: []FileData{},
|
||||
},
|
||||
{
|
||||
name: "single file",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go"},
|
||||
},
|
||||
want: []FileData{
|
||||
{
|
||||
Path: "testdata/code.go",
|
||||
Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple files",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go", "testdata/data.json"},
|
||||
},
|
||||
want: []FileData{
|
||||
{
|
||||
Path: "testdata/code.go",
|
||||
Content: "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n",
|
||||
},
|
||||
{
|
||||
Path: "testdata/data.json",
|
||||
Content: "{\n \"name\": \"test\",\n \"value\": 42\n}\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty file path",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{""},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "file not found",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/nonexistent.go"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"/root/secret.txt"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid",
|
||||
cfg: config.ConfigData{
|
||||
FilePaths: []string{"testdata/code.go", "testdata/nonexistent.go"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ReadFileSources(tt.cfg)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
28
input/stdin.go
Normal file
28
input/stdin.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// DetectStdin checks if stdin contains piped data and reads it.
|
||||
// Returns content and true if stdin is a pipe/file, empty string and false if terminal.
|
||||
func DetectStdin() (string, bool) {
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Terminal (character device) = no stdin data
|
||||
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Pipe or file redirection detected
|
||||
content, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return string(content), true
|
||||
}
|
||||
27
input/stdin_test.go
Normal file
27
input/stdin_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package input
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Integration test helper - run manually with:
|
||||
// echo "test" | STDIN_TEST=1 go test ./input/ -run TestDetectStdinIntegration
|
||||
func TestDetectStdinIntegration(t *testing.T) {
|
||||
if os.Getenv("STDIN_TEST") != "1" {
|
||||
t.Skip("Set STDIN_TEST=1 and pipe data to run this test")
|
||||
}
|
||||
|
||||
content, hasStdin := DetectStdin()
|
||||
|
||||
t.Logf("hasStdin: %v", hasStdin)
|
||||
t.Logf("content length: %d", len(content))
|
||||
t.Logf("content: %q", content)
|
||||
|
||||
// When piped: should detect stdin
|
||||
if hasStdin {
|
||||
assert.NotEmpty(t, content, "Expected content when stdin detected")
|
||||
}
|
||||
}
|
||||
5
input/testdata/code.go
vendored
Normal file
5
input/testdata/code.go
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
func main() {
|
||||
println("hello")
|
||||
}
|
||||
4
input/testdata/data.json
vendored
Normal file
4
input/testdata/data.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "test",
|
||||
"value": 42
|
||||
}
|
||||
1
input/testdata/prompt1.txt
vendored
Normal file
1
input/testdata/prompt1.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Analyze the following code.
|
||||
4
input/testdata/prompt2.txt
vendored
Normal file
4
input/testdata/prompt2.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
Focus on:
|
||||
- Performance
|
||||
- Security
|
||||
- Readability
|
||||
0
input/testdata/prompt_empty.txt
vendored
Normal file
0
input/testdata/prompt_empty.txt
vendored
Normal file
27
input/types.go
Normal file
27
input/types.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package input
|
||||
|
||||
// StdinRole determines how stdin content participates in the query
|
||||
type StdinRole int
|
||||
|
||||
const (
|
||||
// StdinAsPrompt: stdin becomes the entire prompt (replaces other prompts)
|
||||
StdinAsPrompt StdinRole = iota
|
||||
|
||||
// StdinAsPrefixedContent: stdin appends after explicit prompts
|
||||
StdinAsPrefixedContent
|
||||
|
||||
// StdinAsFile: stdin becomes first file in files array
|
||||
StdinAsFile
|
||||
)
|
||||
|
||||
// FileData represents a single input file
|
||||
type FileData struct {
|
||||
Path string
|
||||
Content string
|
||||
}
|
||||
|
||||
// InputData holds all resolved input streams after aggregation
|
||||
type InputData struct {
|
||||
Prompts []string
|
||||
Files []FileData
|
||||
}
|
||||
Reference in New Issue
Block a user