Refactored, added comprehensive testing.
All checks were successful
Release / release (push) Successful in 3m17s

This commit is contained in:
Jay
2025-10-26 23:23:43 -04:00
parent ec32b75267
commit 1936f055e2
61 changed files with 4678 additions and 769 deletions

32
input/aggregate.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
package main
func main() {
println("hello")
}

4
input/testdata/data.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"name": "test",
"value": 42
}

1
input/testdata/prompt1.txt vendored Normal file
View File

@@ -0,0 +1 @@
Analyze the following code.

4
input/testdata/prompt2.txt vendored Normal file
View File

@@ -0,0 +1,4 @@
Focus on:
- Performance
- Security
- Readability

0
input/testdata/prompt_empty.txt vendored Normal file
View File

27
input/types.go Normal file
View 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
}