miller/pkg/climain/errors_json_test.go
John Kerl 7f60e7da57
Add DSL validate/dry-run: put/filter --explain (#2098 PR5) (#2131)
Lets an agent type-check a DSL expression before spending a full input
pass. `mlr put --explain '...'` (and filter) runs the existing
parse -> ValidateAST -> CST build -> Resolve path, then:

- valid: prints "mlr {put,filter}: DSL expression is valid." and exits 0
- invalid: returns the build error up the normal path, so --errors-json
  emits a structured document; exits 1
- -W with fatal warnings: reports and exits 1

The gate lives in the pass-two constructor, before any input file is
opened, so no input stream is read (verified with a nonexistent input
file still validating OK).

Also categorize bare "parse error: ..." messages from the DSL parser as
kind "dsl-parse-error" rather than "generic" (climain/errors_json.go),
so --explain --errors-json gives an agent a useful error kind. The CSV
reader's "parse error on line ..." is stream-time and never reaches this
command-line-parse categorizer.

Tests: dsl-explain/0001-0004 regression cases (valid put/filter, invalid
plain, invalid --errors-json) and categorize unit tests. Regenerated
verb docs, manpage, and the help usage-verbs golden case.

The older -X ("exit after parsing") still exits 0 even on a parse error;
left as-is since --explain is the correct validation path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:12:23 -04:00

201 lines
5.8 KiB
Go

package climain
import (
"fmt"
"testing"
)
// ----------------------------------------------------------------
// Levenshtein
func TestLevenshteinIdentical(t *testing.T) {
if d := levenshtein("filter", "filter"); d != 0 {
t.Errorf("identical strings: got %d, want 0", d)
}
}
func TestLevenshteinEmpty(t *testing.T) {
if d := levenshtein("", "abc"); d != 3 {
t.Errorf("empty a: got %d, want 3", d)
}
if d := levenshtein("abc", ""); d != 3 {
t.Errorf("empty b: got %d, want 3", d)
}
}
func TestLevenshteinSubstitution(t *testing.T) {
// "fliter" → "filter": swap l↔i at positions 1-2 → distance 2
d := levenshtein("fliter", "filter")
if d != 2 {
t.Errorf("fliter/filter: got %d, want 2", d)
}
}
func TestLevenshteinInsertion(t *testing.T) {
// "--jsonn" → "--json": 1 deletion
if d := levenshtein("--jsonn", "--json"); d != 1 {
t.Errorf("--jsonn/--json: got %d, want 1", d)
}
}
func TestLevenshteinDeletion(t *testing.T) {
// "--complemment" → "--complement": 1 extra m
if d := levenshtein("--complemment", "--complement"); d != 1 {
t.Errorf("--complemment/--complement: got %d, want 1", d)
}
}
// ----------------------------------------------------------------
// topMatches
func TestTopMatchesBasic(t *testing.T) {
// "filtter" is distance 1 from "filter" (extra t), clearly the top match.
candidates := []string{"filter", "flatten", "fraction", "grep", "head"}
matches := topMatches("filtter", candidates, 3, 2)
if len(matches) == 0 {
t.Fatal("expected at least one match for 'filtter'")
}
if matches[0] != "filter" {
t.Errorf("expected 'filter' as top match, got %q", matches[0])
}
}
func TestTopMatchesNoneWithinThreshold(t *testing.T) {
candidates := []string{"aaaaa", "bbbbb", "ccccc"}
matches := topMatches("xyz", candidates, 3, 1)
if len(matches) != 0 {
t.Errorf("expected no matches, got %v", matches)
}
}
func TestTopMatchesRespectsCap(t *testing.T) {
// All candidates are within distance 1 of "a"; cap at 2
candidates := []string{"aa", "ab", "ac", "ad", "ae"}
matches := topMatches("a", candidates, 2, 2)
if len(matches) > 2 {
t.Errorf("expected at most 2 matches, got %d", len(matches))
}
}
// ----------------------------------------------------------------
// levenshteinThreshold
func TestThresholdShortQuery(t *testing.T) {
if levenshteinThreshold("ab") != 1 {
t.Error("2-char query should have threshold 1")
}
}
func TestThresholdMediumQuery(t *testing.T) {
if levenshteinThreshold("filter") != 2 {
t.Error("6-char query should have threshold 2")
}
}
func TestThresholdLongQuery(t *testing.T) {
if levenshteinThreshold("--complement") != 3 {
t.Error("12-char query should have threshold 3")
}
}
// ----------------------------------------------------------------
// WantErrorsJSON
func TestWantErrorsJSONFlag(t *testing.T) {
if !WantErrorsJSON([]string{"mlr", "--errors-json", "flitre"}) {
t.Error("should detect --errors-json")
}
if !WantErrorsJSON([]string{"mlr", "flitre", "--errors-json"}) {
t.Error("should detect --errors-json in any position")
}
if WantErrorsJSON([]string{"mlr", "flitre"}) {
t.Error("should not detect --errors-json when absent")
}
}
// ----------------------------------------------------------------
// CLIError round-trip
func TestCLIErrorInterface(t *testing.T) {
err := &CLIError{Kind: "unknown-verb", Token: "foo", Msg: "mlr: verb \"foo\" not found"}
if err.Error() != err.Msg {
t.Errorf("Error() should return Msg, got %q", err.Error())
}
}
// ----------------------------------------------------------------
// categorize
func TestCategorizeUnknownVerb(t *testing.T) {
err := &CLIError{Kind: "unknown-verb", Token: "flitre", Msg: "mlr: verb \"flitre\" not found"}
se := categorize(err)
if se.Kind != "unknown-verb" {
t.Errorf("kind: got %q, want unknown-verb", se.Kind)
}
if se.Token != "flitre" {
t.Errorf("token: got %q, want flitre", se.Token)
}
if se.Hint == "" {
t.Error("hint should be non-empty for unknown-verb")
}
}
func TestCategorizeUnknownFlag(t *testing.T) {
err := &CLIError{Kind: "unknown-flag", Token: "--jsonn", Msg: "mlr: option \"--jsonn\" not recognized"}
se := categorize(err)
if se.Kind != "unknown-flag" {
t.Errorf("kind: got %q, want unknown-flag", se.Kind)
}
if se.Hint == "" {
t.Error("hint should be non-empty for unknown-flag")
}
}
func TestCategorizeVerbOptionError(t *testing.T) {
err := &CLIError{
Kind: "verb-option-error", Token: "--bad", Verb: "cut",
Msg: "mlr cut: option \"--bad\" not recognized",
}
se := categorize(err)
if se.Kind != "verb-option-error" {
t.Errorf("kind: got %q, want verb-option-error", se.Kind)
}
if se.Verb != "cut" {
t.Errorf("verb: got %q, want cut", se.Verb)
}
if se.Hint == "" {
t.Error("hint should be non-empty for verb-option-error")
}
}
func TestCategorizeGenericFallback(t *testing.T) {
err := fmt.Errorf("some unexpected error")
se := categorize(err)
if se.Kind != "generic" {
t.Errorf("kind: got %q, want generic", se.Kind)
}
}
func TestCategorizeDSLParseError(t *testing.T) {
// The DSL parser (pkg/parsing/parser) emits bare "parse error: ..."
// messages; these should categorize as dsl-parse-error, e.g. for
// `mlr put --explain` with --errors-json.
err := fmt.Errorf("parse error: unexpected equals (\"=\")")
se := categorize(err)
if se.Kind != "dsl-parse-error" {
t.Errorf("kind: got %q, want dsl-parse-error", se.Kind)
}
if se.Hint == "" {
t.Error("hint should be non-empty for dsl-parse-error")
}
}
func TestCategorizeDSLParseErrorNotCSV(t *testing.T) {
// The CSV reader's "parse error on line ..." is a stream-time error and
// should not be mistaken for a DSL parse error by the substring match.
err := fmt.Errorf("parse error on line 3, column 5: bare \" in non-quoted-field")
se := categorize(err)
if se.Kind == "dsl-parse-error" {
t.Errorf("kind: got dsl-parse-error, want non-DSL categorization for a CSV parse error")
}
}