Repository: stern/stern
Pull request #292 (2024-03-20 12:52:56)

Add `--stdin` for `stdin` log parsing
Usage:

```sh
$ kubectl logs <pod> | stern --stdin
$ stern --stdin < ./service.log
```

This PR adds the `--stdin` flag for parsing logs from `stdin` instead of pulling them from k8s. It creates a new "tail" struct, `FileTail`, which is heavily based on the original `Tail`. The new class is quite slim and I didn't find any meaningful way to combine the two, so they are mostly their own separate thing. They do share some helper functions and `TailOptions` which I moved to a "utils" file.

It's not uncommon for CLI tools to use `-` to read from `stdin` but I didn't find support for that in `pflag`, and I'm not sure it's better than `--stdin` anyway. (e.g like vim `echo "Hello" | vim -`, and for stern `stern - < ./service.log`)

Feedback welcome.

Implements #291 


Patch:
diff --git a/README.md b/README.md
index a077893..f2d1e5c 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,7 @@ Supported Kubernetes resources are `pod`, `replicationcontroller`, `service`, `d
  `--selector`, `-l`          |                               | Selector (label query) to filter on. If present, default to ".*" for the pod-query.
  `--show-hidden-options`     | `false`                       | Print a list of hidden options.
  `--since`, `-s`             | `48h0m0s`                     | Return logs newer than a relative duration like 5s, 2m, or 3h.
+ `--stdin`                   | `false`                       | Parse logs from stdin. All Kubernetes related flags are ignored when it is set.
  `--tail`                    | `-1`                          | The number of lines from the end of the logs to show. Defaults to -1, showing all logs.
  `--template`                |                               | Template to use for log lines, leave empty to use --output flag.
  `--template-file`, `-T`     |                               | Path to template to use for log lines, leave empty to use --output flag. It overrides --template option.
@@ -313,6 +314,12 @@ Output log lines only:
 stern . --only-log-lines
 ```
 
