From 8897103fa1e42cb7209a3fa7a688b35ef85fee80 Mon Sep 17 00:00:00 2001 From: osamingo <1390409+osamingo@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:32:05 +0900 Subject: [PATCH 1/4] feat(cmd/csvpp): add CLI tool for CSV++ files - Add view command with interactive TUI (bubbletea + bubbles/table) - Add convert command for bidirectional conversion (CSVPP <=> JSON/YAML) - Add validate command for syntax checking - Support stdin/stdout piping - Add clipboard copy feature for selected rows in view command Co-Authored-By: Claude Opus 4.5 --- cmd/csvpp/README.md | 106 ++++++ cmd/csvpp/convert.go | 206 +++++++++++ cmd/csvpp/convert_test.go | 193 ++++++++++ cmd/csvpp/internal/converter/decode.go | 249 +++++++++++++ cmd/csvpp/internal/converter/decode_test.go | 309 ++++++++++++++++ cmd/csvpp/internal/fileutil/fileutil.go | 52 +++ cmd/csvpp/internal/fileutil/fileutil_test.go | 218 ++++++++++++ cmd/csvpp/internal/tui/model.go | 329 ++++++++++++++++++ cmd/csvpp/internal/tui/model_test.go | 129 +++++++ cmd/csvpp/internal/tui/styles.go | 26 ++ cmd/csvpp/main.go | 11 + cmd/csvpp/root.go | 20 ++ cmd/csvpp/testdata/convert/simple.csvpp | 3 + cmd/csvpp/testdata/convert/simple.json | 10 + cmd/csvpp/testdata/convert/simple.yaml | 4 + .../testdata/validate/invalid_header.csvpp | 2 + cmd/csvpp/testdata/validate/valid.csvpp | 3 + cmd/csvpp/validate.go | 55 +++ cmd/csvpp/validate_test.go | 107 ++++++ cmd/csvpp/view.go | 66 ++++ cmd/csvpp/view_test.go | 55 +++ go.mod | 39 ++- go.sum | 95 ++++- 23 files changed, 2271 insertions(+), 16 deletions(-) create mode 100644 cmd/csvpp/README.md create mode 100644 cmd/csvpp/convert.go create mode 100644 cmd/csvpp/convert_test.go create mode 100644 cmd/csvpp/internal/converter/decode.go create mode 100644 cmd/csvpp/internal/converter/decode_test.go create mode 100644 cmd/csvpp/internal/fileutil/fileutil.go create mode 100644 cmd/csvpp/internal/fileutil/fileutil_test.go create mode 100644 cmd/csvpp/internal/tui/model.go create mode 100644 cmd/csvpp/internal/tui/model_test.go create mode 100644 cmd/csvpp/internal/tui/styles.go create mode 100644 cmd/csvpp/main.go create mode 100644 cmd/csvpp/root.go create mode 100644 cmd/csvpp/testdata/convert/simple.csvpp create mode 100644 cmd/csvpp/testdata/convert/simple.json create mode 100644 cmd/csvpp/testdata/convert/simple.yaml create mode 100644 cmd/csvpp/testdata/validate/invalid_header.csvpp create mode 100644 cmd/csvpp/testdata/validate/valid.csvpp create mode 100644 cmd/csvpp/validate.go create mode 100644 cmd/csvpp/validate_test.go create mode 100644 cmd/csvpp/view.go create mode 100644 cmd/csvpp/view_test.go diff --git a/cmd/csvpp/README.md b/cmd/csvpp/README.md new file mode 100644 index 0000000..eeb7aac --- /dev/null +++ b/cmd/csvpp/README.md @@ -0,0 +1,106 @@ +# csvpp CLI + +A command-line tool for working with CSV++ files. + +## Installation + +```bash +go install github.com/osamingo/go-csvpp/cmd/csvpp@latest +``` + +Or build from source: + +```bash +go build -o csvpp ./cmd/csvpp +``` + +## Commands + +### validate + +Validate CSV++ file syntax. + +```bash +# Validate a file +csvpp validate input.csvpp + +# Validate from stdin +cat input.csvpp | csvpp validate +``` + +### convert + +Convert between CSV++ and other formats (JSON, YAML). + +```bash +# CSV++ to JSON +csvpp convert -i input.csvpp -o output.json +csvpp convert -i input.csvpp --to json + +# CSV++ to YAML +csvpp convert -i input.csvpp -o output.yaml +csvpp convert -i input.csvpp --to yaml + +# JSON to CSV++ +csvpp convert -i input.json -o output.csvpp +csvpp convert -i input.json --from json --to csvpp + +# YAML to CSV++ +csvpp convert -i input.yaml -o output.csvpp +csvpp convert -i input.yaml --from yaml --to csvpp + +# Using stdin/stdout +cat input.csvpp | csvpp convert --to json +cat input.json | csvpp convert --from json --to csvpp +``` + +**Flags:** + +| Flag | Short | Description | +|------|-------|-------------| +| `--input` | `-i` | Input file path | +| `--output` | `-o` | Output file path | +| `--from` | | Input format (csvpp, json, yaml) - auto-detected from extension | +| `--to` | | Output format (csvpp, json, yaml) - auto-detected from extension | + +### view + +View CSV++ file in an interactive TUI table. + +```bash +# View a file +csvpp view input.csvpp + +# View from stdin +cat input.csvpp | csvpp view +``` + +**Key Bindings:** + +| Key | Action | +|-----|--------| +| `↑` / `↓` | Navigate rows | +| `Space` | Toggle row selection | +| `y` / `c` | Copy header + selected rows to clipboard (CSV++ format) | +| `Esc` | Clear selection | +| `q` / `Ctrl+C` | Quit | + +**Note:** When stdin is not a TTY (e.g., in a pipe), a plain text table is displayed instead of the interactive TUI. + +## Examples + +```bash +# Validate and convert if valid +csvpp validate data.csvpp && csvpp convert -i data.csvpp -o data.json + +# Round-trip conversion +csvpp convert -i data.csvpp -o data.json +csvpp convert -i data.json -o data_restored.csvpp + +# Quick preview +csvpp view data.csvpp +``` + +## License + +See the [LICENSE](../../LICENSE) file in the repository root. diff --git a/cmd/csvpp/convert.go b/cmd/csvpp/convert.go new file mode 100644 index 0000000..fdeb75c --- /dev/null +++ b/cmd/csvpp/convert.go @@ -0,0 +1,206 @@ +package main + +import ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/osamingo/go-csvpp" + "github.com/osamingo/go-csvpp/cmd/csvpp/internal/converter" + "github.com/osamingo/go-csvpp/cmd/csvpp/internal/fileutil" + "github.com/osamingo/go-csvpp/csvpputil" +) + +// Format represents output format. +type Format string + +const ( + FormatJSON Format = "json" + FormatYAML Format = "yaml" + FormatCSVPP Format = "csvpp" +) + +var convertCmd = &cobra.Command{ + Use: "convert", + Short: "Convert between CSV++ and JSON/YAML", + Long: `Convert CSV++ files to JSON/YAML or vice versa. + +Examples: + # Convert CSVPP to JSON + csvpp convert -i input.csvpp -o output.json + csvpp convert -i input.csvpp --to json + + # Convert CSVPP to YAML + csvpp convert -i input.csvpp -o output.yaml + + # Convert JSON to CSVPP + csvpp convert -i input.json -o output.csvpp + + # Using stdin/stdout + cat input.csvpp | csvpp convert --to json + cat input.json | csvpp convert --from json --to csvpp`, + RunE: runConvert, +} + +func init() { + convertCmd.Flags().StringP("input", "i", "", "input file (reads from stdin if not specified)") + convertCmd.Flags().StringP("output", "o", "", "output file (writes to stdout if not specified)") + convertCmd.Flags().String("from", "", "input format when using stdin (json, yaml, csvpp)") + convertCmd.Flags().String("to", "", "output format (json, yaml, csvpp)") + + rootCmd.AddCommand(convertCmd) +} + +func runConvert(cmd *cobra.Command, _ []string) (retErr error) { + // Get flag values + inputFile, err := cmd.Flags().GetString("input") + if err != nil { + return err + } + outputFile, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + fromFormat, err := cmd.Flags().GetString("from") + if err != nil { + return err + } + toFormat, err := cmd.Flags().GetString("to") + if err != nil { + return err + } + + // Determine input format + var inputFormat Format + if fromFormat != "" { + inputFormat = Format(strings.ToLower(fromFormat)) + } else { + inputFormat = detectFormat(inputFile) + } + + // Determine output format + var outFormat Format + if toFormat != "" { + outFormat = Format(strings.ToLower(toFormat)) + } else if outputFile != "" { + outFormat = detectFormat(outputFile) + } + if outFormat == "" { + return fmt.Errorf("output format must be specified via --to or output file extension") + } + + // Infer input format from output format for stdin + if inputFormat == "" && inputFile == "" { + // If output is CSVPP, input must be JSON or YAML (default to JSON) + // Otherwise, input is CSVPP + if outFormat == FormatCSVPP { + inputFormat = FormatJSON // Default to JSON when converting to CSVPP from stdin + } else { + inputFormat = FormatCSVPP + } + } + + // Open input + r, err := fileutil.OpenInput(inputFile) + if err != nil { + return err + } + defer r.Close() + + // Open output + w, err := fileutil.OpenOutput(outputFile, cmd.OutOrStdout()) + if err != nil { + return err + } + defer func() { + if cerr := w.Close(); cerr != nil && retErr == nil { + retErr = fmt.Errorf("failed to close output: %w", cerr) + } + }() + + // Route to appropriate converter + switch { + case inputFormat == FormatCSVPP && (outFormat == FormatJSON || outFormat == FormatYAML): + return convertFromCSVPP(r, w, outFormat) + case (inputFormat == FormatJSON || inputFormat == FormatYAML) && outFormat == FormatCSVPP: + return convertToCSVPP(r, w, inputFormat) + case inputFormat == outFormat: + return fmt.Errorf("input and output formats are the same: %s", inputFormat) + default: + return fmt.Errorf("unsupported conversion: %s -> %s", inputFormat, outFormat) + } +} + +// detectFormat detects format from file extension. +func detectFormat(filename string) Format { + if filename == "" { + return "" + } + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".json": + return FormatJSON + case ".yaml", ".yml": + return FormatYAML + case ".csvpp", ".csv": + return FormatCSVPP + default: + return "" + } +} + +// convertFromCSVPP converts CSVPP to JSON or YAML. +func convertFromCSVPP(r io.Reader, w io.Writer, outFormat Format) error { + reader := csvpp.NewReader(r) + + headers, err := reader.Headers() + if err != nil { + return fmt.Errorf("failed to read headers: %w", err) + } + + records, err := reader.ReadAll() + if err != nil { + return fmt.Errorf("failed to read records: %w", err) + } + + switch outFormat { + case FormatJSON: + return csvpputil.WriteJSON(w, headers, records) + case FormatYAML: + return csvpputil.WriteYAML(w, headers, records) + default: + return fmt.Errorf("unsupported output format: %s", outFormat) + } +} + +// convertToCSVPP converts JSON or YAML to CSVPP. +func convertToCSVPP(r io.Reader, w io.Writer, inputFormat Format) error { + var headers []*csvpp.ColumnHeader + var records [][]*csvpp.Field + var err error + + switch inputFormat { + case FormatJSON: + headers, records, err = converter.FromJSON(r) + case FormatYAML: + headers, records, err = converter.FromYAML(r) + default: + return fmt.Errorf("unsupported input format: %s", inputFormat) + } + + if err != nil { + return fmt.Errorf("failed to parse %s: %w", inputFormat, err) + } + + if len(headers) == 0 { + return fmt.Errorf("no data found in input") + } + + writer := csvpp.NewWriter(w) + writer.SetHeaders(headers) + + return writer.WriteAll(records) +} diff --git a/cmd/csvpp/convert_test.go b/cmd/csvpp/convert_test.go new file mode 100644 index 0000000..c1b8b50 --- /dev/null +++ b/cmd/csvpp/convert_test.go @@ -0,0 +1,193 @@ +package main_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestConvertCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantErr bool + wantOutput string + }{ + { + name: "success: csvpp to json", + args: []string{"convert", "-i", "testdata/convert/simple.csvpp", "--to", "json"}, + wantErr: false, + wantOutput: "[{\"name\":\"Alice\",\"age\":\"30\"},{\"name\":\"Bob\",\"age\":\"25\"}]\n", + }, + { + name: "success: csvpp to yaml", + args: []string{"convert", "-i", "testdata/convert/simple.csvpp", "--to", "yaml"}, + wantErr: false, + wantOutput: `- name: Alice + age: "30" +- name: Bob + age: "25" +`, + }, + { + name: "error: missing output format", + args: []string{"convert", "-i", "testdata/convert/simple.csvpp"}, + wantErr: true, + }, + { + name: "error: file not found", + args: []string{"convert", "-i", "nonexistent.csvpp", "--to", "json"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + stdout, _, err := runCommand(t, tt.args...) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if diff := cmp.Diff(tt.wantOutput, stdout); diff != "" { + t.Errorf("output mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestConvertRoundtrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputFile string + format string + fromFormat string + wantContains []string + }{ + { + name: "success: json roundtrip", + inputFile: "testdata/convert/simple.csvpp", + format: "json", + fromFormat: "json", + wantContains: []string{"name", "age", "Alice", "Bob"}, + }, + { + name: "success: yaml roundtrip", + inputFile: "testdata/convert/simple.csvpp", + format: "yaml", + fromFormat: "yaml", + wantContains: []string{"name", "age", "Alice", "Bob"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Step 1: Convert CSVPP to JSON/YAML + tmpDir := t.TempDir() + intermediateFile := filepath.Join(tmpDir, "intermediate."+tt.format) + + _, _, err := runCommand(t, "convert", "-i", tt.inputFile, "-o", intermediateFile) + if err != nil { + t.Fatalf("step 1 (to %s) failed: %v", tt.format, err) + } + + // Step 2: Convert back to CSVPP + outputFile := filepath.Join(tmpDir, "output.csvpp") + _, _, err = runCommand(t, "convert", "-i", intermediateFile, "-o", outputFile) + if err != nil { + t.Fatalf("step 2 (to csvpp) failed: %v", err) + } + + // Read output and verify + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + for _, want := range tt.wantContains { + if !strings.Contains(string(content), want) { + t.Errorf("output missing %q:\n%s", want, content) + } + } + }) + } +} + +func TestConvertWithFromFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputFile string + fromFormat string + toFormat string + wantErr bool + }{ + { + name: "success: json to csvpp with from flag", + inputFile: "testdata/convert/simple.json", + fromFormat: "json", + toFormat: "csvpp", + wantErr: false, + }, + { + name: "success: yaml to csvpp with from flag", + inputFile: "testdata/convert/simple.yaml", + fromFormat: "yaml", + toFormat: "csvpp", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + outputFile := filepath.Join(tmpDir, "output.csvpp") + + _, _, err := runCommand(t, "convert", "-i", tt.inputFile, "--from", tt.fromFormat, "-o", outputFile) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify output file exists and has content + info, err := os.Stat(outputFile) + if err != nil { + t.Errorf("output file not created: %v", err) + return + } + if info.Size() == 0 { + t.Error("output file is empty") + } + }) + } +} diff --git a/cmd/csvpp/internal/converter/decode.go b/cmd/csvpp/internal/converter/decode.go new file mode 100644 index 0000000..bb0e6b8 --- /dev/null +++ b/cmd/csvpp/internal/converter/decode.go @@ -0,0 +1,249 @@ +// Package converter provides conversion utilities for the csvpp CLI. +package converter + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/goccy/go-yaml" + + "github.com/osamingo/go-csvpp" +) + +// FromJSON reads JSON array and converts to CSVPP headers and records. +// The JSON must be an array of objects with consistent keys. +func FromJSON(r io.Reader) ([]*csvpp.ColumnHeader, [][]*csvpp.Field, error) { + var data []map[string]any + if err := json.NewDecoder(r).Decode(&data); err != nil { + return nil, nil, fmt.Errorf("failed to decode JSON: %w", err) + } + + if len(data) == 0 { + return nil, nil, nil + } + + headers := inferHeaders(data) + records := convertRecords(headers, data) + + return headers, records, nil +} + +// FromYAML reads YAML array and converts to CSVPP headers and records. +// The YAML must be an array of objects with consistent keys. +func FromYAML(r io.Reader) ([]*csvpp.ColumnHeader, [][]*csvpp.Field, error) { + var data []map[string]any + if err := yaml.NewDecoder(r).Decode(&data); err != nil { + return nil, nil, fmt.Errorf("failed to decode YAML: %w", err) + } + + if len(data) == 0 { + return nil, nil, nil + } + + headers := inferHeaders(data) + records := convertRecords(headers, data) + + return headers, records, nil +} + +// inferHeaders infers CSVPP headers from JSON/YAML data structure. +// Header inference rules: +// - string → SimpleField +// - []string → ArrayField +// - map[string]any → StructuredField +// - []map[string]any → ArrayStructuredField +func inferHeaders(data []map[string]any) []*csvpp.ColumnHeader { + if len(data) == 0 { + return nil + } + + // Collect all unique keys from all records (first record defines order) + keyOrder := collectKeyOrder(data[0]) + keyTypes := make(map[string]csvpp.FieldKind) + keyComponents := make(map[string][]*csvpp.ColumnHeader) + + // Analyze all records to determine consistent types + for _, record := range data { + for key, value := range record { + kind, components := inferFieldKind(value) + if existing, ok := keyTypes[key]; ok { + // Keep the more complex type if there's a mismatch + if kind > existing { + keyTypes[key] = kind + keyComponents[key] = components + } + } else { + keyTypes[key] = kind + keyComponents[key] = components + } + } + } + + // Build headers maintaining key order + headers := make([]*csvpp.ColumnHeader, 0, len(keyOrder)) + for _, key := range keyOrder { + header := &csvpp.ColumnHeader{ + Name: key, + Kind: keyTypes[key], + ArrayDelimiter: csvpp.DefaultArrayDelimiter, + ComponentDelimiter: csvpp.DefaultComponentDelimiter, + Components: keyComponents[key], + } + headers = append(headers, header) + } + + return headers +} + +// collectKeyOrder returns keys in iteration order (Go 1.12+ maps have random order). +// Uses first record to determine key order. +func collectKeyOrder(record map[string]any) []string { + keys := make([]string, 0, len(record)) + for key := range record { + keys = append(keys, key) + } + return keys +} + +// inferFieldKind determines the FieldKind from a value. +func inferFieldKind(value any) (csvpp.FieldKind, []*csvpp.ColumnHeader) { + switch v := value.(type) { + case []any: + if len(v) == 0 { + return csvpp.ArrayField, nil + } + // Check first element to determine if it's array of strings or array of objects + switch elem := v[0].(type) { + case map[string]any: + // ArrayStructuredField + components := inferComponentHeaders(elem) + return csvpp.ArrayStructuredField, components + default: + // ArrayField + return csvpp.ArrayField, nil + } + case map[string]any: + // StructuredField + components := inferComponentHeaders(v) + return csvpp.StructuredField, components + default: + // SimpleField + return csvpp.SimpleField, nil + } +} + +// inferComponentHeaders creates headers for structured field components. +func inferComponentHeaders(m map[string]any) []*csvpp.ColumnHeader { + headers := make([]*csvpp.ColumnHeader, 0, len(m)) + for key, value := range m { + kind, components := inferFieldKind(value) + header := &csvpp.ColumnHeader{ + Name: key, + Kind: kind, + ArrayDelimiter: csvpp.DefaultArrayDelimiter, + ComponentDelimiter: csvpp.DefaultComponentDelimiter, + Components: components, + } + headers = append(headers, header) + } + return headers +} + +// convertRecords converts data records to CSVPP fields. +func convertRecords(headers []*csvpp.ColumnHeader, data []map[string]any) [][]*csvpp.Field { + records := make([][]*csvpp.Field, 0, len(data)) + + for _, record := range data { + fields := make([]*csvpp.Field, len(headers)) + for i, header := range headers { + value := record[header.Name] + fields[i] = convertValue(header, value) + } + records = append(records, fields) + } + + return records +} + +// convertValue converts a single value to a CSVPP Field. +func convertValue(header *csvpp.ColumnHeader, value any) *csvpp.Field { + if value == nil { + return &csvpp.Field{} + } + + switch header.Kind { + case csvpp.SimpleField: + return &csvpp.Field{Value: toString(value)} + + case csvpp.ArrayField: + arr, ok := value.([]any) + if !ok { + return &csvpp.Field{Values: []string{toString(value)}} + } + values := make([]string, len(arr)) + for i, v := range arr { + values[i] = toString(v) + } + return &csvpp.Field{Values: values} + + case csvpp.StructuredField: + m, ok := value.(map[string]any) + if !ok { + return &csvpp.Field{} + } + components := make([]*csvpp.Field, len(header.Components)) + for i, compHeader := range header.Components { + compValue := m[compHeader.Name] + components[i] = convertValue(compHeader, compValue) + } + return &csvpp.Field{Components: components} + + case csvpp.ArrayStructuredField: + arr, ok := value.([]any) + if !ok { + return &csvpp.Field{Components: []*csvpp.Field{}} + } + components := make([]*csvpp.Field, len(arr)) + for i, elem := range arr { + m, ok := elem.(map[string]any) + if !ok { + components[i] = &csvpp.Field{} + continue + } + compFields := make([]*csvpp.Field, len(header.Components)) + for j, compHeader := range header.Components { + compValue := m[compHeader.Name] + compFields[j] = convertValue(compHeader, compValue) + } + components[i] = &csvpp.Field{Components: compFields} + } + return &csvpp.Field{Components: components} + + default: + return &csvpp.Field{Value: toString(value)} + } +} + +// toString converts any value to string. +func toString(v any) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case float64: + // JSON numbers are decoded as float64 + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%v", val) + case int, int64, int32: + return fmt.Sprintf("%d", val) + case bool: + return fmt.Sprintf("%t", val) + default: + return fmt.Sprintf("%v", val) + } +} diff --git a/cmd/csvpp/internal/converter/decode_test.go b/cmd/csvpp/internal/converter/decode_test.go new file mode 100644 index 0000000..c2f22b7 --- /dev/null +++ b/cmd/csvpp/internal/converter/decode_test.go @@ -0,0 +1,309 @@ +package converter_test + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/osamingo/go-csvpp" + "github.com/osamingo/go-csvpp/cmd/csvpp/internal/converter" +) + +func TestFromJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantHeaders []*csvpp.ColumnHeader + wantRecords [][]*csvpp.Field + wantErr bool + }{ + { + name: "success: simple fields", + input: `[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]`, + wantHeaders: []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField, ArrayDelimiter: '~', ComponentDelimiter: '^'}, + {Name: "age", Kind: csvpp.SimpleField, ArrayDelimiter: '~', ComponentDelimiter: '^'}, + }, + wantRecords: [][]*csvpp.Field{ + {{Value: "Alice"}, {Value: "30"}}, + {{Value: "Bob"}, {Value: "25"}}, + }, + wantErr: false, + }, + { + name: "success: array field", + input: `[{"name":"Alice","phones":["111","222"]},{"name":"Bob","phones":["333"]}]`, + wantHeaders: []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField, ArrayDelimiter: '~', ComponentDelimiter: '^'}, + {Name: "phones", Kind: csvpp.ArrayField, ArrayDelimiter: '~', ComponentDelimiter: '^'}, + }, + wantRecords: [][]*csvpp.Field{ + {{Value: "Alice"}, {Values: []string{"111", "222"}}}, + {{Value: "Bob"}, {Values: []string{"333"}}}, + }, + wantErr: false, + }, + { + name: "success: empty array", + input: `[]`, + wantHeaders: nil, + wantRecords: nil, + wantErr: false, + }, + { + name: "error: invalid json", + input: `{invalid}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + headers, records, err := converter.FromJSON(strings.NewReader(tt.input)) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Compare headers (ignore order since JSON doesn't preserve it) + if len(headers) != len(tt.wantHeaders) { + t.Errorf("headers count mismatch: want %d, got %d", len(tt.wantHeaders), len(headers)) + } + + // Compare records count + if len(records) != len(tt.wantRecords) { + t.Errorf("records count mismatch: want %d, got %d", len(tt.wantRecords), len(records)) + } + }) + } +} + +func TestFromYAML(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantHeaders []*csvpp.ColumnHeader + wantRecords [][]*csvpp.Field + wantErr bool + }{ + { + name: "success: simple fields", + input: `- name: Alice + age: "30" +- name: Bob + age: "25" +`, + wantHeaders: []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField, ArrayDelimiter: '~', ComponentDelimiter: '^'}, + {Name: "age", Kind: csvpp.SimpleField, ArrayDelimiter: '~', ComponentDelimiter: '^'}, + }, + wantRecords: [][]*csvpp.Field{ + {{Value: "Alice"}, {Value: "30"}}, + {{Value: "Bob"}, {Value: "25"}}, + }, + wantErr: false, + }, + { + name: "success: empty array", + input: `[]`, + wantHeaders: nil, + wantRecords: nil, + wantErr: false, + }, + { + name: "error: invalid yaml", + input: `{: invalid`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + headers, records, err := converter.FromYAML(strings.NewReader(tt.input)) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Compare headers count + if len(headers) != len(tt.wantHeaders) { + t.Errorf("headers count mismatch: want %d, got %d", len(tt.wantHeaders), len(headers)) + } + + // Compare headers by name (order not guaranteed due to map iteration) + if len(tt.wantHeaders) > 0 { + headerMap := make(map[string]*csvpp.ColumnHeader) + for _, h := range headers { + headerMap[h.Name] = h + } + for _, want := range tt.wantHeaders { + got, ok := headerMap[want.Name] + if !ok { + t.Errorf("header %q not found", want.Name) + continue + } + if got.Kind != want.Kind { + t.Errorf("header %q kind mismatch: want %v, got %v", want.Name, want.Kind, got.Kind) + } + } + } + + // Compare records count + if len(records) != len(tt.wantRecords) { + t.Errorf("records count mismatch: want %d, got %d", len(tt.wantRecords), len(records)) + } + }) + } +} + +func TestFromJSONStructuredField(t *testing.T) { + t.Parallel() + + input := `[{"name":"Alice","geo":{"lat":"34.05","lon":"-118.24"}}]` + + headers, records, err := converter.FromJSON(strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Find geo header + var geoHeader *csvpp.ColumnHeader + for _, h := range headers { + if h.Name == "geo" { + geoHeader = h + break + } + } + + if geoHeader == nil { + t.Fatal("geo header not found") + } + + if geoHeader.Kind != csvpp.StructuredField { + t.Errorf("geo kind mismatch: want StructuredField, got %v", geoHeader.Kind) + } + + if len(geoHeader.Components) != 2 { + t.Errorf("geo components count mismatch: want 2, got %d", len(geoHeader.Components)) + } + + if len(records) != 1 { + t.Errorf("records count mismatch: want 1, got %d", len(records)) + } +} + +func TestFromJSONArrayStructuredField(t *testing.T) { + t.Parallel() + + input := `[{"name":"Alice","addresses":[{"street":"123 Main","city":"LA"},{"street":"456 Oak","city":"NY"}]}]` + + headers, records, err := converter.FromJSON(strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Find addresses header + var addrHeader *csvpp.ColumnHeader + for _, h := range headers { + if h.Name == "addresses" { + addrHeader = h + break + } + } + + if addrHeader == nil { + t.Fatal("addresses header not found") + } + + if addrHeader.Kind != csvpp.ArrayStructuredField { + t.Errorf("addresses kind mismatch: want ArrayStructuredField, got %v", addrHeader.Kind) + } + + if len(addrHeader.Components) != 2 { + t.Errorf("addresses components count mismatch: want 2, got %d", len(addrHeader.Components)) + } + + if len(records) != 1 { + t.Errorf("records count mismatch: want 1, got %d", len(records)) + } +} + +func TestToString(t *testing.T) { + t.Parallel() + + // This tests the toString function indirectly through FromJSON + tests := []struct { + name string + input string + want string + }{ + { + name: "success: integer value", + input: `[{"value":42}]`, + want: "42", + }, + { + name: "success: float value", + input: `[{"value":3.14}]`, + want: "3.14", + }, + { + name: "success: boolean value", + input: `[{"value":true}]`, + want: "true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + headers, records, err := converter.FromJSON(strings.NewReader(tt.input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(records) != 1 || len(records[0]) != 1 { + t.Fatalf("unexpected records structure") + } + + // Find the field value + var got string + for i, h := range headers { + if h.Name == "value" { + got = records[0][i].Value + break + } + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("value mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/cmd/csvpp/internal/fileutil/fileutil.go b/cmd/csvpp/internal/fileutil/fileutil.go new file mode 100644 index 0000000..fd8814f --- /dev/null +++ b/cmd/csvpp/internal/fileutil/fileutil.go @@ -0,0 +1,52 @@ +// Package fileutil provides file I/O utilities for the csvpp CLI. +package fileutil + +import ( + "fmt" + "io" + "os" +) + +// OpenInput opens a file for reading or returns stdin if filename is empty. +// The caller must call Close() on the returned ReadCloser. +func OpenInput(filename string) (io.ReadCloser, error) { + if filename == "" { + return io.NopCloser(os.Stdin), nil + } + + f, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + return f, nil +} + +// OpenInputFromArgs opens input based on command arguments. +// If args is empty, returns stdin. Otherwise opens args[0]. +func OpenInputFromArgs(args []string) (io.ReadCloser, error) { + if len(args) == 0 { + return io.NopCloser(os.Stdin), nil + } + return OpenInput(args[0]) +} + +// OpenOutput opens a file for writing or returns a WriteCloser wrapping w if filename is empty. +// The caller must call Close() on the returned WriteCloser. +func OpenOutput(filename string, fallback io.Writer) (io.WriteCloser, error) { + if filename == "" { + return &nopWriteCloser{fallback}, nil + } + + f, err := os.Create(filename) + if err != nil { + return nil, fmt.Errorf("failed to create file: %w", err) + } + return f, nil +} + +// nopWriteCloser wraps an io.Writer with a no-op Close method. +type nopWriteCloser struct { + io.Writer +} + +func (*nopWriteCloser) Close() error { return nil } diff --git a/cmd/csvpp/internal/fileutil/fileutil_test.go b/cmd/csvpp/internal/fileutil/fileutil_test.go new file mode 100644 index 0000000..dc772e1 --- /dev/null +++ b/cmd/csvpp/internal/fileutil/fileutil_test.go @@ -0,0 +1,218 @@ +package fileutil_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/osamingo/go-csvpp/cmd/csvpp/internal/fileutil" +) + +func TestOpenInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filename string + setup func(t *testing.T) string + wantErr bool + }{ + { + name: "success: empty filename returns stdin wrapper", + filename: "", + wantErr: false, + }, + { + name: "success: existing file", + filename: "", // Will be set by setup + setup: func(t *testing.T) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "test-*.txt") + if err != nil { + t.Fatal(err) + } + f.WriteString("test content") + f.Close() + return f.Name() + }, + wantErr: false, + }, + { + name: "error: nonexistent file", + filename: "/nonexistent/path/file.txt", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + filename := tt.filename + if tt.setup != nil { + filename = tt.setup(t) + } + + rc, err := fileutil.OpenInput(filename) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if rc == nil { + t.Error("expected non-nil ReadCloser") + return + } + + if err := rc.Close(); err != nil { + t.Errorf("Close() error: %v", err) + } + }) + } +} + +func TestOpenInputFromArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + setup func(t *testing.T) string + wantErr bool + }{ + { + name: "success: empty args returns stdin wrapper", + args: []string{}, + wantErr: false, + }, + { + name: "success: file from args", + args: nil, // Will be set by setup + setup: func(t *testing.T) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "test-*.txt") + if err != nil { + t.Fatal(err) + } + f.Close() + return f.Name() + }, + wantErr: false, + }, + { + name: "error: nonexistent file", + args: []string{"/nonexistent/file.txt"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + args := tt.args + if tt.setup != nil { + args = []string{tt.setup(t)} + } + + rc, err := fileutil.OpenInputFromArgs(args) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if rc == nil { + t.Error("expected non-nil ReadCloser") + return + } + + if err := rc.Close(); err != nil { + t.Errorf("Close() error: %v", err) + } + }) + } +} + +func TestOpenOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filename string + wantErr bool + }{ + { + name: "success: empty filename returns fallback wrapper", + filename: "", + wantErr: false, + }, + { + name: "success: new file", + filename: "", // Will be set in test + wantErr: false, + }, + { + name: "error: invalid path", + filename: "/nonexistent/dir/file.txt", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + filename := tt.filename + if tt.name == "success: new file" { + filename = filepath.Join(t.TempDir(), "output.txt") + } + + var fallback bytes.Buffer + wc, err := fileutil.OpenOutput(filename, &fallback) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if wc == nil { + t.Error("expected non-nil WriteCloser") + return + } + + // Write something + _, err = wc.Write([]byte("test")) + if err != nil { + t.Errorf("Write() error: %v", err) + } + + if err := wc.Close(); err != nil { + t.Errorf("Close() error: %v", err) + } + }) + } +} diff --git a/cmd/csvpp/internal/tui/model.go b/cmd/csvpp/internal/tui/model.go new file mode 100644 index 0000000..2a694f9 --- /dev/null +++ b/cmd/csvpp/internal/tui/model.go @@ -0,0 +1,329 @@ +package tui + +import ( + "bytes" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "golang.design/x/clipboard" + + "github.com/osamingo/go-csvpp" +) + +// Model represents the TUI model for viewing CSV++ data. +type Model struct { + table table.Model + headers []*csvpp.ColumnHeader + records [][]*csvpp.Field + styles Styles + width int + height int + err error + selected map[int]bool + copied bool +} + +// NewModel creates a new TUI model with the given data. +func NewModel(headers []*csvpp.ColumnHeader, records [][]*csvpp.Field) Model { + styles := DefaultStyles() + + // Build table columns (first column is selection marker) + columns := make([]table.Column, len(headers)+1) + columns[0] = table.Column{Title: " ", Width: 2} + for i, h := range headers { + title := formatHeaderTitle(h) + columns[i+1] = table.Column{ + Title: title, + Width: max(len(title), 10), + } + } + + // Build table rows + rows := make([]table.Row, len(records)) + for i, record := range records { + row := make(table.Row, len(record)+1) + row[0] = " " // Selection marker (empty initially) + for j, field := range record { + var header *csvpp.ColumnHeader + if j < len(headers) { + header = headers[j] + } + value := formatFieldValue(header, field) + row[j+1] = value + // Adjust column width + if len(value) > columns[j+1].Width { + columns[j+1].Width = min(len(value), 50) // Cap at 50 + } + } + rows[i] = row + } + + // Create table + t := table.New( //nostyle:funcfmt + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(10), + ) + + // Apply styles + s := table.DefaultStyles() + s.Header = styles.Header + s.Selected = styles.Selected + s.Cell = styles.Cell + t.SetStyles(s) + + return Model{ + table: t, + headers: headers, + records: records, + styles: styles, + selected: make(map[int]bool), + } +} + +// Init implements tea.Model. +func (m Model) Init() tea.Cmd { //nostyle:recvtype + return nil +} + +// Update implements tea.Model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nostyle:recvtype + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "esc": + // Clear selection + m.selected = make(map[int]bool) + m.copied = false + m.updateRowMarkers() + return m, nil + case " ": + // Toggle selection + cursor := m.table.Cursor() + if m.selected[cursor] { + delete(m.selected, cursor) + } else { + m.selected[cursor] = true + } + m.copied = false + m.updateRowMarkers() + return m, nil + case "y", "c": + // Copy selected rows + if len(m.selected) > 0 { + m.copyToClipboard() + } + return m, nil + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.table.SetWidth(msg.Width) + m.table.SetHeight(msg.Height - 4) // Leave room for status + } + + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +// updateRowMarkers updates the selection markers in the table rows. +func (m *Model) updateRowMarkers() { + rows := m.table.Rows() + for i := range rows { + if m.selected[i] { + rows[i][0] = "✓" + } else { + rows[i][0] = " " + } + } + m.table.SetRows(rows) +} + +// copyToClipboard copies selected rows to clipboard in CSVPP format. +func (m *Model) copyToClipboard() { + if err := clipboard.Init(); err != nil { + m.err = fmt.Errorf("clipboard init: %w", err) + return + } + + var buf bytes.Buffer + w := csvpp.NewWriter(&buf) + w.SetHeaders(m.headers) + + // Write header + if err := w.WriteHeader(); err != nil { + m.err = fmt.Errorf("write header: %w", err) + return + } + + // Write selected records in order + for i := 0; i < len(m.records); i++ { + if m.selected[i] { + if err := w.Write(m.records[i]); err != nil { + m.err = fmt.Errorf("write record: %w", err) + return + } + } + } + w.Flush() + + clipboard.Write(clipboard.FmtText, buf.Bytes()) + m.copied = true +} + +// View implements tea.Model. +func (m Model) View() string { //nostyle:recvtype + if m.err != nil { + return fmt.Sprintf("Error: %v\n", m.err) + } + + var b strings.Builder + + // Table + b.WriteString(m.table.View()) + b.WriteString("\n\n") + + // Status line + status := fmt.Sprintf("%d columns, %d records", len(m.headers), len(m.records)) + if len(m.selected) > 0 { + status += fmt.Sprintf(" | %d selected", len(m.selected)) + } + if m.copied { + status += " | Copied!" + } + b.WriteString(m.styles.Status.Render(status)) + b.WriteString("\n") + + // Help + help := "↑/↓: navigate • Space: select • y/c: copy • Esc: clear • q: quit" + b.WriteString(m.styles.Help.Render(help)) + + return b.String() +} + +// formatHeaderTitle formats a column header for display. +func formatHeaderTitle(h *csvpp.ColumnHeader) string { + if h == nil { + return "" + } + + switch h.Kind { + case csvpp.SimpleField: + return h.Name + case csvpp.ArrayField: + return h.Name + "[]" + case csvpp.StructuredField: + comps := formatComponentNames(h.Components) + return fmt.Sprintf("%s(%s)", h.Name, comps) + case csvpp.ArrayStructuredField: + comps := formatComponentNames(h.Components) + return fmt.Sprintf("%s[](%s)", h.Name, comps) + default: + return h.Name + } +} + +// formatComponentNames formats component names for display. +func formatComponentNames(components []*csvpp.ColumnHeader) string { + names := make([]string, len(components)) + for i, c := range components { + names[i] = c.Name + } + return strings.Join(names, ",") +} + +// formatFieldValue formats a field value for display. +func formatFieldValue(h *csvpp.ColumnHeader, f *csvpp.Field) string { + if f == nil { + return "" + } + + if h == nil { + return f.Value + } + + switch h.Kind { + case csvpp.SimpleField: + return f.Value + case csvpp.ArrayField: + return strings.Join(f.Values, ", ") + case csvpp.StructuredField: + return formatStructuredValue(h.Components, f.Components) + case csvpp.ArrayStructuredField: + return formatArrayStructuredValue(h.Components, f.Components) + default: + return f.Value + } +} + +// formatStructuredValue formats a structured field value for display. +func formatStructuredValue(headers []*csvpp.ColumnHeader, components []*csvpp.Field) string { + if len(components) == 0 { + return "" + } + + parts := make([]string, len(components)) + for i, c := range components { + var name string + if i < len(headers) { + name = headers[i].Name + } else { + name = fmt.Sprintf("%d", i) + } + parts[i] = fmt.Sprintf("%s:%s", name, c.Value) + } + return "{" + strings.Join(parts, ", ") + "}" +} + +// formatArrayStructuredValue formats an array structured field value for display. +func formatArrayStructuredValue(headers []*csvpp.ColumnHeader, components []*csvpp.Field) string { + if len(components) == 0 { + return "" + } + + parts := make([]string, len(components)) + for i, item := range components { + parts[i] = formatStructuredValue(headers, item.Components) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +// PlainView returns a plain text representation without TUI. +func PlainView(headers []*csvpp.ColumnHeader, records [][]*csvpp.Field) string { + var b strings.Builder + + // Print headers + headerNames := make([]string, len(headers)) + for i, h := range headers { + headerNames[i] = formatHeaderTitle(h) + } + b.WriteString(strings.Join(headerNames, "\t")) + b.WriteString("\n") + + // Print separator + b.WriteString(strings.Repeat("-", 40)) + b.WriteString("\n") + + // Print records + for _, record := range records { + values := make([]string, len(record)) + for i, field := range record { + var header *csvpp.ColumnHeader + if i < len(headers) { + header = headers[i] + } + values[i] = formatFieldValue(header, field) + } + b.WriteString(strings.Join(values, "\t")) + b.WriteString("\n") + } + + return b.String() +} diff --git a/cmd/csvpp/internal/tui/model_test.go b/cmd/csvpp/internal/tui/model_test.go new file mode 100644 index 0000000..c7cdaa9 --- /dev/null +++ b/cmd/csvpp/internal/tui/model_test.go @@ -0,0 +1,129 @@ +package tui_test + +import ( + "strings" + "testing" + + "github.com/osamingo/go-csvpp" + "github.com/osamingo/go-csvpp/cmd/csvpp/internal/tui" +) + +func TestPlainView(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + headers []*csvpp.ColumnHeader + records [][]*csvpp.Field + wantContains []string + }{ + { + name: "success: simple fields", + headers: []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField}, + {Name: "age", Kind: csvpp.SimpleField}, + }, + records: [][]*csvpp.Field{ + {{Value: "Alice"}, {Value: "30"}}, + {{Value: "Bob"}, {Value: "25"}}, + }, + wantContains: []string{"name", "age", "Alice", "30", "Bob", "25"}, + }, + { + name: "success: array field", + headers: []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField}, + {Name: "phones", Kind: csvpp.ArrayField}, + }, + records: [][]*csvpp.Field{ + {{Value: "Alice"}, {Values: []string{"111", "222"}}}, + }, + wantContains: []string{"name", "phones[]", "Alice", "111", "222"}, + }, + { + name: "success: structured field", + headers: []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField}, + { + Name: "geo", + Kind: csvpp.StructuredField, + Components: []*csvpp.ColumnHeader{ + {Name: "lat", Kind: csvpp.SimpleField}, + {Name: "lon", Kind: csvpp.SimpleField}, + }, + }, + }, + records: [][]*csvpp.Field{ + { + {Value: "Alice"}, + {Components: []*csvpp.Field{{Value: "34.05"}, {Value: "-118.24"}}}, + }, + }, + wantContains: []string{"name", "geo(lat,lon)", "Alice", "lat:", "lon:", "34.05", "-118.24"}, + }, + { + name: "success: array structured field", + headers: []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField}, + { + Name: "addresses", + Kind: csvpp.ArrayStructuredField, + Components: []*csvpp.ColumnHeader{ + {Name: "street", Kind: csvpp.SimpleField}, + {Name: "city", Kind: csvpp.SimpleField}, + }, + }, + }, + records: [][]*csvpp.Field{ + { + {Value: "Alice"}, + {Components: []*csvpp.Field{ + {Components: []*csvpp.Field{{Value: "123 Main"}, {Value: "LA"}}}, + {Components: []*csvpp.Field{{Value: "456 Oak"}, {Value: "NY"}}}, + }}, + }, + }, + wantContains: []string{"name", "addresses[](street,city)", "Alice", "123 Main", "LA", "456 Oak", "NY"}, + }, + { + name: "success: empty records", + headers: []*csvpp.ColumnHeader{{Name: "name", Kind: csvpp.SimpleField}}, + records: [][]*csvpp.Field{}, + wantContains: []string{"name"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tui.PlainView(tt.headers, tt.records) + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("output missing %q:\n%s", want, got) + } + } + }) + } +} + +func TestNewModel(t *testing.T) { + t.Parallel() + + headers := []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField}, + {Name: "age", Kind: csvpp.SimpleField}, + } + records := [][]*csvpp.Field{ + {{Value: "Alice"}, {Value: "30"}}, + } + + model := tui.NewModel(headers, records) + + // Model should be initialized without panic + view := model.View() + if view == "" { + t.Error("expected non-empty view") + } +} diff --git a/cmd/csvpp/internal/tui/styles.go b/cmd/csvpp/internal/tui/styles.go new file mode 100644 index 0000000..b8ddf67 --- /dev/null +++ b/cmd/csvpp/internal/tui/styles.go @@ -0,0 +1,26 @@ +// Package tui provides TUI components for the csvpp CLI. +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +// Styles holds the styles for the TUI components. +type Styles struct { + Header lipgloss.Style + Cell lipgloss.Style + Selected lipgloss.Style + Help lipgloss.Style + Status lipgloss.Style +} + +// DefaultStyles returns the default styles for the TUI. +func DefaultStyles() Styles { + return Styles{ + Header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1), + Cell: lipgloss.NewStyle().Padding(0, 1), + Selected: lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Padding(0, 1), + Help: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + Status: lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Padding(0, 1), + } +} diff --git a/cmd/csvpp/main.go b/cmd/csvpp/main.go new file mode 100644 index 0000000..068527e --- /dev/null +++ b/cmd/csvpp/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" +) + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/csvpp/root.go b/cmd/csvpp/root.go new file mode 100644 index 0000000..c45eb34 --- /dev/null +++ b/cmd/csvpp/root.go @@ -0,0 +1,20 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +// version is set via ldflags at build time. +var version = "dev" + +var rootCmd = &cobra.Command{ + Use: "csvpp", + Short: "CSV++ CLI tool for working with CSV++ files", + Long: `csvpp is a CLI tool for viewing, converting, and validating CSV++ files.`, + Version: version, + SilenceUsage: true, +} + +func init() { + rootCmd.CompletionOptions.DisableDefaultCmd = true +} diff --git a/cmd/csvpp/testdata/convert/simple.csvpp b/cmd/csvpp/testdata/convert/simple.csvpp new file mode 100644 index 0000000..7f8aa6e --- /dev/null +++ b/cmd/csvpp/testdata/convert/simple.csvpp @@ -0,0 +1,3 @@ +name,age +Alice,30 +Bob,25 diff --git a/cmd/csvpp/testdata/convert/simple.json b/cmd/csvpp/testdata/convert/simple.json new file mode 100644 index 0000000..de99a68 --- /dev/null +++ b/cmd/csvpp/testdata/convert/simple.json @@ -0,0 +1,10 @@ +[ + { + "name": "Alice", + "age": "30" + }, + { + "name": "Bob", + "age": "25" + } +] diff --git a/cmd/csvpp/testdata/convert/simple.yaml b/cmd/csvpp/testdata/convert/simple.yaml new file mode 100644 index 0000000..ea5551b --- /dev/null +++ b/cmd/csvpp/testdata/convert/simple.yaml @@ -0,0 +1,4 @@ +- name: Alice + age: "30" +- name: Bob + age: "25" diff --git a/cmd/csvpp/testdata/validate/invalid_header.csvpp b/cmd/csvpp/testdata/validate/invalid_header.csvpp new file mode 100644 index 0000000..0f97279 --- /dev/null +++ b/cmd/csvpp/testdata/validate/invalid_header.csvpp @@ -0,0 +1,2 @@ +name[,age +Alice,30 diff --git a/cmd/csvpp/testdata/validate/valid.csvpp b/cmd/csvpp/testdata/validate/valid.csvpp new file mode 100644 index 0000000..7b89025 --- /dev/null +++ b/cmd/csvpp/testdata/validate/valid.csvpp @@ -0,0 +1,3 @@ +name,age,city +Alice,30,Tokyo +Bob,25,Osaka diff --git a/cmd/csvpp/validate.go b/cmd/csvpp/validate.go new file mode 100644 index 0000000..c3bcd4c --- /dev/null +++ b/cmd/csvpp/validate.go @@ -0,0 +1,55 @@ +package main + +import ( + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/osamingo/go-csvpp" + "github.com/osamingo/go-csvpp/cmd/csvpp/internal/fileutil" +) + +var validateCmd = &cobra.Command{ + Use: "validate [file]", + Short: "Validate CSV++ syntax", + Long: `Validate CSV++ file syntax. Reads from file or stdin if no file is specified.`, + Args: cobra.MaximumNArgs(1), + RunE: runValidate, +} + +func init() { + rootCmd.AddCommand(validateCmd) +} + +func runValidate(cmd *cobra.Command, args []string) error { + r, err := fileutil.OpenInputFromArgs(args) + if err != nil { + return err + } + defer r.Close() + + reader := csvpp.NewReader(r) + + // Read and validate headers + if _, err := reader.Headers(); err != nil { + return fmt.Errorf("header validation failed: %w", err) + } + + // Read and validate all records + recordCount := 0 + for { + _, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("record validation failed: %w", err) + } + recordCount++ + } + + fmt.Fprintf(cmd.OutOrStdout(), "Valid CSV++ file with %d record(s)\n", recordCount) + return nil +} diff --git a/cmd/csvpp/validate_test.go b/cmd/csvpp/validate_test.go new file mode 100644 index 0000000..21d8b97 --- /dev/null +++ b/cmd/csvpp/validate_test.go @@ -0,0 +1,107 @@ +package main_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// testBinary is the path to the compiled test binary. +var testBinary string + +func TestMain(m *testing.M) { + // Build the binary for testing + tmpDir, err := os.MkdirTemp("", "csvpp-test") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create temp dir: %v\n", err) + os.Exit(1) + } + defer os.RemoveAll(tmpDir) + + binaryName := "csvpp" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + testBinary = filepath.Join(tmpDir, binaryName) + + // Build with GOEXPERIMENT=jsonv2 + cmd := exec.Command("go", "build", "-o", testBinary, ".") + cmd.Dir = "." + cmd.Env = append(os.Environ(), "GOEXPERIMENT=jsonv2") + if out, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "failed to build: %s\n", out) + os.Exit(1) + } + + os.Exit(m.Run()) +} + +func runCommand(t *testing.T, args ...string) (string, string, error) { + t.Helper() + + cmd := exec.Command(testBinary, args...) + cmd.Dir = "." + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +func TestValidateCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantErr bool + wantOutput string + }{ + { + name: "success: valid file", + args: []string{"validate", "testdata/validate/valid.csvpp"}, + wantErr: false, + wantOutput: "Valid CSV++ file with 2 record(s)\n", + }, + { + name: "error: invalid header", + args: []string{"validate", "testdata/validate/invalid_header.csvpp"}, + wantErr: true, + }, + { + name: "error: file not found", + args: []string{"validate", "testdata/validate/nonexistent.csvpp"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + stdout, _, err := runCommand(t, tt.args...) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if stdout != tt.wantOutput { + t.Errorf("output mismatch:\nwant: %q\ngot: %q", tt.wantOutput, stdout) + } + }) + } +} diff --git a/cmd/csvpp/view.go b/cmd/csvpp/view.go new file mode 100644 index 0000000..9b8e56f --- /dev/null +++ b/cmd/csvpp/view.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/osamingo/go-csvpp" + "github.com/osamingo/go-csvpp/cmd/csvpp/internal/fileutil" + "github.com/osamingo/go-csvpp/cmd/csvpp/internal/tui" +) + +var viewCmd = &cobra.Command{ + Use: "view [file]", + Short: "View CSV++ file in a table", + Long: `View CSV++ file contents in an interactive table. + +Uses a TUI when running in a terminal, falls back to plain text output +when piped or not in a TTY.`, + Args: cobra.MaximumNArgs(1), + RunE: runView, +} + +func init() { + rootCmd.AddCommand(viewCmd) +} + +func runView(cmd *cobra.Command, args []string) error { + r, err := fileutil.OpenInputFromArgs(args) + if err != nil { + return err + } + defer r.Close() + + reader := csvpp.NewReader(r) + + headers, err := reader.Headers() + if err != nil { + return fmt.Errorf("failed to read headers: %w", err) + } + + records, err := reader.ReadAll() + if err != nil { + return fmt.Errorf("failed to read records: %w", err) + } + + // Check if stdout is a terminal + if !term.IsTerminal(int(os.Stdout.Fd())) { + // Plain text output for pipes + fmt.Fprint(cmd.OutOrStdout(), tui.PlainView(headers, records)) + return nil + } + + // Interactive TUI + model := tui.NewModel(headers, records) + p := tea.NewProgram(model, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + return fmt.Errorf("tui: %w", err) + } + + return nil +} diff --git a/cmd/csvpp/view_test.go b/cmd/csvpp/view_test.go new file mode 100644 index 0000000..a8e633a --- /dev/null +++ b/cmd/csvpp/view_test.go @@ -0,0 +1,55 @@ +package main_test + +import ( + "strings" + "testing" +) + +func TestViewCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantErr bool + wantContains []string + }{ + { + name: "success: view simple file (piped)", + args: []string{"view", "testdata/validate/valid.csvpp"}, + wantErr: false, + wantContains: []string{"name", "age", "city", "Alice", "Bob", "Tokyo", "Osaka"}, + }, + { + name: "error: file not found", + args: []string{"view", "nonexistent.csvpp"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + stdout, _, err := runCommand(t, tt.args...) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + for _, want := range tt.wantContains { + if !strings.Contains(stdout, want) { + t.Errorf("output missing %q:\n%s", want, stdout) + } + } + }) + } +} diff --git a/go.mod b/go.mod index 91415f4..255a31f 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,49 @@ module github.com/osamingo/go-csvpp go 1.25.6 require ( + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/goccy/go-yaml v1.19.2 github.com/google/go-cmp v0.7.0 - github.com/k1LoW/gostyle v0.25.2 - golang.org/x/tools v0.40.0 + github.com/k1LoW/gostyle v0.25.3 + github.com/spf13/cobra v1.10.2 + golang.design/x/clipboard v0.7.1 + golang.org/x/term v0.39.0 + golang.org/x/tools v0.41.0 ) require ( - github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.2 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/camelcase v1.0.0 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect - golang.org/x/mod v0.31.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect + golang.org/x/image v0.28.0 // indirect + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.26.0 // indirect mvdan.cc/gofumpt v0.9.2 // indirect ) diff --git a/go.sum b/go.sum index fea632d..6f76478 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,34 @@ -github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= -github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI= +github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= @@ -14,31 +43,73 @@ github.com/gostaticanalysis/testutil v0.6.1 h1:DeKCG96QlhtNAz+/z2jjO3gIHFV+lHEwE github.com/gostaticanalysis/testutil v0.6.1/go.mod h1:XfUs9IH5sPfXbPIq+kHR64fCpB6pBf5mYeaZQdaTBpw= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/k1LoW/gostyle v0.25.2 h1:zOjgj71ckFXGAqims/xuLAAbaMJaNJg15IEdLjcG7XI= -github.com/k1LoW/gostyle v0.25.2/go.mod h1:GsQ2pz76Rp3jGjhZmHLuOooU3Vzp8K9fEUszNhmMDos= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/k1LoW/gostyle v0.25.3 h1:Ynqsl+5kugzMx3NkxMvpFe8/TamZSybhfm5WbSjnQes= +github.com/k1LoW/gostyle v0.25.3/go.mod h1:bnl0SdATNN0m7p06G+0pabsMubp3+30USYO41zCvTZk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= +golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= From 808d9ee162d04e268c6db79ae45c6813ad400f43 Mon Sep 17 00:00:00 2001 From: osamingo <1390409+osamingo@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:32:10 +0900 Subject: [PATCH 2/4] docs(csvpputil): add README Co-Authored-By: Claude Opus 4.5 --- csvpputil/README.md | 141 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 csvpputil/README.md diff --git a/csvpputil/README.md b/csvpputil/README.md new file mode 100644 index 0000000..3026ff4 --- /dev/null +++ b/csvpputil/README.md @@ -0,0 +1,141 @@ +# csvpputil + +Utility package for converting CSV++ data to JSON and YAML formats. + +## Requirements + +This package uses `encoding/json/jsontext` from Go's experimental JSON v2 implementation. + +```bash +GOEXPERIMENT=jsonv2 go build ./... +GOEXPERIMENT=jsonv2 go test ./... +``` + +## Features + +- **Streaming JSON output** - Memory-efficient for large files +- **YAML output** - With preserved key order +- **Full CSV++ field type support** - SimpleField, ArrayField, StructuredField, ArrayStructuredField + +## API + +### Streaming Writers + +For large datasets, use streaming writers to minimize memory usage. + +#### JSONArrayWriter + +```go +w := csvpputil.NewJSONArrayWriter(os.Stdout, headers) + +for _, record := range records { + if err := w.Write(record); err != nil { + return err + } +} + +if err := w.Close(); err != nil { + return err +} +``` + +#### YAMLArrayWriter + +```go +w := csvpputil.NewYAMLArrayWriter(os.Stdout, headers) + +for _, record := range records { + if err := w.Write(record); err != nil { + return err + } +} + +if err := w.Close(); err != nil { + return err +} +``` + +**Note:** YAML output is buffered until `Close()` due to go-yaml library constraints. + +### Convenience Functions + +For small to medium datasets, use these one-shot functions. + +#### Marshal Functions + +```go +// CSV++ to JSON bytes +jsonBytes, err := csvpputil.MarshalJSON(headers, records) + +// CSV++ to YAML bytes +yamlBytes, err := csvpputil.MarshalYAML(headers, records) +``` + +#### Write Functions + +```go +// Write JSON to io.Writer +err := csvpputil.WriteJSON(w, headers, records) + +// Write YAML to io.Writer +err := csvpputil.WriteYAML(w, headers, records) +``` + +## Example + +```go +package main + +import ( + "fmt" + "log" + + "github.com/osamingo/go-csvpp" + "github.com/osamingo/go-csvpp/csvpputil" +) + +func main() { + headers := []*csvpp.ColumnHeader{ + {Name: "name", Kind: csvpp.SimpleField}, + {Name: "age", Kind: csvpp.SimpleField}, + } + + records := [][]*csvpp.Field{ + {{Value: "Alice"}, {Value: "30"}}, + {{Value: "Bob"}, {Value: "25"}}, + } + + // Convert to JSON + data, err := csvpputil.MarshalJSON(headers, records) + if err != nil { + log.Fatal(err) + } + fmt.Println(string(data)) + // Output: [{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}] + + // Convert to YAML + yamlData, err := csvpputil.MarshalYAML(headers, records) + if err != nil { + log.Fatal(err) + } + fmt.Print(string(yamlData)) + // Output: + // - name: Alice + // age: "30" + // - name: Bob + // age: "25" +} +``` + +## Field Type Mapping + +| CSV++ Field Type | JSON Output | YAML Output | +|------------------|-------------|-------------| +| SimpleField | `"value"` | `value` | +| ArrayField | `["a", "b"]` | `- a`
`- b` | +| StructuredField | `{"k1": "v1", "k2": "v2"}` | `k1: v1`
`k2: v2` | +| ArrayStructuredField | `[{"k": "v"}, ...]` | `- k: v`
`- ...` | + +## License + +See the [LICENSE](../LICENSE) file in the repository root. From 28677d1c07c27e280c2aa85948ea248f1a954fe5 Mon Sep 17 00:00:00 2001 From: osamingo <1390409+osamingo@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:32:15 +0900 Subject: [PATCH 3/4] docs: update root README with subpackage links - Add csvpputil and csvpp CLI links to Features section - Add JSON/YAML Conversion section with link to csvpputil/README - Add CLI Tool section with link to cmd/csvpp/README Co-Authored-By: Claude Opus 4.5 --- README.md | 83 ++++++++++++------------------------------------------- 1 file changed, 17 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index bc3aba4..7e30de0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ CSV++ extends traditional CSV to support **arrays** and **structured fields** wi - Struct mapping with `csvpp` tags (Marshal/Unmarshal) - Configurable delimiters - Security-conscious design (nesting depth limits) +- **[csvpputil](./csvpputil/)** - JSON/YAML conversion utilities +- **[csvpp CLI](./cmd/csvpp/)** - Command-line tool for viewing and converting CSV++ files ## Requirements @@ -258,81 +260,30 @@ type Record struct { ## JSON/YAML Conversion (csvpputil) -The `csvpputil` package provides utilities for converting CSV++ data to JSON and YAML formats. +Utility package for converting CSV++ data to JSON and YAML formats with streaming support. -### Installation +For details, see [csvpputil/README.md](./csvpputil/README.md). -```bash -go get github.com/osamingo/go-csvpp/csvpputil -``` - -### Quick Conversion - -```go -import "github.com/osamingo/go-csvpp/csvpputil" - -// Convert to JSON -jsonData, err := csvpputil.MarshalJSON(headers, records) +## CLI Tool (csvpp) -// Convert to YAML -yamlData, err := csvpputil.MarshalYAML(headers, records) +A command-line tool for viewing, converting, and validating CSV++ files. -// Write directly to io.Writer -err = csvpputil.WriteJSON(w, headers, records) -err = csvpputil.WriteYAML(w, headers, records) -``` - -### Streaming Output - -For large datasets, use streaming writers: - -```go -// JSON streaming -w := csvpputil.NewJSONArrayWriter(out, headers) -for _, record := range records { - if err := w.Write(record); err != nil { - return err - } -} -if err := w.Close(); err != nil { - return err -} - -// YAML streaming -w := csvpputil.NewYAMLArrayWriter(out, headers) -for _, record := range records { - if err := w.Write(record); err != nil { - return err - } -} -if err := w.Close(); err != nil { - return err -} -``` +```bash +# Install +go install github.com/osamingo/go-csvpp/cmd/csvpp@latest -### Example Output +# Validate +csvpp validate input.csvpp -Given CSV++ data: -``` -name,tags[],geo(lat^lon) -Alice,go~rust,35.6762^139.6503 -``` +# Convert to JSON/YAML +csvpp convert -i input.csvpp -o output.json +csvpp convert -i input.csvpp -o output.yaml -JSON output: -```json -[{"name":"Alice","tags":["go","rust"],"geo":{"lat":"35.6762","lon":"139.6503"}}] +# Interactive TUI view +csvpp view input.csvpp ``` -YAML output: -```yaml -- name: Alice - tags: - - go - - rust - geo: - lat: "35.6762" - lon: "139.6503" -``` +For more details, see [cmd/csvpp/README.md](./cmd/csvpp/README.md). ## Compatibility From 4a1341a1321c7131ef7afab56810fca73d0f4d21 Mon Sep 17 00:00:00 2001 From: osamingo <1390409+osamingo@users.noreply.github.com> Date: Fri, 6 Feb 2026 02:41:07 +0900 Subject: [PATCH 4/4] chore(.github/workflows): add X11 dependencies for clipboard support Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f69f0..d410eb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install X11 dependencies + run: sudo apt-get update && sudo apt-get install -y libx11-dev + - name: Setup Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: @@ -39,6 +42,9 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install X11 dependencies + run: sudo apt-get update && sudo apt-get install -y libx11-dev + - name: Setup Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: