feat: Add non-interactive mode (#18)

This commit is contained in:
Ed Zynda
2025-05-16 14:06:28 +03:00
committed by GitHub
parent d127a1c4eb
commit 623d132772
8 changed files with 414 additions and 8 deletions

46
internal/format/format.go Normal file
View File

@@ -0,0 +1,46 @@
package format
import (
"encoding/json"
"fmt"
)
// OutputFormat represents the format for non-interactive mode output
type OutputFormat string
const (
// TextFormat is plain text output (default)
TextFormat OutputFormat = "text"
// JSONFormat is output wrapped in a JSON object
JSONFormat OutputFormat = "json"
)
// IsValid checks if the output format is valid
func (f OutputFormat) IsValid() bool {
return f == TextFormat || f == JSONFormat
}
// String returns the string representation of the output format
func (f OutputFormat) String() string {
return string(f)
}
// FormatOutput formats the given content according to the specified format
func FormatOutput(content string, format OutputFormat) (string, error) {
switch format {
case TextFormat:
return content, nil
case JSONFormat:
jsonData := map[string]string{
"response": content,
}
jsonBytes, err := json.MarshalIndent(jsonData, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal JSON: %w", err)
}
return string(jsonBytes), nil
default:
return "", fmt.Errorf("unsupported output format: %s", format)
}
}

View File

@@ -0,0 +1,90 @@
package format
import (
"testing"
)
func TestOutputFormat_IsValid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
format OutputFormat
want bool
}{
{
name: "text format",
format: TextFormat,
want: true,
},
{
name: "json format",
format: JSONFormat,
want: true,
},
{
name: "invalid format",
format: "invalid",
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.format.IsValid(); got != tt.want {
t.Errorf("OutputFormat.IsValid() = %v, want %v", got, tt.want)
}
})
}
}
func TestFormatOutput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
format OutputFormat
want string
wantErr bool
}{
{
name: "text format",
content: "test content",
format: TextFormat,
want: "test content",
wantErr: false,
},
{
name: "json format",
content: "test content",
format: JSONFormat,
want: "{\n \"response\": \"test content\"\n}",
wantErr: false,
},
{
name: "invalid format",
content: "test content",
format: "invalid",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := FormatOutput(tt.content, tt.format)
if (err != nil) != tt.wantErr {
t.Errorf("FormatOutput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("FormatOutput() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,102 @@
package spinner
import (
"context"
"fmt"
"os"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
type Spinner struct {
model spinner.Model
done chan struct{}
prog *tea.Program
ctx context.Context
cancel context.CancelFunc
}
// spinnerModel is the tea.Model for the spinner
type spinnerModel struct {
spinner spinner.Model
message string
quitting bool
}
func (m spinnerModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
m.quitting = true
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case quitMsg:
m.quitting = true
return m, tea.Quit
default:
return m, nil
}
}
func (m spinnerModel) View() string {
if m.quitting {
return ""
}
return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
}
// quitMsg is sent when we want to quit the spinner
type quitMsg struct{}
// NewSpinner creates a new spinner with the given message
func NewSpinner(message string) *Spinner {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = s.Style.Foreground(s.Style.GetForeground())
ctx, cancel := context.WithCancel(context.Background())
model := spinnerModel{
spinner: s,
message: message,
}
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
return &Spinner{
model: s,
done: make(chan struct{}),
prog: prog,
ctx: ctx,
cancel: cancel,
}
}
// Start begins the spinner animation
func (s *Spinner) Start() {
go func() {
defer close(s.done)
go func() {
<-s.ctx.Done()
s.prog.Send(quitMsg{})
}()
_, err := s.prog.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
}
}()
}
// Stop ends the spinner animation
func (s *Spinner) Stop() {
s.cancel()
<-s.done
}

View File

@@ -0,0 +1,24 @@
package spinner
import (
"testing"
"time"
)
func TestSpinner(t *testing.T) {
t.Parallel()
// Create a spinner
s := NewSpinner("Test spinner")
// Start the spinner
s.Start()
// Wait a bit to let it run
time.Sleep(100 * time.Millisecond)
// Stop the spinner
s.Stop()
// If we got here without panicking, the test passes
}