+Read from stdin:
+
+```
+stern --stdin < service.log
+```
+
 ## Completion
 
 Stern supports command-line auto completion for bash, zsh or fish. `stern
diff --git a/cmd/cmd.go b/cmd/cmd.go
index f1c850d..f7d811c 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -86,6 +86,7 @@ type options struct {
 	node                string
 	configFilePath      string
 	showHiddenOptions   bool
+	stdin               bool
 
 	client       kubernetes.Interface
 	clientConfig clientcmd.ClientConfig
@@ -154,8 +155,8 @@ func (o *options) Complete(args []string) error {
 }
 
 func (o *options) Validate() error {
-	if !o.prompt && o.podQuery == "" && o.resource == "" && o.selector == "" && o.fieldSelector == "" {
-		return errors.New("One of pod-query, --selector, --field-selector or --prompt is required")
+	if !o.prompt && o.podQuery == "" && o.resource == "" && o.selector == "" && o.fieldSelector == "" && !o.stdin {
+		return errors.New("One of pod-query, --selector, --field-selector, --prompt or --stdin is required")
 	}
 	if o.selector != "" && o.resource != "" {
 		return errors.New("--selector and the <resource>/<name> query can not be set at the same time")
@@ -317,6 +318,7 @@ func (o *options) sternConfig() (*stern.Config, error) {
 		Resource:              o.resource,
 		OnlyLogLines:          o.onlyLogLines,
 		MaxLogRequests:        maxLogRequests,
+		Stdin:                 o.stdin,
 
 		Out:    o.Out,
 		ErrOut: o.ErrOut,
@@ -419,6 +421,7 @@ func (o *options) AddFlags(fs *pflag.FlagSet) {
 	fs.IntVar(&o.verbosity, "verbosity", o.verbosity, "Number of the log level verbosity")
 	fs.BoolVarP(&o.version, "version", "v", o.version, "Print the version and exit.")
 	fs.BoolVar(&o.showHiddenOptions, "show-hidden-options", o.showHiddenOptions, "Print a list of hidden options.")
+	fs.BoolVar(&o.stdin, "stdin", o.stdin, "Parse logs from stdin. All Kubernetes related flags are ignored when it is set.")
 
 	fs.Lookup("timestamps").NoOptDefVal = "default"
 }
diff --git a/stern/config.go b/stern/config.go
index b15bb80..78fd2df 100644
--- a/stern/config.go
+++ b/stern/config.go
@@ -50,6 +50,7 @@ type Config struct {
 	Resource              string
 	OnlyLogLines          bool
 	MaxLogRequests        int
+	Stdin                 bool
 
 	Out    io.Writer
 	ErrOut io.Writer
diff --git a/stern/file_tail.go b/stern/file_tail.go
new file mode 100644
index 0000000..0a59519
--- /dev/null
+++ b/stern/file_tail.go
@@ -0,0 +1,89 @@
+package stern
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"io"
+	"strings"
+	"text/template"
+
+	"github.com/fatih/color"
+)
+
+type FileTail struct {
+	Options *TailOptions
+	tmpl    *template.Template
+	in      io.Reader
+	out     io.Writer
+	errOut  io.Writer
+}
+
+// NewFileTail returns a new tail of the input reader
+func NewFileTail(tmpl *template.Template, in io.Reader, out, errOut io.Writer, options *TailOptions) *FileTail {
+	return &FileTail{
+		Options: options,
+		tmpl:    tmpl,
+		in:      in,
+		out:     out,
+		errOut:  errOut,
+	}
+}
+
+// Start starts tailing
+func (t *FileTail) Start() error {
+	reader := bufio.NewReader(t.in)
+	err := t.ConsumeReader(reader)
+
+	return err
+}
+
+// ConsumeReader reads the data from the reader and writes into the out
+// writer.
+func (t *FileTail) ConsumeReader(reader *bufio.Reader) error {
+	for {
+		line, err := reader.ReadBytes('\n')
+		if len(line) != 0 {
+			t.consumeLine(strings.TrimSuffix(string(line), "\n"))
+		}
+
+		if err != nil {
+			if err != io.EOF {
+				return err
+			}
+			return nil
+		}
+	}
+}
+
+// Print prints a color coded log message
+func (t *FileTail) Print(msg string) {
+	vm := Log{
+		Message:        msg,
+		NodeName:       "",
+		Namespace:      "",
+		PodName:        "",
+		ContainerName:  "",
+		PodColor:       color.New(color.Reset),
+		ContainerColor: color.New(color.Reset),
+	}
+
+	var buf bytes.Buffer
+	if err := t.tmpl.Execute(&buf, vm); err != nil {
+		fmt.Fprintf(t.errOut, "expanding template failed: %s\n", err)
+		return
+	}
+
+	fmt.Fprint(t.out, buf.String())
+}
+
+func (t *FileTail) consumeLine(line string) {
+	content := line
+
+	if t.Options.IsExclude(content) || !t.Options.IsInclude(content) {
+		return
+	}
+
+	msg := t.Options.HighlightMatchedString(content)
+	t.Print(msg)
+}
diff --git a/stern/stern.go b/stern/stern.go
index c2e1c90..c1630ef 100644
--- a/stern/stern.go
+++ b/stern/stern.go
@@ -17,6 +17,7 @@ package stern
 import (
 	"context"
 	"fmt"
+	"os"
 	"regexp"
 	"strings"
 	"time"
@@ -48,6 +49,30 @@ func Run(ctx context.Context, client kubernetes.Interface, config *Config) error
 		}
 	}
 
+	newTailOptions := func() *TailOptions {
+		return &TailOptions{
+			Timestamps:      config.Timestamps,
+			TimestampFormat: config.TimestampFormat,
+			Location:        config.Location,
+			SinceSeconds:    ptr.To[int64](int64(config.Since.Seconds())),
+			Exclude:         config.Exclude,
+			Include:         config.Include,
+			Highlight:       config.Highlight,
+			Namespace:       config.AllNamespaces || len(namespaces) > 1,
+			TailLines:       config.TailLines,
+			Follow:          config.Follow,
+			OnlyLogLines:    config.OnlyLogLines,
+		}
+	}
+	newTail := func(t *Target) *Tail {
+		return NewTail(client.CoreV1(), t.Node, t.Namespace, t.Pod, t.Container, config.Template, config.Out, config.ErrOut, newTailOptions())
+	}
+
+	if config.Stdin {
+		tail := NewFileTail(config.Template, os.Stdin, config.Out, config.ErrOut, newTailOptions())
+		return tail.Start()
+	}
+
 	var resource struct {
 		kind string
 		name string
@@ -78,21 +103,6 @@ func Run(ctx context.Context, client kubernetes.Interface, config *Config) error
 		ephemeralContainers:    config.EphemeralContainers,
 		containerStates:        config.ContainerStates,
 	})
-	newTail := func(t *Target) *Tail {
-		return NewTail(client.CoreV1(), t.Node, t.Namespace, t.Pod, t.Container, config.Template, config.Out, config.ErrOut, &TailOptions{
-			Timestamps:      config.Timestamps,
-			TimestampFormat: config.TimestampFormat,
-			Location:        config.Location,
-			SinceSeconds:    ptr.To[int64](int64(config.Since.Seconds())),
-			Exclude:         config.Exclude,
-			Include:         config.Include,
-			Highlight:       config.Highlight,
-			Namespace:       config.AllNamespaces || len(namespaces) > 1,
-			TailLines:       config.TailLines,
-			Follow:          config.Follow,
-			OnlyLogLines:    config.OnlyLogLines,
-		})
-	}
 
 	if !config.Follow {
 		var eg errgroup.Group
diff --git a/stern/tail.go b/stern/tail.go
index b03f73a..20ab9ed 100644
--- a/stern/tail.go
+++ b/stern/tail.go
@@ -22,8 +22,6 @@ import (
 	"fmt"
 	"hash/fnv"
 	"io"
-	"regexp"
-	"sort"
 	"strings"
 	"text/template"
 	"time"
@@ -63,95 +61,11 @@ type Tail struct {
 	errOut        io.Writer
 }
 
-type TailOptions struct {
-	Timestamps      bool
-	TimestampFormat string
-	Location        *time.Location
-
-	SinceSeconds *int64
-	SinceTime    *metav1.Time
-	Exclude      []*regexp.Regexp
-	Include      []*regexp.Regexp
-	Highlight    []*regexp.Regexp
-	Namespace    bool
-	TailLines    *int64
-	Follow       bool
-	OnlyLogLines bool
-
-	// regexp for highlighting the matched string
-	reHightlight *regexp.Regexp
-}
-
 type ResumeRequest struct {
 	Timestamp   string // RFC3339 timestamp (not RFC3339Nano)
 	LinesToSkip int    // the number of lines to skip during this timestamp
 }
 
-func (o TailOptions) IsExclude(msg string) bool {
-	for _, rex := range o.Exclude {
-		if rex.MatchString(msg) {
-			return true
-		}
-	}
-
-	return false
-}
-
-func (o TailOptions) IsInclude(msg string) bool {
-	if len(o.Include) == 0 {
-		return true
-	}
-
-	for _, rin := range o.Include {
-		if rin.MatchString(msg) {
-			return true
-		}
-	}
-
-	return false
-}
-
-var colorHighlight = color.New(color.FgRed, color.Bold).SprintFunc()
-
-func (o TailOptions) HighlightMatchedString(msg string) string {
-	highlight := append(o.Include, o.Highlight...)
-	if len(highlight) == 0 {
-		return msg
-	}
-
-	if o.reHightlight == nil {
-		ss := make([]string, len(highlight))
-		for i, hl := range highlight {
-			ss[i] = hl.String()
-		}
-
-		// We expect a longer match
-		sort.Slice(ss, func(i, j int) bool {
-			return len(ss[i]) > len(ss[j])
-		})
-
-		o.reHightlight = regexp.MustCompile("(" + strings.Join(ss, "|") + ")")
-	}
-
-	msg = o.reHightlight.ReplaceAllStringFunc(msg, func(part string) string {
-		return colorHighlight(part)
-	})
-
-	return msg
-}
-
-func (o TailOptions) UpdateTimezoneAndFormat(timestamp string) (string, error) {
-	t, err := time.ParseInLocation(time.RFC3339Nano, timestamp, time.UTC)
-	if err != nil {
-		return "", errors.New("missing timestamp")
-	}
-	format := TimestampFormatDefault
-	if o.TimestampFormat != "" {
-		format = o.TimestampFormat
-	}
-	return t.In(o.Location).Format(format), nil
-}
-
 // NewTail returns a new tail for a Kubernetes container inside a pod
 func NewTail(clientset corev1client.CoreV1Interface, nodeName, namespace, podName, containerName string, tmpl *template.Template, out, errOut io.Writer, options *TailOptions) *Tail {
 	podColor, containerColor := determineColor(podName)
@@ -360,28 +274,6 @@ func (t *Tail) rememberLastTimestamp(timestamp string) {
 	t.last.lines = 1
 }
 
-// Log is the object which will be used together with the template to generate
-// the output.
-type Log struct {
-	// Message is the log message itself
-	Message string `json:"message"`
-
-	// Node name of the pod
-	NodeName string `json:"nodeName"`
-
-	// Namespace of the pod
-	Namespace string `json:"namespace"`
-
-	// PodName of the pod
-	PodName string `json:"podName"`
-
-	// ContainerName of the container
-	ContainerName string `json:"containerName"`
-
-	PodColor       *color.Color `json:"-"`
-	ContainerColor *color.Color `json:"-"`
-}
-
 func (r *ResumeRequest) sinceTime() (*metav1.Time, error) {
 	sinceTime, err := time.Parse(time.RFC3339, r.Timestamp)
 
diff --git a/stern/tail_utils.go b/stern/tail_utils.go
new file mode 100644
index 0000000..08b77b1
--- /dev/null
+++ b/stern/tail_utils.go
@@ -0,0 +1,118 @@
+package stern
+
+import (
+	"errors"
+	"regexp"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/fatih/color"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// Log is the object which will be used together with the template to generate
+// the output.
+type Log struct {
+	// Message is the log message itself
+	Message string `json:"message"`
+
+	// Node name of the pod
+	NodeName string `json:"nodeName"`
+
+	// Namespace of the pod
+	Namespace string `json:"namespace"`
+
+	// PodName of the pod
+	PodName string `json:"podName"`
+
+	// ContainerName of the container
+	ContainerName string `json:"containerName"`
+
+	PodColor       *color.Color `json:"-"`
+	ContainerColor *color.Color `json:"-"`
+}
+
+type TailOptions struct {
+	Timestamps      bool
+	TimestampFormat string
+	Location        *time.Location
+
+	SinceSeconds *int64
+	SinceTime    *metav1.Time
+	Exclude      []*regexp.Regexp
+	Include      []*regexp.Regexp
+	Highlight    []*regexp.Regexp
+	Namespace    bool
+	TailLines    *int64
+	Follow       bool
+	OnlyLogLines bool
+
+	// regexp for highlighting the matched string
+	reHightlight *regexp.Regexp
+}
+
+func (o TailOptions) IsExclude(msg string) bool {
+	for _, rex := range o.Exclude {
+		if rex.MatchString(msg) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (o TailOptions) IsInclude(msg string) bool {
+	if len(o.Include) == 0 {
+		return true
+	}
+
+	for _, rin := range o.Include {
+		if rin.MatchString(msg) {
+			return true
+		}
+	}
+
+	return false
+}
+
+var colorHighlight = color.New(color.FgRed, color.Bold).SprintFunc()
+
+func (o TailOptions) HighlightMatchedString(msg string) string {
+	highlight := append(o.Include, o.Highlight...)
+	if len(highlight) == 0 {
+		return msg
+	}
+
+	if o.reHightlight == nil {
+		ss := make([]string, len(highlight))
+		for i, hl := range highlight {
+			ss[i] = hl.String()
+		}
+
+		// We expect a longer match
+		sort.Slice(ss, func(i, j int) bool {
+			return len(ss[i]) > len(ss[j])
+		})
+
+		o.reHightlight = regexp.MustCompile("(" + strings.Join(ss, "|") + ")")
+	}
+
+	msg = o.reHightlight.ReplaceAllStringFunc(msg, func(part string) string {
+		return colorHighlight(part)
+	})
+
+	return msg
+}
+
+func (o TailOptions) UpdateTimezoneAndFormat(timestamp string) (string, error) {
+	t, err := time.ParseInLocation(time.RFC3339Nano, timestamp, time.UTC)
+	if err != nil {
+		return "", errors.New("missing timestamp")
+	}
+	format := TimestampFormatDefault
+	if o.TimestampFormat != "" {
+		format = o.TimestampFormat
+	}
+	return t.In(o.Location).Format(format), nil
+}


Test patch:
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
index aa602f0..922758d 100644
--- a/cmd/cmd_test.go
+++ b/cmd/cmd_test.go
@@ -109,7 +109,7 @@ func TestOptionsValidate(t *testing.T) {
 		{
 			"No required options",
 			NewOptions(streams),
-			"One of pod-query, --selector, --field-selector or --prompt is required",
+			"One of pod-query, --selector, --field-selector, --prompt or --stdin is required",
 		},
 		{
 			"Specify both selector and resource",
diff --git a/stern/file_tail_test.go b/stern/file_tail_test.go
new file mode 100644
index 0000000..a03412f
--- /dev/null
+++ b/stern/file_tail_test.go
@@ -0,0 +1,47 @@
+package stern
+
+import (
+	"bufio"
+	"bytes"
+	"io"
+	"strings"
+	"testing"
+	"text/template"
+)
+
+func TestConsumeFileTail(t *testing.T) {
+	logLines := `line 1
+line 2
+line 3
+line 4`
+	tmpl := template.Must(template.New("").Parse(`{{printf "%s\n" .Message}}`))
+
+	tests := []struct {
+		name      string
+		resumeReq *ResumeRequest
+		expected  []byte
+	}{
+		{
+			name: "normal",
+			expected: []byte(`line 1
+line 2
+line 3
+line 4
+`),
+		},
+	}
+
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			out := new(bytes.Buffer)
+			tail := NewFileTail(tmpl, nil, out, io.Discard, &TailOptions{})
+			if err := tail.ConsumeReader(bufio.NewReader(strings.NewReader(logLines))); err != nil {
+				t.Fatalf("%d: unexpected err %v", i, err)
+			}
+
+			if !bytes.Equal(tt.expected, out.Bytes()) {
+				t.Errorf("%d: expected %s, but actual %s", i, tt.expected, out)
+			}
+		})
+	}
+}
diff --git a/stern/tail_test.go b/stern/tail_test.go
index 06564f8..eb33bcd 100644
--- a/stern/tail_test.go
+++ b/stern/tail_test.go
@@ -3,15 +3,11 @@ package stern
 import (
 	"bytes"
 	"context"
-	"fmt"
 	"io"
 	"reflect"
-	"regexp"
 	"testing"
 	"text/template"
-	"time"
 
-	"github.com/fatih/color"
 	"k8s.io/client-go/kubernetes/fake"
 )
 
@@ -30,112 +26,6 @@ func TestDetermineColor(t *testing.T) {
 	}
 }
 
-func TestIsIncludeTestOptions(t *testing.T) {
-	msg := "this is a log message"
-
-	tests := []struct {
-		include  []*regexp.Regexp
-		expected bool
-	}{
-		{
-			include:  []*regexp.Regexp{},
-			expected: true,
-		},
-		{
-			include: []*regexp.Regexp{
-				regexp.MustCompile(`this is not`),
-			},
-			expected: false,
-		},
-		{
-			include: []*regexp.Regexp{
-				regexp.MustCompile(`this is`),
-			},
-			expected: true,
-		},
-	}
-
-	for i, tt := range tests {
-		o := &TailOptions{Include: tt.include}
-		if o.IsInclude(msg) != tt.expected {
-			t.Errorf("%d: expected %s, but actual %s", i, fmt.Sprint(tt.expected), fmt.Sprint(!tt.expected))
-		}
-	}
-}
-
-func TestUpdateTimezoneAndFormat(t *testing.T) {
-	location, _ := time.LoadLocation("Asia/Tokyo")
-
-	tests := []struct {
-		name     string
-		format   string
-		message  string
-		expected string
-		err      string
-	}{
-		{
-			"normal case",
-			"", // default format is used if empty
-			"2021-04-18T03:54:44.764981564Z",
-			"2021-04-18T12:54:44.764981564+09:00",
-			"",
-		},
-		{
-			"padding",
-			"",
-			"2021-04-18T03:54:44.764981500Z",
-			"2021-04-18T12:54:44.764981500+09:00",
-			"",
-		},
-		{
-			"timestamp required on non timestamp message",
-			"",
-			"",
-			"",
-			"missing timestamp",
-		},
-		{
-			"not UTC",
-			"",
-			"2021-08-03T01:26:29.953994922+02:00",
-			"2021-08-03T08:26:29.953994922+09:00",
-			"",
-		},
-		{
-			"RFC3339Nano format removed trailing zeros",
-			"",
-			"2021-06-20T08:20:30.331385Z",
-			"2021-06-20T17:20:30.331385000+09:00",
-			"",
-		},
-		{
-			"Specified the short format",
-			TimestampFormatShort,
-			"2021-06-20T08:20:30.331385Z",
-			"06-20 17:20:30",
-			"",
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			tailOptions := &TailOptions{
-				Location:        location,
-				TimestampFormat: tt.format,
-			}
-
-			message, err := tailOptions.UpdateTimezoneAndFormat(tt.message)
-			if tt.expected != message {
-				t.Errorf("expected %q, but actual %q", tt.expected, message)
-			}
-
-			if err != nil && tt.err != err.Error() {
-				t.Errorf("expected %q, but actual %q", tt.err, err)
-			}
-		})
-	}
-}
-
 func TestConsumeStreamTail(t *testing.T) {
 	logLines := `2023-02-13T21:20:30.000000001Z line 1
 2023-02-13T21:20:30.000000002Z line 2
