--- /dev/null
+// Frontend: Format changes
+
+// Copyright (C) 2021 Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package frontend
+
+import (
+ "fmt"
+ "io/fs"
+ "strings"
+
+ "ruderich.org/simon/safcm"
+ "ruderich.org/simon/safcm/cmd/safcm/config"
+)
+
+// NOTE: Be careful when implementing new format* functions. All input from
+// the remote helper is untrusted and must be either escaped with %q or by
+// calling EscapeControlCharacters().
+
+type Changes struct {
+ DryRun bool
+ Quiet bool
+ IsTTY bool
+}
+
+func (c *Changes) FormatChanges(resp safcm.MsgSyncResp) string {
+ var changes []string
+ if len(resp.FileChanges) > 0 {
+ changes = append(changes,
+ c.FormatFileChanges(resp.FileChanges))
+ }
+ if len(resp.PackageChanges) > 0 {
+ changes = append(changes,
+ c.FormatPackageChanges(resp.PackageChanges))
+ }
+ if len(resp.ServiceChanges) > 0 {
+ changes = append(changes,
+ c.FormatServiceChanges(resp.ServiceChanges))
+ }
+ if len(resp.CommandChanges) > 0 {
+ changes = append(changes,
+ c.FormatCommandChanges(resp.CommandChanges))
+ }
+ if len(changes) == 0 {
+ // Notify user that the host was synced successfully
+ return "no changes"
+ }
+
+ x := strings.Join(changes, "\n")
+ // If quiet is used and only commands without output were executed
+ // then don't prepend a newline so that the whole change output of a
+ // host fits in a single line. This makes the output much more
+ // readable with multiple hosts.
+ if strings.Count(x, "\n") == 1 {
+ return x
+ }
+ return "\n" + x
+}
+
+func (c *Changes) FormatFileChanges(changes []safcm.FileChange) string {
+ var buf strings.Builder
+ if c.DryRun {
+ fmt.Fprintf(&buf, "will change %d file(s): (dry-run)\n",
+ len(changes))
+ } else {
+ fmt.Fprintf(&buf, "changed %d file(s):\n", len(changes))
+ }
+ for _, x := range changes {
+ fmt.Fprintf(&buf, "%s:", c.FormatTarget(x.Path))
+
+ var info []string
+ if x.Created {
+ info = append(info,
+ ColorString(c.IsTTY, ColorGreen, "created"),
+ FormatFileType(x.New),
+ FormatFileUserGroup(x.New),
+ FormatFilePerm(x.New),
+ )
+ } else {
+ if x.Old.Mode.Type() != x.New.Mode.Type() {
+ info = append(info, fmt.Sprintf("%s -> %s",
+ FormatFileType(x.Old),
+ FormatFileType(x.New),
+ ))
+ }
+ if x.Old.User != x.New.User ||
+ x.Old.Uid != x.New.Uid ||
+ x.Old.Group != x.New.Group ||
+ x.Old.Gid != x.New.Gid {
+ info = append(info, fmt.Sprintf("%s -> %s",
+ FormatFileUserGroup(x.Old),
+ FormatFileUserGroup(x.New),
+ ))
+ }
+ if config.FileModeToFullPerm(x.Old.Mode) !=
+ config.FileModeToFullPerm(x.New.Mode) {
+ info = append(info, fmt.Sprintf("%s -> %s",
+ FormatFilePerm(x.Old),
+ FormatFilePerm(x.New),
+ ))
+ }
+ }
+ if len(info) > 0 {
+ fmt.Fprint(&buf, " ")
+ fmt.Fprint(&buf, strings.Join(info, ", "))
+ }
+
+ if x.DataDiff != "" {
+ fmt.Fprintf(&buf, "\n%s", c.FormatDiff(x.DataDiff))
+ }
+ fmt.Fprintf(&buf, "\n")
+ }
+ return buf.String()
+}
+
+func FormatFileType(info safcm.FileChangeInfo) string {
+ switch info.Mode.Type() {
+ case 0: // regular file
+ return "file"
+ case fs.ModeSymlink:
+ return "symlink"
+ case fs.ModeDir:
+ return "dir"
+ default:
+ return fmt.Sprintf("invalid type %v", info.Mode.Type())
+ }
+}
+func FormatFileUserGroup(info safcm.FileChangeInfo) string {
+ return fmt.Sprintf("%s(%d) %s(%d)",
+ EscapeControlCharacters(false, info.User), info.Uid,
+ EscapeControlCharacters(false, info.Group), info.Gid)
+}
+func FormatFilePerm(info safcm.FileChangeInfo) string {
+ return fmt.Sprintf("%#o", config.FileModeToFullPerm(info.Mode))
+}
+
+func (c *Changes) FormatPackageChanges(changes []safcm.PackageChange) string {
+ var buf strings.Builder
+ if c.DryRun {
+ fmt.Fprintf(&buf, "will install %d package(s): (dry-run)\n",
+ len(changes))
+ } else {
+ fmt.Fprintf(&buf, "installed %d package(s):\n", len(changes))
+ }
+ for _, x := range changes {
+ // TODO: indicate if installation failed
+ fmt.Fprintf(&buf, "%s\n", c.FormatTarget(x.Name))
+ }
+ return buf.String()
+}
+
+func (c *Changes) FormatServiceChanges(changes []safcm.ServiceChange) string {
+ var buf strings.Builder
+ if c.DryRun {
+ fmt.Fprintf(&buf, "will modify %d service(s): (dry-run)\n",
+ len(changes))
+ } else {
+ fmt.Fprintf(&buf, "modified %d service(s):\n", len(changes))
+ }
+ for _, x := range changes {
+ var info []string
+ if x.Started {
+ info = append(info, "started")
+ }
+ if x.Enabled {
+ info = append(info, "enabled")
+ }
+ fmt.Fprintf(&buf, "%s: %s\n",
+ c.FormatTarget(x.Name),
+ strings.Join(info, ", "))
+ }
+ return buf.String()
+}
+
+func (c *Changes) FormatCommandChanges(changes []safcm.CommandChange) string {
+ const indent = " > "
+
+ // Quiet hides all successful, non-trigger commands which produce no
+ // output. This is useful as many commands will be used to enforce a
+ // certain state (e.g. file not-present, `ainsl`, etc.) and are run on
+ // each sync. Displaying them provides not much useful information.
+ // Instead, quiet shows them only when they produce output (e.g.
+ // `ainsl`, `rm -v`) and thus modify the host's state.
+ var noOutput int
+ if c.Quiet {
+ for _, x := range changes {
+ if x.Trigger == "" &&
+ x.Error == "" &&
+ x.Output == "" {
+ noOutput++
+ }
+ }
+ }
+
+ var buf strings.Builder
+ if c.DryRun {
+ fmt.Fprintf(&buf, "will execute %d command(s)", len(changes))
+ } else {
+ fmt.Fprintf(&buf, "executed %d command(s)", len(changes))
+ }
+ if noOutput > 0 && !c.DryRun {
+ fmt.Fprintf(&buf, ", %d with no output (hidden)", noOutput)
+ }
+ if noOutput != len(changes) {
+ fmt.Fprintf(&buf, ":")
+ }
+ if c.DryRun {
+ fmt.Fprintf(&buf, " (dry-run)")
+ }
+ fmt.Fprintf(&buf, "\n")
+ for _, x := range changes {
+ if noOutput > 0 &&
+ x.Trigger == "" && x.Error == "" && x.Output == "" {
+ continue
+ }
+
+ fmt.Fprintf(&buf, "%s", c.FormatTarget(x.Command))
+ if x.Trigger != "" {
+ fmt.Fprintf(&buf, ", trigger for %q", x.Trigger)
+ }
+ if x.Error != "" {
+ fmt.Fprintf(&buf, ", failed: %q", x.Error)
+ }
+ if x.Output != "" {
+ // TODO: truncate very large outputs?
+ x := indentBlock(x.Output, indent)
+ fmt.Fprintf(&buf, ":\n%s",
+ EscapeControlCharacters(c.IsTTY, x))
+ }
+ fmt.Fprintf(&buf, "\n")
+ }
+ return buf.String()
+}
+
+func (c *Changes) FormatTarget(x string) string {
+ x = fmt.Sprintf("%q", x) // escape!
+ return ColorString(c.IsTTY, ColorCyan, x)
+}
+
+func (c *Changes) FormatDiff(diff string) string {
+ const indent = " "
+
+ diff = indentBlock(diff, indent)
+ // Never color diff content as we want to color the whole diff
+ diff = EscapeControlCharacters(false, diff)
+ if !c.IsTTY {
+ return diff
+ }
+
+ var res []string
+ for _, x := range strings.Split(diff, "\n") {
+ if strings.HasPrefix(x, indent+"+") {
+ x = ColorString(c.IsTTY, ColorGreen, x)
+ } else if strings.HasPrefix(x, indent+"-") {
+ x = ColorString(c.IsTTY, ColorRed, x)
+ }
+ res = append(res, x)
+ }
+ return strings.Join(res, "\n")
+}
+
+func indentBlock(x string, sep string) string {
+ lines := strings.Split(x, "\n")
+ if lines[len(lines)-1] == "" {
+ lines = lines[:len(lines)-1]
+ } else {
+ lines = append(lines, "\\ No newline at end of file")
+ }
+
+ return sep + strings.Join(lines, "\n"+sep)
+}