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

74
output/output.go Normal file
View File

@@ -0,0 +1,74 @@
package output
import (
"fmt"
"os"
"time"
"git.wisehodl.dev/jay/aicli/config"
)
// WriteOutput orchestrates complete output delivery based on configuration.
func WriteOutput(response, model string, duration time.Duration, cfg config.ConfigData) error {
if cfg.Output == "" {
// Write to stdout with optional metadata
formatted := formatOutput(response, model, duration, cfg.Quiet)
return writeStdout(formatted)
}
// Write raw response to file
if err := writeFile(response, cfg.Output); err != nil {
return err
}
// Write metadata to stderr unless quiet
if !cfg.Quiet {
metadata := fmt.Sprintf("Used model: %s\nQuery duration: %.1fs\nWrote response to: %s\n",
model, duration.Seconds(), cfg.Output)
return writeStderr(metadata)
}
return nil
}
// formatOutput constructs the final output string with optional metadata header.
func formatOutput(response, model string, duration time.Duration, quiet bool) string {
if quiet {
return response
}
return fmt.Sprintf(`--- aicli ---
Used model: %s
Query duration: %.1fs
--- response ---
%s`, model, duration.Seconds(), response)
}
// writeStdout writes content to stdout.
func writeStdout(content string) error {
_, err := fmt.Println(content)
if err != nil {
return fmt.Errorf("write stdout: %w", err)
}
return nil
}
// writeStderr writes logs to stderr.
func writeStderr(content string) error {
_, err := fmt.Fprint(os.Stderr, content)
if err != nil {
return fmt.Errorf("write stderr: %w", err)
}
return nil
}
// writeFile writes content to the specified path with permissions 0644.
func writeFile(content, path string) error {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("write output file: %w", err)
}
return nil
}

486
output/output_test.go Normal file
View File