@@ -381,205 +271,3 @@ func TestRemoveSubsecond(t *testing.T) {
 		}
 	}
 }
-
-func TestHighlighIncludedString(t *testing.T) {
-	tests := []struct {
-		msg      string
-		include  []*regexp.Regexp
-		expected string
-	}{
-		{
-			"test matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`test`),
-			},
-			"\x1b[31;1mtest\x1b[0;22m matched",
-		},
-		{
-			"test not-matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`hoge`),
-			},
-			"test not-matched",
-		},
-		{
-			"test matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`not-matched`),
-				regexp.MustCompile(`matched`),
-			},
-			"test \x1b[31;1mmatched\x1b[0;22m",
-		},
-		{
-			"test multiple matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`multiple`),
-				regexp.MustCompile(`matched`),
-			},
-			"test \x1b[31;1mmultiple\x1b[0;22m \x1b[31;1mmatched\x1b[0;22m",
-		},
-		{
-			"test match on the longer one",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`match`),
-				regexp.MustCompile(`match on the longer one`),
-			},
-			"test \x1b[31;1mmatch on the longer one\x1b[0;22m",
-		},
-	}
-
-	orig := color.NoColor
-	color.NoColor = false
-	defer func() {
-		color.NoColor = orig
-	}()
-
-	for i, tt := range tests {
-		o := &TailOptions{Include: tt.include}
-		actual := o.HighlightMatchedString(tt.msg)
-		if actual != tt.expected {
-			t.Errorf("%d: expected %q, but actual %q", i, tt.expected, actual)
-		}
-	}
-}
-
-func TestIncludeAndHighlightMatchedString(t *testing.T) {
-	tests := []struct {
-		msg       string
-		include   []*regexp.Regexp
-		highlight []*regexp.Regexp
-		expected  string
-	}{
-		{
-			"test matched with highlight",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`test`),
-			},
-			[]*regexp.Regexp{
-				regexp.MustCompile(`highlight`),
-			},
-			"\x1b[31;1mtest\x1b[0;22m matched with \x1b[31;1mhighlight\x1b[0;22m",
-		},
-		{
-			"test not-matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`hoge`),
-			},
-			[]*regexp.Regexp{
-				regexp.MustCompile(`highlight`),
-			},
-			"test not-matched",
-		},
-		{
-			"test matched with highlight",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`not-matched`),
-				regexp.MustCompile(`matched`),
-			},
-			[]*regexp.Regexp{
-				regexp.MustCompile(`no-with-highlight`),
-				regexp.MustCompile(`with highlight`),
-			},
-			"test \x1b[31;1mmatched\x1b[0;22m \x1b[31;1mwith highlight\x1b[0;22m",
-		},
-		{
-			"test multiple matched with many highlight",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`multiple`),
-				regexp.MustCompile(`matched`),
-			},
-			[]*regexp.Regexp{
-				regexp.MustCompile(`many`),
-				regexp.MustCompile(`highlight`),
-			},
-			"test \x1b[31;1mmultiple\x1b[0;22m \x1b[31;1mmatched\x1b[0;22m with \x1b[31;1mmany\x1b[0;22m \x1b[31;1mhighlight\x1b[0;22m",
-		},
-		{
-			"test match on the longer one",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`match`),
-				regexp.MustCompile(`match on the longer one`),
-			},
-			[]*regexp.Regexp{
-				regexp.MustCompile(`match`),
-				regexp.MustCompile(`match on the longer one`),
-			},
-			"test \x1b[31;1mmatch on the longer one\x1b[0;22m",
-		},
-	}
-
-	orig := color.NoColor
-	color.NoColor = false
-	defer func() {
-		color.NoColor = orig
-	}()
-
-	for i, tt := range tests {
-		o := &TailOptions{Include: tt.include, Highlight: tt.highlight}
-		actual := o.HighlightMatchedString(tt.msg)
-		if actual != tt.expected {
-			t.Errorf("%d: expected %q, but actual %q", i, tt.expected, actual)
-		}
-	}
-}
-
-func TestHighlightMatchedString(t *testing.T) {
-	tests := []struct {
-		msg       string
-		highlight []*regexp.Regexp
-		expected  string
-	}{
-		{
-			"test matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`test`),
-			},
-			"\x1b[31;1mtest\x1b[0;22m matched",
-		},
-		{
-			"test not-matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`hoge`),
-			},
-			"test not-matched",
-		},
-		{
-			"test matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`not-matched`),
-				regexp.MustCompile(`matched`),
-			},
-			"test \x1b[31;1mmatched\x1b[0;22m",
-		},
-		{
-			"test multiple matched",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`multiple`),
-				regexp.MustCompile(`matched`),
-			},
-			"test \x1b[31;1mmultiple\x1b[0;22m \x1b[31;1mmatched\x1b[0;22m",
-		},
-		{
-			"test match on the longer one",
-			[]*regexp.Regexp{
-				regexp.MustCompile(`match`),
-				regexp.MustCompile(`match on the longer one`),
-			},
-			"test \x1b[31;1mmatch on the longer one\x1b[0;22m",
-		},
-	}
-
-	orig := color.NoColor
-	color.NoColor = false
-	defer func() {
-		color.NoColor = orig
-	}()
-
-	for i, tt := range tests {
-		o := &TailOptions{Highlight: tt.highlight}
-		actual := o.HighlightMatchedString(tt.msg)
-		if actual != tt.expected {
-			t.Errorf("%d: expected %q, but actual %q", i, tt.expected, actual)
-		}
-	}
-}
diff --git a/stern/tail_utils_test.go b/stern/tail_utils_test.go
new file mode 100644
index 0000000..88be7f6
--- /dev/null
+++ b/stern/tail_utils_test.go
@@ -0,0 +1,318 @@
+package stern
+
+import (
+	"fmt"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/fatih/color"
+)
+
+func TestIsIncludeTestOptions(t *testing.T) {
+	msg := "this is a log message"
+
+	tests := []struct {
+		include  []*regexp.Regexp
+		expected bool
+	}{
+		{
+			include:  []*regexp.Regexp{},
+			expected: true,
+		},
+		{
+			include: []*regexp.Regexp{
+				regexp.MustCompile(`this is not`),
+			},
+			expected: false,
+		},
+		{
+			include: []*regexp.Regexp{
+				regexp.MustCompile(`this is`),
+			},
+			expected: true,
+		},
+	}
+
+	for i, tt := range tests {
+		o := &TailOptions{Include: tt.include}
+		if o.IsInclude(msg) != tt.expected {
+			t.Errorf("%d: expected %s, but actual %s", i, fmt.Sprint(tt.expected), fmt.Sprint(!tt.expected))
+		}
+	}
+}
+
+func TestUpdateTimezoneAndFormat(t *testing.T) {
+	location, _ := time.LoadLocation("Asia/Tokyo")
+
+	tests := []struct {
+		name     string
+		format   string
+		message  string
+		expected string
+		err      string
+	}{
+		{
+			"normal case",
+			"", // default format is used if empty
+			"2021-04-18T03:54:44.764981564Z",
+			"2021-04-18T12:54:44.764981564+09:00",
+			"",
+		},
+		{
+			"padding",
+			"",
+			"2021-04-18T03:54:44.764981500Z",
+			"2021-04-18T12:54:44.764981500+09:00",
+			"",
+		},
+		{
+			"timestamp required on non timestamp message",
+			"",
+			"",
+			"",
+			"missing timestamp",
+		},
+		{
+			"not UTC",
+			"",
+			"2021-08-03T01:26:29.953994922+02:00",
+			"2021-08-03T08:26:29.953994922+09:00",
+			"",
+		},
+		{
+			"RFC3339Nano format removed trailing zeros",
+			"",
+			"2021-06-20T08:20:30.331385Z",
+			"2021-06-20T17:20:30.331385000+09:00",
+			"",
+		},
+		{
+			"Specified the short format",
+			TimestampFormatShort,
+			"2021-06-20T08:20:30.331385Z",
+			"06-20 17:20:30",
+			"",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tailOptions := &TailOptions{
+				Location:        location,
+				TimestampFormat: tt.format,
+			}
+
+			message, err := tailOptions.UpdateTimezoneAndFormat(tt.message)
+			if tt.expected != message {
+				t.Errorf("expected %q, but actual %q", tt.expected, message)
+			}
+
+			if err != nil && tt.err != err.Error() {
+				t.Errorf("expected %q, but actual %q", tt.err, err)
+			}
+		})
+	}
+}
+
+func TestHighlighIncludedString(t *testing.T) {
+	tests := []struct {
+		msg      string
+		include  []*regexp.Regexp
+		expected string
+	}{
+		{
+			"test matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`test`),
+			},
+			"\x1b[31;1mtest\x1b[0;22m matched",
+		},
+		{
+			"test not-matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`hoge`),
+			},
+			"test not-matched",
+		},
+		{
+			"test matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`not-matched`),
+				regexp.MustCompile(`matched`),
+			},
+			"test \x1b[31;1mmatched\x1b[0;22m",
+		},
+		{
+			"test multiple matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`multiple`),
+				regexp.MustCompile(`matched`),
+			},
+			"test \x1b[31;1mmultiple\x1b[0;22m \x1b[31;1mmatched\x1b[0;22m",
+		},
+		{
+			"test match on the longer one",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`match`),
+				regexp.MustCompile(`match on the longer one`),
+			},
+			"test \x1b[31;1mmatch on the longer one\x1b[0;22m",
+		},
+	}
+
+	orig := color.NoColor
+	color.NoColor = false
+	defer func() {
+		color.NoColor = orig
+	}()
+
+	for i, tt := range tests {
+		o := &TailOptions{Include: tt.include}
+		actual := o.HighlightMatchedString(tt.msg)
+		if actual != tt.expected {
+			t.Errorf("%d: expected %q, but actual %q", i, tt.expected, actual)
+		}
+	}
+}
+
+func TestIncludeAndHighlightMatchedString(t *testing.T) {
+	tests := []struct {
+		msg       string
+		include   []*regexp.Regexp
+		highlight []*regexp.Regexp
+		expected  string
+	}{
+		{
+			"test matched with highlight",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`test`),
+			},
+			[]*regexp.Regexp{
+				regexp.MustCompile(`highlight`),
+			},
+			"\x1b[31;1mtest\x1b[0;22m matched with \x1b[31;1mhighlight\x1b[0;22m",
+		},
+		{
+			"test not-matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`hoge`),
+			},
+			[]*regexp.Regexp{
+				regexp.MustCompile(`highlight`),
+			},
+			"test not-matched",
+		},
+		{
+			"test matched with highlight",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`not-matched`),
+				regexp.MustCompile(`matched`),
+			},
+			[]*regexp.Regexp{
+				regexp.MustCompile(`no-with-highlight`),
+				regexp.MustCompile(`with highlight`),
+			},
+			"test \x1b[31;1mmatched\x1b[0;22m \x1b[31;1mwith highlight\x1b[0;22m",
+		},
+		{
+			"test multiple matched with many highlight",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`multiple`),
+				regexp.MustCompile(`matched`),
+			},
+			[]*regexp.Regexp{
+				regexp.MustCompile(`many`),
+				regexp.MustCompile(`highlight`),
+			},
+			"test \x1b[31;1mmultiple\x1b[0;22m \x1b[31;1mmatched\x1b[0;22m with \x1b[31;1mmany\x1b[0;22m \x1b[31;1mhighlight\x1b[0;22m",
+		},
+		{
+			"test match on the longer one",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`match`),
+				regexp.MustCompile(`match on the longer one`),
+			},
+			[]*regexp.Regexp{
+				regexp.MustCompile(`match`),
+				regexp.MustCompile(`match on the longer one`),
+			},
+			"test \x1b[31;1mmatch on the longer one\x1b[0;22m",
+		},
+	}
+
+	orig := color.NoColor
+	color.NoColor = false
+	defer func() {
+		color.NoColor = orig
+	}()
+
+	for i, tt := range tests {
+		o := &TailOptions{Include: tt.include, Highlight: tt.highlight}
+		actual := o.HighlightMatchedString(tt.msg)
+		if actual != tt.expected {
+			t.Errorf("%d: expected %q, but actual %q", i, tt.expected, actual)
+		}
+	}
+}
+
+func TestHighlightMatchedString(t *testing.T) {
+	tests := []struct {
+		msg       string
+		highlight []*regexp.Regexp
+		expected  string
+	}{
+		{
+			"test matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`test`),
+			},
+			"\x1b[31;1mtest\x1b[0;22m matched",
+		},
+		{
+			"test not-matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`hoge`),
+			},
+			"test not-matched",
+		},
+		{
+			"test matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`not-matched`),
+				regexp.MustCompile(`matched`),
+			},
+			"test \x1b[31;1mmatched\x1b[0;22m",
+		},
+		{
+			"test multiple matched",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`multiple`),
+				regexp.MustCompile(`matched`),
+			},
+			"test \x1b[31;1mmultiple\x1b[0;22m \x1b[31;1mmatched\x1b[0;22m",
+		},
+		{
+			"test match on the longer one",
+			[]*regexp.Regexp{
+				regexp.MustCompile(`match`),
+				regexp.MustCompile(`match on the longer one`),
+			},
+			"test \x1b[31;1mmatch on the longer one\x1b[0;22m",
+		},
+	}
+
+	orig := color.NoColor
+	color.NoColor = false
+	defer func() {
+		color.NoColor = orig
+	}()
+
+	for i, tt := range tests {
+		o := &TailOptions{Highlight: tt.highlight}
+		actual := o.HighlightMatchedString(tt.msg)
+		if actual != tt.expected {
+			t.Errorf("%d: expected %q, but actual %q", i, tt.expected, actual)
+		}
+	}
+}