From fde6114d384775ef361321fe970b343e9b100bb9 Mon Sep 17 00:00:00 2001 From: clarabennett2626 Date: Wed, 18 Feb 2026 20:02:54 -0500 Subject: [PATCH] feat: add stdin/pipe reader for command composability Integrate StdinSource into the main command so users can pipe logs: cat app.log | logpilot kubectl logs -f pod | logpilot docker logs -f container | logpilot When stdin is a pipe, logpilot runs in streaming mode (no TUI), auto-detecting log format (JSON, logfmt, plain) per line and rendering styled output to stdout. Features: - Automatic pipe detection via IsPipe() - Auto-format detection per line (mixed formats supported) - Graceful signal handling (SIGINT/SIGTERM) - Long line support (up to 1MB) - Configurable backpressure (block or drop-oldest) Closes #9 --- .gitignore | 2 +- cmd/logpilot/main.go | 49 +++++++++++++++++++++++ cmd/logpilot/main_test.go | 81 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 cmd/logpilot/main_test.go diff --git a/.gitignore b/.gitignore index d6ca29e..60cbf85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -logpilot +/logpilot *.exe dist/ .DS_Store diff --git a/cmd/logpilot/main.go b/cmd/logpilot/main.go index 129945b..6ad01ee 100644 --- a/cmd/logpilot/main.go +++ b/cmd/logpilot/main.go @@ -1,10 +1,15 @@ package main import ( + "context" "fmt" "os" + "os/signal" + "syscall" tea "github.com/charmbracelet/bubbletea" + "github.com/clarabennett2626/logpilot/internal/parser" + "github.com/clarabennett2626/logpilot/internal/source" "github.com/clarabennett2626/logpilot/internal/tui" ) @@ -20,6 +25,15 @@ func main() { os.Exit(0) } + // If stdin is a pipe, run in streaming mode (no TUI). + if source.IsPipe() { + if err := runPipeMode(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + p := tea.NewProgram(tui.NewModel(), tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -27,3 +41,38 @@ func main() { } } +// runPipeMode reads from stdin, parses each line, and renders output to stdout. +func runPipeMode() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle signals for graceful shutdown. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + src := source.NewStdinSource() + autoParser := parser.NewAutoParser() + renderer := tui.NewRenderer(tui.DefaultConfig()) + + // Start reading stdin in a goroutine. + errCh := make(chan error, 1) + go func() { + errCh <- src.Start(ctx) + }() + + // Consume lines and render them. + for entry := range src.Lines() { + parsed := autoParser.Parse(entry.Line) + fmt.Println(renderer.RenderEntry(parsed)) + } + + // Check for read errors. + if err := <-errCh; err != nil && ctx.Err() == nil { + return err + } + return nil +} diff --git a/cmd/logpilot/main_test.go b/cmd/logpilot/main_test.go new file mode 100644 index 0000000..d3613cc --- /dev/null +++ b/cmd/logpilot/main_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "os/exec" + "strings" + "testing" +) + +func TestPipeMode_JSON(t *testing.T) { + cmd := exec.Command("go", "run", ".") + cmd.Stdin = strings.NewReader(`{"level":"info","msg":"hello","ts":"2024-01-01T00:00:00Z"}` + "\n") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &bytes.Buffer{} + + if err := cmd.Run(); err != nil { + t.Fatalf("command failed: %v", err) + } + + output := out.String() + if !strings.Contains(output, "hello") { + t.Errorf("expected output to contain 'hello', got: %q", output) + } +} + +func TestPipeMode_MultiFormat(t *testing.T) { + input := `{"level":"info","msg":"json line"} +level=warn msg="logfmt line" +plain text line +` + cmd := exec.Command("go", "run", ".") + cmd.Stdin = strings.NewReader(input) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &bytes.Buffer{} + + if err := cmd.Run(); err != nil { + t.Fatalf("command failed: %v", err) + } + + output := out.String() + for _, want := range []string{"json line", "logfmt line", "plain text line"} { + if !strings.Contains(output, want) { + t.Errorf("expected output to contain %q, got: %q", want, output) + } + } +} + +func TestPipeMode_EmptyInput(t *testing.T) { + cmd := exec.Command("go", "run", ".") + cmd.Stdin = strings.NewReader("") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &bytes.Buffer{} + + if err := cmd.Run(); err != nil { + t.Fatalf("command failed: %v", err) + } + + if out.Len() != 0 { + t.Errorf("expected no output for empty input, got: %q", out.String()) + } +} + +func TestPipeMode_LongLine(t *testing.T) { + long := strings.Repeat("x", 500_000) + cmd := exec.Command("go", "run", ".") + cmd.Stdin = strings.NewReader(long + "\n") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &bytes.Buffer{} + + if err := cmd.Run(); err != nil { + t.Fatalf("command failed: %v", err) + } + + if !strings.Contains(out.String(), "xxx") { + t.Error("expected long line to be processed") + } +}