@@ -0,0 +1,486 @@
package output
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"time"
"git.wisehodl.dev/jay/aicli/config"
"github.com/stretchr/testify/assert"
)
func TestFormatOutput(t *testing.T) {
tests := []struct {
name string
response string
model string
duration time.Duration
quiet bool
want string
}{
{
name: "normal mode with metadata",
response: "This is the response.",
model: "gpt-4",
duration: 2500 * time.Millisecond,
quiet: false,
want: `--- aicli ---
Used model: gpt-4
Query duration: 2.5s
--- response ---
This is the response.`,
},
{
name: "quiet mode response only",
response: "This is the response.",
model: "gpt-4",
duration: 2500 * time.Millisecond,
quiet: true,
want: "This is the response.",
},
{
name: "duration formatting subsecond",
response: "response",
model: "gpt-3.5",
duration: 123 * time.Millisecond,
quiet: false,
want: `--- aicli ---
Used model: gpt-3.5
Query duration: 0.1s
--- response ---
response`,
},
{
name: "duration formatting multi-second",
response: "response",
model: "claude-3",
duration: 12345 * time.Millisecond,
quiet: false,
want: `--- aicli ---
Used model: claude-3
Query duration: 12.3s
--- response ---
response`,
},
{
name: "multiline response preserved",
response: "Line 1\nLine 2\nLine 3",
model: "gpt-4",
duration: 1 * time.Second,
quiet: false,
want: `--- aicli ---
Used model: gpt-4
Query duration: 1.0s
--- response ---
Line 1
Line 2
Line 3`,
},
{
name: "empty response",
response: "",
model: "gpt-4",
duration: 1 * time.Second,
quiet: false,
want: `--- aicli ---
Used model: gpt-4
Query duration: 1.0s
--- response ---
`,
},
{
name: "model name with special chars",
response: "response",
model: "gpt-4-1106-preview",
duration: 5 * time.Second,
quiet: false,
want: `--- aicli ---
Used model: gpt-4-1106-preview
Query duration: 5.0s
--- response ---
response`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatOutput(tt.response, tt.model, tt.duration, tt.quiet)
assert.Equal(t, tt.want, got)
})
}
}
func TestWriteStdout(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "normal content",
content: "test output",
},
{
name: "empty string",
content: "",
},
{
name: "multiline content",
content: "line 1\nline 2\nline 3",
},
{
name: "large content",
content: string(make([]byte, 10000)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := writeStdout(tt.content)
assert.NoError(t, err)
w.Close()
os.Stdout = old
var buf bytes.Buffer
io.Copy(&buf, r)
// writeStdout uses fmt.Println which adds newline
expected := tt.content + "\n"
assert.Equal(t, expected, buf.String())
})
}
}
func TestWriteStderr(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "normal content",
content: "error message",
},
{
name: "empty string",
content: "",
},
{
name: "multiline content",
content: "line 1\nline 2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
err := writeStderr(tt.content)
assert.NoError(t, err)
w.Close()
os.Stderr = old
var buf bytes.Buffer
io.Copy(&buf, r)
assert.Equal(t, tt.content, buf.String())
})
}
}
func TestWriteFile(t *testing.T) {
tests := []struct {
name string
content string
wantErr bool
errContains string
}{
{
name: "normal write",
content: "test content",
},
{
name: "empty content",
content: "",
},
{
name: "multiline content",
content: "line 1\nline 2\nline 3",
},
{
name: "large content",
content: string(make([]byte, 100000)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "output.txt")
err := writeFile(tt.content, path)
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}
assert.NoError(t, err)
// Verify file exists and has correct content
got, err := os.ReadFile(path)
assert.NoError(t, err)
assert.Equal(t, tt.content, string(got))
// Verify permissions
info, err := os.Stat(path)
assert.NoError(t, err)
assert.Equal(t, os.FileMode(0644), info.Mode().Perm())
})
}
}
func TestWriteFileErrors(t *testing.T) {
tests := []struct {
name string
setupPath func() string
errContains string
}{
{
name: "directory does not exist",
setupPath: func() string {
return "/nonexistent/dir/output.txt"
},
errContains: "write output file",
},
{
name: "permission denied",
setupPath: func() string {
tmpDir := t.TempDir()
dir := filepath.Join(tmpDir, "readonly")
os.Mkdir(dir, 0444)
return filepath.Join(dir, "output.txt")
},
errContains: "write output file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := tt.setupPath()
err := writeFile("content", path)
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
})
}
}
func TestWriteFileOverwrite(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "output.txt")
// Write initial content
err := writeFile("initial", path)
assert.NoError(t, err)
got, _ := os.ReadFile(path)
assert.Equal(t, "initial", string(got))
// Overwrite with new content
err = writeFile("overwritten", path)
assert.NoError(t, err)
got, _ = os.ReadFile(path)
assert.Equal(t, "overwritten", string(got))
}
func TestWriteOutput(t *testing.T) {
tests := []struct {
name string
response string
model string
duration time.Duration
cfg config.ConfigData
checkStdout bool
checkStderr bool
checkFile bool
wantStdout string
wantStderr string
wantErr bool
errContains string
}{
{
name: "stdout with metadata",
response: "response text",
model: "gpt-4",
duration: 2 * time.Second,
cfg: config.ConfigData{
Quiet: false,
},
checkStdout: true,
wantStdout: `--- aicli ---
Used model: gpt-4
Query duration: 2.0s
--- response ---
response text
`,
},
{
name: "stdout quiet mode",
response: "response text",
model: "gpt-4",
duration: 2 * time.Second,
cfg: config.ConfigData{
Quiet: true,
},
checkStdout: true,
wantStdout: "response text\n",
},
{
name: "file output with stderr metadata",
response: "response text",
model: "gpt-4",
duration: 3 * time.Second,
cfg: config.ConfigData{
Output: "output.txt",
Quiet: false,
},
checkFile: true,
checkStderr: true,
wantStderr: "Used model: gpt-4\nQuery duration: 3.0s\nWrote response to: .*output.txt\n",
},
{
name: "file output quiet mode",
response: "response text",
model: "gpt-4",
duration: 3 * time.Second,
cfg: config.ConfigData{
Output: "output.txt",
Quiet: true,
},
checkFile: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
// Capture stdout if needed
oldStdout := os.Stdout
var stdoutR *os.File
if tt.checkStdout {
r, w, _ := os.Pipe()
os.Stdout = w
stdoutR = r
}
// Capture stderr if needed
oldStderr := os.Stderr
var stderrR *os.File
if tt.checkStderr {
r, w, _ := os.Pipe()
os.Stderr = w
stderrR = r
}
// Set output path if needed
if tt.cfg.Output != "" {
tt.cfg.Output = filepath.Join(tmpDir, tt.cfg.Output)
}
err := WriteOutput(tt.response, tt.model, tt.duration, tt.cfg)
// Close write ends and restore originals
if tt.checkStdout {
os.Stdout.Close()
os.Stdout = oldStdout
}
if tt.checkStderr {
os.Stderr.Close()
os.Stderr = oldStderr
}
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}
assert.NoError(t, err)
// Read stdout
if tt.checkStdout {
var stdoutBuf bytes.Buffer
io.Copy(&stdoutBuf, stdoutR)
stdoutR.Close()
assert.Equal(t, tt.wantStdout, stdoutBuf.String())
}
// Read stderr
if tt.checkStderr {
var stderrBuf bytes.Buffer
io.Copy(&stderrBuf, stderrR)
stderrR.Close()
got := stderrBuf.String()
assert.Contains(t, got, "Used model: gpt-4")
assert.Contains(t, got, "Query duration: 3.0s")
assert.Contains(t, got, "output.txt")
}
// Check file
if tt.checkFile {
content, err := os.ReadFile(tt.cfg.Output)
assert.NoError(t, err)
assert.Equal(t, tt.response, string(content))
}
})
}
}
func TestWriteOutputFileError(t *testing.T) {
cfg := config.ConfigData{
Output: "/nonexistent/dir/output.txt",
Quiet: false,
}
err := WriteOutput("response", "gpt-4", 1*time.Second, cfg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "write output file")
}