Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions notecard/codes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright 2024 Blues Inc. All rights reserved.
// Use of this source code is governed by licenses granted by the
// copyright holder including that found in the LICENSE file.

package main

import (
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"strings"

"github.com/fatih/color"
)

// codeTokenRegexp matches a Notecard error/status token such as "{io}" or
// "{cell-scan-wait}". Tokens are lowercase words separated by hyphens.
var codeTokenRegexp = regexp.MustCompile(`\{[a-z0-9][a-z0-9-]*\}`)

// codeDescriptions is the lazily-initialized lookup table of code token to
// description, keyed by the full token including braces (e.g. "{io}"). A nil
// value means it has not been loaded yet; an empty (non-nil) map means loading
// was attempted but produced nothing (e.g. offline), so we don't retry.
var codeDescriptions map[string]string

// codesSchemaFilename is the name of the code-definitions schema in the
// notecard-schema repository.
const codesSchemaFilename = "notecard.codes.json"

// parseCodeDescriptions parses the notecard.codes.json schema into a lookup
// table of code token (with braces) to human-readable description.
func parseCodeDescriptions(data []byte) map[string]string {
descriptions := map[string]string{}

var doc struct {
Defs map[string]struct {
Const string `json:"const"`
Description string `json:"description"`
} `json:"$defs"`
}
if err := json.Unmarshal(data, &doc); err != nil {
return descriptions
}

for _, def := range doc.Defs {
if def.Const == "" || def.Description == "" {
continue
}
descriptions[def.Const] = def.Description
}

return descriptions
}

// codesSchemaURL derives the URL of notecard.codes.json from the API schema.
// The codes file lives alongside the per-request schemas that the API schema
// references, so we take the base directory of one of those $refs and append
// the codes filename. This tracks upstream if the schema location ever moves,
// and avoids hardcoding a URL that may differ from the release the API schema
// itself points at.
func codesSchemaURL(schemaURL string, verbose bool) (string, error) {
reader, err := loadOrFetchSchema(schemaURL, verbose)
if err != nil {
return "", err
}
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
var mainSchema map[string]interface{}
if err := json.Unmarshal(data, &mainSchema); err != nil {
return "", err
}
refs := extractRefs(mainSchema, schemaURL)
for _, ref := range refs {
if idx := strings.LastIndex(ref, "/"); idx >= 0 {
return ref[:idx+1] + codesSchemaFilename, nil
}
}
return "", fmt.Errorf("no referenced schema found from which to derive %s location", codesSchemaFilename)
}

// loadCodeDescriptions fetches the code definitions through the same
// fetch-and-cache pipeline used for schema validation and parses them into a
// lookup table. It is loaded once per run; a failure to fetch (e.g. offline
// with a cold cache) simply disables hints rather than surfacing an error.
func loadCodeDescriptions(schemaURL string, verbose bool) map[string]string {
if codeDescriptions != nil {
return codeDescriptions
}
codeDescriptions = map[string]string{}

if err := os.MkdirAll(cacheDir, 0755); err != nil {
return codeDescriptions
}

codesURL, err := codesSchemaURL(schemaURL, verbose)
if err != nil {
if verbose {
fmt.Fprintf(os.Stderr, "*** unable to locate %s: %v ***\n", codesSchemaFilename, err)
}
return codeDescriptions
}

reader, err := loadOrFetchSchema(codesURL, verbose)
if err != nil {
if verbose {
fmt.Fprintf(os.Stderr, "*** unable to load %s: %v ***\n", codesSchemaFilename, err)
}
return codeDescriptions
}
data, err := io.ReadAll(reader)
if err != nil {
return codeDescriptions
}

codeDescriptions = parseCodeDescriptions(data)
return codeDescriptions
}

// explainCodesWith scans text for Notecard error/status tokens and returns a
// hint line for each recognized, distinct token in the order encountered.
// Unrecognized tokens are ignored so that arbitrary "{...}" text never
// produces noise.
func explainCodesWith(text string, descriptions map[string]string) []string {
if len(descriptions) == 0 {
return nil
}

var hints []string
seen := map[string]bool{}
for _, token := range codeTokenRegexp.FindAllString(text, -1) {
if seen[token] {
continue
}
seen[token] = true
if desc, ok := descriptions[token]; ok {
hints = append(hints, fmt.Sprintf("%s %s", token, desc))
}
}
return hints
}

// explainCodes loads the code definitions and returns hints for any recognized
// tokens found in text.
func explainCodes(text, schemaURL string, verbose bool) []string {
return explainCodesWith(text, loadCodeDescriptions(schemaURL, verbose))
}

// printCodeHints writes human-readable explanations for any recognized
// Notecard error/status tokens found in text. Hints are written to stderr so
// they never pollute the JSON response on stdout, and color auto-disables on
// non-TTY output.
func printCodeHints(text, schemaURL string, verbose bool) {
hints := explainCodes(text, schemaURL, verbose)
if len(hints) == 0 {
return
}
label := color.New(color.FgYellow, color.Bold).Sprint("hint:")
for _, hint := range hints {
// Colorize the leading token, leaving the description plain.
parts := strings.SplitN(hint, " ", 2)
token := color.CyanString(parts[0])
desc := ""
if len(parts) > 1 {
desc = parts[1]
}
fmt.Fprintf(os.Stderr, "%s %s %s\n", label, token, desc)
}
}
126 changes: 126 additions & 0 deletions notecard/codes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package main

import (
"strings"
"testing"
)

