Skip to content

Commit 8a5822e

Browse files
authored
Merge pull request #2 from atomikpanda/atomikpanda/rich-cli-output
Add rich CLI output with UI abstraction layer
2 parents 5651d06 + 8b11ec5 commit 8a5822e

11 files changed

Lines changed: 2315 additions & 152 deletions

File tree

cmd/dotular/main.go

Lines changed: 84 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,20 @@ import (
66
"io/fs"
77
"os"
88
"path/filepath"
9+
"strings"
910
"time"
1011

1112
"github.com/spf13/cobra"
1213

1314
"github.com/atomikpanda/dotular/internal/ageutil"
14-
"github.com/atomikpanda/dotular/internal/color"
1515
"github.com/atomikpanda/dotular/internal/audit"
16+
"github.com/atomikpanda/dotular/internal/color"
1617
"github.com/atomikpanda/dotular/internal/config"
1718
"github.com/atomikpanda/dotular/internal/platform"
1819
"github.com/atomikpanda/dotular/internal/registry"
1920
"github.com/atomikpanda/dotular/internal/runner"
2021
"github.com/atomikpanda/dotular/internal/tags"
22+
"github.com/atomikpanda/dotular/internal/ui"
2123
)
2224

2325
var (
@@ -87,7 +89,8 @@ func loadAndResolveConfig(ctx context.Context) (config.Config, error) {
8789
if err != nil {
8890
return config.Config{}, err
8991
}
90-
return registry.Resolve(ctx, cfg, configFile, noCache)
92+
u := ui.New(os.Stdout, os.Stderr)
93+
return registry.Resolve(ctx, cfg, configFile, noCache, u)
9194
}
9295

9396
func newRunner(cfg config.Config) *runner.Runner {
@@ -206,9 +209,10 @@ managed store and records it in the config YAML.`,
206209
if isDir {
207210
typeStr = "directory"
208211
}
209-
fmt.Printf("added %s %q to module %q\n", typeStr, baseName, moduleName)
210-
fmt.Printf(" store: %s\n", dest)
211-
fmt.Printf(" config: %s\n", configFile)
212+
u := ui.New(os.Stdout, os.Stderr)
213+
u.Success(fmt.Sprintf("added %s %q to module %q", typeStr, baseName, moduleName))
214+
u.Info(fmt.Sprintf(" store: %s", dest))
215+
u.Info(fmt.Sprintf(" config: %s", configFile))
212216
return nil
213217
},
214218
}
@@ -275,8 +279,9 @@ func applyCmd() *cobra.Command {
275279
if mod == nil {
276280
return fmt.Errorf("module %q not found in config", name)
277281
}
278-
if err := r.ApplyModule(ctx, *mod); err != nil {
279-
return err
282+
result := r.ApplyModule(ctx, *mod)
283+
if result.Err != nil {
284+
return result.Err
280285
}
281286
}
282287
return nil
@@ -311,8 +316,9 @@ func directionCmd(direction, short string) *cobra.Command {
311316
if mod == nil {
312317
return fmt.Errorf("module %q not found in config", name)
313318
}
314-
if err := r.ApplyModule(ctx, *mod); err != nil {
315-
return err
319+
result := r.ApplyModule(ctx, *mod)
320+
if result.Err != nil {
321+
return result.Err
316322
}
317323
}
318324
return nil
@@ -332,14 +338,39 @@ func listCmd() *cobra.Command {
332338
if err != nil {
333339
return err
334340
}
341+
u := ui.New(os.Stdout, os.Stderr)
335342
for _, mod := range cfg.Modules {
336-
fmt.Fprintf(os.Stdout, "%-30s %d item(s)\n", mod.Name, len(mod.Items))
343+
counts := make(map[string]int)
344+
for _, item := range mod.Items {
345+
counts[item.Type()]++
346+
}
347+
total := len(mod.Items)
348+
breakdown := formatTypeCounts(counts)
349+
u.Info(fmt.Sprintf("%s %s",
350+
color.Bold(fmt.Sprintf("%-30s", mod.Name)),
351+
color.Dim(fmt.Sprintf("%d items (%s)", total, breakdown))))
337352
}
338353
return nil
339354
},
340355
}
341356
}
342357

358+
// formatTypeCounts formats a map of item type counts into a human-readable string.
359+
func formatTypeCounts(counts map[string]int) string {
360+
types := []string{"package", "file", "directory", "script", "binary", "run", "setting"}
361+
var parts []string
362+
for _, t := range types {
363+
if n, ok := counts[t]; ok && n > 0 {
364+
label := t
365+
if n != 1 {
366+
label += "s"
367+
}
368+
parts = append(parts, fmt.Sprintf("%d %s", n, label))
369+
}
370+
}
371+
return strings.Join(parts, ", ")
372+
}
373+
343374
// --- status ------------------------------------------------------------------
344375

345376
func statusCmd() *cobra.Command {
@@ -365,7 +396,8 @@ func platformCmd() *cobra.Command {
365396
Use: "platform",
366397
Short: "Print the detected platform (OS)",
367398
Run: func(cmd *cobra.Command, args []string) {
368-
fmt.Fprintf(os.Stdout, "os: %s\n", platform.Current())
399+
u := ui.New(os.Stdout, os.Stderr)
400+
u.Info(fmt.Sprintf("os: %s", platform.Current()))
369401
},
370402
}
371403
}
@@ -411,7 +443,8 @@ func verifyCmd() *cobra.Command {
411443
return err
412444
}
413445
if !allPassed {
414-
fmt.Fprintln(os.Stderr, color.BoldRed("\nsome verify checks failed"))
446+
u := ui.New(os.Stdout, os.Stderr)
447+
u.Warn("some verify checks failed")
415448
os.Exit(1)
416449
}
417450
return nil
@@ -433,7 +466,8 @@ func encryptCmd() *cobra.Command {
433466
}
434467
src := args[0]
435468
dst := ageutil.RepoPath(src)
436-
fmt.Printf("encrypting %s -> %s\n", src, dst)
469+
u := ui.New(os.Stdout, os.Stderr)
470+
u.Info(fmt.Sprintf("encrypting %s → %s", src, dst))
437471
return key.EncryptFile(src, dst)
438472
},
439473
}
@@ -454,7 +488,8 @@ func decryptCmd() *cobra.Command {
454488
if len(dst) > 4 && dst[len(dst)-4:] == ".age" {
455489
dst = dst[:len(dst)-4]
456490
}
457-
fmt.Printf("decrypting %s -> %s\n", src, dst)
491+
u := ui.New(os.Stdout, os.Stderr)
492+
u.Info(fmt.Sprintf("decrypting %s → %s", src, dst))
458493
return key.DecryptFile(src, dst)
459494
},
460495
}
@@ -493,13 +528,14 @@ func tagCmd() *cobra.Command {
493528
if err != nil {
494529
return err
495530
}
496-
fmt.Printf("machine config: %s\n", tags.ConfigPath())
531+
u := ui.New(os.Stdout, os.Stderr)
532+
u.Info(color.Bold(fmt.Sprintf("machine config: %s", tags.ConfigPath())))
497533
if len(cfg.Tags) == 0 {
498-
fmt.Println("(no tags)")
534+
u.Info(color.Dim("(no tags)"))
499535
return nil
500536
}
501537
for _, t := range cfg.Tags {
502-
fmt.Printf(" - %s\n", t)
538+
u.Info(fmt.Sprintf(" · %s", t))
503539
}
504540
return nil
505541
},
@@ -515,7 +551,8 @@ func tagCmd() *cobra.Command {
515551
if err := tags.Add(args[0]); err != nil {
516552
return err
517553
}
518-
fmt.Printf("added tag %q\n", args[0])
554+
u := ui.New(os.Stdout, os.Stderr)
555+
u.Success(fmt.Sprintf("added tag %q", args[0]))
519556
return nil
520557
},
521558
},
@@ -540,33 +577,33 @@ func logCmd() *cobra.Command {
540577
if err != nil {
541578
return fmt.Errorf("read audit log: %w", err)
542579
}
580+
u := ui.New(os.Stdout, os.Stderr)
543581
if len(entries) == 0 {
544-
fmt.Println("(no log entries)")
582+
u.Info("(no log entries)")
545583
return nil
546584
}
547585

548-
fmt.Println(color.Bold(fmt.Sprintf("%-20s %-8s %-20s %-8s %s",
549-
"TIME", "COMMAND", "MODULE", "OUTCOME", "ITEM")))
550-
fmt.Println(color.Dim(repeatStr("-", 90)))
586+
headers := []string{"TIME", "COMMAND", "MODULE", "OUTCOME", "ITEM"}
587+
var rows [][]string
551588
for _, e := range entries {
552589
ts := e.Time.Local().Format(time.DateTime)
553590
outcome := e.Outcome
554591
if e.Error != "" {
555592
outcome += " (" + e.Error + ")"
556593
}
557-
outcomePadded := fmt.Sprintf("%-8s", outcome)
594+
// Pre-color outcome
558595
switch e.Outcome {
559596
case "success":
560-
outcomePadded = color.Green(outcomePadded)
597+
outcome = color.Green(outcome)
561598
case "failure":
562-
outcomePadded = color.BoldRed(outcomePadded)
599+
outcome = color.BoldRed(outcome)
563600
case "skipped":
564-
outcomePadded = color.Dim(outcomePadded)
601+
outcome = color.Dim(outcome)
565602
}
566-
fmt.Printf("%-20s %-8s %-20s %s %s\n",
567-
ts, e.Command, e.Module, outcomePadded, e.Item)
603+
rows = append(rows, []string{ts, e.Command, e.Module, outcome, e.Item})
568604
}
569-
fmt.Printf("\nlog: %s\n", audit.LogPath())
605+
u.Table(headers, rows, nil)
606+
u.Info(fmt.Sprintf("\nlog: %s", audit.LogPath()))
570607
return nil
571608
},
572609
}
@@ -589,7 +626,7 @@ func registryCmd() *cobra.Command {
589626
Use: "list",
590627
Short: "List cached registry modules",
591628
RunE: func(cmd *cobra.Command, args []string) error {
592-
cfg, err := loadConfig()
629+
_, err := loadConfig()
593630
if err != nil {
594631
return err
595632
}
@@ -598,31 +635,32 @@ func registryCmd() *cobra.Command {
598635
if err != nil {
599636
return err
600637
}
638+
u := ui.New(os.Stdout, os.Stderr)
601639
if len(lock.Registry) == 0 {
602-
fmt.Println("(no cached registry modules)")
640+
u.Info("(no cached registry modules)")
603641
return nil
604642
}
605-
fmt.Println(color.Bold(fmt.Sprintf("%-50s %-8s %s", "REF", "TRUST", "FETCHED")))
606-
fmt.Println(color.Dim(repeatStr("-", 80)))
643+
headers := []string{"REF", "TRUST", "FETCHED"}
644+
var rows [][]string
607645
for ref, entry := range lock.Registry {
608646
ref := registry.ParseRef(ref)
609647
trustStr := ref.Trust.String()
610-
trustPadded := fmt.Sprintf("%-8s", trustStr)
648+
// Pre-color trust
611649
switch trustStr {
612650
case "official":
613-
trustPadded = color.BoldGreen(trustPadded)
651+
trustStr = color.BoldGreen(trustStr)
614652
case "community":
615-
trustPadded = color.Yellow(trustPadded)
653+
trustStr = color.Yellow(trustStr)
616654
default:
617-
trustPadded = color.Dim(trustPadded)
655+
trustStr = color.Dim(trustStr)
618656
}
619-
fmt.Printf("%-50s %s %s\n",
657+
rows = append(rows, []string{
620658
ref.Raw,
621-
trustPadded,
659+
trustStr,
622660
entry.FetchedAt.Local().Format(time.DateTime),
623-
)
661+
})
624662
}
625-
_ = cfg
663+
u.Table(headers, rows, nil)
626664
return nil
627665
},
628666
},
@@ -633,7 +671,8 @@ func registryCmd() *cobra.Command {
633671
if err := registry.ClearCache(); err != nil {
634672
return err
635673
}
636-
fmt.Println("registry cache cleared")
674+
u := ui.New(os.Stdout, os.Stderr)
675+
u.Success("registry cache cleared")
637676
return nil
638677
},
639678
},
@@ -647,23 +686,15 @@ func registryCmd() *cobra.Command {
647686
if err != nil {
648687
return err
649688
}
650-
_, err = registry.Resolve(ctx, cfg, configFile, true)
689+
u := ui.New(os.Stdout, os.Stderr)
690+
_, err = registry.Resolve(ctx, cfg, configFile, true, u)
651691
if err != nil {
652692
return err
653693
}
654-
fmt.Println("registry modules updated")
694+
u.Success("registry modules updated")
655695
return nil
656696
},
657697
},
658698
)
659699
return cmd
660700
}
661-
662-
// repeatStr returns s repeated n times.
663-
func repeatStr(s string, n int) string {
664-
b := make([]byte, n*len(s))
665-
for i := range b {
666-
b[i] = s[i%len(s)]
667-
}
668-
return string(b)
669-
}

cmd/dotular/main_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,20 @@ func TestBuildRoot(t *testing.T) {
4242
}
4343
}
4444

45-
func TestRepeatStr(t *testing.T) {
45+
func TestFormatTypeCounts(t *testing.T) {
4646
tests := []struct {
47-
s string
48-
n int
49-
want string
47+
counts map[string]int
48+
want string
5049
}{
51-
{"-", 5, "-----"},
52-
{"ab", 4, "abababab"},
53-
{"-", 0, ""},
50+
{map[string]int{"package": 3}, "3 packages"},
51+
{map[string]int{"file": 1}, "1 file"},
52+
{map[string]int{"package": 2, "file": 1, "run": 3}, "2 packages, 1 file, 3 runs"},
53+
{map[string]int{}, ""},
5454
}
5555
for _, tt := range tests {
56-
got := repeatStr(tt.s, tt.n)
56+
got := formatTypeCounts(tt.counts)
5757
if got != tt.want {
58-
t.Errorf("repeatStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
58+
t.Errorf("formatTypeCounts(%v) = %q, want %q", tt.counts, got, tt.want)
5959
}
6060
}
6161
}

0 commit comments

Comments
 (0)