Skip to content

Commit fde6114

Browse files
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
1 parent 94de208 commit fde6114

3 files changed

Lines changed: 131 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
logpilot
1+
/logpilot
22
*.exe
33
dist/
44
.DS_Store

cmd/logpilot/main.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package main
22

33
import (
4+
"context"
45
"fmt"
56
"os"
7+
"os/signal"
8+
"syscall"
69

710
tea "github.com/charmbracelet/bubbletea"
11+
"github.com/clarabennett2626/logpilot/internal/parser"
12+
"github.com/clarabennett2626/logpilot/internal/source"
813
"github.com/clarabennett2626/logpilot/internal/tui"
914
)
1015

@@ -20,10 +25,54 @@ func main() {
2025
os.Exit(0)
2126
}
2227

28+
// If stdin is a pipe, run in streaming mode (no TUI).
29+
if source.IsPipe() {
30+
if err := runPipeMode(); err != nil {
31+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
32+
os.Exit(1)
33+
}
34+
return
35+
}
36+
2337
p := tea.NewProgram(tui.NewModel(), tea.WithAltScreen())
2438
if _, err := p.Run(); err != nil {
2539
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
2640
os.Exit(1)
2741
}
2842
}
2943

44+
// runPipeMode reads from stdin, parses each line, and renders output to stdout.
45+
func runPipeMode() error {
46+
ctx, cancel := context.WithCancel(context.Background())
47+
defer cancel()
48+
49+
// Handle signals for graceful shutdown.
50+
sigCh := make(chan os.Signal, 1)
51+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
52+
go func() {
53+
<-sigCh
54+
cancel()
55+
}()
56+
57+
src := source.NewStdinSource()
58+
autoParser := parser.NewAutoParser()
59+
renderer := tui.NewRenderer(tui.DefaultConfig())
60+
61+
// Start reading stdin in a goroutine.
62+
errCh := make(chan error, 1)
63+
go func() {
64+
errCh <- src.Start(ctx)
65+
}()
66+
67+
// Consume lines and render them.
68+
for entry := range src.Lines() {
69+
parsed := autoParser.Parse(entry.Line)
70+
fmt.Println(renderer.RenderEntry(parsed))
71+
}
72+
73+
// Check for read errors.
74+
if err := <-errCh; err != nil && ctx.Err() == nil {
75+
return err
76+
}
77+
return nil
78+
}

cmd/logpilot/main_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"os/exec"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestPipeMode_JSON(t *testing.T) {
11+
cmd := exec.Command("go", "run", ".")
12+
cmd.Stdin = strings.NewReader(`{"level":"info","msg":"hello","ts":"2024-01-01T00:00:00Z"}` + "\n")
13+
var out bytes.Buffer
14+
cmd.Stdout = &out
15+
cmd.Stderr = &bytes.Buffer{}
16+
17+
if err := cmd.Run(); err != nil {
18+
t.Fatalf("command failed: %v", err)
19+
}
20+
21+
output := out.String()
22+
if !strings.Contains(output, "hello") {
23+
t.Errorf("expected output to contain 'hello', got: %q", output)
24+
}
25+
}
26+
27+
func TestPipeMode_MultiFormat(t *testing.T) {
28+
input := `{"level":"info","msg":"json line"}
29+
level=warn msg="logfmt line"
30+
plain text line
31+
`
32+
cmd := exec.Command("go", "run", ".")
33+
cmd.Stdin = strings.NewReader(input)
34+
var out bytes.Buffer
35+
cmd.Stdout = &out
36+
cmd.Stderr = &bytes.Buffer{}
37+
38+
if err := cmd.Run(); err != nil {
39+
t.Fatalf("command failed: %v", err)
40+
}
41+
42+
output := out.String()
43+
for _, want := range []string{"json line", "logfmt line", "plain text line"} {
44+
if !strings.Contains(output, want) {
45+
t.Errorf("expected output to contain %q, got: %q", want, output)
46+
}
47+
}
48+
}
49+
50+
func TestPipeMode_EmptyInput(t *testing.T) {
51+
cmd := exec.Command("go", "run", ".")
52+
cmd.Stdin = strings.NewReader("")
53+
var out bytes.Buffer
54+
cmd.Stdout = &out
55+
cmd.Stderr = &bytes.Buffer{}
56+
57+
if err := cmd.Run(); err != nil {
58+
t.Fatalf("command failed: %v", err)
59+
}
60+
61+
if out.Len() != 0 {
62+
t.Errorf("expected no output for empty input, got: %q", out.String())
63+
}
64+
}
65+
66+
func TestPipeMode_LongLine(t *testing.T) {
67+
long := strings.Repeat("x", 500_000)
68+
cmd := exec.Command("go", "run", ".")
69+
cmd.Stdin = strings.NewReader(long + "\n")
70+
var out bytes.Buffer
71+
cmd.Stdout = &out
72+
cmd.Stderr = &bytes.Buffer{}
73+
74+
if err := cmd.Run(); err != nil {
75+
t.Fatalf("command failed: %v", err)
76+
}
77+
78+
if !strings.Contains(out.String(), "xxx") {
79+
t.Error("expected long line to be processed")
80+
}
81+
}

0 commit comments

Comments
 (0)