package bonfire import ( "errors" "io" "os" "path/filepath" "strings" "unicode" "git.golem15.com/golem15/summercms/internal/argmask" "git.golem15.com/golem15/summercms/modules/sunscreen" "github.com/charmbracelet/colorprofile" ) // UsageError marks a mistake in how a command was called: an unknown // command or flag, or a wrong number of arguments. Execute returns ExitUsage // for it. type UsageError struct { Err error // command is the command that was called wrongly, when known; the // error block suggests its help. command string } func (e *UsageError) Error() string { return e.Err.Error() } func (e *UsageError) Unwrap() error { return e.Err } // Usage wraps err as a UsageError; nil stays nil. func Usage(err error) error { if err == nil { return nil } var ue *UsageError if errors.As(err, &ue) { return err } return &UsageError{Err: err} } // isUsage reports a UsageError in the chain or cobra's unknown command error. func isUsage(err error) bool { var ue *UsageError if errors.As(err, &ue) { return true } return strings.HasPrefix(err.Error(), "unknown command") } // HintError adds an explanation and a command to try to an error. The error // block prints both under the error message; {bin} in Try becomes summer or // bin/. type HintError struct { Err error Explanation string Try string } func (e *HintError) Error() string { return e.Err.Error() } func (e *HintError) Unwrap() error { return e.Err } // WithHint returns err with an explanation and a Try line (several lines are // allowed); "{bin}" in try is replaced by how the binary is called. A nil err // stays nil. func WithHint(err error, explanation, try string) error { if err == nil { return nil } return &HintError{Err: err, Explanation: explanation, Try: try} } // ErrReported marks an error whose details the command already printed: // the error block prints nothing and the exit code is 1, or the code given // with WithExitCode. var ErrReported = errors.New("bonfire: error already reported") type reportedError struct { err error } func (e *reportedError) Error() string { return e.err.Error() } func (e *reportedError) Unwrap() error { return e.err } func (e *reportedError) Is(target error) bool { return target == ErrReported } // Reported returns an error that matches both ErrReported and err, for a // command that has already printed its problems. A nil err gives // ErrReported. func Reported(err error) error { if err == nil { return ErrReported } return &reportedError{err: err} } // Sanitize removes control characters other than tab from s; carriage // returns and line feeds become spaces, so a multi-line value stays on one // line. Text from outside the operator's control (database or remote error // text, names an administrator typed) then cannot move the cursor, clear // the screen or set the window title. Error blocks, process views, failure // lines and tables pass their text through it, and so does diary's console // channel. A clean string is returned unchanged. func Sanitize(s string) string { clean := true for _, r := range s { if r != '\t' && unicode.IsControl(r) { clean = false break } } if clean { return s } var b strings.Builder for _, r := range s { switch { case r == '\n' || r == '\r': b.WriteByte(' ') case r != '\t' && unicode.IsControl(r): default: b.WriteRune(r) } } return b.String() } // sanitizeLines applies Sanitize to every line of s, keeping the line // breaks. func sanitizeLines(s string) string { lines := strings.Split(s, "\n") for i, line := range lines { lines[i] = Sanitize(line) } return strings.Join(lines, "\n") } // exitCodeError carries the exit code Execute returns for an error. type exitCodeError struct { err error code int } func (e *exitCodeError) Error() string { return e.err.Error() } func (e *exitCodeError) Unwrap() error { return e.err } // WithExitCode returns err carrying code as the process exit code: Execute // returns code instead of ExitFailure or ExitUsage. A code outside 1 to 255 // gives ExitFailure. A nil err stays nil. Wrap it with Reported when the // error was already printed, as summer does with the exit code of an app // binary it ran. func WithExitCode(err error, code int) error { if err == nil { return nil } return &exitCodeError{err: err, code: code} } // RenderError writes err to w as the console error block: the ERROR badge // and the message, the explanation and the Try lines of a HintError ({bin} // replaced by summer or bin/, for the root named name), then "Run with // -v for details." at verbosity 0 or the wrapped error chain under "Caused // by:" at -v and above. cobra's unknown command error becomes `Command "x" // is not defined.` with its suggestions as Try lines, and other usage errors // suggest the command's help. Every message passes through sunscreen.Scrub, // and every line through Sanitize. // Errors wrapped by Reported print nothing. func RenderError(w io.Writer, name string, err error, g Globals) { if err == nil || w == nil || errors.Is(err, ErrReported) { return } if cw, ok := w.(*colorprofile.Writer); ok { w = cw.Forward } bin := usageName(name) p := DefaultPalette() message := err.Error() explanation, try := "", "" var hint *HintError if errors.As(err, &hint) { explanation, try = hint.Explanation, hint.Try } if strings.HasPrefix(message, "unknown command") { cmdName, suggestions := parseUnknownCommand(message) message = `Command "` + cmdName + `" is not defined.` if try == "" { var lines []string for _, s := range suggestions { lines = append(lines, "{bin} "+s) } if len(lines) == 0 { lines = []string{"{bin} list"} } try = strings.Join(lines, "\n") } } else if try == "" { var ue *UsageError if errors.As(err, &ue) { if ue.command != "" { try = "{bin} help " + ue.command } else { try = "{bin} list" } } } var b strings.Builder b.WriteString("\n") for i, line := range strings.Split(sanitizeLines(sunscreen.Scrub(strings.TrimRight(message, "\n"))), "\n") { if i == 0 { b.WriteString(" " + p.ErrorBold.Reverse(true).Render(" ERROR ") + " " + line + "\n") continue } b.WriteString(" " + line + "\n") } if explanation != "" { b.WriteString("\n") for _, line := range strings.Split(sanitizeLines(sunscreen.Scrub(explanation)), "\n") { b.WriteString(" " + line + "\n") } } if try != "" { b.WriteString("\n " + p.Header.Render("Try:") + "\n") for _, line := range strings.Split(strings.ReplaceAll(try, "{bin}", bin), "\n") { b.WriteString(" " + p.Success.Render(Sanitize(sunscreen.Scrub(line))) + "\n") } } if g.Verbosity > 0 { if chain := causes(err, err.Error()); len(chain) > 0 { b.WriteString("\n " + p.Header.Render("Caused by:") + "\n") for _, c := range chain { b.WriteString(" " + p.Muted.Render("- "+sanitizeLines(sunscreen.Scrub(c))) + "\n") } } } else { b.WriteString("\n " + p.Muted.Render("Run with -v for details.") + "\n") } b.WriteString("\n") _ = writeStyled(w, g, b.String()) } // wrongDirectoryExplanation is the hint added when an application binary // fails outside its application root. const wrongDirectoryExplanation = "This command runs from the application root." // wrongDirectory adds a hint to err when an application binary (any root // name other than summer) failed while the working directory dir ("" means // os.Getwd) has no config directory and a parent directory holds // summer.yaml: compass then found no configuration, which is why the command // failed (D-19). The Try line is "cd ; bin/ ", // with the values of secret-looking flags masked (tryArgs). // Usage errors, Reported errors and summer are left alone; nothing else // about the failure changes. func wrongDirectory(name string, err error, args []string, dir string) error { if err == nil || name == "summer" || errors.Is(err, ErrReported) || isUsage(err) { return err } if dir == "" { wd, werr := os.Getwd() if werr != nil { return err } dir = wd } if info, serr := os.Stat(filepath.Join(dir, "config")); serr == nil && info.IsDir() { return err } root := "" for d := filepath.Dir(dir); ; d = filepath.Dir(d) { if _, serr := os.Stat(filepath.Join(d, "summer.yaml")); serr == nil { root = d break } if filepath.Dir(d) == d { break } } if root == "" { return err } rel, rerr := filepath.Rel(dir, root) if rerr != nil { return err } try := "cd " + argmask.Quote(rel) + "; {bin}" + tryArgs(args) return &HintError{Err: err, Explanation: wrongDirectoryExplanation, Try: try} } // tryArgs renders args for the wrong-directory Try line: empty for no // args, otherwise a space and argmask.Join(args), which quotes each // argument for a shell and masks the values of secret-named long flags // (also a value that starts with "-"; only "--" is left alone), so a // password typed on the command line does not reach stderr and CI logs. func tryArgs(args []string) string { if len(args) == 0 { return "" } return " " + argmask.Join(args) } // parseUnknownCommand reads the command name and cobra's "Did you mean // this?" suggestions from an unknown command error. func parseUnknownCommand(msg string) (string, []string) { name := "" if i := strings.Index(msg, `"`); i >= 0 { if j := strings.Index(msg[i+1:], `"`); j >= 0 { name = msg[i+1 : i+1+j] } } var suggestions []string if _, rest, ok := strings.Cut(msg, "Did you mean this?"); ok { for _, line := range strings.Split(rest, "\n") { if s := strings.TrimSpace(line); s != "" { suggestions = append(suggestions, s) } } } return name, suggestions } // causes walks the wrapped errors below err (errors.Join members expanded) // and returns each distinct message other than top. func causes(err error, top string) []string { var out []string seen := map[string]bool{top: true} var walk func(error) walk = func(e error) { for e != nil { if msg := e.Error(); !seen[msg] { seen[msg] = true out = append(out, msg) } if joined, ok := e.(interface{ Unwrap() []error }); ok { for _, m := range joined.Unwrap() { walk(m) } return } e = errors.Unwrap(e) } } walk(errors.Unwrap(err)) if joined, ok := err.(interface{ Unwrap() []error }); ok { for _, m := range joined.Unwrap() { walk(m) } } return out }