From a9b721a0b1e12a7af23ce13eb330b5df789b4e52 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Sat, 5 Sep 2026 14:24:04 -0400 Subject: [PATCH] Improve capture safety and guided CLI usability --- .github/tui.svg | 18 +-- README.md | 52 +++++-- cmd/n2k/capture_safety.go | 124 +++++++++++++++ cmd/n2k/cli.go | 55 +++++-- cmd/n2k/commands.go | 132 +++++++++------- cmd/n2k/completion.go | 7 +- cmd/n2k/input_validation.go | 52 +++++++ cmd/n2k/inspection.go | 138 +++++++++++++++++ cmd/n2k/main.go | 2 +- cmd/n2k/main_test.go | 1 + cmd/n2k/output.go | 32 ++++ cmd/n2k/session.go | 160 +++++++++++++++++++ cmd/n2k/tui.go | 300 ++++++++++++++++++++++++++---------- cmd/n2k/usability_test.go | 267 ++++++++++++++++++++++++++++++++ go.mod | 2 +- 15 files changed, 1169 insertions(+), 173 deletions(-) create mode 100644 cmd/n2k/capture_safety.go create mode 100644 cmd/n2k/input_validation.go create mode 100644 cmd/n2k/inspection.go create mode 100644 cmd/n2k/session.go create mode 100644 cmd/n2k/usability_test.go diff --git a/.github/tui.svg b/.github/tui.svg index a85d0f9..d351b16 100644 --- a/.github/tui.svg +++ b/.github/tui.svg @@ -14,27 +14,27 @@ - Sniff decoded traffic + 1 Sniff decoded traffic Stream typed JSON or physical-value text from CAN, USB, gateway, or capture - Record raw observations + 2 Record raw observations Capture replayable candump or context-rich observation JSON lines - Replay a capture + 3 Replay a capture Decode a candump file with optional source timing and CEL filtering - Validate a source + 4 Validate a source Summarize typed and undecodable messages; optionally enforce strict mode - Inventory network devices + 5 Inventory network devices Actively discover a live bus or inspect device identities captured in a file - Explore the PGN schema - Autocomplete known PGNs and inspect fields, ranges, units, and confidence + 6 Explore the PGN schema + Search PGNs by name or number and inspect fields, ranges, units, and confidence - Update n2k + 7 Update n2k Check GitHub releases and update through Homebrew, Go, or a verified binary - 1–7 jump • / filter • enter configure • ? help • q quit + 1–7 configure • / filter • enter configure • ? help • q quit diff --git a/README.md b/README.md index 67d06a6..dbfce3d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Release](https://img.shields.io/github/v/release/open-ships/n2k-cli)](https://github.com/open-ships/n2k-cli/releases) -Decode, record, replay, validate, filter, and discover devices - all from a nicely packaged TUI. +Decode, record, replay, validate, filter, and discover devices - with guided terminal workflows and scriptable commands. Powered by the [`open-ships/n2k`](https://github.com/open-ships/n2k) Go library. @@ -63,16 +63,34 @@ n2k The command center provides: -- Fuzzy command search with `/`, keyboard navigation, and `1`–`7` shortcuts. +- Fuzzy command search with `/`, keyboard navigation, and numbered `1`–`7` shortcuts. Escape clears a palette filter before leaving. - Guided source selection for SocketCAN, USB-CAN, TCP/UDP gateways, and files. - Inline validation for paths, durations, addresses, formats, and PGNs. -- Tab-completable capture paths, common interfaces, gateway addresses, CEL - filters, durations, and every known PGN number. -- A reviewable, copyable command preview before anything runs. -- Terminal-aware colors, contextual key help, cancellation, and a full-screen - alternate buffer that leaves the shell clean. +- Tab completion for local capture paths, interfaces, gateway addresses, CEL + filters, and durations; PGN search by name or number. Enter advances input fields. +- A reviewable, copyable command preview before anything runs, plus explicit + confirmation before replacing an existing recording. +- Terminal-aware colors, compact layouts for small terminals, contextual key help, + and a full-screen command palette that leaves the shell clean. +- Readable results, progress counts, saved-file confirmation, and retained settings + for editing or retrying. Ctrl+C stops the running operation and saves captured data; + the next-action menu lets you continue or quit. +Use `n2k tui --accessible` (or `N2K_ACCESSIBLE=1`) for screen-reader-friendly +prompts. In forms, Escape or Ctrl+C cancels configuration and returns to workflow +selection; Escape finishes or clears an active search first. Shift+Tab goes to +the previous field. In accessible mode, follow the numbered prompts and use +Ctrl+C to exit. + +Try an offline inspection from a source checkout, without connecting hardware: + +```bash +n2k sniff --file testdata/sample.log --output text --filter 'pgn == 128267' +n2k devices --file testdata/sample.log --output text +n2k pgn heading --output text +``` + ### Scriptable commands The same workflows retain deterministic stdout and exit behavior: @@ -96,6 +114,8 @@ n2k sniff -i can0 -f 'pgn == 127250' --unknown | jq . # Record, replay, validate, discover, and inspect schema support n2k record -i can0 --out capture.log +# Existing files are protected; replace only when intended: +n2k record -i can0 --out capture.log --overwrite n2k record --tcp 192.168.4.1:1457 --out observations.jsonl --output-format jsonl n2k replay --timing=false capture.log n2k validate --file capture.log --strict @@ -103,6 +123,8 @@ n2k devices --tcp 192.168.4.1:1457 --wait 5s n2k devices --file capture.log.gz n2k devices list --file capture.log.gz # "list" is an optional, readable alias n2k pgn 127250 +n2k pgn 127250 --output text +n2k pgn heading --output text n2k pgn list | jq 'select(.complete == true)' ``` @@ -184,9 +206,21 @@ Completion is dynamic rather than a static command list. It understands: `sniff` and `replay` default to JSON lines containing typed structs and their exact wire values. The demo projects the metadata envelope down to its PGN for readability. Set `--output text` for the concrete `pgn.`, source address, -scaled physical values, SI units, and lookup type names. +scaled physical values, SI units, and labels for common marine enumerations +(with numeric fallbacks for other lookups). + +`record` writes replayable candump by default. Existing destinations require +`--overwrite`; an input capture cannot also be the output, including through +symlinks or hard links. The wizard suggests a fresh filename and asks before +replacement. JSONL is a detailed export and cannot currently be replayed by n2k. +Empty files and unsupported capture formats produce actionable errors. + +`devices`, `validate`, and `pgn` accept `--output text` for readable tables and +summaries. The guided workflows default to readable output; scriptable commands +keep JSON as their default. Validation JSON includes `undecodableByPgn`, and its +text summary identifies the failing PGNs with an inspection command. -`record` writes replayable candump by default. JSON-lines mode retains each +In JSON-lines mode, `record` retains each owned source observation, including adapter and network identity, source and receipt timestamps, gateway-relative time, direction, and frame bytes. The Go library's observation stream additionally exposes assembled messages and diff --git a/cmd/n2k/capture_safety.go b/cmd/n2k/capture_safety.go new file mode 100644 index 0000000..e1384e0 --- /dev/null +++ b/cmd/n2k/capture_safety.go @@ -0,0 +1,124 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/open-ships/n2k" +) + +func expandPath(path string) string { + if path == "~" || strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + if path == "~" { + return home + } + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + return path +} + +// Probe using the library's parser so accepted candump syntax stays consistent +// with replay. Stop at the first observation; never pace the probe by timestamps. +func validateCapture(ctx context.Context, path, displayPath string) error { + file, err := os.Open(path) // #nosec G304 -- operator-selected capture. + if err != nil { + return fmt.Errorf("opening capture %q: %w", displayPath, err) + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("capture %q must be a regular candump file", displayPath) + } + if info.Size() == 0 { + return fmt.Errorf("capture %q is empty; record some traffic before inspecting it", displayPath) + } + prefix := make([]byte, 4096) + n, err := file.Read(prefix) + if err != nil && !errors.Is(err, io.EOF) { + return err + } + prefix = bytes.TrimSpace(prefix[:n]) + probeCtx, cancel := context.WithCancel(ctx) + defer cancel() + for observation, err := range n2k.Observe(probeCtx, n2k.File(path)) { + if err != nil { + return fmt.Errorf("reading capture %q: %w", displayPath, err) + } + if observation.Frame != nil { + return nil + } + } + if err := ctx.Err(); err != nil { + return err + } + if bytes.HasPrefix(prefix, []byte("{")) || bytes.HasPrefix(prefix, []byte("[")) { + return fmt.Errorf("capture %q contains JSON; replay requires candump text (record with --output-format candump)", displayPath) + } + return fmt.Errorf("capture %q contains no readable CAN frames; use candump -L/-l text or a gzip capture", displayPath) +} + +func checkRecordPaths(inputPath, outputPath string) error { + if inputPath == "" || outputPath == "" || outputPath == "-" { + return nil + } + input, err := os.Stat(expandPath(inputPath)) + if err != nil { + return fmt.Errorf("opening capture %q: %w", inputPath, err) + } + output, err := os.Stat(expandPath(outputPath)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("checking output %q: %w", outputPath, err) + } + if os.SameFile(input, output) { + return errors.New("input and output refer to the same capture; choose a different output path") + } + return nil +} + +// Recheck the opened destination before truncation, including symlinks and +// hard links that resolve to the source capture. +func checkOutputFile(inputPath string, output *os.File) error { + info, err := output.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return errors.New("output must be a regular file; use --out - to stream to stdout") + } + if inputPath != "" { + input, err := os.Stat(expandPath(inputPath)) + if err != nil { + return err + } + if os.SameFile(input, info) { + return errors.New("input and output refer to the same capture; choose a different output path") + } + } + return nil +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (reader contextReader) Read(data []byte) (int, error) { + if err := reader.ctx.Err(); err != nil { + return 0, err + } + return reader.reader.Read(data) +} diff --git a/cmd/n2k/cli.go b/cmd/n2k/cli.go index ff8f990..00c5cd2 100644 --- a/cmd/n2k/cli.go +++ b/cmd/n2k/cli.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "os/signal" "strconv" "strings" "text/tabwriter" @@ -70,6 +71,9 @@ func (parsed parsedCommand) durationValue(name string) (time.Duration, error) { if err != nil { return 0, fmt.Errorf("invalid --%s duration %q: %w", name, value, err) } + if duration <= 0 { + return 0, fmt.Errorf("--%s must be greater than zero", name) + } return duration, nil } @@ -137,11 +141,22 @@ func (app *cli) ExecuteContext(ctx context.Context, args []string) error { } func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error { + if ctx.Err() != nil { + return nil + } + if parsed.spec.name != "tui" { + var stop context.CancelFunc + ctx, stop = signal.NotifyContext(ctx, os.Interrupt) + defer stop() + } + if err := validateFilter(parsed.stringValue("filter")); err != nil { + return err + } switch parsed.spec.name { case "tui": return app.runInteractive(ctx, parsed.boolValue("accessible")) case "sniff": - source, _, cleanup, err := preparedSource(parsed, true) + source, _, cleanup, err := preparedSource(ctx, parsed, true) if err != nil { return err } @@ -155,7 +170,10 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error { parsed.stringValue("output"), ) case "record": - source, _, cleanup, err := preparedSource(parsed, true) + if err := checkRecordPaths(parsed.stringValue("file"), parsed.stringValue("out")); err != nil { + return err + } + source, _, cleanup, err := preparedSource(ctx, parsed, true) if err != nil { return err } @@ -164,8 +182,10 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error { ctx, app.out, source, + parsed.stringValue("file"), parsed.stringValue("out"), parsed.stringValue("output-format"), + parsed.boolValue("overwrite"), ) case "replay": file := parsed.stringValue("file") @@ -178,7 +198,7 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error { if file == "" { return errors.New("a candump capture path is required") } - file, cleanup, err := prepareCapture(file) + file, cleanup, err := prepareCapture(ctx, file) if err != nil { return err } @@ -193,17 +213,17 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error { parsed.stringValue("output"), ) case "validate": - source, _, cleanup, err := preparedSource(parsed, true) + source, _, cleanup, err := preparedSource(ctx, parsed, true) if err != nil { return err } defer cleanup() - return runValidate(ctx, app.out, source, parsed.boolValue("strict")) + return runValidate(ctx, app.out, source, parsed.boolValue("strict"), parsed.stringValue("output")) case "devices": if len(parsed.positionals) == 1 && parsed.positionals[0] != "list" { return fmt.Errorf("unknown devices action %q: use \"list\"", parsed.positionals[0]) } - source, values, cleanup, err := preparedSource(parsed, true) + source, values, cleanup, err := preparedSource(ctx, parsed, true) if err != nil { return err } @@ -217,11 +237,11 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error { return err } if values.file != "" || values.udp != "" { - return runPassiveDevices(ctx, app.out, source, wait, values.udp != "") + return runPassiveDevices(ctx, app.out, source, wait, values.udp != "", parsed.stringValue("output")) } - return runActiveDevices(ctx, app.out, source, values.tcp != "", wait, claimTimeout) + return runActiveDevices(ctx, app.out, source, values.tcp != "", wait, claimTimeout, parsed.stringValue("output")) case "pgn": - return runPGN(app.out, parsed.positionals[0]) + return runPGN(app.out, parsed.positionals[0], parsed.stringValue("output")) case "completion": return writeShellCompletion(app.out, parsed.positionals[0]) case "version": @@ -235,12 +255,12 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error { } } -func preparedSource(parsed parsedCommand, allowReadOnly bool) (n2k.Option, sourceFlagValues, func(), error) { +func preparedSource(ctx context.Context, parsed parsedCommand, allowReadOnly bool) (n2k.Option, sourceFlagValues, func(), error) { values := sourceValues(parsed, allowReadOnly) if _, err := values.option(); err != nil { return nil, values, func() {}, err } - cleanup, err := values.prepareCapture() + cleanup, err := values.prepareCapture(ctx) if err != nil { return nil, values, func() {}, err } @@ -348,7 +368,7 @@ func parseCommand(spec commandSpec, args []string) (parsedCommand, error) { value := inline if !hasValue { - if index+1 >= len(args) { + if index+1 >= len(args) || (strings.HasPrefix(args[index+1], "-") && args[index+1] != "-") { return parsedCommand{}, fmt.Errorf("--%s requires <%s>", flag.name, flag.valueName) } index++ @@ -422,7 +442,7 @@ func commandSpecs() []commandSpec { }, { name: "record", - summary: "Capture owned raw observations", + summary: "Record a replayable capture or detailed JSON export", usage: "record [source flags] [options]", examples: []string{ "n2k record -i can0 --out capture.log", @@ -430,6 +450,7 @@ func commandSpecs() []commandSpec { }, flags: append( readSourceFlags(), + flagSpec{name: "overwrite", kind: boolFlag, defaultVal: "false", description: "replace an existing output file (never the input capture)"}, flagSpec{name: "out", short: "o", valueName: "path", defaultVal: "-", description: "output path, or - for stdout", file: true}, flagSpec{ name: "output-format", @@ -438,7 +459,7 @@ func commandSpecs() []commandSpec { description: "capture format", choices: []completionItem{ {value: "candump", description: "replayable candump text"}, - {value: "jsonl", description: "owned observation JSON lines"}, + {value: "jsonl", description: "detailed JSON export (cannot replay)"}, }, }, ), @@ -471,6 +492,7 @@ func commandSpecs() []commandSpec { }, flags: append( readSourceFlags(), + summaryOutputFlag(), flagSpec{name: "strict", kind: boolFlag, defaultVal: "false", description: "fail when undecodable messages are found"}, ), }, @@ -488,6 +510,7 @@ func commandSpecs() []commandSpec { }, flags: append( readSourceFlags(), + summaryOutputFlag(), flagSpec{name: "wait", valueName: "duration", defaultVal: "3s", kind: durationFlag, description: "live/UDP observation window"}, flagSpec{name: "claim-timeout", valueName: "duration", defaultVal: "2s", kind: durationFlag, description: "writable-source address-claim timeout"}, ), @@ -495,12 +518,14 @@ func commandSpecs() []commandSpec { { name: "pgn", summary: "Describe or list typed PGN support", - usage: "pgn ", + usage: "pgn [--output json|text]", + flags: []flagSpec{summaryOutputFlag()}, minArgs: 1, maxArgs: 1, examples: []string{ "n2k pgn 127250", "n2k pgn list", + "n2k pgn heading --output text", }, }, { diff --git a/cmd/n2k/commands.go b/cmd/n2k/commands.go index d2d6a75..7d75794 100644 --- a/cmd/n2k/commands.go +++ b/cmd/n2k/commands.go @@ -35,11 +35,11 @@ func (values sourceFlagValues) option() (n2k.Option, error) { return sourceOption(values.iface, values.usb, values.file, values.tcp, values.udp, values.format, values.timing) } -func (values *sourceFlagValues) prepareCapture() (func(), error) { +func (values *sourceFlagValues) prepareCapture(ctx context.Context) (func(), error) { if values.file == "" { return func() {}, nil } - path, cleanup, err := prepareCapture(values.file) + path, cleanup, err := prepareCapture(ctx, values.file) if err != nil { return func() {}, err } @@ -51,7 +51,8 @@ func (values *sourceFlagValues) prepareCapture() (func(), error) { // file. n2k.File owns candump parsing, so keeping decompression at this // boundary avoids duplicating the library's parser and preserves all file // source behavior. -func prepareCapture(path string) (string, func(), error) { +func prepareCapture(ctx context.Context, path string) (string, func(), error) { + path = expandPath(path) file, err := os.Open(path) // #nosec G304 -- the CLI reads the operator-selected capture path. if err != nil { return "", func() {}, fmt.Errorf("opening capture %q: %w", path, err) @@ -64,7 +65,7 @@ func prepareCapture(path string) (string, func(), error) { return "", func() {}, fmt.Errorf("inspecting capture %q: %w", path, peekErr) } if len(header) < 2 || header[0] != 0x1f || header[1] != 0x8b { - return path, func() {}, nil + return path, func() {}, validateCapture(ctx, path, path) } compressed, err := gzip.NewReader(buffered) @@ -80,7 +81,7 @@ func prepareCapture(path string) (string, func(), error) { expandedPath := expanded.Name() removeExpanded := func() { _ = os.Remove(expandedPath) } limited := io.LimitReader(compressed, maxExpandedCaptureBytes+1) - written, err := io.Copy(expanded, limited) + written, err := io.Copy(expanded, contextReader{ctx: ctx, reader: limited}) if err != nil { _ = expanded.Close() removeExpanded() @@ -99,6 +100,10 @@ func prepareCapture(path string) (string, func(), error) { removeExpanded() return "", func() {}, fmt.Errorf("finishing gzip capture %q: %w", path, err) } + if err := validateCapture(ctx, expandedPath, path); err != nil { + removeExpanded() + return "", func() {}, err + } return expandedPath, removeExpanded, nil } @@ -125,22 +130,23 @@ func runSniff(ctx context.Context, out io.Writer, source n2k.Option, expression if err := writer.Write(message); err != nil { return err } + countProgress(ctx) } return nil } -func runRecord(ctx context.Context, stdout io.Writer, source n2k.Option, outputPath, outputFormat string) error { +func runRecord(ctx context.Context, stdout io.Writer, source n2k.Option, inputPath, outputPath, outputFormat string, overwrite bool) (resultErr error) { if outputFormat != "candump" && outputFormat != "jsonl" { return fmt.Errorf("unknown output format %q: use candump or jsonl", outputFormat) } - writer, closeWriter, err := outputWriter(outputPath, stdout) + writer, closeWriter, err := outputWriter(inputPath, outputPath, stdout, overwrite) if err != nil { return err } - defer closeWriter() + defer func() { resultErr = errors.Join(resultErr, closeWriter()) }() buffered := bufio.NewWriter(writer) - defer func() { _ = buffered.Flush() }() + defer func() { resultErr = errors.Join(resultErr, buffered.Flush()) }() encoder := json.NewEncoder(buffered) for observation, observeErr := range n2k.Observe(ctx, source) { if observeErr != nil { @@ -153,6 +159,7 @@ func runRecord(ctx context.Context, stdout io.Writer, source n2k.Option, outputP if err := encoder.Encode(observation); err != nil { return fmt.Errorf("encoding observation: %w", err) } + countProgress(ctx) continue } if observation.Frame == nil { @@ -161,19 +168,38 @@ func runRecord(ctx context.Context, stdout io.Writer, source n2k.Option, outputP if _, err := fmt.Fprintln(buffered, formatCandump(observation)); err != nil { return fmt.Errorf("writing capture: %w", err) } + countProgress(ctx) } - return buffered.Flush() + return nil // Deferred flush and close report errors, including after cancellation. } -func outputWriter(path string, stdout io.Writer) (io.Writer, func(), error) { +func outputWriter(inputPath, path string, stdout io.Writer, overwrite bool) (io.Writer, func() error, error) { if path == "" || path == "-" { - return stdout, func() {}, nil + return stdout, func() error { return nil }, nil + } + path = expandPath(path) + flags := os.O_CREATE | os.O_WRONLY | os.O_EXCL + if overwrite { + flags = os.O_CREATE | os.O_WRONLY } - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // #nosec G304 -- the CLI writes the operator-selected capture path. + file, err := os.OpenFile(path, flags, 0o600) // #nosec G304 -- the CLI writes the operator-selected capture path. if err != nil { - return nil, func() {}, fmt.Errorf("opening output: %w", err) + if errors.Is(err, os.ErrExist) { + return nil, nil, fmt.Errorf("output %q already exists; choose a new path or use --overwrite to replace it", path) + } + return nil, nil, fmt.Errorf("opening output: %w", err) + } + if err := checkOutputFile(inputPath, file); err != nil { + _ = file.Close() + return nil, nil, err + } + if overwrite { + if err := file.Truncate(0); err != nil { + _ = file.Close() + return nil, nil, fmt.Errorf("replacing output: %w", err) + } } - return file, func() { _ = file.Close() }, nil + return file, file.Close, nil } func formatCandump(observation n2k.Observation) string { @@ -216,19 +242,21 @@ func runReplay(ctx context.Context, out io.Writer, file string, timing bool, exp if err := writer.Write(message); err != nil { return err } + countProgress(ctx) } return nil } type validationSummary struct { - Messages int `json:"messages"` - Typed int `json:"typed"` - Undecodable int `json:"undecodable"` - ByPGN map[uint32]int `json:"byPgn"` + Messages int `json:"messages"` + Typed int `json:"typed"` + Undecodable int `json:"undecodable"` + ByPGN map[uint32]int `json:"byPgn"` + UndecodableByPGN map[uint32]int `json:"undecodableByPgn"` } -func runValidate(ctx context.Context, out io.Writer, source n2k.Option, strict bool) error { - summary := validationSummary{ByPGN: make(map[uint32]int)} +func runValidate(ctx context.Context, out io.Writer, source n2k.Option, strict bool, outputFormat string) error { + summary := validationSummary{ByPGN: make(map[uint32]int), UndecodableByPGN: make(map[uint32]int)} for message, receiveErr := range n2k.Receive(ctx, source, n2k.IncludeUnknown()) { if receiveErr != nil { if ctx.Err() != nil { @@ -237,8 +265,10 @@ func runValidate(ctx context.Context, out io.Writer, source n2k.Option, strict b return receiveErr } summary.Messages++ + countProgress(ctx) if unknown, ok := message.(*pgn.UnknownPGN); ok { summary.Undecodable++ + summary.UndecodableByPGN[unknown.Info.PGN]++ summary.ByPGN[unknown.Info.PGN]++ continue } @@ -247,13 +277,11 @@ func runValidate(ctx context.Context, out io.Writer, source n2k.Option, strict b summary.ByPGN[carrier.MessageInfo().PGN]++ } } - encoder := json.NewEncoder(out) - encoder.SetIndent("", " ") - if err := encoder.Encode(summary); err != nil { - return fmt.Errorf("encoding summary: %w", err) + if err := writeValidation(out, summary, outputFormat); err != nil { + return err } if strict && summary.Undecodable > 0 { - return fmt.Errorf("%d undecodable messages", summary.Undecodable) + return fmt.Errorf("%d undecodable messages; see undecodableByPgn for the failing PGNs", summary.Undecodable) } return nil } @@ -267,7 +295,7 @@ type deviceRecord struct { ConfigInfo *pgn.ConfigurationInformation `json:"configInfo,omitempty"` } -func runActiveDevices(ctx context.Context, out io.Writer, source n2k.Option, reconnect bool, wait, claimTimeout time.Duration) error { +func runActiveDevices(ctx context.Context, out io.Writer, source n2k.Option, reconnect bool, wait, claimTimeout time.Duration, outputFormat string) error { opts := []n2k.Option{source, n2k.WithClaimTimeout(claimTimeout)} if reconnect { opts = append(opts, n2k.WithReconnect(n2k.ReconnectPolicy{})) @@ -282,7 +310,6 @@ func runActiveDevices(ctx context.Context, out io.Writer, source n2k.Option, rec select { case <-timer.C: case <-ctx.Done(): - return nil } if err := client.Err(); err != nil { return err @@ -301,10 +328,11 @@ func runActiveDevices(ctx context.Context, out io.Writer, source n2k.Option, rec ConfigInfo: device.ConfigInfo, }) } - return encodeDevices(out, records) + setProgress(ctx, len(records)) + return writeDevices(out, records, outputFormat) } -func runPassiveDevices(ctx context.Context, out io.Writer, source n2k.Option, wait time.Duration, bounded bool) error { +func runPassiveDevices(ctx context.Context, out io.Writer, source n2k.Option, wait time.Duration, bounded bool, outputFormat string) error { scanCtx := ctx cancel := func() {} if bounded { @@ -322,10 +350,9 @@ func runPassiveDevices(ctx context.Context, out io.Writer, source n2k.Option, wa } inventory.observe(message) } - if ctx.Err() != nil { - return nil - } - return encodeDevices(out, inventory.snapshot()) + records := inventory.snapshot() + setProgress(ctx, len(records)) + return writeDevices(out, records, outputFormat) } type deviceInventory struct { @@ -458,31 +485,26 @@ func sortedPGNNumbers() []uint32 { return numbers } -func runPGN(out io.Writer, value string) error { +func runPGN(out io.Writer, value, outputFormat string) error { + infos, err := findPGNs(value) + if err != nil { + return err + } + if outputFormat == messageOutputText { + _, numericErr := strconv.ParseUint(value, 0, 32) + return writePGNTable(out, infos, value == "list" || (numericErr != nil && len(infos) > 1)) + } encoder := json.NewEncoder(out) encoder.SetIndent("", " ") if value == "list" { - for _, number := range sortedPGNNumbers() { - for _, info := range pgn.PgnInfoLookup[number] { - if err := encoder.Encode(info); err != nil { - return fmt.Errorf("encoding PGN metadata: %w", err) - } + for _, info := range infos { + if err := encoder.Encode(info); err != nil { + return err } } return nil } - number, err := strconv.ParseUint(value, 0, 32) - if err != nil { - return fmt.Errorf("invalid PGN %q", value) - } - infos := pgn.PgnInfoLookup[uint32(number)] - if len(infos) == 0 { - return fmt.Errorf("PGN %d is not in the typed metadata", number) - } - if err := encoder.Encode(infos); err != nil { - return fmt.Errorf("encoding PGN metadata: %w", err) - } - return nil + return encoder.Encode(infos) } // sourceOption converts mutually exclusive source flags into the one n2k @@ -507,9 +529,15 @@ func sourceOption(iface, usb, file, tcp, udp, format string, timing bool) (n2k.O sources = append(sources, n2k.File(file, fileOpts...)) } if tcp != "" { + if err := addressValidator("TCP")(tcp); err != nil { + return nil, err + } sources = append(sources, n2k.TCP(tcp, stream)) } if udp != "" { + if err := addressValidator("UDP")(udp); err != nil { + return nil, err + } sources = append(sources, n2k.UDP(udp, stream)) } if len(sources) != 1 { diff --git a/cmd/n2k/completion.go b/cmd/n2k/completion.go index bedb8de..5aa24bc 100644 --- a/cmd/n2k/completion.go +++ b/cmd/n2k/completion.go @@ -222,14 +222,13 @@ func completePGNs(prefix string) []completionItem { } for _, number := range sortedPGNNumbers() { value := strconv.FormatUint(uint64(number), 10) - if !strings.HasPrefix(value, prefix) { - continue - } description := "typed PGN" if infos := pgn.PgnInfoLookup[number]; len(infos) > 0 && infos[0].Description != "" { description = sanitizeDescription(infos[0].Description) } - items = append(items, completionItem{value: value, description: description}) + if strings.HasPrefix(value, prefix) || strings.Contains(strings.ToLower(description), strings.ToLower(prefix)) { + items = append(items, completionItem{value: value, description: description}) + } } return items } diff --git a/cmd/n2k/input_validation.go b/cmd/n2k/input_validation.go new file mode 100644 index 0000000..7ea89d7 --- /dev/null +++ b/cmd/n2k/input_validation.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "net" + "strconv" + "strings" + "sync" + + "github.com/google/cel-go/cel" +) + +// Match the metadata and decoded-message variables supported by n2k.Filter. +var filterEnvironment = sync.OnceValues(func() (*cel.Env, error) { + return cel.NewEnv( + cel.Variable("pgn", cel.IntType), cel.Variable("source", cel.IntType), + cel.Variable("priority", cel.IntType), cel.Variable("destination", cel.IntType), + cel.Variable("msg", cel.DynType), + ) +}) + +func validateFilter(expression string) error { + if strings.TrimSpace(expression) == "" { + return nil + } + env, err := filterEnvironment() + if err != nil { + return err + } + ast, issues := env.Compile(expression) + if issues != nil && issues.Err() != nil { + return fmt.Errorf("invalid filter; use a comparison such as pgn == 127250:\n%w", issues.Err()) + } + if ast.OutputType() != cel.BoolType && ast.OutputType() != cel.DynType { + return fmt.Errorf("filter must produce true or false; use a comparison such as pgn == 127250") + } + return nil +} + +func addressValidator(network string) func(string) error { + return func(value string) error { + host, port, err := net.SplitHostPort(value) + if err != nil || (network == "TCP" && strings.TrimSpace(host) == "") || strings.ContainsAny(host, " \t\r\n/") { + return fmt.Errorf("enter a %s address as host:port (for example 192.168.4.1:1457); UDP may use :1457", network) + } + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return fmt.Errorf("%s port must be a number from 1 to 65535", network) + } + return nil + } +} diff --git a/cmd/n2k/inspection.go b/cmd/n2k/inspection.go new file mode 100644 index 0000000..f8443d9 --- /dev/null +++ b/cmd/n2k/inspection.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" + "text/tabwriter" + + "github.com/open-ships/n2k/pgn" +) + +func summaryOutputFlag() flagSpec { + return flagSpec{name: "output", valueName: "format", defaultVal: messageOutputJSON, description: "result format", choices: []completionItem{ + {value: "json", description: "JSON for scripts"}, {value: "text", description: "readable summary and tables"}, + }} +} + +func findPGNs(query string) ([]*pgn.PgnInfo, error) { + query = strings.TrimSpace(query) + if number, err := strconv.ParseUint(query, 0, 32); err == nil { + if infos := pgn.PgnInfoLookup[uint32(number)]; len(infos) > 0 { + return infos, nil + } + return nil, fmt.Errorf("PGN %d is not in the typed metadata; use n2k pgn list --output text to browse", number) + } + var matches []*pgn.PgnInfo + for _, number := range sortedPGNNumbers() { + for _, info := range pgn.PgnInfoLookup[number] { + if query == "list" || (query != "" && strings.Contains(strings.ToLower(info.Description+" "+info.Id), strings.ToLower(query))) { + matches = append(matches, info) + } + } + } + if len(matches) == 0 { + return nil, fmt.Errorf("no known PGNs match %q; try a number or name such as heading", query) + } + return matches, nil +} + +func writePGNTable(out io.Writer, infos []*pgn.PgnInfo, listOnly bool) error { + writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + if listOnly { + _, _ = fmt.Fprintln(writer, "PGN\tNAME\tSCHEMA") + for _, info := range infos { + _, _ = fmt.Fprintf(writer, "%d\t%s\t%s\n", info.PGN, singleLine(info.Description), schemaConfidence(info)) + } + _, _ = fmt.Fprintln(writer, "\nInspect fields with: n2k pgn --output text") + return writer.Flush() + } + for _, info := range infos { + _, _ = fmt.Fprintf(writer, "PGN %d · %s\nSchema: %s · Transport: %s\n\n", info.PGN, singleLine(info.Description), schemaConfidence(info), info.Type) + _, _ = fmt.Fprintln(writer, "FIELD\tUNITS\tRANGE / VALUES") + orders := make([]int, 0, len(info.Fields)) + for order := range info.Fields { + orders = append(orders, order) + } + sort.Ints(orders) + for _, order := range orders { + field := info.Fields[order] + values := lookupName(field) + if field.RangeMin != nil && field.RangeMax != nil { + values = fmt.Sprintf("%g to %g", *field.RangeMin, *field.RangeMax) + } + if field.Description != "" { + values += " " + singleLine(field.Description) + } + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\n", singleLine(field.Name), field.Unit, strings.TrimSpace(values)) + } + } + return writer.Flush() +} + +func schemaConfidence(info *pgn.PgnInfo) string { + if info.Fallback { + return "fallback" + } + if info.Complete { + return "complete" + } + return "partial" +} + +func writeDevices(out io.Writer, devices []deviceRecord, format string) error { + if format != messageOutputText { + return encodeDevices(out, devices) + } + if len(devices) == 0 { + _, err := fmt.Fprintln(out, "No devices observed. Try a longer discovery window or a different source.") + return err + } + writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "ADDRESS\tMANUFACTURER\tMODEL\tLAST SEEN") + for _, device := range devices { + manufacturer, model := "Unknown", "Not observed" + if device.Name != nil { + manufacturer = pgn.ManufacturerCodeConst(device.Name.ManufacturerCode).String() + } + if device.ProductInfo != nil && device.ProductInfo.ModelId != "" { + model = device.ProductInfo.ModelId + } + _, _ = fmt.Fprintf(writer, "%d\t%s\t%s\t%s\n", device.Address, singleLine(manufacturer), singleLine(model), device.LastSeen.Format("2006-01-02 15:04:05 MST")) + } + return writer.Flush() +} + +func writeValidation(out io.Writer, summary validationSummary, format string) error { + if format != messageOutputText { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + return encoder.Encode(summary) + } + writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintf(writer, "%d messages · %d decoded · %d undecodable\n", summary.Messages, summary.Typed, summary.Undecodable) + if summary.Undecodable > 0 { + _, _ = fmt.Fprintln(writer, "\nPGN\tUNDECODABLE\tINSPECT") + numbers := make([]uint32, 0, len(summary.UndecodableByPGN)) + for number := range summary.UndecodableByPGN { + numbers = append(numbers, number) + } + sort.Slice(numbers, func(i, j int) bool { return numbers[i] < numbers[j] }) + for _, number := range numbers { + _, _ = fmt.Fprintf(writer, "%d\t%d\tn2k sniff --file --unknown --filter 'pgn == %d'\n", number, summary.UndecodableByPGN[number], number) + } + } + return writer.Flush() +} + +func singleLine(value string) string { + return strings.Map(func(r rune) rune { + if r < 32 || r == 127 { + return ' ' + } + return r + }, value) +} diff --git a/cmd/n2k/main.go b/cmd/n2k/main.go index 6cd15b2..be88a3e 100644 --- a/cmd/n2k/main.go +++ b/cmd/n2k/main.go @@ -27,7 +27,7 @@ var ( ) func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop() app := newCLI(os.Stdin, os.Stdout, os.Stderr) diff --git a/cmd/n2k/main_test.go b/cmd/n2k/main_test.go index 7655877..b7b1503 100644 --- a/cmd/n2k/main_test.go +++ b/cmd/n2k/main_test.go @@ -482,6 +482,7 @@ func TestDevicesWizardBuildsPassiveCaptureInventory(t *testing.T) { require.Equal(t, []string{ "devices", "--file", "capture.log.gz", + "--output", "text", }, config.args()) } diff --git a/cmd/n2k/output.go b/cmd/n2k/output.go index 1498fe4..c95fe33 100644 --- a/cmd/n2k/output.go +++ b/cmd/n2k/output.go @@ -143,6 +143,9 @@ func formatTypedField(message reflect.Value, field reflect.StructField, value re } if descriptor != nil { if lookup := lookupName(descriptor); lookup != "" { + if label, ok := enumLabel(lookup, value); ok { + return strconv.Quote(label) + } return lookup + "(" + fmt.Sprint(value.Interface()) + ")" } } @@ -161,6 +164,35 @@ func formatTypedField(message reflect.Value, field reflect.StructField, value re return string(encoded) } +// Use library labels for common marine lookups. Unknown values and lookup +// families retain the explicit lookup name and wire value above. +func enumLabel(lookup string, value reflect.Value) (string, bool) { + if !value.CanUint() || value.Uint() > 255 { + return "", false + } + number := uint8(value.Uint()) + var label string + switch lookup { + case "DIRECTION_REFERENCE": + label = pgn.DirectionReferenceConst(number).String() + case "WIND_REFERENCE": + label = pgn.WindReferenceConst(number).String() + case "WATER_REFERENCE": + label = pgn.WaterReferenceConst(number).String() + case "DIRECTION_RUDDER": + label = pgn.DirectionRudderConst(number).String() + case "YES_NO": + label = pgn.YesNoConst(number).String() + case "OFF_ON": + label = pgn.OffOnConst(number).String() + case "OK_WARNING": + label = pgn.OkWarningConst(number).String() + default: + return "", false + } + return label, !strings.Contains(label, "Const(") +} + func formatPhysicalValue(value float64, descriptor *pgn.FieldDescriptor) string { if descriptor == nil || descriptor.Resolution == 0 { return strconv.FormatFloat(value, 'f', -1, 64) diff --git a/cmd/n2k/session.go b/cmd/n2k/session.go new file mode 100644 index 0000000..8bf5b27 --- /dev/null +++ b/cmd/n2k/session.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync/atomic" + "time" + + "charm.land/huh/v2" +) + +type progressKey struct{} +type executionProgress struct{ count atomic.Int64 } + +func countProgress(ctx context.Context) { + if progress, ok := ctx.Value(progressKey{}).(*executionProgress); ok { + progress.count.Add(1) + } +} + +func setProgress(ctx context.Context, count int) { + if progress, ok := ctx.Value(progressKey{}).(*executionProgress); ok { + progress.count.Store(int64(count)) + } +} + +func (app *cli) executeInteractive(ctx context.Context, config *wizardConfig) error { + args := config.args() + _, _ = fmt.Fprintf(app.errOut, "\n%s\n", renderCommandPreview(args)) + if config.command == "pgn" || config.command == "update" { + return app.ExecuteContext(ctx, args) + } + runCtx, stop := signal.NotifyContext(ctx, os.Interrupt) + defer stop() + progress := &executionProgress{} + runCtx = context.WithValue(runCtx, progressKey{}, progress) + started := time.Now() + _, _ = fmt.Fprintln(app.errOut, "Opening source. Ctrl+C stops this run and keeps captured data.") + done := make(chan struct{}) + finished := make(chan struct{}) + go func() { + defer close(finished) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + count := progress.count.Load() + state := "receiving" + if count == 0 { + state = "waiting for matching traffic" + } + if config.command == "devices" { + state = "discovering devices" + } + _, _ = fmt.Fprintf(app.errOut, " %s · %d %s · %s elapsed\n", state, count, config.progressUnit(), time.Since(started).Round(time.Second)) + } + } + }() + err := app.ExecuteContext(runCtx, args) + close(done) + <-finished // Finish status writes before starting the next interactive form. + count := progress.count.Load() + state := "Finished" + if runCtx.Err() != nil { + state = "Stopped" + } + if err != nil { + state = "Failed" + } + _, _ = fmt.Fprintf(app.errOut, "%s: %d %s in %s.\n", state, count, config.progressUnit(), time.Since(started).Round(time.Millisecond)) + if config.command == "record" && err == nil { + if config.outputPath == "-" { + _, _ = fmt.Fprintln(app.errOut, "Capture written to stdout.") + } else { + _, _ = fmt.Fprintf(app.errOut, "Saved capture: %s\n", expandPath(config.outputPath)) + } + } + if count == 0 && err == nil { + _, _ = fmt.Fprintln(app.errOut, "No matching traffic was observed. Check the source and filter; for decoding, try including unknown PGNs.") + } + return err +} + +func (config *wizardConfig) progressUnit() string { + if config.command == "record" { + return "observations saved" + } + if config.command == "devices" { + return "devices" + } + return "messages" +} + +func (app *cli) nextWorkflowAction(ctx context.Context, failed, accessible bool) (string, error) { + action := "another" + options := []huh.Option[string]{ + huh.NewOption("Edit settings", "edit"), + huh.NewOption("Run again", "retry"), + huh.NewOption("Another workflow", "another"), + huh.NewOption("Quit", "quit"), + } + if failed { + action = "edit" + options[1] = huh.NewOption("Retry", "retry") + } + form := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title("What next?").Options(options...).Value(&action))) + ok, err := app.runForm(ctx, form, accessible) + if !ok { + return "quit", err + } + return action, err +} + +func freshCapturePath(now time.Time) string { + base := "capture-" + now.Format("20060102-150405") + for index := 0; ; index++ { + path := base + ".log" + if index > 0 { + path = fmt.Sprintf("%s-%d.log", base, index) + } + if _, err := os.Lstat(path); err != nil { + return path + } + } +} + +func (app *cli) confirmOverwrite(ctx context.Context, config *wizardConfig, accessible bool) (bool, error) { + if config.command != "record" { + return true, nil + } + config.overwrite = false // Each run needs a fresh, explicit replacement choice. + inputPath := "" + if config.source == "file" { + inputPath = config.file + } + if err := checkRecordPaths(inputPath, config.outputPath); err != nil { + return false, err + } + if config.outputPath == "-" { + return true, nil + } + _, err := os.Lstat(expandPath(config.outputPath)) + if os.IsNotExist(err) { + return true, nil + } + if err != nil { + return false, err + } + form := huh.NewForm(huh.NewGroup(huh.NewConfirm(). + Title("Replace the existing capture?"). + Description(fmt.Sprintf("%s already exists. Replacing it erases its current contents.", config.outputPath)). + Affirmative("Replace").Negative("Keep file").Value(&config.overwrite))) + ok, err := app.runForm(ctx, form, accessible) + return ok && config.overwrite, err +} diff --git a/cmd/n2k/tui.go b/cmd/n2k/tui.go index b13d7e2..99efee1 100644 --- a/cmd/n2k/tui.go +++ b/cmd/n2k/tui.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" @@ -22,7 +23,14 @@ type paletteItem struct { description string } -func (item paletteItem) Title() string { return item.title } +func (item paletteItem) Title() string { + for index, choice := range commandPaletteItems() { + if choice.command == item.command { + return fmt.Sprintf("%d %s", index+1, item.title) + } + } + return item.title +} func (item paletteItem) Description() string { return item.description } func (item paletteItem) FilterValue() string { return item.command + " " + item.title + " " + item.description @@ -34,6 +42,7 @@ type paletteModel struct { canceled bool width int height int + isDark bool } func newPaletteModel() paletteModel { @@ -50,7 +59,7 @@ func newPaletteModel() paletteModel { model.SetFilteringEnabled(true) model.SetStatusBarItemName("workflow", "workflows") model.InfiniteScrolling = true - return paletteModel{list: model, width: 84, height: 24} + return paletteModel{list: model, width: 84, height: 24, isDark: true} } func commandPaletteItems() []paletteItem { @@ -63,7 +72,7 @@ func commandPaletteItems() []paletteItem { { command: "record", title: "Record raw observations", - description: "Capture replayable candump or context-rich observation JSON lines", + description: "Save a replayable capture or detailed JSON export", }, { command: "replay", @@ -83,7 +92,7 @@ func commandPaletteItems() []paletteItem { { command: "pgn", title: "Explore the PGN schema", - description: "Autocomplete known PGNs and inspect fields, ranges, units, and confidence", + description: "Search PGNs by name or number; inspect fields, ranges, units, and confidence", }, { command: "update", @@ -100,20 +109,24 @@ func (model paletteModel) Init() tea.Cmd { func (model paletteModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { switch message := message.(type) { case tea.WindowSizeMsg: - model.width = max(48, message.Width) - model.height = max(16, message.Height) - model.list.SetSize(model.width-6, model.height-8) + model.width = max(1, message.Width) + model.height = max(1, message.Height) + model.resizeList() case tea.BackgroundColorMsg: - model.list.Styles = list.DefaultStyles(message.IsDark()) - delegate := list.NewDefaultDelegate() - delegate.Styles = list.NewDefaultItemStyles(message.IsDark()) - delegate.SetSpacing(1) - model.list.SetDelegate(delegate) + model.isDark = message.IsDark() + model.list.Styles = list.DefaultStyles(model.isDark) + model.resizeList() case tea.KeyPressMsg: key := message.String() if model.list.FilterState() != list.Filtering { switch key { - case "q", "esc", "ctrl+c": + case "esc": + if model.list.FilterState() != list.Unfiltered { + break + } + model.canceled = true + return model, tea.Quit + case "q", "ctrl+c": model.canceled = true return model, tea.Quit case "enter": @@ -122,6 +135,9 @@ func (model paletteModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { return model, tea.Quit } case "1", "2", "3", "4", "5", "6", "7": + if model.list.FilterState() != list.Unfiltered { + break + } index := int(key[0] - '1') items := commandPaletteItems() if index >= 0 && index < len(items) { @@ -147,9 +163,15 @@ func (model paletteModel) View() tea.View { subtitle := lipgloss.NewStyle(). Foreground(muted). Render("NMEA 2000 developer console") + hintText := "1–7 configure • / filter • enter open • ? help • q quit" + if model.list.FilterState() == list.FilterApplied { + hintText = "esc clear filter • enter open • q quit" + } else if model.list.FilterState() == list.Filtering { + hintText = "enter apply filter • esc cancel filter" + } hint := lipgloss.NewStyle(). Foreground(muted). - Render("1–7 jump • / filter • enter configure • ? help • q quit") + Render(hintText) body := lipgloss.JoinVertical( lipgloss.Left, title+" "+subtitle, @@ -158,28 +180,87 @@ func (model paletteModel) View() tea.View { "", hint, ) - view := tea.NewView(lipgloss.NewStyle().Padding(1, 3).Render(body)) + content := lipgloss.NewStyle().Padding(1, 3).Render(body) + if model.compact() { + hint := "↑/↓ choose · enter open\n/ search · esc back · q quit" + if model.list.FilterState() == list.Filtering { + hint = "enter applies filter\nesc cancels filter" + } + content = lipgloss.NewStyle().Padding(0, 1).Render(title + "\n" + model.list.View() + "\n" + hint) + } + if model.width < 30 || model.height < 10 { + content = lipgloss.NewStyle().MaxWidth(model.width).MaxHeight(model.height).Render("Resize terminal to 30×10.\nq to quit") + } + view := tea.NewView(content) view.AltScreen = true view.WindowTitle = "n2k command center" return view } +func (model paletteModel) compact() bool { return model.width < 70 || model.height < 24 } + +func (model *paletteModel) resizeList() { + delegate := list.NewDefaultDelegate() + delegate.Styles = list.NewDefaultItemStyles(model.isDark) + delegate.ShowDescription = !model.compact() + delegate.SetSpacing(0) + model.list.SetDelegate(delegate) + model.list.SetShowStatusBar(!model.compact()) + model.list.SetShowHelp(!model.compact()) + if model.compact() { + model.list.SetSize(max(1, model.width-2), max(1, model.height-5)) + } else { + model.list.SetSize(max(1, model.width-6), max(1, model.height-8)) + } +} + func (app *cli) runInteractive(ctx context.Context, accessible bool) error { accessible = accessible || envTruthy("N2K_ACCESSIBLE") updated, err := app.maybeOfferUpdate(ctx, accessible) if err != nil || updated { return err } - command, selected, err := app.chooseCommand(ctx, accessible) - if err != nil || !selected { - return err - } - args, confirmed, err := app.configureCommand(ctx, command, accessible) - if err != nil || !confirmed { - return err + var config *wizardConfig + action := "another" + for ctx.Err() == nil { + if action == "another" { + command, selected, err := app.chooseCommand(ctx, accessible) + if err != nil || !selected { + return err + } + config = defaultWizardConfig(command) + action = "edit" + } + if action == "edit" { + _, confirmed, err := app.configureWizard(ctx, config, accessible) + if err != nil { + return err + } + if !confirmed { + action = "another" + continue + } + } + confirmed, runErr := app.confirmOverwrite(ctx, config, accessible) + if runErr == nil && !confirmed { + action = "edit" + continue + } + if runErr == nil { + runErr = app.executeInteractive(ctx, config) + } + if ctx.Err() != nil { + return nil + } + if runErr != nil { + _, _ = fmt.Fprintf(app.errOut, "\nn2k: %v\nYour settings are preserved.\n", runErr) + } + action, err = app.nextWorkflowAction(ctx, runErr != nil, accessible) + if err != nil || action == "quit" { + return err + } } - _, _ = fmt.Fprintf(app.errOut, "\n%s\n\n", renderCommandPreview(args)) - return app.ExecuteContext(ctx, args) + return nil } func (app *cli) chooseCommand(ctx context.Context, accessible bool) (string, bool, error) { @@ -234,6 +315,7 @@ type wizardConfig struct { unknown bool output string outputPath string + overwrite bool recordFormat string strict bool wait string @@ -250,8 +332,9 @@ func defaultWizardConfig(command string) *wizardConfig { command: command, source: "file", format: "raw", - output: "json", - outputPath: "-", + output: "text", + outputPath: freshCapturePath(time.Now()), + timing: command == "replay", recordFormat: "candump", wait: "3s", claimTimeout: "2s", @@ -263,29 +346,47 @@ func defaultWizardConfig(command string) *wizardConfig { } } -func (app *cli) configureCommand(ctx context.Context, command string, accessible bool) ([]string, bool, error) { - config := defaultWizardConfig(command) +func (app *cli) configureWizard(ctx context.Context, config *wizardConfig, accessible bool) ([]string, bool, error) { + command := config.command + config.run = true + config.overwrite = false var groups []*huh.Group switch command { case "sniff", "record", "validate", "devices": groups = append(groups, sourceWizardGroups(config)...) groups = append(groups, commandOptionGroups(config)...) case "replay": - config.timing = true groups = append(groups, replayWizardGroups(config)...) case "pgn": - groups = append(groups, pgnWizardGroups(config)...) + groups = append(groups, pgnWizardGroups(config, accessible)...) case "update": default: return nil, false, fmt.Errorf("no interactive workflow for %q", command) } - groups = append(groups, confirmationGroup(config)) + if command == "devices" || command == "validate" || command == "pgn" { + groups = append(groups, resultOutputGroup(config)) + } + if !accessible { + groups = append(groups, confirmationGroup(config)) + } form := huh.NewForm(groups...).WithShowHelp(true).WithShowErrors(true) ok, err := app.runForm(ctx, form, accessible) if err != nil || !ok || !config.run { return nil, false, err } + if accessible { + // Huh's accessible renderer does not evaluate DescriptionFunc. Build + // the preview after the answers have been collected so it is reviewable. + confirmation := huh.NewForm(huh.NewGroup( + huh.NewNote().Title("Ready to run").Description(renderCommandPreview(config.args())), + huh.NewConfirm().Title("Run this command now?").Affirmative("Run").Negative("Cancel").Value(&config.run), + )) + ok, err = app.runForm(ctx, confirmation, true) + if err != nil || !ok || !config.run { + return nil, false, err + } + } return config.args(), true, nil } @@ -368,7 +469,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group { Title("Capture path"). Description("Plain or gzip candump -L/-l format; Tab accepts a suggestion"). Placeholder("capture.log"). - Suggestions([]string{"testdata/sample.log", "capture.log", "capture.log.gz"}). + SuggestionsFunc(func() []string { return pathSuggestions(config.file) }, &config.file). Value(&config.file). Validate(existingPath), ).WithHideFunc(func() bool { return config.source != "file" }), @@ -376,7 +477,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group { huh.NewInput(). Title("SocketCAN interface"). Placeholder("can0"). - Suggestions([]string{"can0", "vcan0"}). + SuggestionsFunc(func() []string { return completionValues(socketCANInterfaces()) }, &config.iface). Value(&config.iface). Validate(required("interface")), ).WithHideFunc(func() bool { return config.source != "interface" }), @@ -384,7 +485,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group { huh.NewInput(). Title("USB-CAN serial port"). Placeholder("/dev/ttyUSB0"). - Suggestions([]string{"/dev/ttyUSB0", "/dev/ttyACM0"}). + SuggestionsFunc(func() []string { return completionValues(completeUSBDevices(config.usb)) }, &config.usb). Value(&config.usb). Validate(required("serial port")), ).WithHideFunc(func() bool { return config.source != "usb" }), @@ -394,7 +495,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group { Placeholder("192.168.4.1:1457"). Suggestions([]string{"192.168.4.1:1457", "localhost:1457"}). Value(&config.tcp). - Validate(required("TCP address")), + Validate(addressValidator("TCP")), ).WithHideFunc(func() bool { return config.source != "tcp" }), } if config.allowReadOnly { @@ -405,7 +506,7 @@ func sourceWizardGroups(config *wizardConfig) []*huh.Group { Placeholder(":1457"). Suggestions([]string{":1457", "127.0.0.1:1457"}). Value(&config.udp). - Validate(required("UDP address")), + Validate(addressValidator("UDP")), ).WithHideFunc(func() bool { return config.source != "udp" }), ) } @@ -442,15 +543,15 @@ func commandOptionGroups(config *wizardConfig) []*huh.Group { huh.NewGroup( huh.NewInput(). Title("Output path"). - Description("Use - to stream to stdout"). + Description("A new file is suggested. Existing files require a separate replacement choice; - streams to stdout."). Value(&config.outputPath). - Suggestions([]string{"-", "capture.log", "observations.jsonl"}). + SuggestionsFunc(func() []string { return pathSuggestions(config.outputPath) }, &config.outputPath). Validate(required("output path")), huh.NewSelect[string](). Title("Capture format"). Options( huh.NewOption("Replayable candump text", "candump"), - huh.NewOption("Owned observation JSON lines", "jsonl"), + huh.NewOption("Detailed JSON export (cannot replay)", "jsonl"), ). Value(&config.recordFormat), ), @@ -494,16 +595,17 @@ func messageOptionGroups(config *wizardConfig) []*huh.Group { huh.NewSelect[string](). Title("Message output"). Options( - huh.NewOption("Typed JSON lines with exact wire values", "json"), - huh.NewOption("Concrete PGN types with physical values and units", "text"), + huh.NewOption("Readable physical values and units", "text"), + huh.NewOption("JSON lines with exact wire values", "json"), ). Value(&config.output), huh.NewInput(). Title("CEL filter"). - Description("Optional; metadata-only filters avoid decode work"). + Description("Optional: pgn == 127250 keeps heading messages; Tab completes"). Placeholder("pgn == 127250"). Suggestions([]string{"pgn == 127250", "source == 0", "priority <= 3"}). - Value(&config.filter), + Value(&config.filter). + Validate(validateFilter), huh.NewConfirm(). Title("Include unknown PGNs?"). Value(&config.unknown), @@ -518,7 +620,7 @@ func replayWizardGroups(config *wizardConfig) []*huh.Group { huh.NewInput(). Title("Capture path"). Placeholder("capture.log"). - Suggestions([]string{"testdata/sample.log", "capture.log", "capture.log.gz"}). + SuggestionsFunc(func() []string { return pathSuggestions(config.file) }, &config.file). Value(&config.file). Validate(existingPath), huh.NewConfirm(). @@ -531,32 +633,46 @@ func replayWizardGroups(config *wizardConfig) []*huh.Group { ) } -func pgnWizardGroups(config *wizardConfig) []*huh.Group { - suggestions := make([]string, 0, len(pgn.PgnInfoLookup)) +func pgnWizardGroups(config *wizardConfig, accessible bool) []*huh.Group { + options := make([]huh.Option[string], 0, len(pgn.PgnInfoLookup)) for _, number := range sortedPGNNumbers() { - suggestions = append(suggestions, strconv.FormatUint(uint64(number), 10)) + infos := pgn.PgnInfoLookup[number] + label := fmt.Sprintf("%d · %s", number, infos[0].Description) + if len(infos) > 1 { + label += fmt.Sprintf(" (+%d variants)", len(infos)-1) + } + options = append(options, huh.NewOption(label, strconv.FormatUint(uint64(number), 10))) + } + var field huh.Field = huh.NewSelect[string]().Title("Find a PGN").Description("Press / to search by name or number, for example heading or 127250").Options(options...).Height(8).Value(&config.pgnNumber) + if accessible { + field = huh.NewInput().Title("PGN number or name").Description("A number shows fields; a name such as heading searches known PGNs.").Value(&config.pgnNumber).Validate(func(value string) error { + _, err := findPGNs(value) + return err + }) } return []*huh.Group{ - huh.NewGroup( - huh.NewSelect[string](). - Title("PGN schema action"). - Options( - huh.NewOption("Describe one PGN", "describe"), - huh.NewOption("List every typed PGN variant", "list"), - ). - Value(&config.pgnAction), - ), - huh.NewGroup( - huh.NewInput(). - Title("PGN number"). - Description("Type to narrow known PGNs; Tab accepts the suggestion"). - Suggestions(suggestions). - Value(&config.pgnNumber). - Validate(validatePGN), - ).WithHideFunc(func() bool { return config.pgnAction == "list" }), + huh.NewGroup(huh.NewSelect[string]().Title("PGN schema action").Options( + huh.NewOption("Describe one PGN", "describe"), huh.NewOption("List known PGNs", "list"), + ).Value(&config.pgnAction)), + huh.NewGroup(field).WithHideFunc(func() bool { return config.pgnAction == "list" }), } } +func pathSuggestions(prefix string) []string { return completionValues(completeFiles(prefix)) } +func completionValues(items []completionItem) []string { + values := make([]string, 0, len(items)) + for _, item := range items { + values = append(values, item.value) + } + return values +} + +func resultOutputGroup(config *wizardConfig) *huh.Group { + return huh.NewGroup(huh.NewSelect[string]().Title("Result output").Options( + huh.NewOption("Readable summary and tables", "text"), huh.NewOption("JSON for scripts", "json"), + ).Value(&config.output)) +} + func confirmationGroup(config *wizardConfig) *huh.Group { return huh.NewGroup( huh.NewNote(). @@ -582,9 +698,9 @@ func (config *wizardConfig) args() []string { args = append(args, config.file, "--timing="+strconv.FormatBool(config.timing)) case "pgn": if config.pgnAction == "list" { - return append(args, "list") + return append(args, "list", "--output", config.output) } - return append(args, config.pgnNumber) + return append(args, config.pgnNumber, "--output", config.output) } switch config.command { @@ -598,11 +714,16 @@ func (config *wizardConfig) args() []string { } case "record": args = append(args, "--out", config.outputPath, "--output-format", config.recordFormat) + if config.overwrite { + args = append(args, "--overwrite") + } case "validate": + args = append(args, "--output", config.output) if config.strict { args = append(args, "--strict") } case "devices": + args = append(args, "--output", config.output) if config.source != "file" { args = append(args, "--wait", config.wait) } @@ -634,7 +755,12 @@ func (config *wizardConfig) sourceArgs() []string { } func (app *cli) runForm(ctx context.Context, form *huh.Form, accessible bool) (bool, error) { - err := form. + if accessible { + _, _ = fmt.Fprintln(app.errOut, "Follow the prompts. Ctrl+C exits.") + } else { + _, _ = fmt.Fprintln(app.errOut, "Esc / Ctrl+C cancels · Shift+Tab goes back") + } + err := form.WithKeyMap(wizardKeyMap()).WithProgramOptions(tea.WithFilter(formKeyFilter(form))). WithInput(app.in). WithOutput(app.errOut). WithAccessible(accessible). @@ -652,7 +778,7 @@ func existingPath(value string) error { if err := required("capture path")(value); err != nil { return err } - info, err := os.Stat(value) + info, err := os.Stat(expandPath(value)) if err != nil { return fmt.Errorf("cannot read %q: %w", value, err) } @@ -684,20 +810,6 @@ func durationValidator(label string) func(string) error { } } -func validatePGN(value string) error { - if value == "list" { - return nil - } - number, err := strconv.ParseUint(value, 0, 32) - if err != nil { - return fmt.Errorf("enter a decimal or hexadecimal PGN") - } - if len(pgn.PgnInfoLookup[uint32(number)]) == 0 { - return fmt.Errorf("PGN %d is not in the typed metadata", number) - } - return nil -} - func renderCommandPreview(args []string) string { parts := make([]string, 0, len(args)+1) parts = append(parts, "n2k") @@ -722,3 +834,27 @@ func envTruthy(name string) bool { return false } } + +func wizardKeyMap() *huh.KeyMap { + keymap := huh.NewDefaultKeyMap() + keymap.Input.AcceptSuggestion.SetKeys("tab", "ctrl+e") + keymap.Input.AcceptSuggestion.SetHelp("tab", "complete") + keymap.Input.Next.SetKeys("enter") + return keymap +} + +// Let a select field finish its search with Escape as its own help advertises. +// Outside search, Escape cancels the form through Huh's standard quit key. +func formKeyFilter(form *huh.Form) func(tea.Model, tea.Msg) tea.Msg { + return func(_ tea.Model, message tea.Msg) tea.Msg { + if pressed, ok := message.(tea.KeyPressMsg); ok && pressed.Code == tea.KeyEscape { + for _, binding := range form.GetFocusedField().KeyBinds() { + if key.Matches(pressed, binding) { + return message + } + } + return tea.KeyPressMsg(tea.Key{Code: 'c', Mod: tea.ModCtrl}) + } + return message + } +} diff --git a/cmd/n2k/usability_test.go b/cmd/n2k/usability_test.go new file mode 100644 index 0000000..3614a73 --- /dev/null +++ b/cmd/n2k/usability_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "github.com/open-ships/n2k" + "github.com/stretchr/testify/require" +) + +func TestRecordRequiresExplicitOverwrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "capture.log") + original := []byte("previous capture\n") + require.NoError(t, os.WriteFile(path, original, 0o600)) + args := []string{"record", "--file", "../../testdata/sample.log", "--out", path} + _, _, err := executeCommand(context.Background(), args...) + require.ErrorContains(t, err, "--overwrite") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, original, data) + _, _, err = executeCommand(context.Background(), append(args, "--overwrite")...) + require.NoError(t, err) + _, _, err = executeCommand(context.Background(), "replay", path, "--timing=false") + require.NoError(t, err) +} + +func TestRecordNeverOverwritesItsInput(t *testing.T) { + for _, alias := range []string{"same path", "hard link", "symbolic link", "gzip"} { + t.Run(alias, func(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "source.log") + data, err := os.ReadFile("../../testdata/sample.log") + require.NoError(t, err) + require.NoError(t, os.WriteFile(input, data, 0o600)) + output := input + switch alias { + case "hard link", "symbolic link": + output = filepath.Join(dir, "alias.log") + if alias == "hard link" { + err = os.Link(input, output) + } else { + err = os.Symlink(input, output) + } + if err != nil { + t.Skipf("link unavailable: %v", err) + } + case "gzip": + input = gzipTestCapture(t, input) + output = input + data, err = os.ReadFile(input) + require.NoError(t, err) + } + _, _, err = executeCommand(context.Background(), "record", "--file", input, "--out", output, "--overwrite") + require.ErrorContains(t, err, "same capture") + remaining, err := os.ReadFile(input) + require.NoError(t, err) + require.Equal(t, data, remaining) + }) + } +} + +func TestCapturePreflightExplainsUnreadableInput(t *testing.T) { + for _, test := range []struct{ name, body, want string }{ + {"empty", "", "is empty"}, + {"text", "this is not a capture\n", "no readable CAN frames"}, + {"json", " {\"kind\":\"frame\"}\n", "contains JSON"}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "capture.log") + require.NoError(t, os.WriteFile(path, []byte(test.body), 0o600)) + for _, input := range []string{path, gzipTestCapture(t, path)} { + for _, command := range []string{"sniff", "replay", "validate", "devices"} { + stdout, _, err := executeCommand(context.Background(), command, "--file", input) + require.ErrorContains(t, err, test.want, command) + require.Empty(t, stdout) + } + } + }) + } +} + +func TestJSONExportIsExplicitlyRejectedByReplay(t *testing.T) { + path := filepath.Join(t.TempDir(), "observations.jsonl") + _, _, err := executeCommand(context.Background(), "record", "--file", "../../testdata/sample.log", "--out", path, "--output-format", "jsonl") + require.NoError(t, err) + _, _, err = executeCommand(context.Background(), "replay", path) + require.ErrorContains(t, err, "--output-format candump") +} + +func TestCapturePreflightAllowsCommentsBeforeFrames(t *testing.T) { + path := filepath.Join(t.TempDir(), "commented.log") + sample, err := os.ReadFile("../../testdata/sample.log") + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append([]byte("[capture notes]\n"), sample...), 0o600)) + stdout, _, err := executeCommand(context.Background(), "sniff", "--file", path, "--output", "text") + require.NoError(t, err) + require.Contains(t, stdout, "WaterDepth") +} + +func TestInvalidSourceDoesNotReplaceExistingRecording(t *testing.T) { + dir := t.TempDir() + input, output := filepath.Join(dir, "invalid.log"), filepath.Join(dir, "keep.log") + require.NoError(t, os.WriteFile(input, []byte("invalid capture"), 0o600)) + require.NoError(t, os.WriteFile(output, []byte("keep this"), 0o600)) + _, _, err := executeCommand(context.Background(), "record", "--file", input, "--out", output, "--overwrite") + require.Error(t, err) + data, err := os.ReadFile(output) + require.NoError(t, err) + require.Equal(t, "keep this", string(data)) +} + +func TestFilterAndAddressErrorsPrecedeSourceIO(t *testing.T) { + for _, filter := range []string{"pgn = 127250", "pgn", "unknown_variable == 1"} { + _, _, err := executeCommand(context.Background(), "sniff", "--file", "missing.log", "--filter", filter) + require.ErrorContains(t, err, "filter") + require.NotContains(t, err.Error(), "opening capture") + } + for _, expression := range []string{"", "pgn == 127250", "msg.heading > 1 && source == 11"} { + require.NoError(t, validateFilter(expression)) + } + for _, address := range []string{"gateway", "localhost:0", "localhost:99999", ":1457"} { + require.Error(t, addressValidator("TCP")(address)) + } + require.NoError(t, addressValidator("UDP")(":1457")) + require.NoError(t, addressValidator("TCP")("[::1]:1457")) + _, _, err := executeCommand(context.Background(), "sniff", "--file", "--output", "text") + require.ErrorContains(t, err, "--file requires ") +} + +func TestPaletteEscapeClearsAppliedFilterBeforeQuitting(t *testing.T) { + model := newPaletteModel() + model.list.SetFilterText("pgn") + model.list.SetFilterState(list.FilterApplied) + updated, _ := model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape})) + model = updated.(paletteModel) + require.False(t, model.canceled) + require.Equal(t, list.Unfiltered, model.list.FilterState()) + updated, _ = model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape})) + require.True(t, updated.(paletteModel).canceled) +} + +func TestFilteredPaletteDoesNotOpenHiddenNumberShortcut(t *testing.T) { + model := newPaletteModel() + model.list.SetFilterText("pgn") + model.list.SetFilterState(list.FilterApplied) + updated, _ := model.Update(tea.KeyPressMsg(tea.Key{Code: '3', Text: "3"})) + require.Empty(t, updated.(paletteModel).selected) +} + +func TestWizardTabCompletesWithoutSubmittingPartialInput(t *testing.T) { + value := "test" + input := huh.NewInput().Value(&value).Suggestions([]string{"testdata/sample.log"}).Validate(existingPath) + input.WithKeyMap(wizardKeyMap()) + input.Focus() + updated, _ := input.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab})) + require.Equal(t, "testdata/sample.log", value) + require.NotContains(t, updated.View(), "cannot read") +} + +func TestFormEscapeLetsSearchHandleItsOwnKey(t *testing.T) { + field := huh.NewSelect[string]().Options(huh.NewOption("Vessel Heading", "127250")) + form := huh.NewForm(huh.NewGroup(field)) + filter := formKeyFilter(form) + field.Focus() + field.Update(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"})) + escape := tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape}) + require.Equal(t, "esc", filter(nil, escape).(tea.KeyPressMsg).String()) + field.Focus() + field.Update(tea.KeyPressMsg(tea.Key{Code: 'h', Text: "h"})) + field.Update(escape) // Finish entering the filter. + require.Equal(t, "esc", filter(nil, escape).(tea.KeyPressMsg).String()) + field.Update(escape) // Clear the applied filter. + require.Equal(t, "ctrl+c", filter(nil, escape).(tea.KeyPressMsg).String()) +} + +func TestCompactPaletteKeepsControlsInsideTerminal(t *testing.T) { + for _, size := range [][2]int{{80, 24}, {40, 12}, {30, 10}, {20, 6}} { + model := newPaletteModel() + updated, _ := model.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + view := updated.(paletteModel).View().Content + require.LessOrEqual(t, lipgloss.Width(view), size[0]) + require.LessOrEqual(t, lipgloss.Height(view), size[1]) + if size[0] >= 30 { + require.Contains(t, view, "enter") + require.Contains(t, view, "quit") + } + } +} + +func TestReadableInspectionAndJSONDefaults(t *testing.T) { + stdout, _, err := executeCommand(context.Background(), "pgn", "127250", "--output", "text") + require.NoError(t, err) + require.Contains(t, stdout, "Vessel Heading") + require.Contains(t, stdout, "Heading") + require.Less(t, len(strings.Split(stdout, "\n")), 25) + stdout, _, err = executeCommand(context.Background(), "pgn", "heading", "--output", "text") + require.NoError(t, err) + require.Contains(t, stdout, "127250") + stdout, _, err = executeCommand(context.Background(), "pgn", "127250") + require.NoError(t, err) + require.True(t, json.Valid([]byte(stdout))) + stdout, _, err = executeCommand(context.Background(), "devices", "--file", "../../testdata/sample.log", "--output", "text") + require.NoError(t, err) + require.Contains(t, stdout, "MANUFACTURER") + require.Contains(t, stdout, "LAST SEEN") + stdout, _, err = executeCommand(context.Background(), "validate", "--file", "../../testdata/sample.log") + require.NoError(t, err) + var summary validationSummary + require.NoError(t, json.Unmarshal([]byte(stdout), &summary)) + total := 0 + for _, count := range summary.UndecodableByPGN { + total += count + } + require.Equal(t, summary.Undecodable, total) + require.Positive(t, total) + stdout, _, err = executeCommand(context.Background(), "validate", "--file", "../../testdata/sample.log", "--output", "text", "--strict") + require.Error(t, err) + require.Contains(t, stdout, "UNDECODABLE") + require.Contains(t, stdout, "--unknown --filter") +} + +func TestPathSuggestionsUseActualFilesystem(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "weekend-sail.log") + require.NoError(t, os.WriteFile(file, []byte("capture"), 0o600)) + require.Contains(t, pathSuggestions(filepath.Join(dir, "week")), file) +} + +func TestInteractiveExecutionReportsSaveAndEmptyFilter(t *testing.T) { + var out, status bytes.Buffer + app := newCLI(strings.NewReader(""), &out, &status) + config := defaultWizardConfig("record") + config.file = "../../testdata/sample.log" + config.outputPath = filepath.Join(t.TempDir(), "saved.log") + require.NoError(t, app.executeInteractive(context.Background(), config)) + require.Empty(t, out.String()) + require.Contains(t, status.String(), "Saved capture: "+config.outputPath) + require.Contains(t, status.String(), "observations saved") + status.Reset() + config = defaultWizardConfig("sniff") + config.file = "../../testdata/sample.log" + config.filter = "pgn == 999999" + require.NoError(t, app.executeInteractive(context.Background(), config)) + require.Contains(t, status.String(), "No matching traffic") +} + +func TestStoppedRecordingFlushesAndRemainsReplayable(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) + defer cancel() + path := filepath.Join(t.TempDir(), "partial.log") + source := n2k.File("../../testdata/sample.log", n2k.OriginalTiming()) + require.NoError(t, runRecord(ctx, io.Discard, source, "../../testdata/sample.log", path, "candump", false)) + stdout, _, err := executeCommand(context.Background(), "replay", path, "--timing=false", "--unknown") + require.NoError(t, err) + require.NotEmpty(t, stdout) +} diff --git a/go.mod b/go.mod index 83c54b4..9e6abb3 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( charm.land/lipgloss/v2 v2.0.5 github.com/Masterminds/semver/v3 v3.5.0 github.com/charmbracelet/x/term v0.2.2 + github.com/google/cel-go v0.27.0 github.com/open-ships/n2k v1.2.0 github.com/stretchr/testify v1.9.0 ) @@ -31,7 +32,6 @@ require ( github.com/creack/goselect v0.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/cel-go v0.27.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect