From 37164158ca0e0e63a14d39cea2341050232e3825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Wed, 14 Oct 2026 16:28:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lazysecrets=20list=20marks=20what's=20w?= =?UTF-8?q?rong=20(=E2=9C=97)=20or=20due=20(=E2=9A=A0)=20by=20the=20icon,?= =?UTF-8?q?=20and=20writes=20expired=20or=20expires=20in=204d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/lazysecrets/list.go | 53 +++++++++++++++++------------ cmd/lazysecrets/list_test.go | 65 +++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index 8c0d29e..4ca7c04 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -73,8 +73,9 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t if *color != "always" && *color != "never" { return fmt.Errorf("--color: %q is not always or never", *color) } - m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, color: *color == "always"} now := time.Now() + m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry, + color: *color == "always", warn: expiryWarn, now: now} if *iconsOn { m.icons = icons.NerdFont } @@ -146,8 +147,9 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ") -// expiryOf says, as the TUI does, that a secret, key or certificate has -// expired (✗ expired) or will within warn (⚠ 12d), or is "". +// expiryOf says that a secret, key or certificate has expired, in red, +// or when it will if that's within warn (expires in 12d), in yellow, or +// is "". Its mark (✗, ⚠) is before the name. func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string { render := renderer(color) left := e.Expires.Sub(now) @@ -155,20 +157,17 @@ func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time case e.Expires.IsZero() || left > warn: return "" case left <= 0: - return render(theme.Error, "✗ expired") + return render(theme.Error, "expired") case left < 24*time.Hour: - return render(theme.Warning, fmt.Sprintf("⚠ %dh", max(1, int(left.Hours())))) + return render(theme.Warning, fmt.Sprintf("expires in %dh", max(1, int(left.Hours())))) } - return render(theme.Warning, fmt.Sprintf("⚠ %dd", int(left.Hours()/24))) + return render(theme.Warning, fmt.Sprintf("expires in %dd", int(left.Hours()/24))) } -// errorOf is why a list couldn't be read, or "": with colour, a red ✗ -// before it, as the TUI marks lists it can't read. +// errorOf is why a list couldn't be read, in red, or "". Its mark (✗) +// is before the name. func errorOf(e listing.Entry, theme styles.Styles, color bool) string { - if e.Error == "" || !color { - return e.Error - } - return theme.Error.Render("✗ " + e.Error) + return renderer(color)(theme.Error, e.Error) } // nameOf is an entry's name for the text format: the path with the @@ -189,14 +188,17 @@ func nameOf(e listing.Entry, theme styles.Styles, color bool) string { } // marks says what goes before the name, in a field of its own so a -// search leaves it out: ★ for a starred entry and ◷ for one used lately, -// as the TUI marks them, then the provider's icon. The marks are -// right-aligned in room for every kind turned on, so they sit by the icon -// and names line up. +// search leaves it out: ✗ in red when something's wrong (it couldn't be +// read, or has expired) or ⚠ when it expires within warn; ★ for a starred +// entry and ◷ for one used lately, as the TUI marks them; then the +// provider's icon. The marks are right-aligned in room for every kind +// turned on, so they sit by the icon and names line up. type marks struct { - theme styles.Styles - icons icons.Set - stars, recent, color bool + theme styles.Styles + icons icons.Set + stars, recent, expiry, color bool + warn time.Duration // ui.expiryWarningDays + now time.Time } func (m marks) of(e listing.Entry) string { @@ -209,6 +211,15 @@ func (m marks) of(e listing.Entry) string { parts = append(parts, render(st, s)) } } + // What's wrong, then what to watch: room for one of them. + switch left := e.Expires.Sub(m.now); { + case e.Error != "" || (m.expiry && !e.Expires.IsZero() && left <= 0): + mark(true, m.theme.Error, "✗") + case m.expiry && !e.Expires.IsZero() && left <= m.warn: + mark(true, m.theme.Warning, "⚠") + default: + mark(false, m.theme.Error, "") + } if m.stars { mark(e.Starred, m.theme.Favourite, "★") } @@ -224,8 +235,8 @@ func (m marks) of(e listing.Entry) string { } if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" { parts = append(parts, render(m.theme.Kind(kind), icon)) - } else if len(parts) > 0 { - pad = strings.TrimSuffix(pad, " ") // nothing after the last mark + } else if len(parts) == 0 { + pad = strings.TrimSuffix(pad, " ") // no mark after the room } return pad + strings.Join(parts, " ") } diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go index d8099f6..1dd358c 100644 --- a/cmd/lazysecrets/list_test.go +++ b/cmd/lazysecrets/list_test.go @@ -33,12 +33,13 @@ func TestListMarks(t *testing.T) { e listing.Entry want string }{ - {plain, listing.Entry{Provider: "azure", Starred: true}, "★ " + az}, - {plain, listing.Entry{Provider: "azure"}, " " + az}, // room for the star, so names line up - {marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, az}, - {noIcons, listing.Entry{Provider: "azure", Starred: true}, "★"}, - {marks{}, listing.Entry{Provider: "azure", Starred: true}, ""}, - {colored, listing.Entry{Provider: "azure", Starred: true}, styles.Default().Favourite.Render("★")}, + // Room for the status mark and the star, so names line up. + {plain, listing.Entry{Provider: "azure", Starred: true}, " ★ " + az}, + {plain, listing.Entry{Provider: "azure"}, " " + az}, + {marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az}, + {noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"}, + {marks{}, listing.Entry{Provider: "azure", Starred: true}, " "}, + {colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")}, } { if got := c.m.of(c.e); got != c.want { t.Errorf("%+v: %q, want %q", c.e, got, c.want) @@ -53,10 +54,10 @@ func TestListRecentMarks(t *testing.T) { e listing.Entry want string }{ - // Right-aligned in room for two: by the icon, names lined up. - {listing.Entry{Provider: "azure", Starred: true, Recent: true}, "★ ◷ " + az}, - {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, - {listing.Entry{Provider: "azure"}, " " + az}, + // Right-aligned in room for three: by the icon, names lined up. + {listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az}, + {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, + {listing.Entry{Provider: "azure"}, " " + az}, } { if got := m.of(c.e); got != c.want { t.Errorf("%+v: %q, want %q", c.e, got, c.want) @@ -72,20 +73,52 @@ func TestListExpiry(t *testing.T) { want string }{ {time.Time{}, ""}, - {now.Add(-time.Hour), "✗ expired"}, - {now.Add(12*24*time.Hour + time.Hour), "⚠ 12d"}, - {now.Add(5 * time.Hour), "⚠ 5h"}, + {now.Add(-time.Hour), "expired"}, + {now.Add(12*24*time.Hour + time.Hour), "expires in 12d"}, + {now.Add(5 * time.Hour), "expires in 5h"}, {now.Add(60 * 24 * time.Hour), ""}, } { if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want { t.Errorf("expires %v: %q, want %q", c.expires, got, c.want) } } - if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("✗ expired") { + if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("expired") { t.Errorf("expired isn't red: %q", got) } } +func TestListStatusMark(t *testing.T) { + az := icons.NerdFont.Azure + now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + m := marks{icons: icons.NerdFont, stars: true, expiry: true, warn: 30 * 24 * time.Hour, now: now} + for _, c := range []struct { + e listing.Entry + want string + }{ + // Right-aligned in room for two, as with ★ and ◷. + {listing.Entry{Provider: "azure", Error: "403 Forbidden"}, " ✗ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour), Starred: true}, "✗ ★ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(5 * 24 * time.Hour)}, " ⚠ " + az}, + {listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour), Starred: true}, " ★ " + az}, + } { + if got := m.of(c.e); got != c.want { + t.Errorf("%+v: %q, want %q", c.e, got, c.want) + } + } + m.expiry = false // --expiry=false: no ⚠ or ✗ for expiry; errors still + if got := m.of(listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}); got != " "+az { + t.Errorf("--expiry=false: %q", got) + } + m.expiry, m.color, m.theme = true, true, styles.Default() + if got := m.of(listing.Entry{Error: "403"}); !strings.Contains(got, styles.Default().Error.Render("✗")) { + t.Errorf("not a red ✗: %q", got) + } + if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) { + t.Errorf("not a yellow ⚠: %q", got) + } +} + func TestListErrors(t *testing.T) { e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"} if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" { @@ -94,8 +127,8 @@ func TestListErrors(t *testing.T) { if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" { t.Errorf("plain: %q", got) } - if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("✗ 403 Forbidden") { - t.Errorf("why isn't a red ✗: %q", got) + if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") { + t.Errorf("why isn't red: %q", got) } if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" { t.Errorf("no error: %q", got)