feat: bootstrap cli entrypoint with bubble tea and toml config

This commit is contained in:
2026-04-28 20:49:07 +02:00
parent 914b3b9eff
commit f738833aa8
10 changed files with 338 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
package app
import (
"context"
"os"
tea "github.com/charmbracelet/bubbletea"
"parasocial/internal/config"
"parasocial/internal/tui"
)
// Run loads the application configuration and starts the terminal UI.
func Run(ctx context.Context) error {
cfg, err := config.LoadDefault()
if err != nil {
return err
}
program := tea.NewProgram(
tui.New(cfg.Streamers),
tea.WithContext(ctx),
tea.WithInput(os.Stdin),
tea.WithOutput(os.Stdout),
)
_, err = program.Run()
return err
}
+70
View File
@@ -0,0 +1,70 @@
package config
import (
"errors"
"fmt"
"os"
"strings"
"github.com/BurntSushi/toml"
)
const configFileName = "config.toml"
// Config is the user-owned TOML configuration for parasocial.
type Config struct {
Streamers []string `toml:"streamers"`
}
// DefaultPath returns the config file path relative to the current working directory.
func DefaultPath() string {
return configFileName
}
// LoadDefault reads config from the current working directory.
func LoadDefault() (Config, error) {
return Load(DefaultPath())
}
// Load reads, normalizes, and validates a TOML config file.
func Load(path string) (Config, error) {
var cfg Config
if _, err := toml.DecodeFile(path, &cfg); err != nil {
if errors.Is(err, os.ErrNotExist) {
return Config{}, fmt.Errorf("config file not found at %s", path)
}
return Config{}, fmt.Errorf("load config %s: %w", path, err)
}
cfg.Streamers = normalizeStreamers(cfg.Streamers)
if len(cfg.Streamers) == 0 {
return Config{}, fmt.Errorf("config %s must define at least one streamer", path)
}
return cfg, nil
}
func normalizeStreamers(streamers []string) []string {
seen := make(map[string]struct{}, len(streamers))
normalized := make([]string, 0, len(streamers))
for _, streamer := range streamers {
streamer = normalizeStreamer(streamer)
if streamer == "" {
continue
}
if _, ok := seen[streamer]; ok {
continue
}
seen[streamer] = struct{}{}
normalized = append(normalized, streamer)
}
return normalized
}
func normalizeStreamer(streamer string) string {
streamer = strings.TrimSpace(streamer)
streamer = strings.TrimPrefix(streamer, "#")
return strings.ToLower(strings.TrimSpace(streamer))
}
+75
View File
@@ -0,0 +1,75 @@
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestLoadValidConfig(t *testing.T) {
path := writeConfig(t, `streamers = [" Alpha ", "#Beta", "alpha", "", " #Gamma "]`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
want := []string{"alpha", "beta", "gamma"}
if !reflect.DeepEqual(cfg.Streamers, want) {
t.Fatalf("Streamers = %#v, want %#v", cfg.Streamers, want)
}
}
func TestLoadRejectsMissingStreamerList(t *testing.T) {
path := writeConfig(t, `streamers = []`)
_, err := Load(path)
if err == nil {
t.Fatal("Load() error = nil, want error")
}
if !strings.Contains(err.Error(), "at least one streamer") {
t.Fatalf("Load() error = %q, want empty streamer message", err)
}
}
func TestLoadRejectsInvalidTOML(t *testing.T) {
path := writeConfig(t, `streamers = [`)
_, err := Load(path)
if err == nil {
t.Fatal("Load() error = nil, want error")
}
if !strings.Contains(err.Error(), "load config") {
t.Fatalf("Load() error = %q, want load config context", err)
}
}
func TestDefaultPathUsesCurrentWorkingDirectoryConfig(t *testing.T) {
if got := DefaultPath(); got != "config.toml" {
t.Fatalf("DefaultPath() = %q, want %q", got, "config.toml")
}
}
func TestLoadRejectsMissingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing.toml")
_, err := Load(path)
if err == nil {
t.Fatal("Load() error = nil, want error")
}
if !strings.Contains(err.Error(), "config file not found") {
t.Fatalf("Load() error = %q, want missing file context", err)
}
}
func writeConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
+46
View File
@@ -0,0 +1,46 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Model is the initial terminal UI for parasocial.
type Model struct {
streamers []string
}
// New returns a Bubble Tea model that displays configured streamers.
func New(streamers []string) Model {
return Model{streamers: append([]string(nil), streamers...)}
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
return m, tea.Quit
}
}
return m, nil
}
func (m Model) View() string {
var builder strings.Builder
builder.WriteString("Streamers\n")
for i, streamer := range m.streamers {
fmt.Fprintf(&builder, "%d. %s\n", i+1, streamer)
}
builder.WriteString("\n")
return builder.String()
}
+13
View File
@@ -0,0 +1,13 @@
package tui
import "testing"
func TestViewDisplaysStreamers(t *testing.T) {
model := New([]string{"alpha", "beta"})
got := model.View()
want := "Streamers\n1. alpha\n2. beta\n\n"
if got != want {
t.Fatalf("View() = %q, want %q", got, want)
}
}