refactor(cli): unify non-interactive command presentation
Route root, backup, and images command output through shared presentation helpers and add UI adoption guardrails to prevent regressions in styling usage.
- commit
- 3679601727de6d8ac0cee16623e03cace0d1ce0d
- author
- brice · · 2026-02-12 17:38 UTC
- parent
- 83ea58e
- signature
- unsigned
7 files · +404 -31
M internal/adapters/in/cli/backup.go +21 -3
| @@ -52,9 +52,14 @@ func newBackupListCmd() *cobra.Command { | |||
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | if len(jobs) == 0 { | |
| 55 | - | fmt.Println("No backups found") | |
| 55 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 56 | 58 | return nil | |
| 57 | 59 | } | |
| 60 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 58 | 63 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 59 | 64 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |
| 60 | 65 | return err | |
| @@ -96,6 +101,9 @@ func newBackupRunCmd() *cobra.Command { | |||
| 96 | 101 | if result.Backup == nil { | |
| 97 | 102 | return fmt.Errorf("backup run completed without backup payload") | |
| 98 | 103 | } | |
| 104 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil { | |
| 105 | + | return err | |
| 106 | + | } | |
| 99 | 107 | ||
| 100 | 108 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 101 | 109 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil { | |
| @@ -133,9 +141,14 @@ func newBackupDetectCmd() *cobra.Command { | |||
| 133 | 141 | } | |
| 134 | 142 | ||
| 135 | 143 | if len(dbs) == 0 { | |
| 136 | - | fmt.Println("No supported databases detected") | |
| 144 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil { | |
| 145 | + | return err | |
| 146 | + | } | |
| 137 | 147 | return nil | |
| 138 | 148 | } | |
| 149 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil { | |
| 150 | + | return err | |
| 151 | + | } | |
| 139 | 152 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 140 | 153 | if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil { | |
| 141 | 154 | return err | |
| @@ -172,9 +185,14 @@ func newBackupStatusCmd() *cobra.Command { | |||
| 172 | 185 | } | |
| 173 | 186 | ||
| 174 | 187 | if len(jobs) == 0 { | |
| 175 | - | fmt.Println("No backup status available") | |
| 188 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 176 | 191 | return nil | |
| 177 | 192 | } | |
| 193 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil { | |
| 194 | + | return err | |
| 195 | + | } | |
| 178 | 196 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 179 | 197 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil { | |
| 180 | 198 | return err | |
| @@ -52,9 +52,14 @@ func newBackupListCmd() *cobra.Command { | |||||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | if len(jobs) == 0 { | 54 | if len(jobs) == 0 { | ||
| 55 | - | fmt.Println("No backups found") | 55 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil { |
| 56 | + | return err | |||
| 57 | + | } | |||
| 56 | return nil | 58 | return nil | ||
| 57 | } | 59 | } | ||
| 60 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 58 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 63 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 59 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | 64 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | ||
| 60 | return err | 65 | return err | ||
| @@ -96,6 +101,9 @@ func newBackupRunCmd() *cobra.Command { | |||||
| 96 | if result.Backup == nil { | 101 | if result.Backup == nil { | ||
| 97 | return fmt.Errorf("backup run completed without backup payload") | 102 | return fmt.Errorf("backup run completed without backup payload") | ||
| 98 | } | 103 | } | ||
| 104 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil { | |||
| 105 | + | return err | |||
| 106 | + | } | |||
| 99 | 107 | ||||
| 100 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 108 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 101 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil { | 109 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil { | ||
| @@ -133,9 +141,14 @@ func newBackupDetectCmd() *cobra.Command { | |||||
| 133 | } | 141 | } | ||
| 134 | 142 | ||||
| 135 | if len(dbs) == 0 { | 143 | if len(dbs) == 0 { | ||
| 136 | - | fmt.Println("No supported databases detected") | 144 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil { |
| 145 | + | return err | |||
| 146 | + | } | |||
| 137 | return nil | 147 | return nil | ||
| 138 | } | 148 | } | ||
| 149 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil { | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 139 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 152 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 140 | if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil { | 153 | if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil { | ||
| 141 | return err | 154 | return err | ||
| @@ -172,9 +185,14 @@ func newBackupStatusCmd() *cobra.Command { | |||||
| 172 | } | 185 | } | ||
| 173 | 186 | ||||
| 174 | if len(jobs) == 0 { | 187 | if len(jobs) == 0 { | ||
| 175 | - | fmt.Println("No backup status available") | 188 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil { |
| 189 | + | return err | |||
| 190 | + | } | |||
| 176 | return nil | 191 | return nil | ||
| 177 | } | 192 | } | ||
| 193 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 178 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 196 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 179 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil { | 197 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil { | ||
| 180 | return err | 198 | return err | ||
M internal/adapters/in/cli/images.go +15 -11
| @@ -84,10 +84,14 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | 86 | if len(images) == 0 { | |
| 87 | - | if _, err := fmt.Fprintln(out, "No images found"); err != nil { | |
| 87 | + | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { | |
| 88 | 88 | return err | |
| 89 | 89 | } | |
| 90 | - | _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)") | |
| 90 | + | err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)")) | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil { | |
| 91 | 95 | return err | |
| 92 | 96 | } | |
| 93 | 97 | ||
| @@ -113,7 +117,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||
| 113 | 117 | return err | |
| 114 | 118 | } | |
| 115 | 119 | ||
| 116 | - | _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) | |
| 120 | + | err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) | |
| 117 | 121 | return err | |
| 118 | 122 | } | |
| 119 | 123 | ||
| @@ -140,22 +144,22 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 140 | 144 | } | |
| 141 | 145 | } | |
| 142 | 146 | ||
| 143 | - | if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil { | |
| 147 | + | if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil { | |
| 144 | 148 | return err | |
| 145 | 149 | } | |
| 146 | - | if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |
| 150 | + | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |
| 147 | 151 | return err | |
| 148 | 152 | } | |
| 149 | 153 | if opts.RuntimeOnly { | |
| 150 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | |
| 154 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)")) | |
| 151 | 155 | return err | |
| 152 | 156 | } | |
| 153 | 157 | if keepLast == 0 { | |
| 154 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)") | |
| 158 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--keep=0)")) | |
| 155 | 159 | return err | |
| 156 | 160 | } | |
| 157 | 161 | ||
| 158 | - | _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) | |
| 162 | + | err = cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) | |
| 159 | 163 | return err | |
| 160 | 164 | } | |
| 161 | 165 | ||
| @@ -167,16 +171,16 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 167 | 171 | return fmt.Errorf("failed to prune images: empty response") | |
| 168 | 172 | } | |
| 169 | 173 | ||
| 170 | - | if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | |
| 174 | + | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | |
| 171 | 175 | return err | |
| 172 | 176 | } | |
| 173 | 177 | ||
| 174 | 178 | if opts.RuntimeOnly { | |
| 175 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | |
| 179 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)")) | |
| 176 | 180 | return err | |
| 177 | 181 | } | |
| 178 | 182 | ||
| 179 | - | _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |
| 183 | + | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |
| 180 | 184 | return err | |
| 181 | 185 | } | |
| 182 | 186 | ||
| @@ -84,10 +84,14 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | if len(images) == 0 { | 86 | if len(images) == 0 { | ||
| 87 | - | if _, err := fmt.Fprintln(out, "No images found"); err != nil { | 87 | + | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { |
| 88 | return err | 88 | return err | ||
| 89 | } | 89 | } | ||
| 90 | - | _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)") | 90 | + | err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)")) |
| 91 | + | return err | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil { | |||
| 91 | return err | 95 | return err | ||
| 92 | } | 96 | } | ||
| 93 | 97 | ||||
| @@ -113,7 +117,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||||
| 113 | return err | 117 | return err | ||
| 114 | } | 118 | } | ||
| 115 | 119 | ||||
| 116 | - | _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) | 120 | + | err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) |
| 117 | return err | 121 | return err | ||
| 118 | } | 122 | } | ||
| 119 | 123 | ||||
| @@ -140,22 +144,22 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||||
| 140 | } | 144 | } | ||
| 141 | } | 145 | } | ||
| 142 | 146 | ||||
| 143 | - | if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil { | 147 | + | if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil { |
| 144 | return err | 148 | return err | ||
| 145 | } | 149 | } | ||
| 146 | - | if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | 150 | + | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { |
| 147 | return err | 151 | return err | ||
| 148 | } | 152 | } | ||
| 149 | if opts.RuntimeOnly { | 153 | if opts.RuntimeOnly { | ||
| 150 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | 154 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)")) |
| 151 | return err | 155 | return err | ||
| 152 | } | 156 | } | ||
| 153 | if keepLast == 0 { | 157 | if keepLast == 0 { | ||
| 154 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)") | 158 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--keep=0)")) |
| 155 | return err | 159 | return err | ||
| 156 | } | 160 | } | ||
| 157 | 161 | ||||
| 158 | - | _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) | 162 | + | err = cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) |
| 159 | return err | 163 | return err | ||
| 160 | } | 164 | } | ||
| 161 | 165 | ||||
| @@ -167,16 +171,16 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||||
| 167 | return fmt.Errorf("failed to prune images: empty response") | 171 | return fmt.Errorf("failed to prune images: empty response") | ||
| 168 | } | 172 | } | ||
| 169 | 173 | ||||
| 170 | - | if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | 174 | + | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { |
| 171 | return err | 175 | return err | ||
| 172 | } | 176 | } | ||
| 173 | 177 | ||||
| 174 | if opts.RuntimeOnly { | 178 | if opts.RuntimeOnly { | ||
| 175 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | 179 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)")) |
| 176 | return err | 180 | return err | ||
| 177 | } | 181 | } | ||
| 178 | 182 | ||||
| 179 | - | _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | 183 | + | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) |
| 180 | return err | 184 | return err | ||
| 181 | } | 185 | } | ||
| 182 | 186 | ||||
M internal/adapters/in/cli/parity_matrix_test.go +52 -0
| @@ -6,6 +6,35 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | + | type uiAdoptionExpectation struct { | |
| 10 | + | family string | |
| 11 | + | file string | |
| 12 | + | functions []string | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | var uiAdoptionExpectations = []uiAdoptionExpectation{ | |
| 16 | + | { | |
| 17 | + | family: "server", | |
| 18 | + | file: "serve.go", | |
| 19 | + | functions: []string{"newStartCmd"}, | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | family: "root/server", | |
| 23 | + | file: "root.go", | |
| 24 | + | functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"}, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | family: "backups", | |
| 28 | + | file: "backup.go", | |
| 29 | + | functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"}, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | family: "images", | |
| 33 | + | file: "images.go", | |
| 34 | + | functions: []string{"runImagesList", "runImagesPrune"}, | |
| 35 | + | }, | |
| 36 | + | } | |
| 37 | + | ||
| 9 | 38 | func TestLocalParityMatrix(t *testing.T) { | |
| 10 | 39 | checks := []struct { | |
| 11 | 40 | file string | |
| @@ -29,3 +58,26 @@ func TestLocalParityMatrix(t *testing.T) { | |||
| 29 | 58 | } | |
| 30 | 59 | } | |
| 31 | 60 | } | |
| 61 | + | ||
| 62 | + | func TestUIAdoptionMatrixCoverage(t *testing.T) { | |
| 63 | + | for _, expect := range uiAdoptionExpectations { | |
| 64 | + | expect := expect | |
| 65 | + | t.Run(expect.family, func(t *testing.T) { | |
| 66 | + | if len(expect.functions) == 0 { | |
| 67 | + | t.Fatalf("ui adoption matrix for %s is empty", expect.family) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | content, err := os.ReadFile(expect.file) | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatalf("failed to read %s: %v", expect.file, err) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | source := string(content) | |
| 76 | + | for _, fn := range expect.functions { | |
| 77 | + | if !strings.Contains(source, "func "+fn+"(") { | |
| 78 | + | t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| @@ -6,6 +6,35 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | + | type uiAdoptionExpectation struct { | |||
| 10 | + | family string | |||
| 11 | + | file string | |||
| 12 | + | functions []string | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | var uiAdoptionExpectations = []uiAdoptionExpectation{ | |||
| 16 | + | { | |||
| 17 | + | family: "server", | |||
| 18 | + | file: "serve.go", | |||
| 19 | + | functions: []string{"newStartCmd"}, | |||
| 20 | + | }, | |||
| 21 | + | { | |||
| 22 | + | family: "root/server", | |||
| 23 | + | file: "root.go", | |||
| 24 | + | functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"}, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | family: "backups", | |||
| 28 | + | file: "backup.go", | |||
| 29 | + | functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"}, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | family: "images", | |||
| 33 | + | file: "images.go", | |||
| 34 | + | functions: []string{"runImagesList", "runImagesPrune"}, | |||
| 35 | + | }, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 9 | func TestLocalParityMatrix(t *testing.T) { | 38 | func TestLocalParityMatrix(t *testing.T) { | ||
| 10 | checks := []struct { | 39 | checks := []struct { | ||
| 11 | file string | 40 | file string | ||
| @@ -29,3 +58,26 @@ func TestLocalParityMatrix(t *testing.T) { | |||||
| 29 | } | 58 | } | ||
| 30 | } | 59 | } | ||
| 31 | } | 60 | } | ||
| 61 | + | ||||
| 62 | + | func TestUIAdoptionMatrixCoverage(t *testing.T) { | |||
| 63 | + | for _, expect := range uiAdoptionExpectations { | |||
| 64 | + | expect := expect | |||
| 65 | + | t.Run(expect.family, func(t *testing.T) { | |||
| 66 | + | if len(expect.functions) == 0 { | |||
| 67 | + | t.Fatalf("ui adoption matrix for %s is empty", expect.family) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | content, err := os.ReadFile(expect.file) | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatalf("failed to read %s: %v", expect.file, err) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | source := string(content) | |||
| 76 | + | for _, fn := range expect.functions { | |||
| 77 | + | if !strings.Contains(source, "func "+fn+"(") { | |||
| 78 | + | t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | }) | |||
| 82 | + | } | |||
| 83 | + | } | |||
A internal/adapters/in/cli/presentation.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var cliWriteLine = func(w io.Writer, msg string) error { | |
| 11 | + | _, err := fmt.Fprintln(w, msg) | |
| 12 | + | return err | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | var cliWritef = func(w io.Writer, format string, args ...any) error { | |
| 16 | + | _, err := fmt.Fprintf(w, format, args...) | |
| 17 | + | return err | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func cliRenderTitle(msg string) string { | |
| 21 | + | return styles.Theme.Title.Render(msg) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func cliRenderMuted(msg string) string { | |
| 25 | + | return styles.Theme.Muted.Render(msg) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func cliRenderEmptyState(msg string) string { | |
| 29 | + | return styles.Theme.Muted.Render(msg) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func cliRenderListItem(msg string) string { | |
| 33 | + | return styles.RenderListItem(msg, false) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func cliRenderMeta(label, value string) string { | |
| 37 | + | return styles.Theme.Bold.Render(label) + " " + styles.Theme.Muted.Render(value) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func cliRenderSuccess(msg string) string { | |
| 41 | + | return styles.RenderSuccess(msg) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func cliRenderWarning(msg string) string { | |
| 45 | + | return styles.RenderWarning(msg) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func cliRenderInfo(msg string) string { | |
| 49 | + | return styles.RenderInfo(msg) | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var cliWriteLine = func(w io.Writer, msg string) error { | |||
| 11 | + | _, err := fmt.Fprintln(w, msg) | |||
| 12 | + | return err | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | var cliWritef = func(w io.Writer, format string, args ...any) error { | |||
| 16 | + | _, err := fmt.Fprintf(w, format, args...) | |||
| 17 | + | return err | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func cliRenderTitle(msg string) string { | |||
| 21 | + | return styles.Theme.Title.Render(msg) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func cliRenderMuted(msg string) string { | |||
| 25 | + | return styles.Theme.Muted.Render(msg) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func cliRenderEmptyState(msg string) string { | |||
| 29 | + | return styles.Theme.Muted.Render(msg) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func cliRenderListItem(msg string) string { | |||
| 33 | + | return styles.RenderListItem(msg, false) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func cliRenderMeta(label, value string) string { | |||
| 37 | + | return styles.Theme.Bold.Render(label) + " " + styles.Theme.Muted.Render(value) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func cliRenderSuccess(msg string) string { | |||
| 41 | + | return styles.RenderSuccess(msg) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func cliRenderWarning(msg string) string { | |||
| 45 | + | return styles.RenderWarning(msg) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func cliRenderInfo(msg string) string { | |||
| 49 | + | return styles.RenderInfo(msg) | |||
| 50 | + | } | |||
M internal/adapters/in/cli/root.go +48 -15
| @@ -195,10 +195,18 @@ func newVersionCmd() *cobra.Command { | |||
| 195 | 195 | return &cobra.Command{ | |
| 196 | 196 | Use: "version", | |
| 197 | 197 | Short: "Print version information", | |
| 198 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 199 | - | cmd.Printf("Gordon %s\n", Version) | |
| 200 | - | cmd.Printf("Commit: %s\n", Commit) | |
| 201 | - | cmd.Printf("Build Date: %s\n", BuildDate) | |
| 198 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 199 | + | out := cmd.OutOrStdout() | |
| 200 | + | if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil { | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil { | |
| 204 | + | return err | |
| 205 | + | } | |
| 206 | + | if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil { | |
| 207 | + | return err | |
| 208 | + | } | |
| 209 | + | return nil | |
| 202 | 210 | }, | |
| 203 | 211 | } | |
| 204 | 212 | } | |
| @@ -214,15 +222,21 @@ func runReloadRemote(ctx context.Context, client *remote.Client) error { | |||
| 214 | 222 | if shouldFallbackToLocal(err) { | |
| 215 | 223 | localErr := runReload() | |
| 216 | 224 | if localErr == nil { | |
| 217 | - | fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err) | |
| 218 | - | fmt.Println("Configuration reloaded successfully") | |
| 225 | + | if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil { | |
| 226 | + | return writeErr | |
| 227 | + | } | |
| 228 | + | if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil { | |
| 229 | + | return writeErr | |
| 230 | + | } | |
| 219 | 231 | return nil | |
| 220 | 232 | } | |
| 221 | 233 | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | |
| 222 | 234 | } | |
| 223 | 235 | return fmt.Errorf("failed to reload: %w", err) | |
| 224 | 236 | } | |
| 225 | - | fmt.Println("Configuration reloaded successfully") | |
| 237 | + | if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil { | |
| 238 | + | return err | |
| 239 | + | } | |
| 226 | 240 | return nil | |
| 227 | 241 | } | |
| 228 | 242 | ||
| @@ -291,7 +305,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i | |||
| 291 | 305 | return fmt.Errorf("failed to get process logs: %w", err) | |
| 292 | 306 | } | |
| 293 | 307 | for _, line := range logLines { | |
| 294 | - | fmt.Println(line) | |
| 308 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |
| 309 | + | return err | |
| 310 | + | } | |
| 295 | 311 | } | |
| 296 | 312 | } else { | |
| 297 | 313 | // Container logs | |
| @@ -300,7 +316,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i | |||
| 300 | 316 | return fmt.Errorf("failed to get container logs: %w", err) | |
| 301 | 317 | } | |
| 302 | 318 | for _, line := range logLines { | |
| 303 | - | fmt.Println(line) | |
| 319 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |
| 320 | + | return err | |
| 321 | + | } | |
| 304 | 322 | } | |
| 305 | 323 | } | |
| 306 | 324 | return nil | |
| @@ -321,7 +339,9 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri | |||
| 321 | 339 | } | |
| 322 | 340 | ||
| 323 | 341 | for line := range ch { | |
| 324 | - | fmt.Println(line) | |
| 342 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |
| 343 | + | return err | |
| 344 | + | } | |
| 325 | 345 | } | |
| 326 | 346 | return nil | |
| 327 | 347 | } | |
| @@ -342,13 +362,26 @@ func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines | |||
| 342 | 362 | // For local container logs, we need Docker access which requires | |
| 343 | 363 | // the runtime to be initialized. For now, suggest using remote mode | |
| 344 | 364 | // or direct docker logs command. | |
| 345 | - | fmt.Printf("Container logs for %s\n", logDomain) | |
| 346 | - | fmt.Println("To view container logs locally, use:") | |
| 347 | - | fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain) | |
| 365 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { | |
| 366 | + | return err | |
| 367 | + | } | |
| 368 | + | if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil { | |
| 369 | + | return err | |
| 370 | + | } | |
| 371 | + | if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil { | |
| 372 | + | return err | |
| 373 | + | } | |
| 348 | 374 | if follow { | |
| 349 | - | fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain) | |
| 375 | + | if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil { | |
| 376 | + | return err | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | if err := cliWriteLine(os.Stdout, ""); err != nil { | |
| 380 | + | return err | |
| 381 | + | } | |
| 382 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil { | |
| 383 | + | return err | |
| 350 | 384 | } | |
| 351 | - | fmt.Println("\nOr use remote mode to access logs via the admin API.") | |
| 352 | 385 | return nil | |
| 353 | 386 | } | |
| 354 | 387 | ||
| @@ -195,10 +195,18 @@ func newVersionCmd() *cobra.Command { | |||||
| 195 | return &cobra.Command{ | 195 | return &cobra.Command{ | ||
| 196 | Use: "version", | 196 | Use: "version", | ||
| 197 | Short: "Print version information", | 197 | Short: "Print version information", | ||
| 198 | - | Run: func(cmd *cobra.Command, args []string) { | 198 | + | RunE: func(cmd *cobra.Command, args []string) error { |
| 199 | - | cmd.Printf("Gordon %s\n", Version) | 199 | + | out := cmd.OutOrStdout() |
| 200 | - | cmd.Printf("Commit: %s\n", Commit) | 200 | + | if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil { |
| 201 | - | cmd.Printf("Build Date: %s\n", BuildDate) | 201 | + | return err |
| 202 | + | } | |||
| 203 | + | if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil { | |||
| 204 | + | return err | |||
| 205 | + | } | |||
| 206 | + | if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil { | |||
| 207 | + | return err | |||
| 208 | + | } | |||
| 209 | + | return nil | |||
| 202 | }, | 210 | }, | ||
| 203 | } | 211 | } | ||
| 204 | } | 212 | } | ||
| @@ -214,15 +222,21 @@ func runReloadRemote(ctx context.Context, client *remote.Client) error { | |||||
| 214 | if shouldFallbackToLocal(err) { | 222 | if shouldFallbackToLocal(err) { | ||
| 215 | localErr := runReload() | 223 | localErr := runReload() | ||
| 216 | if localErr == nil { | 224 | if localErr == nil { | ||
| 217 | - | fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err) | 225 | + | if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil { |
| 218 | - | fmt.Println("Configuration reloaded successfully") | 226 | + | return writeErr |
| 227 | + | } | |||
| 228 | + | if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil { | |||
| 229 | + | return writeErr | |||
| 230 | + | } | |||
| 219 | return nil | 231 | return nil | ||
| 220 | } | 232 | } | ||
| 221 | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | 233 | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | ||
| 222 | } | 234 | } | ||
| 223 | return fmt.Errorf("failed to reload: %w", err) | 235 | return fmt.Errorf("failed to reload: %w", err) | ||
| 224 | } | 236 | } | ||
| 225 | - | fmt.Println("Configuration reloaded successfully") | 237 | + | if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil { |
| 238 | + | return err | |||
| 239 | + | } | |||
| 226 | return nil | 240 | return nil | ||
| 227 | } | 241 | } | ||
| 228 | 242 | ||||
| @@ -291,7 +305,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i | |||||
| 291 | return fmt.Errorf("failed to get process logs: %w", err) | 305 | return fmt.Errorf("failed to get process logs: %w", err) | ||
| 292 | } | 306 | } | ||
| 293 | for _, line := range logLines { | 307 | for _, line := range logLines { | ||
| 294 | - | fmt.Println(line) | 308 | + | if err := cliWriteLine(os.Stdout, line); err != nil { |
| 309 | + | return err | |||
| 310 | + | } | |||
| 295 | } | 311 | } | ||
| 296 | } else { | 312 | } else { | ||
| 297 | // Container logs | 313 | // Container logs | ||
| @@ -300,7 +316,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i | |||||
| 300 | return fmt.Errorf("failed to get container logs: %w", err) | 316 | return fmt.Errorf("failed to get container logs: %w", err) | ||
| 301 | } | 317 | } | ||
| 302 | for _, line := range logLines { | 318 | for _, line := range logLines { | ||
| 303 | - | fmt.Println(line) | 319 | + | if err := cliWriteLine(os.Stdout, line); err != nil { |
| 320 | + | return err | |||
| 321 | + | } | |||
| 304 | } | 322 | } | ||
| 305 | } | 323 | } | ||
| 306 | return nil | 324 | return nil | ||
| @@ -321,7 +339,9 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri | |||||
| 321 | } | 339 | } | ||
| 322 | 340 | ||||
| 323 | for line := range ch { | 341 | for line := range ch { | ||
| 324 | - | fmt.Println(line) | 342 | + | if err := cliWriteLine(os.Stdout, line); err != nil { |
| 343 | + | return err | |||
| 344 | + | } | |||
| 325 | } | 345 | } | ||
| 326 | return nil | 346 | return nil | ||
| 327 | } | 347 | } | ||
| @@ -342,13 +362,26 @@ func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines | |||||
| 342 | // For local container logs, we need Docker access which requires | 362 | // For local container logs, we need Docker access which requires | ||
| 343 | // the runtime to be initialized. For now, suggest using remote mode | 363 | // the runtime to be initialized. For now, suggest using remote mode | ||
| 344 | // or direct docker logs command. | 364 | // or direct docker logs command. | ||
| 345 | - | fmt.Printf("Container logs for %s\n", logDomain) | 365 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { |
| 346 | - | fmt.Println("To view container logs locally, use:") | 366 | + | return err |
| 347 | - | fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain) | 367 | + | } |
| 368 | + | if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil { | |||
| 369 | + | return err | |||
| 370 | + | } | |||
| 371 | + | if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil { | |||
| 372 | + | return err | |||
| 373 | + | } | |||
| 348 | if follow { | 374 | if follow { | ||
| 349 | - | fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain) | 375 | + | if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil { |
| 376 | + | return err | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | if err := cliWriteLine(os.Stdout, ""); err != nil { | |||
| 380 | + | return err | |||
| 381 | + | } | |||
| 382 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil { | |||
| 383 | + | return err | |||
| 350 | } | 384 | } | ||
| 351 | - | fmt.Println("\nOr use remote mode to access logs via the admin API.") | |||
| 352 | return nil | 385 | return nil | ||
| 353 | } | 386 | } | ||
| 354 | 387 | ||||
M internal/adapters/in/cli/serve.go +3 -2
| @@ -2,7 +2,6 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "fmt" | |
| 6 | 5 | "os" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/gordon/internal/app" | |
| @@ -40,7 +39,9 @@ func newStartCmd() *cobra.Command { | |||
| 40 | 39 | Deprecated: "use 'gordon serve' instead", | |
| 41 | 40 | Hidden: true, // Hide from help but still works | |
| 42 | 41 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 43 | - | fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead") | |
| 42 | + | if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil { | |
| 43 | + | return err | |
| 44 | + | } | |
| 44 | 45 | return app.Run(context.Background(), configPath) | |
| 45 | 46 | }, | |
| 46 | 47 | } | |
| @@ -2,7 +2,6 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "fmt" | |||
| 6 | "os" | 5 | "os" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/gordon/internal/app" | 7 | "github.com/bnema/gordon/internal/app" | ||
| @@ -40,7 +39,9 @@ func newStartCmd() *cobra.Command { | |||||
| 40 | Deprecated: "use 'gordon serve' instead", | 39 | Deprecated: "use 'gordon serve' instead", | ||
| 41 | Hidden: true, // Hide from help but still works | 40 | Hidden: true, // Hide from help but still works | ||
| 42 | RunE: func(cmd *cobra.Command, args []string) error { | 41 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 43 | - | fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead") | 42 | + | if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil { |
| 43 | + | return err | |||
| 44 | + | } | |||
| 44 | return app.Run(context.Background(), configPath) | 45 | return app.Run(context.Background(), configPath) | ||
| 45 | }, | 46 | }, | ||
| 46 | } | 47 | } | ||
A internal/adapters/in/cli/ui_adoption_test.go +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "go/ast" | |
| 7 | + | "go/parser" | |
| 8 | + | "go/token" | |
| 9 | + | "io" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var uiAdoptionHelperCalls = map[string]struct{}{ | |
| 18 | + | "cliWriteLine": {}, | |
| 19 | + | "cliWritef": {}, | |
| 20 | + | "cliRenderTitle": {}, | |
| 21 | + | "cliRenderMuted": {}, | |
| 22 | + | "cliRenderEmptyState": {}, | |
| 23 | + | "cliRenderListItem": {}, | |
| 24 | + | "cliRenderMeta": {}, | |
| 25 | + | "cliRenderSuccess": {}, | |
| 26 | + | "cliRenderWarning": {}, | |
| 27 | + | "cliRenderInfo": {}, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | var uiAdoptionSeamMu sync.Mutex | |
| 31 | + | ||
| 32 | + | func TestPresentationHelpers(t *testing.T) { | |
| 33 | + | if got := cliRenderTitle("Title"); got == "" { | |
| 34 | + | t.Fatal("cliRenderTitle returned empty output") | |
| 35 | + | } | |
| 36 | + | if got := cliRenderEmptyState("none"); got == "" { | |
| 37 | + | t.Fatal("cliRenderEmptyState returned empty output") | |
| 38 | + | } | |
| 39 | + | if got := cliRenderSuccess("ok"); got == "" { | |
| 40 | + | t.Fatal("cliRenderSuccess returned empty output") | |
| 41 | + | } | |
| 42 | + | if got := cliRenderWarning("warn"); got == "" { | |
| 43 | + | t.Fatal("cliRenderWarning returned empty output") | |
| 44 | + | } | |
| 45 | + | if got := cliRenderInfo("info"); got == "" { | |
| 46 | + | t.Fatal("cliRenderInfo returned empty output") | |
| 47 | + | } | |
| 48 | + | if got := cliRenderListItem("item"); got == "" { | |
| 49 | + | t.Fatal("cliRenderListItem returned empty output") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestUIAdoption(t *testing.T) { | |
| 54 | + | for _, expect := range uiAdoptionExpectations { | |
| 55 | + | expect := expect | |
| 56 | + | t.Run(expect.family, func(t *testing.T) { | |
| 57 | + | fset := token.NewFileSet() | |
| 58 | + | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatalf("failed to parse %s: %v", expect.file, err) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | for _, fnName := range expect.functions { | |
| 64 | + | fnName := fnName | |
| 65 | + | t.Run(fnName, func(t *testing.T) { | |
| 66 | + | fn := findFuncDecl(fileNode, fnName) | |
| 67 | + | if fn == nil { | |
| 68 | + | t.Fatalf("function %s not found in %s", fnName, expect.file) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | hasHelperCall := false | |
| 72 | + | hasSharedUIUsage := false | |
| 73 | + | hasForbiddenRawPrint := false | |
| 74 | + | ||
| 75 | + | ast.Inspect(fn.Body, func(n ast.Node) bool { | |
| 76 | + | call, ok := n.(*ast.CallExpr) | |
| 77 | + | if !ok { | |
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | switch fun := call.Fun.(type) { | |
| 82 | + | case *ast.Ident: | |
| 83 | + | if _, ok := uiAdoptionHelperCalls[fun.Name]; ok { | |
| 84 | + | hasHelperCall = true | |
| 85 | + | } | |
| 86 | + | case *ast.SelectorExpr: | |
| 87 | + | if isForbiddenRawPrintCall(fun) { | |
| 88 | + | hasForbiddenRawPrint = true | |
| 89 | + | } | |
| 90 | + | pkgIdent, ok := fun.X.(*ast.Ident) | |
| 91 | + | if !ok { | |
| 92 | + | return true | |
| 93 | + | } | |
| 94 | + | if pkgIdent.Name == "styles" || pkgIdent.Name == "components" { | |
| 95 | + | hasSharedUIUsage = true | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | return true | |
| 100 | + | }) | |
| 101 | + | ||
| 102 | + | if !hasHelperCall && !hasSharedUIUsage { | |
| 103 | + | t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | if hasForbiddenRawPrint { | |
| 107 | + | t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool { | |
| 116 | + | pkgIdent, ok := sel.X.(*ast.Ident) | |
| 117 | + | if !ok { | |
| 118 | + | return false | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | if pkgIdent.Name == "fmt" { | |
| 122 | + | switch sel.Sel.Name { | |
| 123 | + | case "Print", "Printf", "Println": | |
| 124 | + | return true | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | return false | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | type imagesClientStub struct { | |
| 136 | + | listResp []dto.Image | |
| 137 | + | pruneResp *dto.ImagePruneResponse | |
| 138 | + | err error | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) { | |
| 142 | + | if s.err != nil { | |
| 143 | + | return nil, s.err | |
| 144 | + | } | |
| 145 | + | return s.listResp, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (s imagesClientStub) PruneImages(context.Context, int) (*dto.ImagePruneResponse, error) { | |
| 149 | + | if s.err != nil { | |
| 150 | + | return nil, s.err | |
| 151 | + | } | |
| 152 | + | return s.pruneResp, nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestUIAdoptionRuntimeSeams(t *testing.T) { | |
| 156 | + | uiAdoptionSeamMu.Lock() | |
| 157 | + | defer uiAdoptionSeamMu.Unlock() | |
| 158 | + | ||
| 159 | + | origWriteLine := cliWriteLine | |
| 160 | + | origWritef := cliWritef | |
| 161 | + | defer func() { | |
| 162 | + | cliWriteLine = origWriteLine | |
| 163 | + | cliWritef = origWritef | |
| 164 | + | }() | |
| 165 | + | ||
| 166 | + | lineCalls := 0 | |
| 167 | + | writefCalls := 0 | |
| 168 | + | cliWriteLine = func(w io.Writer, msg string) error { | |
| 169 | + | lineCalls++ | |
| 170 | + | _, err := io.WriteString(w, msg+"\n") | |
| 171 | + | return err | |
| 172 | + | } | |
| 173 | + | cliWritef = func(w io.Writer, format string, args ...any) error { | |
| 174 | + | writefCalls++ | |
| 175 | + | _, err := io.WriteString(w, "formatted\n") | |
| 176 | + | return err | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | versionCmd := newVersionCmd() | |
| 180 | + | versionCmd.SetArgs(nil) | |
| 181 | + | versionCmd.SetOut(new(bytes.Buffer)) | |
| 182 | + | if err := versionCmd.Execute(); err != nil { | |
| 183 | + | t.Fatalf("version command execution failed: %v", err) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | imgBuf := new(bytes.Buffer) | |
| 187 | + | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil { | |
| 188 | + | t.Fatalf("runImagesList failed: %v", err) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | pruneBuf := new(bytes.Buffer) | |
| 192 | + | if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil { | |
| 193 | + | t.Fatalf("runImagesPrune failed: %v", err) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | if lineCalls == 0 { | |
| 197 | + | t.Fatal("expected cliWriteLine to be called") | |
| 198 | + | } | |
| 199 | + | if writefCalls == 0 { | |
| 200 | + | t.Fatal("expected cliWritef to be called") | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { | |
| 205 | + | for _, decl := range file.Decls { | |
| 206 | + | fn, ok := decl.(*ast.FuncDecl) | |
| 207 | + | if !ok { | |
| 208 | + | continue | |
| 209 | + | } | |
| 210 | + | if fn.Name.Name == name { | |
| 211 | + | return fn | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | return nil | |
| 215 | + | } | |
| @@ -0,0 +1,215 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "go/ast" | |||
| 7 | + | "go/parser" | |||
| 8 | + | "go/token" | |||
| 9 | + | "io" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var uiAdoptionHelperCalls = map[string]struct{}{ | |||
| 18 | + | "cliWriteLine": {}, | |||
| 19 | + | "cliWritef": {}, | |||
| 20 | + | "cliRenderTitle": {}, | |||
| 21 | + | "cliRenderMuted": {}, | |||
| 22 | + | "cliRenderEmptyState": {}, | |||
| 23 | + | "cliRenderListItem": {}, | |||
| 24 | + | "cliRenderMeta": {}, | |||
| 25 | + | "cliRenderSuccess": {}, | |||
| 26 | + | "cliRenderWarning": {}, | |||
| 27 | + | "cliRenderInfo": {}, | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | var uiAdoptionSeamMu sync.Mutex | |||
| 31 | + | ||||
| 32 | + | func TestPresentationHelpers(t *testing.T) { | |||
| 33 | + | if got := cliRenderTitle("Title"); got == "" { | |||
| 34 | + | t.Fatal("cliRenderTitle returned empty output") | |||
| 35 | + | } | |||
| 36 | + | if got := cliRenderEmptyState("none"); got == "" { | |||
| 37 | + | t.Fatal("cliRenderEmptyState returned empty output") | |||
| 38 | + | } | |||
| 39 | + | if got := cliRenderSuccess("ok"); got == "" { | |||
| 40 | + | t.Fatal("cliRenderSuccess returned empty output") | |||
| 41 | + | } | |||
| 42 | + | if got := cliRenderWarning("warn"); got == "" { | |||
| 43 | + | t.Fatal("cliRenderWarning returned empty output") | |||
| 44 | + | } | |||
| 45 | + | if got := cliRenderInfo("info"); got == "" { | |||
| 46 | + | t.Fatal("cliRenderInfo returned empty output") | |||
| 47 | + | } | |||
| 48 | + | if got := cliRenderListItem("item"); got == "" { | |||
| 49 | + | t.Fatal("cliRenderListItem returned empty output") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestUIAdoption(t *testing.T) { | |||
| 54 | + | for _, expect := range uiAdoptionExpectations { | |||
| 55 | + | expect := expect | |||
| 56 | + | t.Run(expect.family, func(t *testing.T) { | |||
| 57 | + | fset := token.NewFileSet() | |||
| 58 | + | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatalf("failed to parse %s: %v", expect.file, err) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | for _, fnName := range expect.functions { | |||
| 64 | + | fnName := fnName | |||
| 65 | + | t.Run(fnName, func(t *testing.T) { | |||
| 66 | + | fn := findFuncDecl(fileNode, fnName) | |||
| 67 | + | if fn == nil { | |||
| 68 | + | t.Fatalf("function %s not found in %s", fnName, expect.file) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | hasHelperCall := false | |||
| 72 | + | hasSharedUIUsage := false | |||
| 73 | + | hasForbiddenRawPrint := false | |||
| 74 | + | ||||
| 75 | + | ast.Inspect(fn.Body, func(n ast.Node) bool { | |||
| 76 | + | call, ok := n.(*ast.CallExpr) | |||
| 77 | + | if !ok { | |||
| 78 | + | return true | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | switch fun := call.Fun.(type) { | |||
| 82 | + | case *ast.Ident: | |||
| 83 | + | if _, ok := uiAdoptionHelperCalls[fun.Name]; ok { | |||
| 84 | + | hasHelperCall = true | |||
| 85 | + | } | |||
| 86 | + | case *ast.SelectorExpr: | |||
| 87 | + | if isForbiddenRawPrintCall(fun) { | |||
| 88 | + | hasForbiddenRawPrint = true | |||
| 89 | + | } | |||
| 90 | + | pkgIdent, ok := fun.X.(*ast.Ident) | |||
| 91 | + | if !ok { | |||
| 92 | + | return true | |||
| 93 | + | } | |||
| 94 | + | if pkgIdent.Name == "styles" || pkgIdent.Name == "components" { | |||
| 95 | + | hasSharedUIUsage = true | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | return true | |||
| 100 | + | }) | |||
| 101 | + | ||||
| 102 | + | if !hasHelperCall && !hasSharedUIUsage { | |||
| 103 | + | t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | if hasForbiddenRawPrint { | |||
| 107 | + | t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool { | |||
| 116 | + | pkgIdent, ok := sel.X.(*ast.Ident) | |||
| 117 | + | if !ok { | |||
| 118 | + | return false | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | if pkgIdent.Name == "fmt" { | |||
| 122 | + | switch sel.Sel.Name { | |||
| 123 | + | case "Print", "Printf", "Println": | |||
| 124 | + | return true | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") { | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | return false | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | type imagesClientStub struct { | |||
| 136 | + | listResp []dto.Image | |||
| 137 | + | pruneResp *dto.ImagePruneResponse | |||
| 138 | + | err error | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) { | |||
| 142 | + | if s.err != nil { | |||
| 143 | + | return nil, s.err | |||
| 144 | + | } | |||
| 145 | + | return s.listResp, nil | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (s imagesClientStub) PruneImages(context.Context, int) (*dto.ImagePruneResponse, error) { | |||
| 149 | + | if s.err != nil { | |||
| 150 | + | return nil, s.err | |||
| 151 | + | } | |||
| 152 | + | return s.pruneResp, nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestUIAdoptionRuntimeSeams(t *testing.T) { | |||
| 156 | + | uiAdoptionSeamMu.Lock() | |||
| 157 | + | defer uiAdoptionSeamMu.Unlock() | |||
| 158 | + | ||||
| 159 | + | origWriteLine := cliWriteLine | |||
| 160 | + | origWritef := cliWritef | |||
| 161 | + | defer func() { | |||
| 162 | + | cliWriteLine = origWriteLine | |||
| 163 | + | cliWritef = origWritef | |||
| 164 | + | }() | |||
| 165 | + | ||||
| 166 | + | lineCalls := 0 | |||
| 167 | + | writefCalls := 0 | |||
| 168 | + | cliWriteLine = func(w io.Writer, msg string) error { | |||
| 169 | + | lineCalls++ | |||
| 170 | + | _, err := io.WriteString(w, msg+"\n") | |||
| 171 | + | return err | |||
| 172 | + | } | |||
| 173 | + | cliWritef = func(w io.Writer, format string, args ...any) error { | |||
| 174 | + | writefCalls++ | |||
| 175 | + | _, err := io.WriteString(w, "formatted\n") | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | versionCmd := newVersionCmd() | |||
| 180 | + | versionCmd.SetArgs(nil) | |||
| 181 | + | versionCmd.SetOut(new(bytes.Buffer)) | |||
| 182 | + | if err := versionCmd.Execute(); err != nil { | |||
| 183 | + | t.Fatalf("version command execution failed: %v", err) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | imgBuf := new(bytes.Buffer) | |||
| 187 | + | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil { | |||
| 188 | + | t.Fatalf("runImagesList failed: %v", err) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | pruneBuf := new(bytes.Buffer) | |||
| 192 | + | if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil { | |||
| 193 | + | t.Fatalf("runImagesPrune failed: %v", err) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | if lineCalls == 0 { | |||
| 197 | + | t.Fatal("expected cliWriteLine to be called") | |||
| 198 | + | } | |||
| 199 | + | if writefCalls == 0 { | |||
| 200 | + | t.Fatal("expected cliWritef to be called") | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { | |||
| 205 | + | for _, decl := range file.Decls { | |||
| 206 | + | fn, ok := decl.(*ast.FuncDecl) | |||
| 207 | + | if !ok { | |||
| 208 | + | continue | |||
| 209 | + | } | |||
| 210 | + | if fn.Name.Name == name { | |||
| 211 | + | return fn | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | return nil | |||
| 215 | + | } | |||