// fixtureCodesJSON is a representative slice of notecard.codes.json used to
// keep these tests hermetic (no network, no embedded copy).
const fixtureCodesJSON = `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/blues/notecard-schema/master/notecard.codes.json",
"title": "Notecard Error and Status Code Definitions",
"$defs": {
"network": { "const": "{network}", "description": "General wireless connectivity error." },
"host-unreachable": { "const": "{host-unreachable}", "description": "The host is unreachable." },
"template-incompatible": { "const": "{template-incompatible}", "description": "Operation is incompatible with the Notefile template." },
"connected": { "const": "{connected}", "description": "Notecard is connected to the cellular network." },
"cell-registered": { "const": "{cell-registered}", "description": "Wireless service successfully registered." },
"missing-desc": { "const": "{missing-desc}" }
}
}`

func TestParseCodeDescriptions(t *testing.T) {
descriptions := parseCodeDescriptions([]byte(fixtureCodesJSON))

if got := descriptions["{network}"]; got != "General wireless connectivity error." {
t.Errorf("{network} = %q, want the network description", got)
}
// Entries missing a description must be skipped, not stored empty.
if _, ok := descriptions["{missing-desc}"]; ok {
t.Error("{missing-desc} should be skipped because it has no description")
}
// Every stored key must be a braced token with a non-empty description.
for token, desc := range descriptions {
if !strings.HasPrefix(token, "{") || !strings.HasSuffix(token, "}") {
t.Errorf("code key %q is not a braced token", token)
}
if desc == "" {
t.Errorf("code %q has an empty description", token)
}
}
}

func TestParseCodeDescriptionsMalformed(t *testing.T) {
// Malformed JSON must disable hints, never panic or error out.
if got := parseCodeDescriptions([]byte(`not json`)); len(got) != 0 {
t.Errorf("malformed input should yield no descriptions, got %v", got)
}
}

func TestExplainCodesWith(t *testing.T) {
descriptions := parseCodeDescriptions([]byte(fixtureCodesJSON))

tests := []struct {
name string
text string
// wantTokens are the tokens (with braces) we expect to appear, in
// order, in the returned hints. nil means we expect no hints.
wantTokens []string
}{
{
name: "promoted error string with req prefix",
// This is the shape note-go produces: "<req>: <rsp.Err>"
text: `note.template: template mismatch {template-incompatible}`,
wantTokens: []string{"{template-incompatible}"},
},
{
name: "multiple distinct tokens",
text: `note.add: cannot reach notehub {network} {host-unreachable}`,
wantTokens: []string{"{network}", "{host-unreachable}"},
},
{
name: "duplicate tokens deduplicated",
text: `{network} something {network} again`,
wantTokens: []string{"{network}"},
},
{
name: "status codes in a successful response",
text: `{"status":"{connected}{cell-registered}"}`,
wantTokens: []string{"{connected}", "{cell-registered}"},
},
{
name: "undocumented token ignored",
// {io} is a real note-go error qualifier but is not (yet)
// present in notecard.codes.json, so it must be skipped.
text: `{io} is undocumented but {network} is documented`,
wantTokens: []string{"{network}"},
},
{
name: "no tokens at all",
text: `everything is fine`,
wantTokens: nil,
},
{
name: "empty string",
text: ``,
wantTokens: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hints := explainCodesWith(tt.text, descriptions)
if len(hints) != len(tt.wantTokens) {
t.Fatalf("got %d hints %v, want %d for tokens %v",
len(hints), hints, len(tt.wantTokens), tt.wantTokens)
}
for i, token := range tt.wantTokens {
if !strings.HasPrefix(hints[i], token+" ") {
t.Errorf("hint %d = %q, want it to start with %q and include a description", i, hints[i], token)
}
if strings.TrimSpace(strings.TrimPrefix(hints[i], token)) == "" {
t.Errorf("hint %d = %q has no description", i, hints[i])
}
}
})
}
}

func TestExplainCodesWithNoDescriptions(t *testing.T) {
// With no descriptions loaded (e.g. offline), scanning is a no-op.
if got := explainCodesWith(`{network} {io}`, nil); got != nil {
t.Errorf("expected no hints with nil descriptions, got %v", got)
}
}
13 changes: 13 additions & 0 deletions notecard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func getFlagGroups() []lib.FlagGroup {
lib.GetFlagByName("output"),
lib.GetFlagByName("fast"),
lib.GetFlagByName("trace"),
lib.GetFlagByName("nohints"),
},
},
{
Expand Down Expand Up @@ -242,6 +243,8 @@ func main() {
flag.StringVar(&actionSideload, "sideload", "", "side-load a .bin or .bins into the Notecard's storage")
var actionNoBin bool
flag.BoolVar(&actionNoBin, "nobin", false, "when side-loading, force the inline dfu.put path and do not use card.binary")
var actionNoHints bool
flag.BoolVar(&actionNoHints, "nohints", false, "suppress human-readable hints explaining Notecard error and status codes")
var actionEcho int
flag.IntVar(&actionEcho, "echo", 0, "perform <N> iterations of a communications reliability test to the Notecard")
var actionRTC string
Expand Down Expand Up @@ -864,6 +867,11 @@ func main() {
rspJSON, _ = note.JSONMarshal(rsp)
}
fmt.Printf("%s\n", rspJSON)

// Hint at the meaning of any error/status codes in the response
if !actionNoHints {
printCodeHints(string(rspJSON), jsonSchemaUrl, actionVerbose)
}
}
}

Expand Down Expand Up @@ -951,6 +959,11 @@ func main() {
} else {
fmt.Printf("%s\n", err)
}

// Hint at the meaning of any error/status codes in the error
if !actionNoHints {
printCodeHints(err.Error(), jsonSchemaUrl, actionVerbose)
}
exitFailAndCloseCard()
}
}
Expand Down
Loading