feat: lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers #70

Merged
keb merged 45 commits from feat/list into main 2026-10-14 21:30:44 +02:00
2 changed files with 73 additions and 29 deletions
Showing only changes of commit 37164158ca - Show all commits

No files matched your search

+30 -19
View File
@@ -73,8 +73,9 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
if *color != "always" && *color != "never" { if *color != "always" && *color != "never" {
return fmt.Errorf("--color: %q is not always or never", *color) 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() 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 { if *iconsOn {
m.icons = icons.NerdFont 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", " ") var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
// expiryOf says, as the TUI does, that a secret, key or certificate has // expiryOf says that a secret, key or certificate has expired, in red,
// expired (✗ expired) or will within warn (⚠ 12d), or is "". // 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 { func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
render := renderer(color) render := renderer(color)
left := e.Expires.Sub(now) 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: case e.Expires.IsZero() || left > warn:
return "" return ""
case left <= 0: case left <= 0:
return render(theme.Error, "✗ expired") return render(theme.Error, "expired")
case left < 24*time.Hour: 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 ✗ // errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
// before it, as the TUI marks lists it can't read. // is before the name.
func errorOf(e listing.Entry, theme styles.Styles, color bool) string { func errorOf(e listing.Entry, theme styles.Styles, color bool) string {
if e.Error == "" || !color { return renderer(color)(theme.Error, e.Error)
return e.Error
}
return theme.Error.Render("✗ " + e.Error)
} }
// nameOf is an entry's name for the text format: the path with the // 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 // 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, // search leaves it out: ✗ in red when something's wrong (it couldn't be
// as the TUI marks them, then the provider's icon. The marks are // read, or has expired) or ⚠ when it expires within warn; ★ for a starred
// right-aligned in room for every kind turned on, so they sit by the icon // entry and ◷ for one used lately, as the TUI marks them; then the
// and names line up. // 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 { type marks struct {
theme styles.Styles theme styles.Styles
icons icons.Set icons icons.Set
stars, recent, color bool stars, recent, expiry, color bool
warn time.Duration // ui.expiryWarningDays
now time.Time
} }
func (m marks) of(e listing.Entry) string { 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)) 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 { if m.stars {
mark(e.Starred, m.theme.Favourite, "★") 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 != "" { if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" {
parts = append(parts, render(m.theme.Kind(kind), icon)) parts = append(parts, render(m.theme.Kind(kind), icon))
} else if len(parts) > 0 { } else if len(parts) == 0 {
pad = strings.TrimSuffix(pad, " ") // nothing after the last mark pad = strings.TrimSuffix(pad, " ") // no mark after the room
} }
return pad + strings.Join(parts, " ") return pad + strings.Join(parts, " ")
} }
+43 -10
View File
@@ -33,12 +33,13 @@ func TestListMarks(t *testing.T) {
e listing.Entry e listing.Entry
want string want string
}{ }{
// 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", Starred: true}, " ★ " + az},
{plain, listing.Entry{Provider: "azure"}, " " + az}, // room for the star, so names line up {plain, listing.Entry{Provider: "azure"}, " " + az},
{marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, az}, {marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az},
{noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"}, {noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"},
{marks{}, listing.Entry{Provider: "azure", Starred: true}, " "}, {marks{}, listing.Entry{Provider: "azure", Starred: true}, " "},
{colored, listing.Entry{Provider: "azure", Starred: true}, styles.Default().Favourite.Render("★")}, {colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")},
} { } {
if got := c.m.of(c.e); got != c.want { if got := c.m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want) t.Errorf("%+v: %q, want %q", c.e, got, c.want)
@@ -53,7 +54,7 @@ func TestListRecentMarks(t *testing.T) {
e listing.Entry e listing.Entry
want string want string
}{ }{
// Right-aligned in room for two: by the icon, names lined up. // 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", Starred: true, Recent: true}, " ★ ◷ " + az},
{listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az},
{listing.Entry{Provider: "azure"}, " " + az}, {listing.Entry{Provider: "azure"}, " " + az},
@@ -72,20 +73,52 @@ func TestListExpiry(t *testing.T) {
want string want string
}{ }{
{time.Time{}, ""}, {time.Time{}, ""},
{now.Add(-time.Hour), "✗ expired"}, {now.Add(-time.Hour), "expired"},
{now.Add(12*24*time.Hour + time.Hour), "⚠ 12d"}, {now.Add(12*24*time.Hour + time.Hour), "expires in 12d"},
{now.Add(5 * time.Hour), "⚠ 5h"}, {now.Add(5 * time.Hour), "expires in 5h"},
{now.Add(60 * 24 * time.Hour), ""}, {now.Add(60 * 24 * time.Hour), ""},
} { } {
if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want { 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) 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) 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) { func TestListErrors(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"} 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" { 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" { if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" {
t.Errorf("plain: %q", got) t.Errorf("plain: %q", got)
} }
if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("✗ 403 Forbidden") { if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") {
t.Errorf("why isn't a red ✗: %q", got) t.Errorf("why isn't red: %q", got)
} }
if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" { if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" {
t.Errorf("no error: %q", got) t.Errorf("no error: %q", got)