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:
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
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/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.
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=