From dd3e3704eafcc8b39d941ba1e009c02364057bed Mon Sep 17 00:00:00 2001 From: hedhyw Date: Sun, 2 Jun 2024 11:59:33 +0200 Subject: [PATCH 1/2] feat: support standart input --- .golangci.json | 14 +- Makefile | 7 +- README.md | 29 +++- cmd/jlv/main.go | 20 ++- example.jlv.jsonc | 4 +- internal/app/app.go | 15 +- internal/app/app_test.go | 3 +- internal/app/helper.go | 29 +++- internal/app/logstable.go | 9 +- internal/app/statefiltering_test.go | 2 +- internal/app/stateinitial_test.go | 8 +- internal/app/stateloaded.go | 2 +- internal/app/stateloaded_test.go | 3 +- internal/app/style.go | 5 +- internal/app/style_test.go | 2 - internal/pkg/config/config.go | 11 +- internal/pkg/config/config_test.go | 23 ++- internal/pkg/source/entry.go | 12 +- internal/pkg/source/entry_test.go | 6 - internal/pkg/source/fileinput/fileinput.go | 29 ++++ .../pkg/source/fileinput/fileinput_test.go | 56 +++++++ internal/pkg/source/input.go | 15 ++ internal/pkg/source/level_test.go | 2 - .../pkg/source/readerinput/readerinput.go | 90 ++++++++++++ .../source/readerinput/readerinput_test.go | 139 ++++++++++++++++++ internal/pkg/source/source.go | 24 +-- internal/pkg/source/source_test.go | 29 +--- 27 files changed, 485 insertions(+), 103 deletions(-) create mode 100644 internal/pkg/source/fileinput/fileinput.go create mode 100644 internal/pkg/source/fileinput/fileinput_test.go create mode 100644 internal/pkg/source/input.go create mode 100644 internal/pkg/source/readerinput/readerinput.go create mode 100644 internal/pkg/source/readerinput/readerinput_test.go diff --git a/.golangci.json b/.golangci.json index de917df..a8428bf 100644 --- a/.golangci.json +++ b/.golangci.json @@ -7,8 +7,6 @@ "bodyclose", "wsl", "funlen", - "maligned", - "exhaustivestruct", "gci", "wrapcheck", "varnamelen", @@ -18,24 +16,16 @@ "thelper", "paralleltest", "tagliatelle", - "scopelint", - "golint", - "interfacer", "nonamedreturns", "exhaustruct", "nolintlint", - "deadcode", "wastedassign", - "structcheck", - "varcheck", - "ifshort", - "nosnakecase", "rowserrcheck", "depguard", "ireturn", "gomoddirectives", - "tagalign", - "testifylint" + "execinquery", + "tagalign" ] }, "linters-settings": { diff --git a/Makefile b/Makefile index 239c5d6..a09e526 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -GOLANG_CI_LINT_VER:=v1.55.2 +GOLANG_CI_LINT_VER:=v1.59.0 OUT_BIN?=${PWD}/bin/jlv COVER_PACKAGES=./... VERSION?=${shell git describe --tags} @@ -10,6 +10,11 @@ run: go run ./cmd/jlv assets/example.log .PHONY: build +run.stdin: + @echo "building ${VERSION}" + go run ./cmd/jlv < assets/example.log +.PHONY: build + build: @echo "building ${VERSION}" go build \ diff --git a/README.md b/README.md index e5149c5..95b8003 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,34 @@ The application is designed to help in visualization, navigation, and analyzing ## Usage -```sh -jlv file.json -jlv -config .jlv.jsonc file.json +### Reading from file +```shell +jlv assets/example.log +jlv -config example.jlv.jsonc assets/example.log +``` + +### Reading from Stdin + +```shell +jlv < assets/example.log +``` + +Common applications: + +```shell +curl https://raw.githubusercontent.com/hedhyw/json-log-viewer/main/assets/example.log | jlv + +jlv << EOF +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "day 1"} +{"time":"1970-01-02T00:00:00.00","level":"INFO","message": "day 2"} +EOF + +kubectl logs pod/POD_NAME -f | jlv +docker logs 000000000000 | jlv ``` +### Hotkeys + | Key | Action | | ------ | -------------- | | Enter | Open/Close log | diff --git a/cmd/jlv/main.go b/cmd/jlv/main.go index d11d627..b3f57cd 100644 --- a/cmd/jlv/main.go +++ b/cmd/jlv/main.go @@ -10,6 +10,9 @@ import ( "github.com/hedhyw/json-log-viewer/internal/app" "github.com/hedhyw/json-log-viewer/internal/pkg/config" + "github.com/hedhyw/json-log-viewer/internal/pkg/source" + "github.com/hedhyw/json-log-viewer/internal/pkg/source/fileinput" + "github.com/hedhyw/json-log-viewer/internal/pkg/source/readerinput" ) const configFileName = ".jlv.jsonc" @@ -18,16 +21,23 @@ func main() { configPath := flag.String("config", "", "Path to the config") flag.Parse() - if flag.NArg() != 1 { - fatalf("Invalid arguments, usage: %s file.log\n", os.Args[0]) - } - cfg, err := readConfig(*configPath) if err != nil { fatalf("Error reading config: %s\n", err) } - appModel := app.NewModel(flag.Args()[0], cfg) + var sourceInput source.Input + + switch flag.NArg() { + case 0: + sourceInput = readerinput.New(os.Stdin, cfg.StdinReadTimeout) + case 1: + sourceInput = fileinput.New(flag.Arg(0)) + default: + fatalf("Invalid arguments, usage: %s file.log\n", os.Args[0]) + } + + appModel := app.NewModel(sourceInput, cfg) program := tea.NewProgram(appModel, tea.WithAltScreen()) if _, err := program.Run(); err != nil { diff --git a/example.jlv.jsonc b/example.jlv.jsonc index fd7cd86..a2115a1 100644 --- a/example.jlv.jsonc +++ b/example.jlv.jsonc @@ -69,5 +69,7 @@ "reloadThreshold": 1000000000, // The maximum size of the file in bytes. // The rest of the file will be ignored. - "maxFileSizeBytes": 1073741824 + "maxFileSizeBytes": 1073741824, + // StdinReadTimeout is the timeout (in nanoseconds) of reading from the standart input. + "stdinReadTimeout": 1000000000 } \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index ae7a2a5..2cffba3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,12 +5,13 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/hedhyw/json-log-viewer/internal/pkg/config" + "github.com/hedhyw/json-log-viewer/internal/pkg/source" ) // Application global state. type Application struct { - Path string - Config *config.Config + SourceInput source.Input + Config *config.Config BaseStyle lipgloss.Style FooterStyle lipgloss.Style @@ -18,15 +19,15 @@ type Application struct { LastWindowSize tea.WindowSizeMsg } -func newApplication(path string, config *config.Config) Application { +func newApplication(sourceInput source.Input, config *config.Config) Application { const ( initialWidth = 70 initialHeight = 20 ) return Application{ - Path: path, - Config: config, + SourceInput: sourceInput, + Config: config, BaseStyle: getBaseStyle(), FooterStyle: getFooterStyle(), @@ -40,6 +41,6 @@ func newApplication(path string, config *config.Config) Application { // NewModel initializes a new application model. It accept the path // to the file with logs. -func NewModel(path string, config *config.Config) tea.Model { - return newStateInitial(newApplication(path, config)) +func NewModel(sourceInput source.Input, config *config.Config) tea.Model { + return newStateInitial(newApplication(sourceInput, config)) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d904bf5..2122c71 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -10,6 +10,7 @@ import ( "github.com/hedhyw/json-log-viewer/internal/app" "github.com/hedhyw/json-log-viewer/internal/pkg/config" + "github.com/hedhyw/json-log-viewer/internal/pkg/source/fileinput" "github.com/hedhyw/json-log-viewer/internal/pkg/tests" ) @@ -18,7 +19,7 @@ func newTestModel(tb testing.TB, content []byte) tea.Model { testFile := tests.RequireCreateFile(tb, content) - model := app.NewModel(testFile, config.GetDefaultConfig()) + model := app.NewModel(fileinput.New(testFile), config.GetDefaultConfig()) model = handleUpdate(model, model.Init()()) return model diff --git a/internal/app/helper.go b/internal/app/helper.go index 9fcad06..d214da5 100644 --- a/internal/app/helper.go +++ b/internal/app/helper.go @@ -1,6 +1,8 @@ package app import ( + "context" + "errors" "fmt" "runtime" "strings" @@ -20,11 +22,9 @@ type helper struct { Application } +// LoadEntries reads and parses entries from the input source. func (h helper) LoadEntries() tea.Msg { - logEntries, err := source.LoadLogsFromFile( - h.Path, - h.Config, - ) + logEntries, err := h.loadEntriesFromSourceInput() if err != nil { return events.ErrorOccuredMsg{Err: err} } @@ -34,6 +34,27 @@ func (h helper) LoadEntries() tea.Msg { return events.LogEntriesLoadedMsg(logEntries) } +func (h helper) loadEntriesFromSourceInput() (logEntries source.LazyLogEntries, err error) { + ctx := context.Background() + + readCloser, err := h.SourceInput.ReadCloser(ctx) + if err != nil { + return nil, fmt.Errorf("readcloser: %w", err) + } + + defer func() { err = errors.Join(err, readCloser.Close()) }() + + logEntries, err = source.ParseLogEntriesFromReader( + readCloser, + h.Config, + ) + if err != nil { + return nil, fmt.Errorf("reading logs: %w", err) + } + + return logEntries, nil +} + func (h helper) getLogLevelStyle( logEntries source.LazyLogEntries, baseStyle lipgloss.Style, diff --git a/internal/app/logstable.go b/internal/app/logstable.go index 01d0927..affe699 100644 --- a/internal/app/logstable.go +++ b/internal/app/logstable.go @@ -54,7 +54,7 @@ func newLogsTableModel(application Application, logEntries source.LazyLogEntries minRenderedRows: application.Config.PrerenderRows, allEntries: logEntries, lastCursor: 0, - renderedRows: make([]table.Row, 0, application.Config.PrerenderRows*2), + renderedRows: make([]table.Row, 0, application.Config.PrerenderRows), }.withRenderedRows() return logsTableModel{ @@ -90,12 +90,15 @@ func (m logsTableModel) Update(msg tea.Msg) (logsTableModel, tea.Cmd) { } func (m logsTableModel) handleWindowSizeMsg(msg tea.WindowSizeMsg) logsTableModel { - const heightOffset = 4 + const ( + heightOffset = 4 + widthOffset = -10 + ) x, y := m.BaseStyle.GetFrameSize() m.lazyTable.table.SetWidth(msg.Width - x*2) m.lazyTable.table.SetHeight(msg.Height - y*2 - footerSize - heightOffset) - m.lazyTable.table.SetColumns(getColumns(m.lazyTable.table.Width()-10, m.Config)) + m.lazyTable.table.SetColumns(getColumns(m.lazyTable.table.Width()+widthOffset, m.Config)) m.lastWindowSize = msg return m diff --git a/internal/app/statefiltering_test.go b/internal/app/statefiltering_test.go index 102a2e0..4f1a0ca 100644 --- a/internal/app/statefiltering_test.go +++ b/internal/app/statefiltering_test.go @@ -136,7 +136,7 @@ func TestStateFilteringReset(t *testing.T) { rendered := model.View() index := strings.Index(rendered, "filtered 0 by:") - if assert.Greater(t, index, 0) { + if assert.Positive(t, index) { rendered = rendered[:index] } diff --git a/internal/app/stateinitial_test.go b/internal/app/stateinitial_test.go index 2a46a7b..388605b 100644 --- a/internal/app/stateinitial_test.go +++ b/internal/app/stateinitial_test.go @@ -1,12 +1,15 @@ package app_test import ( + "bytes" "fmt" "testing" + "time" "github.com/hedhyw/json-log-viewer/internal/app" "github.com/hedhyw/json-log-viewer/internal/pkg/config" "github.com/hedhyw/json-log-viewer/internal/pkg/events" + "github.com/hedhyw/json-log-viewer/internal/pkg/source/readerinput" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" @@ -16,7 +19,10 @@ import ( func TestStateInitial(t *testing.T) { t.Parallel() - model := app.NewModel("", config.GetDefaultConfig()) + model := app.NewModel( + readerinput.New(bytes.NewReader([]byte{}), time.Millisecond), + config.GetDefaultConfig(), + ) _, ok := model.(app.StateInitialModel) require.Truef(t, ok, "%s", model) diff --git a/internal/app/stateloaded.go b/internal/app/stateloaded.go index 0a50f66..314f7e9 100644 --- a/internal/app/stateloaded.go +++ b/internal/app/stateloaded.go @@ -137,7 +137,7 @@ func (s StateLoadedModel) handleRequestOpenJSON() (tea.Model, tea.Cmd) { } func (s StateLoadedModel) handleViewRowsReloadRequestedMsg() (tea.Model, tea.Cmd) { - if time.Since(s.lastReloadAt) < s.Config.ReloadThreshold { + if time.Since(s.lastReloadAt) < s.Config.ReloadThreshold || s.reloading { return s, nil } diff --git a/internal/app/stateloaded_test.go b/internal/app/stateloaded_test.go index f56f684..70f0ff8 100644 --- a/internal/app/stateloaded_test.go +++ b/internal/app/stateloaded_test.go @@ -211,8 +211,9 @@ func overwriteFileInStateLoaded(tb testing.TB, model tea.Model, content []byte) stateLoaded, ok := model.(app.StateLoadedModel) require.True(tb, ok) + // nolint: gosec // Test. err := os.WriteFile( - stateLoaded.Application().Path, + stateLoaded.Application().SourceInput.String(), content, os.ModePerm, ) diff --git a/internal/app/style.go b/internal/app/style.go index c16eccb..c10aaa4 100644 --- a/internal/app/style.go +++ b/internal/app/style.go @@ -7,7 +7,8 @@ import ( // Component sizes. const ( - footerSize = 1 + footerSize = 1 + footerPaddingLeft = 2 ) // Possible colors. @@ -41,5 +42,5 @@ func getBaseStyle() lipgloss.Style { } func getFooterStyle() lipgloss.Style { - return lipgloss.NewStyle().Height(footerSize).PaddingLeft(2) + return lipgloss.NewStyle().Height(footerSize).PaddingLeft(footerPaddingLeft) } diff --git a/internal/app/style_test.go b/internal/app/style_test.go index 1f6d435..2cbadd0 100644 --- a/internal/app/style_test.go +++ b/internal/app/style_test.go @@ -48,8 +48,6 @@ func TestGetColorForLogLevel(t *testing.T) { }} for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.Level.String(), func(t *testing.T) { t.Parallel() diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index d3b9c85..22af276 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -26,9 +26,12 @@ type Config struct { // The number of rows to prerender. PrerenderRows int `json:"prerenderRows"` // ReloadThreshold is the minimum duration between reloading rows. - ReloadThreshold time.Duration `json:"reloadThreshold"` + ReloadThreshold time.Duration `json:"reloadThreshold" validate:"min=100ms"` // MaxFileSizeBytes is the maximum size of the file to load. - MaxFileSizeBytes int64 `json:"maxFileSizeBytes"` + MaxFileSizeBytes int64 `json:"maxFileSizeBytes" validate:"min=1"` + + // StdinReadTimeout is the timeout of reading from the standart input. + StdinReadTimeout time.Duration `json:"stdinReadTimeout" validate:"min=100ms"` } // FieldKind describes the type of the log field. @@ -56,12 +59,14 @@ type Field struct { // GetDefaultConfig returns the configuration with default values. func GetDefaultConfig() *Config { + // nolint: mnd // Default config. return &Config{ Path: "default", CustomLevelMapping: GetDefaultCustomLevelMapping(), PrerenderRows: 100, ReloadThreshold: time.Second, MaxFileSizeBytes: 1024 * 1024 * 1024, + StdinReadTimeout: time.Second, Fields: []Field{{ Title: "Time", Kind: FieldKindNumericTime, @@ -129,6 +134,8 @@ func readConfigFromFile(path string) (cfg *Config, err error) { defer func() { err = errors.Join(err, file.Close()) }() + cfg = GetDefaultConfig() + err = json.NewDecoder( jsoncjson.NewReader(file), ).Decode(&cfg) diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index f27b923..ee8f799 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -30,6 +30,26 @@ func TestReadDefault(t *testing.T) { } } +func TestReadPartlyDefault(t *testing.T) { + t.Parallel() + + const reloadThreshold = time.Minute + time.Second + + configDefault := config.GetDefaultConfig() + configJSON := tests.RequireEncodeJSON(t, map[string]any{ + "reloadThreshold": reloadThreshold, + }) + configFile := tests.RequireCreateFile(t, configJSON) + + assert.NotEqual(t, reloadThreshold, configDefault.ReloadThreshold) + + cfg, err := config.Read(configFile) + if assert.NoError(t, err) { + assert.Equal(t, reloadThreshold, cfg.ReloadThreshold) + assert.Equal(t, configDefault.StdinReadTimeout, cfg.StdinReadTimeout) + } +} + func TestReadNotFound(t *testing.T) { t.Parallel() @@ -144,7 +164,8 @@ func ExampleGetDefaultConfig() { // }, // "prerenderRows": 100, // "reloadThreshold": 1000000000, - // "maxFileSizeBytes": 1073741824 + // "maxFileSizeBytes": 1073741824, + // "stdinReadTimeout": 1000000000 // } } diff --git a/internal/pkg/source/entry.go b/internal/pkg/source/entry.go index 1f63224..4497b2c 100644 --- a/internal/pkg/source/entry.go +++ b/internal/pkg/source/entry.go @@ -209,12 +209,18 @@ func guessTimeFieldKind(timeStr string) config.FieldKind { intLength := len(strconv.FormatInt(intValue, 10)) + const ( + unixSecondsLength = 10 + unixMilliLength = 13 + unixMicroLength = 16 + ) + switch { - case intLength <= 10: + case intLength <= unixSecondsLength: return config.FieldKindSecondTime - case intLength > 10 && intLength <= 13: + case intLength > unixSecondsLength && intLength <= unixMilliLength: return config.FieldKindMilliTime - case intLength > 13 && intLength <= 16: + case intLength > unixMilliLength && intLength <= unixMicroLength: return config.FieldKindMicroTime default: return config.FieldKindTime diff --git a/internal/pkg/source/entry_test.go b/internal/pkg/source/entry_test.go index 9b1b982..34b4904 100644 --- a/internal/pkg/source/entry_test.go +++ b/internal/pkg/source/entry_test.go @@ -188,8 +188,6 @@ func TestParseLogEntryDefault(t *testing.T) { }} for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.Name, func(t *testing.T) { t.Parallel() @@ -368,7 +366,6 @@ func TestSecondTimeFormatting(t *testing.T) { }} for _, testCase := range secondsTestCases { - testCase := testCase t.Run(testCase.TestName, func(t *testing.T) { t.Parallel() actual := source.ParseLogEntry(json.RawMessage(testCase.JSON), cfg) @@ -403,7 +400,6 @@ func TestMillisecondTimeFormatting(t *testing.T) { }} for _, testCase := range millisecondTestCases { - testCase := testCase t.Run(testCase.TestName, func(t *testing.T) { t.Parallel() actual := source.ParseLogEntry(json.RawMessage(testCase.JSON), cfg) @@ -438,7 +434,6 @@ func TestMicrosecondTimeFormatting(t *testing.T) { }} for _, testCase := range microsecondTestCases { - testCase := testCase t.Run(testCase.TestName, func(t *testing.T) { t.Parallel() actual := source.ParseLogEntry(json.RawMessage(testCase.JSON), cfg) @@ -495,7 +490,6 @@ func TestNumericKindTimeFormatting(t *testing.T) { }} for _, testCase := range numericKindCases { - testCase := testCase t.Run(testCase.TestName, func(t *testing.T) { t.Parallel() actual := source.ParseLogEntry(json.RawMessage(testCase.JSON), cfg) diff --git a/internal/pkg/source/fileinput/fileinput.go b/internal/pkg/source/fileinput/fileinput.go new file mode 100644 index 0000000..645a164 --- /dev/null +++ b/internal/pkg/source/fileinput/fileinput.go @@ -0,0 +1,29 @@ +package fileinput + +import ( + "context" + "io" + "os" +) + +// FileInput is the source that represents the file. +type FileInput struct { + fileName string +} + +// New initializes a new FileInput with the given file. +func New(fileName string) FileInput { + return FileInput{ + fileName: fileName, + } +} + +// ReadCloser opens the file. Call Close after usage. +func (s FileInput) ReadCloser(context.Context) (io.ReadCloser, error) { + return os.Open(s.fileName) +} + +// String implements fmt.Stringer. +func (s FileInput) String() string { + return s.fileName +} diff --git a/internal/pkg/source/fileinput/fileinput_test.go b/internal/pkg/source/fileinput/fileinput_test.go new file mode 100644 index 0000000..5a0dee4 --- /dev/null +++ b/internal/pkg/source/fileinput/fileinput_test.go @@ -0,0 +1,56 @@ +package fileinput_test + +import ( + "context" + "io" + "testing" + + "github.com/hedhyw/json-log-viewer/assets" + "github.com/hedhyw/json-log-viewer/internal/pkg/source/fileinput" + "github.com/hedhyw/json-log-viewer/internal/pkg/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileInputString(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + expected := assets.ExampleJSONLog() + testFile := tests.RequireCreateFile(t, expected) + + t.Run("ReadCloser", func(t *testing.T) { + t.Parallel() + + input := fileinput.New(testFile) + + readCloser, err := input.ReadCloser(ctx) + require.NoError(t, err) + + t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) + + actual, err := io.ReadAll(readCloser) + require.NoError(t, err) + + assert.Equal(t, expected, actual) + }) + + t.Run("String", func(t *testing.T) { + t.Parallel() + + input := fileinput.New(testFile) + + assert.Equal(t, testFile, input.String()) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + input := fileinput.New("not_found_for_" + t.Name()) + + _, err := input.ReadCloser(ctx) + require.Error(t, err) + }) +} diff --git a/internal/pkg/source/input.go b/internal/pkg/source/input.go new file mode 100644 index 0000000..e0f1506 --- /dev/null +++ b/internal/pkg/source/input.go @@ -0,0 +1,15 @@ +package source + +import ( + "context" + "fmt" + "io" +) + +// Input returns the getter of read-closer for the given input source. +type Input interface { + // ReadCloser returns a reader from the input. Call `Close` after usage. + ReadCloser(ctx context.Context) (io.ReadCloser, error) + + fmt.Stringer +} diff --git a/internal/pkg/source/level_test.go b/internal/pkg/source/level_test.go index dcdf46f..3ac13c7 100644 --- a/internal/pkg/source/level_test.go +++ b/internal/pkg/source/level_test.go @@ -72,8 +72,6 @@ func TestParseLevel(t *testing.T) { }} for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.Input, func(t *testing.T) { t.Parallel() diff --git a/internal/pkg/source/readerinput/readerinput.go b/internal/pkg/source/readerinput/readerinput.go new file mode 100644 index 0000000..7c08756 --- /dev/null +++ b/internal/pkg/source/readerinput/readerinput.go @@ -0,0 +1,90 @@ +package readerinput + +import ( + "bufio" + "bytes" + "context" + "io" + "time" +) + +// ReaderInput reads from the configured input with some timeout. +type ReaderInput struct { + readTimeout time.Duration + + linesChan <-chan []byte + errChan <-chan error + + lastErr error + content []byte +} + +// New initializes ReaderInput with the given reader and timeout. +func New( + reader io.Reader, + timeout time.Duration, +) *ReaderInput { + scanner := bufio.NewScanner(reader) + + linesChan := make(chan []byte) + errChan := make(chan error, 1) + + go func() { + for scanner.Scan() { + linesChan <- scanner.Bytes() + } + + if err := scanner.Err(); err != nil { + errChan <- err + close(errChan) + } else { + close(linesChan) + } + }() + + return &ReaderInput{ + readTimeout: timeout, + + linesChan: linesChan, + errChan: errChan, + + lastErr: nil, + content: make([]byte, 0), + } +} + +// String implements fmt.Stringer. +func (s *ReaderInput) String() string { + return "-" +} + +// ReadCloser reads the content from the input. +func (s *ReaderInput) ReadCloser(ctx context.Context) (io.ReadCloser, error) { + if s.lastErr != nil { + return nil, s.lastErr + } + + ctx, cancel := context.WithTimeout(ctx, s.readTimeout) + defer cancel() + +loop: + for ctx.Err() == nil { + select { + case line, ok := <-s.linesChan: + if !ok { + break loop + } + + s.content = append(s.content, line...) + s.content = append(s.content, "\n"...) + case err := <-s.errChan: + s.lastErr = err + + return nil, s.lastErr + case <-ctx.Done(): + break loop + } + } + + return io.NopCloser(bytes.NewReader(s.content)), nil +} diff --git a/internal/pkg/source/readerinput/readerinput_test.go b/internal/pkg/source/readerinput/readerinput_test.go new file mode 100644 index 0000000..fe73be2 --- /dev/null +++ b/internal/pkg/source/readerinput/readerinput_test.go @@ -0,0 +1,139 @@ +package readerinput_test + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + "testing/iotest" + "time" + + "github.com/hedhyw/json-log-viewer/assets" + "github.com/hedhyw/json-log-viewer/internal/pkg/source/readerinput" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReaderInput(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expected := assets.ExampleJSONLog() + + t.Run("ReadCloser", func(t *testing.T) { + t.Parallel() + + input := readerinput.New(bytes.NewReader(expected), time.Minute) + + readCloser, err := input.ReadCloser(ctx) + require.NoError(t, err) + + t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) + + actual, err := io.ReadAll(readCloser) + require.NoError(t, err) + + assert.Equal(t, bytes.TrimSpace(expected), bytes.TrimSpace(actual)) + }) + + t.Run("ReadCloser_twice", func(t *testing.T) { + t.Parallel() + + input := readerinput.New(bytes.NewReader(expected), time.Minute) + + for range 2 { + readCloser, err := input.ReadCloser(ctx) + require.NoError(t, err) + + t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) + + actual, err := io.ReadAll(readCloser) + require.NoError(t, err) + + assert.Equal(t, bytes.TrimSpace(expected), bytes.TrimSpace(actual)) + } + }) + + t.Run("ReadCloser_error", func(t *testing.T) { + t.Parallel() + + // nolint: err113 // Test error. + errReader := errors.New(t.Name()) + + input := readerinput.New(iotest.ErrReader(errReader), time.Minute) + + _, err := input.ReadCloser(ctx) + require.Error(t, err) + require.ErrorIs(t, err, errReader) + + _, err = input.ReadCloser(ctx) + require.Error(t, err) + require.ErrorIs(t, err, errReader) + }) + + t.Run("ReadCloser_wait", func(t *testing.T) { + t.Parallel() + + const ( + lineFirst = "line first\n" + lineSecond = "line second\n" + + timeout = 200 * time.Millisecond + ) + + pipeReader, pipeWriter := io.Pipe() + + t.Cleanup(func() { assert.NoError(t, pipeReader.Close()) }) + t.Cleanup(func() { assert.NoError(t, pipeWriter.Close()) }) + + input := readerinput.New(pipeReader, timeout) + + _, err := pipeWriter.Write([]byte(lineFirst)) + require.NoError(t, err) + + readCloser, err := input.ReadCloser(ctx) + require.NoError(t, err) + + actual, err := io.ReadAll(readCloser) + require.NoError(t, err) + + assert.Equal(t, lineFirst, string(actual)) + + _, err = pipeWriter.Write([]byte(lineSecond)) + require.NoError(t, err) + + readCloser, err = input.ReadCloser(ctx) + require.NoError(t, err) + + actual, err = io.ReadAll(readCloser) + require.NoError(t, err) + + assert.Equal(t, lineFirst+lineSecond, string(actual)) + }) + + t.Run("String", func(t *testing.T) { + t.Parallel() + + input := readerinput.New(bytes.NewReader(nil), time.Minute) + + assert.Equal(t, "-", input.String()) + }) + + t.Run("empty", func(t *testing.T) { + t.Parallel() + + input := readerinput.New(bytes.NewReader(nil), time.Minute) + + readCloser, err := input.ReadCloser(ctx) + require.NoError(t, err) + + t.Cleanup(func() { assert.NoError(t, readCloser.Close()) }) + + actual, err := io.ReadAll(readCloser) + require.NoError(t, err) + + assert.Empty(t, actual) + }) +} diff --git a/internal/pkg/source/source.go b/internal/pkg/source/source.go index 2ddc985..09a5116 100644 --- a/internal/pkg/source/source.go +++ b/internal/pkg/source/source.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "os" "github.com/hedhyw/json-log-viewer/internal/pkg/config" ) @@ -17,26 +16,7 @@ const ( logEntriesEstimateNumber = 256 ) -// LoadLogsFromFile loads json log entries from file. -func LoadLogsFromFile( - path string, - cfg *config.Config, -) (_ LazyLogEntries, err error) { - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("os: %w", err) - } - - defer func() { err = errors.Join(err, file.Close()) }() - - logEntries, err := ParseLogEntriesFromReader(file, cfg) - if err != nil { - return nil, fmt.Errorf("parsing from reader: %w", err) - } - - return logEntries.Reverse(), nil -} - +// ParseLogEntriesFromReader reads the input and parses all logs. func ParseLogEntriesFromReader( reader io.Reader, cfg *config.Config, @@ -66,5 +46,5 @@ func ParseLogEntriesFromReader( } } - return logEntries, nil + return logEntries.Reverse(), nil } diff --git a/internal/pkg/source/source_test.go b/internal/pkg/source/source_test.go index ff4bbe4..b70b001 100644 --- a/internal/pkg/source/source_test.go +++ b/internal/pkg/source/source_test.go @@ -1,6 +1,7 @@ package source_test import ( + "bytes" "strings" "testing" @@ -10,7 +11,6 @@ import ( "github.com/hedhyw/json-log-viewer/assets" "github.com/hedhyw/json-log-viewer/internal/pkg/config" "github.com/hedhyw/json-log-viewer/internal/pkg/source" - "github.com/hedhyw/json-log-viewer/internal/pkg/tests" ) func TestLoadLogsFromFile(t *testing.T) { @@ -19,34 +19,21 @@ func TestLoadLogsFromFile(t *testing.T) { t.Run("ok", func(t *testing.T) { t.Parallel() - testFile := tests.RequireCreateFile(t, assets.ExampleJSONLog()) - - logEntries, err := source.LoadLogsFromFile( - testFile, + logEntries, err := source.ParseLogEntriesFromReader( + bytes.NewReader(assets.ExampleJSONLog()), config.GetDefaultConfig(), ) require.NoError(t, err) assert.NotEmpty(t, logEntries) }) - t.Run("not_found", func(t *testing.T) { - t.Parallel() - - _, err := source.LoadLogsFromFile( - "not_found_for_"+t.Name(), - config.GetDefaultConfig(), - ) - assert.Error(t, err) - }) - t.Run("large_line", func(t *testing.T) { t.Parallel() longLine := strings.Repeat("1", 2*1024*1024) - testFile := tests.RequireCreateFile(t, []byte(longLine)) - logEntries, err := source.LoadLogsFromFile( - testFile, + logEntries, err := source.ParseLogEntriesFromReader( + strings.NewReader(longLine), config.GetDefaultConfig(), ) require.NoError(t, err) @@ -54,17 +41,15 @@ func TestLoadLogsFromFile(t *testing.T) { }) } -func TestLoadLogsFromFileLimited(t *testing.T) { +func TestParseLogEntriesFromReaderLimited(t *testing.T) { t.Parallel() content := `{}` - testFile := tests.RequireCreateFile(t, []byte(content)) - cfg := config.GetDefaultConfig() cfg.MaxFileSizeBytes = 1 - logEntries, err := source.LoadLogsFromFile(testFile, cfg) + logEntries, err := source.ParseLogEntriesFromReader(strings.NewReader(content), cfg) require.NoError(t, err) if assert.Len(t, logEntries, 1) { From 07f6bf5f87fd3a1b6d489a85eaf069e4d32e280f Mon Sep 17 00:00:00 2001 From: hedhyw Date: Sun, 2 Jun 2024 12:00:51 +0200 Subject: [PATCH 2/2] refactor: use spaces in example config --- example.jlv.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example.jlv.jsonc b/example.jlv.jsonc index a2115a1..c7c2ebf 100644 --- a/example.jlv.jsonc +++ b/example.jlv.jsonc @@ -71,5 +71,5 @@ // The rest of the file will be ignored. "maxFileSizeBytes": 1073741824, // StdinReadTimeout is the timeout (in nanoseconds) of reading from the standart input. - "stdinReadTimeout": 1000000000 + "stdinReadTimeout": 1000000000 } \ No newline at end of file