1 // Frontend: Format changes
3 // SPDX-License-Identifier: GPL-3.0-or-later
4 // Copyright (C) 2021-2024 Simon Ruderich
13 "ruderich.org/simon/safcm"
14 "ruderich.org/simon/safcm/cmd/safcm/config"
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().
27 func (c *Changes) FormatChanges(resp safcm.MsgSyncResp) string {
29 if len(resp.FileChanges) > 0 {
30 changes = append(changes,
31 c.FormatFileChanges(resp.FileChanges))
33 if len(resp.PackageChanges) > 0 {
34 changes = append(changes,
35 c.FormatPackageChanges(resp.PackageChanges))
37 if len(resp.ServiceChanges) > 0 {
38 changes = append(changes,
39 c.FormatServiceChanges(resp.ServiceChanges))
41 if len(resp.CommandChanges) > 0 {
42 changes = append(changes,
43 c.FormatCommandChanges(resp.CommandChanges))
45 if len(changes) == 0 {
46 // Notify user that the host was synced successfully
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 {
61 func (c *Changes) FormatFileChanges(changes []safcm.FileChange) string {
62 var buf strings.Builder
64 fmt.Fprintf(&buf, "will change %d file(s): (dry-run)\n",
67 fmt.Fprintf(&buf, "changed %d file(s):\n", len(changes))
69 for _, x := range changes {
70 fmt.Fprintf(&buf, "%s:", c.FormatTarget(x.Path))
75 ColorString(c.IsTTY, ColorGreen, "created"),
76 FormatFileType(x.New),
77 FormatFileUserGroup(x.New),
78 FormatFilePerm(x.New),
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),
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),
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),
105 fmt.Fprint(&buf, " ")
106 fmt.Fprint(&buf, strings.Join(info, ", "))
109 if x.DataDiff != "" {
110 fmt.Fprintf(&buf, "\n%s", c.FormatDiff(x.DataDiff))
112 fmt.Fprintf(&buf, "\n")
117 func FormatFileType(info safcm.FileChangeInfo) string {
118 switch info.Mode.Type() {
119 case 0: // regular file
126 return fmt.Sprintf("invalid type %v", info.Mode.Type())
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)
134 func FormatFilePerm(info safcm.FileChangeInfo) string {
135 return fmt.Sprintf("%#o", config.FileModeToFullPerm(info.Mode))
138 func (c *Changes) FormatPackageChanges(changes []safcm.PackageChange) string {
139 var buf strings.Builder
141 fmt.Fprintf(&buf, "will install %d package(s): (dry-run)\n",
144 fmt.Fprintf(&buf, "installed %d package(s):\n", len(changes))
146 for _, x := range changes {
147 // TODO: indicate if installation failed
148 fmt.Fprintf(&buf, "%s\n", c.FormatTarget(x.Name))
153 func (c *Changes) FormatServiceChanges(changes []safcm.ServiceChange) string {
154 var buf strings.Builder
156 fmt.Fprintf(&buf, "will modify %d service(s): (dry-run)\n",
159 fmt.Fprintf(&buf, "modified %d service(s):\n", len(changes))
161 for _, x := range changes {
164 info = append(info, "started")
167 info = append(info, "enabled")
169 fmt.Fprintf(&buf, "%s: %s\n",
170 c.FormatTarget(x.Name),
171 strings.Join(info, ", "))
176 func (c *Changes) FormatCommandChanges(changes []safcm.CommandChange) string {
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.
187 for _, x := range changes {
188 if x.Trigger == "" &&
196 var buf strings.Builder
198 fmt.Fprintf(&buf, "will execute %d command(s)", len(changes))
200 fmt.Fprintf(&buf, "executed %d command(s)", len(changes))
202 if noOutput > 0 && !c.DryRun {
203 fmt.Fprintf(&buf, ", %d with no output (hidden)", noOutput)
205 if noOutput != len(changes) {
206 fmt.Fprintf(&buf, ":")
209 fmt.Fprintf(&buf, " (dry-run)")
211 fmt.Fprintf(&buf, "\n")
212 for _, x := range changes {
214 x.Trigger == "" && x.Error == "" && x.Output == "" {
218 fmt.Fprintf(&buf, "%s", c.FormatTarget(x.Command))
220 fmt.Fprintf(&buf, ", trigger for %q", x.Trigger)
223 fmt.Fprintf(&buf, ", failed: %q", x.Error)
226 // TODO: truncate very large outputs?
227 x := indentBlock(x.Output, indent)
228 fmt.Fprintf(&buf, ":\n%s",
229 EscapeControlCharacters(c.IsTTY, x))
231 fmt.Fprintf(&buf, "\n")
236 func (c *Changes) FormatTarget(x string) string {
237 x = fmt.Sprintf("%q", x) // escape!
238 return ColorString(c.IsTTY, ColorCyan, x)
241 func (c *Changes) FormatDiff(diff string) string {
244 diff = indentBlock(diff, indent)
245 // Never color diff content as we want to color the whole diff
246 diff = EscapeControlCharacters(false, diff)
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)
260 return strings.Join(res, "\n")
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]
268 lines = append(lines, "\\ No newline at end of file")
271 return sep + strings.Join(lines, "\n"+sep)