diff --git a/CHANGELOG.md b/CHANGELOG.md index 0844dfec..97597ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.21.0] - 2022-12-30 + +### Changed + +- `sq version` respects `--json` flag. +- `sq version` respects `--verbose` flag. It also shows less info when `-v` is not set. +- `sq version` shows `latest_version` info when `--verbose` and there's a newer version available. + ## [v0.20.0] - 2022-12-29 ### Added @@ -92,7 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#89]: Bug with SQL generated for joins. -[v0.19.0]: https://github.com/neilotoole/sq/compare/v0.19.0...v0.20.0 +[v0.21.0]: https://github.com/neilotoole/sq/compare/v0.20.0...v0.21.0 +[v0.20.0]: https://github.com/neilotoole/sq/compare/v0.19.0...v0.20.0 [v0.19.0]: https://github.com/neilotoole/sq/compare/v0.18.2...v0.19.0 [v0.18.2]: https://github.com/neilotoole/sq/compare/v0.18.0...v0.18.2 [v0.18.0]: https://github.com/neilotoole/sq/compare/v0.17.0...v0.18.0 diff --git a/cli/buildinfo/buildinfo.go b/cli/buildinfo/buildinfo.go index 86e12705..9d7d416c 100644 --- a/cli/buildinfo/buildinfo.go +++ b/cli/buildinfo/buildinfo.go @@ -1,14 +1,22 @@ // Package buildinfo hosts build info variables populated via ldflags. package buildinfo -// defaultVersion is the default value for Version if not +import ( + "fmt" + "strings" + + "github.com/neilotoole/sq/libsq/core/stringz" + "golang.org/x/mod/semver" +) + +// DefaultVersion is the default value for Version if not // set via ldflags. -const defaultVersion = "v0.0.0-dev" +const DefaultVersion = "v0.0.0-dev" var ( // Version is the build version. If not set at build time via - // ldflags, Version takes the value of defaultVersion. - Version = defaultVersion + // ldflags, Version takes the value of DefaultVersion. + Version = DefaultVersion // Commit is the commit hash. Commit string @@ -16,3 +24,56 @@ var ( // Timestamp is the timestamp of when the cli was built. Timestamp string ) + +// BuildInfo encapsulates Version, Commit and Timestamp. +type BuildInfo struct { + Version string `json:"version"` + Commit string `json:"commit,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +// String returns a string representation of BuildInfo. +func (bi BuildInfo) String() string { + s := bi.Version + if bi.Commit != "" { + s += " " + bi.Commit + } + if bi.Timestamp != "" { + s += " " + bi.Timestamp + } + return s +} + +// Info returns BuildInfo. +func Info() BuildInfo { + return BuildInfo{ + Version: Version, + Commit: Commit, + Timestamp: Timestamp, + } +} + +func init() { //nolint:gochecknoinits + if strings.HasSuffix(Version, "~dev") { + Version = strings.Replace(Version, "~dev", "-dev", 1) + } + + if Version != "" && !semver.IsValid(Version) { + // We want to panic here because it is a pipeline/build failure + // to have an invalid non-empty Version. + panic(fmt.Sprintf("Invalid BuildInfo.Version value: %q", Version)) + } + + if Timestamp != "" { + // Make sure Timestamp is normalized + t := stringz.TimestampToRFC3339(Timestamp) + if t != "" { + Timestamp = t + } + } +} + +// IsDefaultVersion returns true if Version is empty or DefaultVersion. +func IsDefaultVersion() bool { + return Version == "" || Version == DefaultVersion +} diff --git a/cli/cli.go b/cli/cli.go index dad9044c..584a3631 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -550,11 +550,12 @@ func (rc *RunContext) Close() error { type writers struct { fm *output.Formatting - recordw output.RecordWriter - metaw output.MetadataWriter - srcw output.SourceWriter - errw output.ErrorWriter - pingw output.PingWriter + recordw output.RecordWriter + metaw output.MetadataWriter + srcw output.SourceWriter + errw output.ErrorWriter + pingw output.PingWriter + versionw output.VersionWriter } // newWriters returns a writers instance configured per defaults and/or @@ -571,12 +572,13 @@ func newWriters(log lg.Log, cmd *cobra.Command, defaults config.Defaults, out, e // flags and set the various writer fields depending upon which // writers the format implements. w = &writers{ - fm: fm, - recordw: tablew.NewRecordWriter(out2, fm), - metaw: tablew.NewMetadataWriter(out2, fm), - srcw: tablew.NewSourceWriter(out2, fm), - pingw: tablew.NewPingWriter(out2, fm), - errw: tablew.NewErrorWriter(errOut2, fm), + fm: fm, + recordw: tablew.NewRecordWriter(out2, fm), + metaw: tablew.NewMetadataWriter(out2, fm), + srcw: tablew.NewSourceWriter(out2, fm), + pingw: tablew.NewPingWriter(out2, fm), + errw: tablew.NewErrorWriter(errOut2, fm), + versionw: tablew.NewVersionWriter(out2, fm), } // Invoke getFormat to see if the format was specified @@ -589,6 +591,7 @@ func newWriters(log lg.Log, cmd *cobra.Command, defaults config.Defaults, out, e w.recordw = jsonw.NewStdRecordWriter(out2, fm) w.metaw = jsonw.NewMetadataWriter(out2, fm) w.errw = jsonw.NewErrorWriter(log, errOut2, fm) + w.versionw = jsonw.NewVersionWriter(out2, fm) case config.FormatTable: // Table is the base format, already set above, no need to do anything. diff --git a/cli/cmd_version.go b/cli/cmd_version.go index 476fe1b9..8f75c94e 100644 --- a/cli/cmd_version.go +++ b/cli/cmd_version.go @@ -1,9 +1,17 @@ package cli import ( - "fmt" + "bufio" + "bytes" + "context" + "io" + "net/http" + "strings" + "time" + "github.com/neilotoole/sq/libsq/core/errz" "github.com/spf13/cobra" + "golang.org/x/mod/semver" "github.com/neilotoole/sq/cli/buildinfo" ) @@ -15,23 +23,110 @@ func newVersionCmd() *cobra.Command { RunE: execVersion, } + cmd.Flags().BoolP(flagJSON, flagJSONShort, false, flagJSONUsage) + return cmd } func execVersion(cmd *cobra.Command, args []string) error { rc := RunContextFrom(cmd.Context()) - rc.writers.fm.Hilite.Fprintf(rc.Out, "sq %s", buildinfo.Version) - if len(buildinfo.Commit) > 0 { - fmt.Fprint(rc.Out, " ") - rc.writers.fm.Faint.Fprint(rc.Out, "#"+buildinfo.Commit) + // We'd like to display that there's an update available, but + // we don't want to wait around long for that. + // So, we swallow (but log) any error from the goroutine. + ctx, cancelFn := context.WithTimeout(cmd.Context(), time.Second*2) + defer cancelFn() + + resultCh := make(chan string) + go func() { + var err error + v, err := fetchBrewVersion(ctx) + if err != nil { + rc.Log.Error(err) + } + + // OK if v is empty + resultCh <- v + }() + + var latestVersion string + select { + case <-ctx.Done(): + case latestVersion = <-resultCh: + if latestVersion != "" && !strings.HasPrefix(latestVersion, "v") { + latestVersion = "v" + latestVersion + } } - if len(buildinfo.Timestamp) > 0 { - fmt.Fprint(rc.Out, " ") - rc.writers.fm.Faint.Fprint(rc.Out, buildinfo.Timestamp) - } - - fmt.Fprintln(rc.Out) - return nil + return rc.writers.versionw.Version(buildinfo.Info(), latestVersion) +} + +func fetchBrewVersion(ctx context.Context) (string, error) { + const u = `https://raw.githubusercontent.com/neilotoole/homebrew-sq/master/sq.rb` + + req, err := http.NewRequestWithContext(ctx, "GET", u, http.NoBody) + if err != nil { + return "", errz.Err(err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", errz.Wrap(err, "failed to check edgectl brew repo") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if err != nil { + return "", errz.Errorf("failed to check edgectl brew repo: %d %s", + resp.StatusCode, http.StatusText(resp.StatusCode)) + } + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", errz.Wrap(err, "failed to read edgectl brew repo body") + } + + return getVersionFromBrewFormula(body) +} + +// getVersionFromBrewFormula returns the first brew version +// from f, which is a brew ruby formula. The version is returned +// without a "v" prefix, e.g. "0.1.2", not "v0.1.2". +func getVersionFromBrewFormula(f []byte) (string, error) { + var ( + line string + val string + err error + ) + + sc := bufio.NewScanner(bytes.NewReader(f)) + for sc.Scan() { + line = sc.Text() + if err = sc.Err(); err != nil { + return "", errz.Err(err) + } + + val = strings.TrimSpace(line) + if strings.HasPrefix(val, `version "`) { + // found it + val = val[9:] + val = strings.TrimSuffix(val, `"`) + if !semver.IsValid("v" + val) { // semver pkg requires "v" prefix + return "", errz.Errorf("invalid brew formula: invalid semver") + } + return val, nil + } + + if strings.HasPrefix(line, "bottle") { + // Gone too far + return "", errz.New("unable to parse brew formula") + } + } + + if sc.Err() != nil { + return "", errz.Wrap(err, "invalid brew formula") + } + + return "", errz.New("invalid brew formula") } diff --git a/cli/cmd_version_test.go b/cli/cmd_version_test.go new file mode 100644 index 00000000..d5401190 --- /dev/null +++ b/cli/cmd_version_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "context" + "os" + "testing" + + "golang.org/x/mod/semver" + + "github.com/stretchr/testify/require" +) + +func TestGetVersionFromBrewFormula(t *testing.T) { + f, err := os.ReadFile("testdata/sq-0.20.0.rb") + require.NoError(t, err) + + vers, err := getVersionFromBrewFormula(f) + require.NoError(t, err) + require.Equal(t, "0.20.0", vers) +} + +func TestFetchBrewVersion(t *testing.T) { + latest, err := fetchBrewVersion(context.Background()) + require.NoError(t, err) + require.True(t, semver.IsValid("v"+latest)) +} diff --git a/cli/output/jsonw/jsonw.go b/cli/output/jsonw/jsonw.go index 865eb6da..53efb921 100644 --- a/cli/output/jsonw/jsonw.go +++ b/cli/output/jsonw/jsonw.go @@ -3,15 +3,42 @@ package jsonw import ( "bytes" + "fmt" "io" "strings" "github.com/neilotoole/sq/cli/output" "github.com/neilotoole/sq/cli/output/jsonw/internal" + jcolorenc "github.com/neilotoole/sq/cli/output/jsonw/internal/jcolorenc" "github.com/neilotoole/sq/libsq/core/errz" "github.com/neilotoole/sq/libsq/core/sqlz" ) +// writeJSON prints a JSON representation of v to out, using specs +// from fm. +func writeJSON(out io.Writer, fm *output.Formatting, v any) error { + buf := &bytes.Buffer{} + + enc := jcolorenc.NewEncoder(buf) + enc.SetColors(internal.NewColors(fm)) + enc.SetEscapeHTML(false) + if fm.Pretty { + enc.SetIndent("", fm.Indent) + } + + err := enc.Encode(v) + if err != nil { + return errz.Err(err) + } + + _, err = fmt.Fprint(out, buf.String()) + if err != nil { + return errz.Err(err) + } + + return nil +} + // NewStdRecordWriter returns a record writer that outputs each // record as a JSON object that is an element of JSON array. This is // to say, standard JSON. For example: diff --git a/cli/output/jsonw/versionw.go b/cli/output/jsonw/versionw.go new file mode 100644 index 00000000..5f47b8b0 --- /dev/null +++ b/cli/output/jsonw/versionw.go @@ -0,0 +1,36 @@ +package jsonw + +import ( + "io" + + "github.com/neilotoole/sq/cli/buildinfo" + "github.com/neilotoole/sq/cli/output" +) + +var _ output.VersionWriter = (*versionWriter)(nil) + +// versionWriter implements output.VersionWriter for JSON. +type versionWriter struct { + out io.Writer + fm *output.Formatting +} + +// NewVersionWriter returns a new output.VersionWriter instance +// that outputs version info in JSON. +func NewVersionWriter(out io.Writer, fm *output.Formatting) output.VersionWriter { + return &versionWriter{out: out, fm: fm} +} + +func (w *versionWriter) Version(info buildinfo.BuildInfo, latestVersion string) error { + type cliBuildInfo struct { + buildinfo.BuildInfo + LatestVersion string `json:"latest_version"` + } + + bi := cliBuildInfo{ + BuildInfo: info, + LatestVersion: latestVersion, + } + + return writeJSON(w.out, w.fm, bi) +} diff --git a/cli/output/tablew/versionw.go b/cli/output/tablew/versionw.go new file mode 100644 index 00000000..7428e7b0 --- /dev/null +++ b/cli/output/tablew/versionw.go @@ -0,0 +1,54 @@ +package tablew + +import ( + "fmt" + "io" + + "github.com/neilotoole/sq/cli/buildinfo" + "github.com/neilotoole/sq/cli/output" + "golang.org/x/mod/semver" +) + +var _ output.VersionWriter = (*versionWriter)(nil) + +// versionWriter implements output.VersionWriter for JSON. +type versionWriter struct { + out io.Writer + fm *output.Formatting +} + +// NewVersionWriter returns a new output.VersionWriter instance +// that outputs version info in JSON. +func NewVersionWriter(out io.Writer, fm *output.Formatting) output.VersionWriter { + return &versionWriter{out: out, fm: fm} +} + +func (w *versionWriter) Version(bi buildinfo.BuildInfo, latestVersion string) error { + fmt.Fprintf(w.out, "sq %s", bi.Version) + + // Only print more if --verbose is set. + if !w.fm.Verbose { + fmt.Fprintln(w.out) + return nil + } + + if len(bi.Commit) > 0 { + fmt.Fprint(w.out, " ") + w.fm.Faint.Fprint(w.out, "#"+bi.Commit) + } + + if len(bi.Timestamp) > 0 { + fmt.Fprint(w.out, " ") + w.fm.Faint.Fprint(w.out, bi.Timestamp) + } + + showUpdate := semver.Compare(latestVersion, bi.Version) > 0 + if showUpdate { + fmt.Fprint(w.out, " ") + w.fm.Faint.Fprint(w.out, "Update available: ") + w.fm.Number.Fprint(w.out, latestVersion) + } + + fmt.Fprintln(w.out) + return nil +} diff --git a/cli/output/writers.go b/cli/output/writers.go index 34da0f50..4190d11a 100644 --- a/cli/output/writers.go +++ b/cli/output/writers.go @@ -9,6 +9,7 @@ package output import ( "time" + "github.com/neilotoole/sq/cli/buildinfo" "github.com/neilotoole/sq/libsq/core/sqlz" "github.com/neilotoole/sq/libsq/driver" "github.com/neilotoole/sq/libsq/source" @@ -81,6 +82,14 @@ type PingWriter interface { Close() error } +// VersionWriter prints the CLI version. +type VersionWriter interface { + // Version prints version info. Arg latestVersion is the latest + // version available from the homebrew repository. The value + // may be empty. + Version(info buildinfo.BuildInfo, latestVersion string) error +} + // FlushThreshold is the size in bytes after which a writer // should flush any internal buffer. const FlushThreshold = 1000 diff --git a/cli/testdata/sq-0.20.0.rb b/cli/testdata/sq-0.20.0.rb new file mode 100644 index 00000000..a6f955c2 --- /dev/null +++ b/cli/testdata/sq-0.20.0.rb @@ -0,0 +1,68 @@ +# typed: false +# frozen_string_literal: true + +# This file was generated by GoReleaser. DO NOT EDIT. +class Sq < Formula + desc "sq: swiss-army knife for data" + homepage "https://github.com/neilotoole/sq" + version "0.20.0" + license "MIT" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-macos-arm64.tar.gz" + sha256 "3804a2cbb7789df4d0008af45dea59969a932b242ec7f9e18c71deeb4132d4b5" + + def install + bin.install "sq" + bash_completion.install "completions/sq.bash" => "sq" + zsh_completion.install "completions/sq.zsh" => "_sq" + fish_completion.install "completions/sq.fish" + man1.install "manpages/sq.1.gz" + end + end + if Hardware::CPU.intel? + url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-macos-amd64.tar.gz" + sha256 "5674316e5a8efd4430a30679cf5129e5c2e9966e30d294ba0901266ce8c9e212" + + def install + bin.install "sq" + bash_completion.install "completions/sq.bash" => "sq" + zsh_completion.install "completions/sq.zsh" => "_sq" + fish_completion.install "completions/sq.fish" + man1.install "manpages/sq.1.gz" + end + end + end + + on_linux do + if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? + url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-linux-arm64.tar.gz" + sha256 "0769b928a202cf0c7174b6e0fb4f086375600bba333e8bbc2ec45a8b8d43464d" + + def install + bin.install "sq" + bash_completion.install "completions/sq.bash" => "sq" + zsh_completion.install "completions/sq.zsh" => "_sq" + fish_completion.install "completions/sq.fish" + man1.install "manpages/sq.1.gz" + end + end + if Hardware::CPU.intel? + url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-linux-amd64.tar.gz" + sha256 "38263083d09dc5c0f5dd1d3cf24daac90fa7b013c865be7c9192fe7eca439486" + + def install + bin.install "sq" + bash_completion.install "completions/sq.bash" => "sq" + zsh_completion.install "completions/sq.zsh" => "_sq" + fish_completion.install "completions/sq.fish" + man1.install "manpages/sq.1.gz" + end + end + end + + test do + system "#{bin}/sq --version" + end +end \ No newline at end of file diff --git a/libsq/core/stringz/stringz.go b/libsq/core/stringz/stringz.go index c90e0f11..373b2e71 100644 --- a/libsq/core/stringz/stringz.go +++ b/libsq/core/stringz/stringz.go @@ -402,3 +402,131 @@ func TrimLen(s string, maxLen int) string { return s[:maxLen] } + +const ( + // RFC3339Milli is an RFC3339 format with millisecond precision. + RFC3339Milli = "2006-01-02T15:04:05.000Z07:00" + + // RFC3339MilliZulu is the same as RFC3339Milli, but in zulu time. + RFC3339MilliZulu = "2006-01-02T15:04:05.000Z" + + // rfc3339variant is a variant using "-0700" suffix. + rfc3339variant = "2006-01-02T15:04:05-0700" + + // RFC3339Zulu is an RFC3339 format, in Zulu time. + RFC3339Zulu = "2006-01-02T15:04:05Z" + + // ISO8601 is similar to RFC3339Milli, but doesn't have the colon + // in the timezone offset. + ISO8601 = "2006-01-02T15:04:05.000Z0700" + + // DateOnly is a date-only format. + DateOnly = "2006-01-02" +) + +// TimestampUTC returns the RFC3339Milli representation of t in UTC. +func TimestampUTC(t time.Time) string { + return t.UTC().Format(RFC3339Milli) +} + +// DateUTC returns a date representation (2020-10-31) of t in UTC. +func DateUTC(t time.Time) string { + return t.UTC().Format(DateOnly) +} + +// TimestampToRFC3339 takes a RFC3339Milli, ISO8601 or RFC3339 +// timestamp, and returns RFC3339. That is, the milliseconds are dropped. +// On error, the empty string is returned. +func TimestampToRFC3339(s string) string { + t, err := ParseTimestampUTC(s) + if err != nil { + return "" + } + return t.UTC().Format(RFC3339Zulu) +} + +// TimestampToDate takes a RFC3339Milli, ISO8601 or RFC3339 +// timestamp, and returns just the date component. +// On error, the empty string is returned. +func TimestampToDate(s string) string { + t, err := ParseTimestampUTC(s) + if err != nil { + return "" + } + return t.UTC().Format(DateOnly) +} + +// ParseTimestampUTC is the counterpart of TimestampUTC. It attempts +// to parse s first in RFC3339Milli, then time.RFC3339 format, falling +// back to the subtly different ISO8601 format. +func ParseTimestampUTC(s string) (time.Time, error) { + t, err := time.Parse(RFC3339Milli, s) + if err == nil { + return t.UTC(), nil + } + + // Fallback to RFC3339 + t, err = time.Parse(time.RFC3339, s) + if err == nil { + return t.UTC(), nil + } + + // Fallback to ISO8601 + t, err = time.Parse(ISO8601, s) + if err == nil { + return t.UTC(), nil + } + + t, err = time.Parse(rfc3339variant, s) + if err == nil { + return t.UTC(), nil + } + + return time.Time{}, errz.Errorf("failed to parse timestamp {%s}", s) +} + +// ParseLocalDate accepts a date string s, returning the local midnight +// time of that date. Arg s must in format "2006-01-02". +func ParseLocalDate(s string) (time.Time, error) { + if !strings.ContainsRune(s, 'T') { + // It's a date + t, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return t, err + } + + return t, nil + } + + // There's a 'T' in s, which means its probably a timestamp. + return time.Time{}, errz.Errorf("invalid date format: %s", s) +} + +// ParseUTCDate accepts a date string s, returning the UTC midnight +// time of that date. Arg s must in format "2006-01-02". +func ParseUTCDate(s string) (time.Time, error) { + if !strings.ContainsRune(s, 'T') { + // It's a date + t, err := time.ParseInLocation("2006-01-02", s, time.UTC) + if err != nil { + return t, err + } + + return t, nil + } + + // There's a 'T' in s, which means its probably a timestamp. + return time.Time{}, errz.Errorf("invalid date format: %s", s) +} + +// ParseDateOrTimestampUTC attempts to parse s as either +// a date (see ParseUTCDate), or timestamp (see ParseTimestampUTC). +// The returned time is in UTC. +func ParseDateOrTimestampUTC(s string) (time.Time, error) { + if strings.ContainsRune(s, 'T') { + return ParseTimestampUTC(s) + } + + t, err := ParseUTCDate(s) + return t.UTC(), err +} diff --git a/libsq/core/stringz/stringz_test.go b/libsq/core/stringz/stringz_test.go index 1e34f8c8..c56b01bd 100644 --- a/libsq/core/stringz/stringz_test.go +++ b/libsq/core/stringz/stringz_test.go @@ -3,6 +3,7 @@ package stringz_test import ( "strings" "testing" + "time" "github.com/neilotoole/sq/testh/tutil" @@ -303,3 +304,54 @@ func TestLineCount(t *testing.T) { }) } } + +func TestTimestampUTC(t *testing.T) { + tm := time.Date(2021, 0o1, 0o1, 7, 7, 7, 0, time.UTC) + s := stringz.TimestampUTC(tm) + t.Log(s) + require.Equal(t, "2021-01-01T07:07:07.000Z", s) + + s = stringz.TimestampUTC(time.Now().UTC()) + t.Log(s) +} + +func TestParseDateOrTimestampUTC(t *testing.T) { + testCases := []struct { + in string + want int64 + wantErr bool + }{ + {in: "", wantErr: true}, + {in: "not_a_time", wantErr: true}, + {in: "2021-01-16T18:18:49.348-0700", want: 1610846329}, + {in: "2021-01-16T18:26:39.216-07:00", want: 1610846799}, + {in: "2021-01-16T18:26:39-07:00", want: 1610846799}, + {in: "2021-01-17T01:26:39.216Z", want: 1610846799}, + {in: "2021-01-17", want: 1610841600}, + } + + for i, tc := range testCases { + tc := tc + t.Run(tutil.Name(i, tc.in), func(t *testing.T) { + tm, err := stringz.ParseDateOrTimestampUTC(tc.in) + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + t.Logf("Got: %s", stringz.TimestampUTC(tm)) + ut := tm.Unix() + require.Equal(t, tc.want, ut) + }) + } +} + +func TestZuluTimestamp(t *testing.T) { + const ( + input = `2022-12-30T09:36:31-0700` + want = `2022-12-30T16:36:31Z` + ) + got := stringz.TimestampToRFC3339(input) + require.Equal(t, want, got) +} diff --git a/testh/post-install-macos.sh b/testh/post-install-macos.sh new file mode 100755 index 00000000..7e721681 --- /dev/null +++ b/testh/post-install-macos.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# ".completions.sh regenerates completions to "./completions". + +set +e +brew uninstall neilotoole/sq/sq + +if [[ $(which sq) ]]; then + echo "sq is still present" + rm $(which sq) +fi + +set -e +brew install neilotoole/sq/sq + +# TODO: Test the version