")
+ f.Add("")
+ addStringSeedFiles(f, "example.xml")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ if len(input) > 4096 {
+ t.Skip()
+ }
+
+ output, err := XMLToJSON(input)
+ if err != nil {
+ return
+ }
+
+ if !json.Valid([]byte(output)) {
+ t.Fatalf("XMLToJSON returned invalid JSON: %q", output)
+ }
+ })
+}
+
+func FuzzJSONToXML(f *testing.F) {
+ f.Add(`{"root":{"name":"Alice"}}`)
+ f.Add(`{"root":{"items":[1,2,3]}}`)
+ f.Add(`[]`)
+ f.Add("")
+ addStringSeedFiles(f, "example.json", "nested.json", "json-with-urls.json")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ if len(input) > 4096 {
+ t.Skip()
+ }
+
+ output, err := JSONToXML(input)
+ if err != nil {
+ return
+ }
+
+ if _, err := mxj.NewMapXml([]byte(output)); err != nil {
+ t.Fatalf("JSONToXML returned invalid XML: %v", err)
+ }
+ })
+}
+
+func FuzzYAMLToTOML(f *testing.F) {
+ f.Add("name: Alice\nage: 30\n")
+ f.Add("items:\n - one\n - two\n")
+ f.Add("{not: valid")
+ f.Add("")
+ addStringSeedFiles(f, "example.yaml", "nested.yaml")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ if len(input) > 4096 {
+ t.Skip()
+ }
+
+ output, err := YAMLToTOML(input)
+ if err != nil {
+ return
+ }
+
+ var data any
+ if err := toml.Unmarshal([]byte(output), &data); err != nil {
+ t.Fatalf("YAMLToTOML returned invalid TOML: %v", err)
+ }
+ })
+}
+
+func FuzzTOMLToYAML(f *testing.F) {
+ f.Add("name = \"Alice\"\nage = 30\n")
+ f.Add("[user]\nname = \"Bob\"\n")
+ f.Add("[[items]]\nname = \"one\"\n")
+ f.Add("")
+ addStringSeedFiles(f, "example.toml")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ if len(input) > 4096 {
+ t.Skip()
+ }
+
+ output, err := TOMLToYAML(input)
+ if err != nil {
+ return
+ }
+
+ var data any
+ if err := yaml.Unmarshal([]byte(output), &data); err != nil {
+ t.Fatalf("TOMLToYAML returned invalid YAML: %v", err)
+ }
+ })
+}
diff --git a/internal/converter/converter_test.go b/internal/converter/converter_test.go
new file mode 100644
index 0000000..5809f43
--- /dev/null
+++ b/internal/converter/converter_test.go
@@ -0,0 +1,30 @@
+package converter
+
+import "testing"
+
+func TestJSONToXMLInvalidElementName(t *testing.T) {
+ t.Parallel()
+
+ _, err := JSONToXML(`{"0":""}`)
+ if err == nil {
+ t.Fatal("expected error for invalid XML element name")
+ }
+}
+
+func TestJSONToTOMLTopLevelArrayReturnsError(t *testing.T) {
+ t.Parallel()
+
+ _, err := JSONToTOML(`[]`)
+ if err == nil {
+ t.Fatal("expected error for top-level array TOML encoding")
+ }
+}
+
+func TestYAMLToTOMLScalarReturnsError(t *testing.T) {
+ t.Parallel()
+
+ _, err := YAMLToTOML("00")
+ if err == nil {
+ t.Fatal("expected error for scalar TOML encoding")
+ }
+}
diff --git a/internal/csv2json/csv2json.go b/internal/csv2json/csv2json.go
index 24fe81b..5b8f6f2 100644
--- a/internal/csv2json/csv2json.go
+++ b/internal/csv2json/csv2json.go
@@ -108,7 +108,7 @@ func arrayContentMatch(str string) (string, int) {
i := strings.Index(str, "[")
if i >= 0 {
j := strings.Index(str, "]")
- if j >= 0 {
+ if j > i {
index, err := strconv.Atoi(str[i+1 : j])
if err != nil {
return str, -1
diff --git a/internal/csv2json/csv2json_test.go b/internal/csv2json/csv2json_test.go
new file mode 100644
index 0000000..e189792
--- /dev/null
+++ b/internal/csv2json/csv2json_test.go
@@ -0,0 +1,113 @@
+package csv2json
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func addCSVSeedsFromFiles(f *testing.F, fileNames ...string) {
+ for _, fileName := range fileNames {
+ content, err := os.ReadFile(filepath.Join("../../testdata", fileName))
+ if err != nil {
+ continue
+ }
+
+ f.Add(string(content))
+ }
+}
+
+func convertNoPanic(t *testing.T, input string) (result string, err error) {
+ t.Helper()
+
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("Convert panicked: %v", r)
+ }
+ }()
+
+ return Convert(input)
+}
+
+func TestConvertConflictingHeadersReturnError(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ }{
+ {
+ name: "scalar_then_object",
+ input: "a,a.b\n1,2\n",
+ },
+ {
+ name: "object_then_scalar",
+ input: "a.b,a\n1,2\n",
+ },
+ {
+ name: "array_then_object",
+ input: "a[0],a.b\n1,2\n",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := convertNoPanic(t, tt.input)
+ if err == nil {
+ t.Fatal("expected error for conflicting CSV headers")
+ }
+ })
+ }
+}
+
+func TestConvertHighArrayIndexDoesNotPanic(t *testing.T) {
+ t.Parallel()
+
+ output, err := convertNoPanic(t, "items[2].name\nhello\n")
+ if err != nil {
+ t.Fatalf("Convert should not fail for sparse array indexes: %v", err)
+ }
+
+ if !json.Valid([]byte(output)) {
+ t.Fatalf("expected valid JSON output, got: %s", output)
+ }
+
+ if !strings.Contains(output, "hello") {
+ t.Fatalf("expected output to include converted value, got: %s", output)
+ }
+}
+
+func TestConvertMalformedArrayHeaderDoesNotPanic(t *testing.T) {
+ t.Parallel()
+
+ output, err := convertNoPanic(t, "][\n0\n")
+ if err != nil {
+ return
+ }
+
+ if !json.Valid([]byte(output)) {
+ t.Fatalf("expected valid JSON output, got: %s", output)
+ }
+}
+
+func FuzzConvertDoesNotPanic(f *testing.F) {
+ f.Add("name,age\nAlice,30\n")
+ f.Add("a,a.b\n1,2\n")
+ f.Add("items[2].name\nhello\n")
+ f.Add("")
+ addCSVSeedsFromFiles(f, "example.csv")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("Convert panicked for input %q: %v", input, r)
+ }
+ }()
+
+ _, _ = Convert(input)
+ })
+}
diff --git a/internal/htmlfmt/htmlfmt_fuzz_test.go b/internal/htmlfmt/htmlfmt_fuzz_test.go
new file mode 100644
index 0000000..4a19c62
--- /dev/null
+++ b/internal/htmlfmt/htmlfmt_fuzz_test.go
@@ -0,0 +1,33 @@
+package htmlfmt
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func addHTMLSeedsFromFiles(f *testing.F, fileNames ...string) {
+ for _, fileName := range fileNames {
+ content, err := os.ReadFile(filepath.Join("../../testdata", fileName))
+ if err != nil {
+ continue
+ }
+
+ f.Add(string(content))
+ }
+}
+
+func FuzzFormatHTML(f *testing.F) {
+ f.Add("Hello
")
+ f.Add("missing end")
+ f.Add("")
+ addHTMLSeedsFromFiles(f, "html-with-urls.html")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ if len(input) > 4096 {
+ t.Skip()
+ }
+
+ _ = Format(input)
+ })
+}
diff --git a/internal/ui/base_pager_model.go b/internal/ui/base_pager_model.go
index 5df9566..dd599ce 100644
--- a/internal/ui/base_pager_model.go
+++ b/internal/ui/base_pager_model.go
@@ -228,16 +228,12 @@ func (m *BasePagerModel) StatusBarView() string {
// FormatHelpColumns formats the help view with columns
func (m *BasePagerModel) FormatHelpColumns(col1 []string) string {
s := "\n"
- s += "k/↑ up " + col1[0] + "\n"
- s += "j/↓ down " + col1[1] + "\n"
- s += "b/pgup page up " + col1[2] + "\n"
- s += "f/pgdn page down " + col1[3] + "\n"
- s += "u ½ page up " + col1[4] + "\n"
- s += "d ½ page down "
-
- if len(col1) > 5 {
- s += col1[5]
- }
+ s += "k/↑ up " + helpColumnValue(col1, 0) + "\n"
+ s += "j/↓ down " + helpColumnValue(col1, 1) + "\n"
+ s += "b/pgup page up " + helpColumnValue(col1, 2) + "\n"
+ s += "f/pgdn page down " + helpColumnValue(col1, 3) + "\n"
+ s += "u ½ page up " + helpColumnValue(col1, 4) + "\n"
+ s += "d ½ page down " + helpColumnValue(col1, 5)
s = Indent(s, 2)
@@ -255,3 +251,11 @@ func (m *BasePagerModel) FormatHelpColumns(col1 []string) string {
return HelpViewStyle(s)
}
+
+func helpColumnValue(columns []string, index int) string {
+ if index < 0 || index >= len(columns) {
+ return ""
+ }
+
+ return columns[index]
+}
diff --git a/internal/ui/base_pager_model_test.go b/internal/ui/base_pager_model_test.go
new file mode 100644
index 0000000..92be31a
--- /dev/null
+++ b/internal/ui/base_pager_model_test.go
@@ -0,0 +1,27 @@
+package ui
+
+import "testing"
+
+func TestFormatHelpColumnsHandlesShortColumnList(t *testing.T) {
+ t.Parallel()
+
+ m := BasePagerModel{Common: &CommonModel{Width: 80, Height: 24}}
+
+ if m.FormatHelpColumns([]string{"copy", "edit"}) == "" {
+ t.Fatal("expected non-empty help output")
+ }
+}
+
+func TestHelpColumnValueBounds(t *testing.T) {
+ t.Parallel()
+
+ columns := []string{"a", "b"}
+
+ if got := helpColumnValue(columns, 0); got != "a" {
+ t.Fatalf("expected first value to be 'a', got %q", got)
+ }
+
+ if got := helpColumnValue(columns, 5); got != "" {
+ t.Fatalf("expected out-of-range value to be empty, got %q", got)
+ }
+}
diff --git a/internal/yamlfmt/yamlfmt.go b/internal/yamlfmt/yamlfmt.go
index 0c003d7..9179a79 100644
--- a/internal/yamlfmt/yamlfmt.go
+++ b/internal/yamlfmt/yamlfmt.go
@@ -2,6 +2,7 @@ package yamlfmt
import (
"bytes"
+ "fmt"
"gopkg.in/yaml.v3"
)
@@ -22,5 +23,11 @@ func Format(content string) (string, error) {
return "", err
}
- return buf.String(), nil
+ formatted := buf.String()
+ var validation any
+ if err := yaml.Unmarshal([]byte(formatted), &validation); err != nil {
+ return "", fmt.Errorf("formatted YAML is invalid: %w", err)
+ }
+
+ return formatted, nil
}
diff --git a/internal/yamlfmt/yamlfmt_fuzz_test.go b/internal/yamlfmt/yamlfmt_fuzz_test.go
new file mode 100644
index 0000000..2d7f1f5
--- /dev/null
+++ b/internal/yamlfmt/yamlfmt_fuzz_test.go
@@ -0,0 +1,44 @@
+package yamlfmt
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func addYAMLSeedsFromFiles(f *testing.F, fileNames ...string) {
+ for _, fileName := range fileNames {
+ content, err := os.ReadFile(filepath.Join("../../testdata", fileName))
+ if err != nil {
+ continue
+ }
+
+ f.Add(string(content))
+ }
+}
+
+func FuzzFormatYAML(f *testing.F) {
+ f.Add("name: Alice\nage: 30\n")
+ f.Add("items:\n - one\n - two\n")
+ f.Add("{not: valid")
+ f.Add("")
+ addYAMLSeedsFromFiles(f, "example.yaml", "nested.yaml")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ if len(input) > 4096 {
+ t.Skip()
+ }
+
+ output, err := Format(input)
+ if err != nil {
+ return
+ }
+
+ var data any
+ if err := yaml.Unmarshal([]byte(output), &data); err != nil {
+ t.Fatalf("Format returned invalid YAML: %v", err)
+ }
+ })
+}
diff --git a/internal/yamlfmt/yamlfmt_test.go b/internal/yamlfmt/yamlfmt_test.go
new file mode 100644
index 0000000..d4b8bd9
--- /dev/null
+++ b/internal/yamlfmt/yamlfmt_test.go
@@ -0,0 +1,28 @@
+package yamlfmt
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestFormatValidYAML(t *testing.T) {
+ t.Parallel()
+
+ formatted, err := Format("name: Alice\nage: 30\n")
+ if err != nil {
+ t.Fatalf("Format returned error for valid YAML: %v", err)
+ }
+
+ if !strings.Contains(formatted, "name: Alice") {
+ t.Fatalf("unexpected formatted YAML output: %q", formatted)
+ }
+}
+
+func TestFormatReturnsErrorForInvalidFormattedYAML(t *testing.T) {
+ t.Parallel()
+
+ _, err := Format("0: \n0.:")
+ if err == nil {
+ t.Fatal("expected error for YAML that formats into invalid output")
+ }
+}
diff --git a/tui/base64-decoder/main.go b/tui/base64-decoder/main.go
index 97b38b2..9b8df26 100644
--- a/tui/base64-decoder/main.go
+++ b/tui/base64-decoder/main.go
@@ -135,7 +135,7 @@ func (m Base64Model) helpView() (s string) {
s += "j/↓ down " + col1[1] + "\n"
s += "b/pgup page up " + col1[2] + "\n"
s += "f/pgdn page down " + col1[3] + "\n"
- s += "u ½ page up " + col1[4] + "\n"
+ s += "u ½ page up " + "\n"
s += "d ½ page down "
if len(col1) > 5 {
diff --git a/tui/base64-decoder/main_test.go b/tui/base64-decoder/main_test.go
new file mode 100644
index 0000000..7dab144
--- /dev/null
+++ b/tui/base64-decoder/main_test.go
@@ -0,0 +1,16 @@
+package base64decoder
+
+import (
+ "testing"
+
+ "github.com/skatkov/devtui/internal/ui"
+)
+
+func TestHelpViewDoesNotPanic(t *testing.T) {
+ t.Parallel()
+
+ m := NewBase64Model(&ui.CommonModel{Width: 80, Height: 24})
+ if m.helpView() == "" {
+ t.Fatal("expected non-empty help view")
+ }
+}
diff --git a/tui/base64-encoder/main.go b/tui/base64-encoder/main.go
index a697f81..4e1b1ff 100644
--- a/tui/base64-encoder/main.go
+++ b/tui/base64-encoder/main.go
@@ -172,7 +172,7 @@ func (m Base64Model) helpView() (s string) {
s += "j/↓ down " + col1[1] + "\n"
s += "b/pgup page up " + col1[2] + "\n"
s += "f/pgdn page down " + col1[3] + "\n"
- s += "u ½ page up " + col1[4] + "\n"
+ s += "u ½ page up " + "\n"
s += "d ½ page down "
if len(col1) > 5 {
diff --git a/tui/base64-encoder/main_test.go b/tui/base64-encoder/main_test.go
new file mode 100644
index 0000000..88412ab
--- /dev/null
+++ b/tui/base64-encoder/main_test.go
@@ -0,0 +1,16 @@
+package base64encoder
+
+import (
+ "testing"
+
+ "github.com/skatkov/devtui/internal/ui"
+)
+
+func TestHelpViewDoesNotPanic(t *testing.T) {
+ t.Parallel()
+
+ m := NewBase64Model(&ui.CommonModel{Width: 80, Height: 24})
+ if m.helpView() == "" {
+ t.Fatal("expected non-empty help view")
+ }
+}
diff --git a/tui/json2toon/fuzz_test.go b/tui/json2toon/fuzz_test.go
new file mode 100644
index 0000000..db122be
--- /dev/null
+++ b/tui/json2toon/fuzz_test.go
@@ -0,0 +1,68 @@
+package json2toon
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/hannes-sistemica/toon"
+)
+
+func addJSONSeedsFromFiles(f *testing.F, fileNames ...string) {
+ for _, fileName := range fileNames {
+ content, err := os.ReadFile(filepath.Join("../../testdata", fileName))
+ if err != nil {
+ continue
+ }
+
+ f.Add(string(content))
+ }
+}
+
+func addJSONOptionSeedsFromFiles(f *testing.F, fileNames ...string) {
+ for _, fileName := range fileNames {
+ content, err := os.ReadFile(filepath.Join("../../testdata", fileName))
+ if err != nil {
+ continue
+ }
+
+ f.Add(string(content), uint8(2), "")
+ }
+}
+
+func FuzzConvertNoPanic(f *testing.F) {
+ f.Add(`{"name":"Alice"}`)
+ f.Add(`{"items":[1,2,3]}`)
+ f.Add(`{invalid`)
+ f.Add("")
+ addJSONSeedsFromFiles(f, "example.json", "nested.json", "json-with-urls.json")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ if len(input) > 4096 {
+ t.Skip()
+ }
+
+ _, _ = Convert(input)
+ })
+}
+
+func FuzzConvertWithOptionsNoPanic(f *testing.F) {
+ f.Add(`{"name":"Alice"}`, uint8(2), "")
+ f.Add(`{"items":[{"id":1}]}`, uint8(4), "#")
+ f.Add(`{invalid`, uint8(0), "!")
+ addJSONOptionSeedsFromFiles(f, "example.json", "nested.json", "json-with-urls.json")
+
+ f.Fuzz(func(t *testing.T, input string, indent uint8, marker string) {
+ if len(input) > 4096 || len(marker) > 8 {
+ t.Skip()
+ }
+
+ opts := toon.EncodeOptions{
+ Indent: int(indent % 8),
+ Delimiter: ",",
+ LengthMarker: marker,
+ }
+
+ _, _ = ConvertWithOptions(input, opts)
+ })
+}
diff --git a/tui/jsonrepair/fuzz_test.go b/tui/jsonrepair/fuzz_test.go
new file mode 100644
index 0000000..f4f1dee
--- /dev/null
+++ b/tui/jsonrepair/fuzz_test.go
@@ -0,0 +1,42 @@
+package jsonrepair
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func addJSONSeedsFromFiles(f *testing.F, fileNames ...string) {
+ for _, fileName := range fileNames {
+ content, err := os.ReadFile(filepath.Join("../../testdata", fileName))
+ if err != nil {
+ continue
+ }
+
+ f.Add(string(content))
+ }
+}
+
+func FuzzRepairJSONProducesValidJSON(f *testing.F) {
+ f.Add(`{"name":"Alice"}`)
+ f.Add(`{'name':'Alice'}`)
+ f.Add("```json\n{'name': 'Alice'}\n```")
+ f.Add("")
+ addJSONSeedsFromFiles(f, "example.json", "nested.json", "json-with-urls.json")
+
+ f.Fuzz(func(t *testing.T, input string) {
+ if len(input) > 4096 {
+ t.Skip()
+ }
+
+ output, err := RepairJSON(input)
+ if err != nil {
+ return
+ }
+
+ if !json.Valid([]byte(output)) {
+ t.Fatalf("RepairJSON returned invalid JSON: %q", output)
+ }
+ })
+}