|
4 | 4 | package cmd |
5 | 5 |
|
6 | 6 | import ( |
| 7 | + "encoding/json" |
7 | 8 | "fmt" |
| 9 | + "io" |
8 | 10 | "os" |
9 | 11 | "sort" |
10 | | - "text/tabwriter" |
| 12 | + "strings" |
11 | 13 |
|
12 | 14 | "github.com/carabiner-dev/policy" |
13 | 15 | "github.com/spf13/cobra" |
| 16 | + "golang.org/x/term" |
| 17 | + "google.golang.org/protobuf/encoding/protojson" |
| 18 | +) |
| 19 | + |
| 20 | +const ( |
| 21 | + outputFormatTable = "table" |
| 22 | + outputFormatJSON = "json" |
| 23 | +) |
| 24 | + |
| 25 | +const ( |
| 26 | + defaultTerminalWidth = 100 |
| 27 | + shortDigestLen = 9 |
14 | 28 | ) |
15 | 29 |
|
16 | 30 | func addCheckUpdate(parentCmd *cobra.Command) { |
| 31 | + var format string |
17 | 32 | cmd := &cobra.Command{ |
18 | 33 | Short: "check policy references for available updates", |
19 | 34 | Use: "check-update [flags] <location> [<location>...]", |
@@ -43,45 +58,164 @@ reported showing the old and new commits and digests.`, |
43 | 58 | return err |
44 | 59 | } |
45 | 60 |
|
46 | | - if len(updates) == 0 { |
47 | | - fmt.Fprintln(os.Stderr, "no policy references have updates available") |
| 61 | + switch format { |
| 62 | + case outputFormatJSON: |
| 63 | + return writeUpdatesJSON(os.Stdout, updates) |
| 64 | + case outputFormatTable, "": |
| 65 | + if len(updates) == 0 { |
| 66 | + fmt.Fprintln(os.Stderr, "no policy references have updates available") |
| 67 | + return nil |
| 68 | + } |
| 69 | + printUpdatesTable(os.Stdout, updates) |
48 | 70 | return nil |
| 71 | + default: |
| 72 | + return fmt.Errorf("unknown format %q (use table or json)", format) |
49 | 73 | } |
50 | | - |
51 | | - printUpdatesTable(os.Stdout, updates) |
52 | | - return nil |
53 | 74 | }, |
54 | 75 | } |
| 76 | + cmd.Flags().StringVarP(&format, "format", "f", outputFormatTable, "output format: table, json") |
55 | 77 | parentCmd.AddCommand(cmd) |
56 | 78 | } |
57 | 79 |
|
58 | | -func printUpdatesTable(out *os.File, updates map[string][]*policy.RefUpdate) { |
| 80 | +// updatePlan is the JSON shape emitted by --format=json. It is intended |
| 81 | +// to be stable enough to be re-ingested as a plan for a future apply |
| 82 | +// command (similar to terraform plan output). |
| 83 | +type updatePlan struct { |
| 84 | + Version int `json:"version"` |
| 85 | + Files map[string][]planEntry `json:"files"` |
| 86 | +} |
| 87 | + |
| 88 | +type planEntry struct { |
| 89 | + Old json.RawMessage `json:"old"` |
| 90 | + New json.RawMessage `json:"new"` |
| 91 | +} |
| 92 | + |
| 93 | +func writeUpdatesJSON(out io.Writer, updates map[string][]*policy.RefUpdate) error { |
| 94 | + plan := updatePlan{ |
| 95 | + Version: 1, |
| 96 | + Files: map[string][]planEntry{}, |
| 97 | + } |
| 98 | + marshaler := protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: false} |
| 99 | + for file, refs := range updates { |
| 100 | + entries := make([]planEntry, 0, len(refs)) |
| 101 | + for _, r := range refs { |
| 102 | + oldRaw, err := marshaler.Marshal(r.Old) |
| 103 | + if err != nil { |
| 104 | + return fmt.Errorf("marshaling old ref for %s: %w", file, err) |
| 105 | + } |
| 106 | + newRaw, err := marshaler.Marshal(r.New) |
| 107 | + if err != nil { |
| 108 | + return fmt.Errorf("marshaling new ref for %s: %w", file, err) |
| 109 | + } |
| 110 | + entries = append(entries, planEntry{Old: oldRaw, New: newRaw}) |
| 111 | + } |
| 112 | + plan.Files[file] = entries |
| 113 | + } |
| 114 | + |
| 115 | + enc := json.NewEncoder(out) |
| 116 | + enc.SetIndent("", " ") |
| 117 | + return enc.Encode(plan) |
| 118 | +} |
| 119 | + |
| 120 | +func printUpdatesTable(out io.Writer, updates map[string][]*policy.RefUpdate) { |
59 | 121 | files := make([]string, 0, len(updates)) |
60 | 122 | for f := range updates { |
61 | 123 | files = append(files, f) |
62 | 124 | } |
63 | 125 | sort.Strings(files) |
64 | 126 |
|
65 | | - tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) |
66 | | - fmt.Fprintln(tw, "FILE\tREFERENCE\tOLD URI\tNEW URI\tOLD DIGEST\tNEW DIGEST") |
67 | | - for _, f := range files { |
| 127 | + width := terminalWidth(out) |
| 128 | + // Reserve space for: "| > " (4), " | " between uri and old (3), |
| 129 | + // " | " between old and new (3), " |" trailing (2). Plus one space |
| 130 | + // of padding on each side of each cell (already part of those 3/2 |
| 131 | + // separators), so content budget = width - 12. |
| 132 | + const chromeWidth = 12 |
| 133 | + const minURIWidth = 20 |
| 134 | + uriWidth := width - chromeWidth - 2*shortDigestLen |
| 135 | + if uriWidth < minURIWidth { |
| 136 | + uriWidth = minURIWidth |
| 137 | + } |
| 138 | + |
| 139 | + rowWidth := chromeWidth + uriWidth + 2*shortDigestLen |
| 140 | + border := strings.Repeat("-", rowWidth) |
| 141 | + |
| 142 | + p := func(format string, args ...any) { |
| 143 | + _, _ = fmt.Fprintf(out, format, args...) |
| 144 | + } |
| 145 | + for i, f := range files { |
| 146 | + if i > 0 { |
| 147 | + p("\n") |
| 148 | + } |
| 149 | + p("%s\n", border) |
| 150 | + p("| %s |\n", padRight(f, rowWidth-4)) |
| 151 | + p("%s\n", border) |
68 | 152 | for _, u := range updates[f] { |
69 | | - name := u.Old.GetId() |
70 | | - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", |
71 | | - f, name, |
72 | | - u.Old.GetLocation().GetUri(), |
73 | | - u.New.GetLocation().GetUri(), |
74 | | - short(u.Old.GetLocation().GetDigest()["sha256"]), |
75 | | - short(u.New.GetLocation().GetDigest()["sha256"]), |
| 153 | + uri := bareURI(u.Old.GetLocation().GetUri()) |
| 154 | + oldDigest := short(u.Old.GetLocation().GetDigest()["sha256"]) |
| 155 | + newDigest := short(u.New.GetLocation().GetDigest()["sha256"]) |
| 156 | + p("| > %s | %s | %s |\n", |
| 157 | + padRight(trimLeft(uri, uriWidth), uriWidth), |
| 158 | + padRight(oldDigest, shortDigestLen), |
| 159 | + padRight(newDigest, shortDigestLen), |
76 | 160 | ) |
77 | 161 | } |
| 162 | + p("%s\n", border) |
| 163 | + } |
| 164 | +} |
| 165 | + |
| 166 | +// bareURI strips the pinned commit from a VCS locator (the "@<rev>" before |
| 167 | +// the subpath fragment), so the URI that is displayed identifies the |
| 168 | +// policy location rather than the old revision. |
| 169 | +func bareURI(uri string) string { |
| 170 | + hashIdx := strings.Index(uri, "#") |
| 171 | + prefix, suffix := uri, "" |
| 172 | + if hashIdx >= 0 { |
| 173 | + prefix = uri[:hashIdx] |
| 174 | + suffix = uri[hashIdx:] |
| 175 | + } |
| 176 | + if at := strings.LastIndex(prefix, "@"); at >= 0 { |
| 177 | + // Only trim when the '@' is part of the revision (i.e. after "://"). |
| 178 | + if scheme := strings.Index(prefix, "://"); scheme < 0 || at > scheme+3 { |
| 179 | + prefix = prefix[:at] |
| 180 | + } |
78 | 181 | } |
79 | | - _ = tw.Flush() |
| 182 | + return prefix + suffix |
80 | 183 | } |
81 | 184 |
|
82 | 185 | func short(s string) string { |
83 | | - if len(s) > 12 { |
84 | | - return s[:12] |
| 186 | + if len(s) > shortDigestLen { |
| 187 | + return s[:shortDigestLen] |
85 | 188 | } |
86 | 189 | return s |
87 | 190 | } |
| 191 | + |
| 192 | +// trimLeft shortens s from the left with a leading ellipsis so it fits in |
| 193 | +// `width` runes. |
| 194 | +func trimLeft(s string, width int) string { |
| 195 | + if len(s) <= width { |
| 196 | + return s |
| 197 | + } |
| 198 | + if width <= 3 { |
| 199 | + return s[len(s)-width:] |
| 200 | + } |
| 201 | + return "..." + s[len(s)-(width-3):] |
| 202 | +} |
| 203 | + |
| 204 | +func padRight(s string, width int) string { |
| 205 | + if len(s) >= width { |
| 206 | + return s |
| 207 | + } |
| 208 | + return s + strings.Repeat(" ", width-len(s)) |
| 209 | +} |
| 210 | + |
| 211 | +func terminalWidth(out io.Writer) int { |
| 212 | + f, ok := out.(*os.File) |
| 213 | + if !ok { |
| 214 | + return defaultTerminalWidth |
| 215 | + } |
| 216 | + w, _, err := term.GetSize(int(f.Fd())) //nolint:gosec // fd values fit in int on all supported platforms |
| 217 | + if err != nil || w <= 0 { |
| 218 | + return defaultTerminalWidth |
| 219 | + } |
| 220 | + return w |
| 221 | +} |
0 commit comments