]> ruderich.org/simon Gitweb - safcm/safcm.git/blob - frontend/changes.go
Use SPDX license identifiers
[safcm/safcm.git] / frontend / changes.go
1 // Frontend: Format changes
2
3 // SPDX-License-Identifier: GPL-3.0-or-later
4 // Copyright (C) 2021-2024  Simon Ruderich
5
6 package frontend
7
8 import (
9         "fmt"
10         "io/fs"
11         "strings"
12
13         "ruderich.org/simon/safcm"
14         "ruderich.org/simon/safcm/cmd/safcm/config"
15 )
16
17 // NOTE: Be careful when implementing new format* functions. All input from
18 // the remote helper is untrusted and must be either escaped with %q or by
19 // calling EscapeControlCharacters().
20
21 type Changes struct {
22         DryRun bool
23         Quiet  bool
24         IsTTY  bool
25 }
26
27 func (c *Changes) FormatChanges(resp safcm.MsgSyncResp) string {
28         var changes []string
29         if len(resp.FileChanges) > 0 {
30                 changes = append(changes,
31                         c.FormatFileChanges(resp.FileChanges))
32         }
33         if len(resp.PackageChanges) > 0 {
34                 changes = append(changes,
35                         c.FormatPackageChanges(resp.PackageChanges))
36         }
37         if len(resp.ServiceChanges) > 0 {
38                 changes = append(changes,
39                         c.FormatServiceChanges(resp.ServiceChanges))
40         }
41         if len(resp.CommandChanges) > 0 {
42                 changes = append(changes,
43                         c.FormatCommandChanges(resp.CommandChanges))
44         }
45         if len(changes) == 0 {
46                 // Notify user that the host was synced successfully
47                 return "no changes"
48         }
49
50         x := strings.Join(changes, "\n")
51         // If quiet is used and only commands without output were executed
52         // then don't prepend a newline so that the whole change output of a
53         // host fits in a single line. This makes the output much more
54         // readable with multiple hosts.
55         if strings.Count(x, "\n") == 1 {
56                 return x
57         }
58         return "\n" + x
59 }
60
61 func (c *Changes) FormatFileChanges(changes []safcm.FileChange) string {
62         var buf strings.Builder
63         if c.DryRun {
64                 fmt.Fprintf(&buf, "will change %d file(s): (dry-run)\n",
65                         len(changes))
66         } else {
67                 fmt.Fprintf(&buf, "changed %d file(s):\n", len(changes))
68         }
69         for _, x := range changes {
70                 fmt.Fprintf(&buf, "%s:", c.FormatTarget(x.Path))
71
72                 var info []string
73                 if x.Created {
74                         info = append(info,
75                                 ColorString(c.IsTTY, ColorGreen, "created"),
76                                 FormatFileType(x.New),
77                                 FormatFileUserGroup(x.New),
78                                 FormatFilePerm(x.New),
79                         )
80                 } else {
81                         if x.Old.Mode.Type() != x.New.Mode.Type() {
82                                 info = append(info, fmt.Sprintf("%s -> %s",
83                                         FormatFileType(x.Old),
84                                         FormatFileType(x.New),
85                                 ))
86                         }
87                         if x.Old.User != x.New.User ||
88                                 x.Old.Uid != x.New.Uid ||
89                                 x.Old.Group != x.New.Group ||
90                                 x.Old.Gid != x.New.Gid {
91                                 info = append(info, fmt.Sprintf("%s -> %s",
92                                         FormatFileUserGroup(x.Old),
93                                         FormatFileUserGroup(x.New),
94                                 ))
95                         }
96                         if config.FileModeToFullPerm(x.Old.Mode) !=
97                                 config.FileModeToFullPerm(x.New.Mode) {
98                                 info = append(info, fmt.Sprintf("%s -> %s",
99                                         FormatFilePerm(x.Old),
100                                         FormatFilePerm(x.New),
101                                 ))
102                         }
103                 }
104                 if len(info) > 0 {
105                         fmt.Fprint(&buf, " ")
106                         fmt.Fprint(&buf, strings.Join(info, ", "))
107                 }
108
109                 if x.DataDiff != "" {
110                         fmt.Fprintf(&buf, "\n%s", c.FormatDiff(x.DataDiff))
111                 }
112                 fmt.Fprintf(&buf, "\n")
113         }
114         return buf.String()
115 }
116
117 func FormatFileType(info safcm.FileChangeInfo) string {
118         switch info.Mode.Type() {
119         case 0: // regular file
120                 return "file"
121         case fs.ModeSymlink:
122                 return "symlink"
123         case fs.ModeDir:
124                 return "dir"
125         default:
126                 return fmt.Sprintf("invalid type %v", info.Mode.Type())
127         }
128 }
129 func FormatFileUserGroup(info safcm.FileChangeInfo) string {
130         return fmt.Sprintf("%s(%d) %s(%d)",
131                 EscapeControlCharacters(false, info.User), info.Uid,
132                 EscapeControlCharacters(false, info.Group), info.Gid)
133 }
134 func FormatFilePerm(info safcm.FileChangeInfo) string {
135         return fmt.Sprintf("%#o", config.FileModeToFullPerm(info.Mode))
136 }
137
138 func (c *Changes) FormatPackageChanges(changes []safcm.PackageChange) string {
139         var buf strings.Builder
140         if c.DryRun {
141                 fmt.Fprintf(&buf, "will install %d package(s): (dry-run)\n",
142                         len(changes))
143         } else {
144                 fmt.Fprintf(&buf, "installed %d package(s):\n", len(changes))
145         }
146         for _, x := range changes {
147                 // TODO: indicate if installation failed
148                 fmt.Fprintf(&buf, "%s\n", c.FormatTarget(x.Name))
149         }
150         return buf.String()
151 }
152
153 func (c *Changes) FormatServiceChanges(changes []safcm.ServiceChange) string {
154         var buf strings.Builder
155         if c.DryRun {
156                 fmt.Fprintf(&buf, "will modify %d service(s): (dry-run)\n",
157                         len(changes))
158         } else {
159                 fmt.Fprintf(&buf, "modified %d service(s):\n", len(changes))
160         }
161         for _, x := range changes {
162                 var info []string
163                 if x.Started {
164                         info = append(info, "started")
165                 }
166                 if x.Enabled {
167                         info = append(info, "enabled")
168                 }
169                 fmt.Fprintf(&buf, "%s: %s\n",
170                         c.FormatTarget(x.Name),
171                         strings.Join(info, ", "))
172         }
173         return buf.String()
174 }
175
176 func (c *Changes) FormatCommandChanges(changes []safcm.CommandChange) string {
177         const indent = "   > "
178
179         // Quiet hides all successful, non-trigger commands which produce no
180         // output. This is useful as many commands will be used to enforce a
181         // certain state (e.g. file not-present, `ainsl`, etc.) and are run on
182         // each sync. Displaying them provides not much useful information.
183         // Instead, quiet shows them only when they produce output (e.g.
184         // `ainsl`, `rm -v`) and thus modify the host's state.
185         var noOutput int
186         if c.Quiet {
187                 for _, x := range changes {
188                         if x.Trigger == "" &&
189                                 x.Error == "" &&
190                                 x.Output == "" {
191                                 noOutput++
192                         }
193                 }
194         }
195
196         var buf strings.Builder
197         if c.DryRun {
198                 fmt.Fprintf(&buf, "will execute %d command(s)", len(changes))
199         } else {
200                 fmt.Fprintf(&buf, "executed %d command(s)", len(changes))
201         }
202         if noOutput > 0 && !c.DryRun {
203                 fmt.Fprintf(&buf, ", %d with no output (hidden)", noOutput)
204         }
205         if noOutput != len(changes) {
206                 fmt.Fprintf(&buf, ":")
207         }
208         if c.DryRun {
209                 fmt.Fprintf(&buf, " (dry-run)")
210         }
211         fmt.Fprintf(&buf, "\n")
212         for _, x := range changes {
213                 if noOutput > 0 &&
214                         x.Trigger == "" && x.Error == "" && x.Output == "" {
215                         continue
216                 }
217
218                 fmt.Fprintf(&buf, "%s", c.FormatTarget(x.Command))
219                 if x.Trigger != "" {
220                         fmt.Fprintf(&buf, ", trigger for %q", x.Trigger)
221                 }
222                 if x.Error != "" {
223                         fmt.Fprintf(&buf, ", failed: %q", x.Error)
224                 }
225                 if x.Output != "" {
226                         // TODO: truncate very large outputs?
227                         x := indentBlock(x.Output, indent)
228                         fmt.Fprintf(&buf, ":\n%s",
229                                 EscapeControlCharacters(c.IsTTY, x))
230                 }
231                 fmt.Fprintf(&buf, "\n")
232         }
233         return buf.String()
234 }
235
236 func (c *Changes) FormatTarget(x string) string {
237         x = fmt.Sprintf("%q", x) // escape!
238         return ColorString(c.IsTTY, ColorCyan, x)
239 }
240
241 func (c *Changes) FormatDiff(diff string) string {
242         const indent = "   "
243
244         diff = indentBlock(diff, indent)
245         // Never color diff content as we want to color the whole diff
246         diff = EscapeControlCharacters(false, diff)
247         if !c.IsTTY {
248                 return diff
249         }
250
251         var res []string
252         for _, x := range strings.Split(diff, "\n") {
253                 if strings.HasPrefix(x, indent+"+") {
254                         x = ColorString(c.IsTTY, ColorGreen, x)
255                 } else if strings.HasPrefix(x, indent+"-") {
256                         x = ColorString(c.IsTTY, ColorRed, x)
257                 }
258                 res = append(res, x)
259         }
260         return strings.Join(res, "\n")
261 }
262
263 func indentBlock(x string, sep string) string {
264         lines := strings.Split(x, "\n")
265         if lines[len(lines)-1] == "" {
266                 lines = lines[:len(lines)-1]
267         } else {
268                 lines = append(lines, "\\ No newline at end of file")
269         }
270
271         return sep + strings.Join(lines, "\n"+sep)
272 }