From bf94db05a9ca5d54a501cdb28d9fbec0cdd05b43 Mon Sep 17 00:00:00 2001 From: Alex Bucknall Date: Tue, 7 Jul 2026 16:43:02 +0100 Subject: [PATCH] feat: hint at Notecard error and status code meanings Print a human-readable hint whenever a Notecard request returns, or a successful response contains, a recognized {code}. Definitions come from notecard.codes.json (blues/notecard-schema), fetched through the same fetch-and-cache pipeline used for schema validation rather than embedded. The codes URL is derived from the API schema's own $ref base, so it is pulled from the exact same release the API schema points at (version-pinned, no drift) and follows any NOTE_JSON_SCHEMA_URL override. Nothing is committed to this repo. Hints are written to stderr so JSON on stdout stays clean, color auto-disables on non-TTY, and unrecognized tokens are skipped silently. On by default; suppress with -nohints. If the codes file cannot be fetched (offline, cold cache) hints are simply unavailable. Note: the upstream codes file omits common error qualifiers ({io}, {timeout}, {not-supported}, {syntax}, ...); tracked in blues/notecard-schema#305. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018ngtas2Zmb7rFBd6y2hV8Y --- notecard/codes.go | 172 +++++++++++++++++++++++++++++++++++++++++ notecard/codes_test.go | 126 ++++++++++++++++++++++++++++++ notecard/main.go | 13 ++++ 3 files changed, 311 insertions(+) create mode 100644 notecard/codes.go create mode 100644 notecard/codes_test.go diff --git a/notecard/codes.go b/notecard/codes.go new file mode 100644 index 0000000..754639e --- /dev/null +++ b/notecard/codes.go @@ -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) + } +} diff --git a/notecard/codes_test.go b/notecard/codes_test.go new file mode 100644 index 0000000..4420930 --- /dev/null +++ b/notecard/codes_test.go @@ -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: ": " + 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) + } +} diff --git a/notecard/main.go b/notecard/main.go index c9d003e..8248b8c 100644 --- a/notecard/main.go +++ b/notecard/main.go @@ -72,6 +72,7 @@ func getFlagGroups() []lib.FlagGroup { lib.GetFlagByName("output"), lib.GetFlagByName("fast"), lib.GetFlagByName("trace"), + lib.GetFlagByName("nohints"), }, }, { @@ -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 iterations of a communications reliability test to the Notecard") var actionRTC string @@ -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) + } } } @@ -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() } }