feat: add no-header flag to csv export (#841)

A-R-Narke created

Change summary

cli/contacts_export.go      | 19 +++++++++----
cli/contacts_export_test.go | 55 ++++++++++++++++++++++++++++++++++++--
docs/docs/Features/CLI.md   |  9 +++++
3 files changed, 73 insertions(+), 10 deletions(-)

Detailed changes

cli/contacts_export.go 🔗

@@ -17,6 +17,7 @@ func RunContactsExport(args []string) error {
 	fs := flag.NewFlagSet("contacts export", flag.ExitOnError)
 	format := fs.String("f", "json", "output format: json or csv")
 	output := fs.String("o", "", "output file path (default: stdout)")
+	noHeader := fs.Bool("no-header", false, "omit CSV header row")
 	help := fs.Bool("h", false, "show help")
 
 	if err := fs.Parse(args); err != nil {
@@ -35,6 +36,7 @@ func RunContactsExport(args []string) error {
 		fmt.Println("  matcha contacts export              # JSON to stdout")
 		fmt.Println("  matcha contacts export -f csv       # CSV to stdout")
 		fmt.Println("  matcha contacts export -o out.json  # JSON to file")
+		fmt.Println("  matcha contacts export -f csv --no-header  # CSV without headers")
 		return nil
 	}
 
@@ -43,10 +45,10 @@ func RunContactsExport(args []string) error {
 		return fmt.Errorf("invalid format '%s': must be 'json' or 'csv'", *format)
 	}
 
-	return runExportContacts(formatStr, *output)
+	return runExportContacts(formatStr, *output, *noHeader)
 }
 
-func runExportContacts(format, outputPath string) error {
+func runExportContacts(format, outputPath string, noHeader bool) error {
 	var contacts []config.Contact
 	var err error
 
@@ -82,12 +84,15 @@ func runExportContacts(format, outputPath string) error {
 
 	var outputData []byte
 	if format == "json" {
+		if noHeader {
+			return fmt.Errorf("the --no-header flag is only valid with CSV format")
+		}
 		outputData, err = exportToJSON(contacts)
 		if err != nil {
 			return fmt.Errorf("failed to export to JSON: %w", err)
 		}
 	} else {
-		outputData, err = exportToCSV(contacts)
+		outputData, err = exportToCSV(contacts, noHeader)
 		if err != nil {
 			return fmt.Errorf("failed to export to CSV: %w", err)
 		}
@@ -115,12 +120,14 @@ func exportToJSON(contacts []config.Contact) ([]byte, error) {
 	return json.MarshalIndent(contacts, "", "  ")
 }
 
-func exportToCSV(contacts []config.Contact) ([]byte, error) {
+func exportToCSV(contacts []config.Contact, noHeader bool) ([]byte, error) {
 	var buf strings.Builder
 	writer := csv.NewWriter(&buf)
 
-	if err := writer.Write([]string{"name", "email", "last_used", "use_count"}); err != nil {
-		return nil, err
+	if !noHeader {
+		if err := writer.Write([]string{"name", "email", "last_used", "use_count"}); err != nil {
+			return nil, err
+		}
 	}
 
 	for _, c := range contacts {

cli/contacts_export_test.go 🔗

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 	"time"
 
@@ -61,7 +62,7 @@ func TestExportToCSV(t *testing.T) {
 		},
 	}
 
-	data, err := exportToCSV(contacts)
+	data, err := exportToCSV(contacts, false)
 	if err != nil {
 		t.Fatalf("exportToCSV failed: %v", err)
 	}
@@ -86,6 +87,54 @@ func TestExportToCSV(t *testing.T) {
 	}
 }
 
+func TestExportToCSVNoHeader(t *testing.T) {
+	contacts := []config.Contact{
+		{
+			Name:     "John Doe",
+			Email:    "john@example.com",
+			LastUsed: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
+			UseCount: 5,
+		},
+		{
+			Name:     "Jane Smith",
+			Email:    "jane@test.com",
+			LastUsed: time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC),
+			UseCount: 10,
+		},
+	}
+
+	data, err := exportToCSV(contacts, true)
+	if err != nil {
+		t.Fatalf("exportToCSV failed: %v", err)
+	}
+
+	output := string(data)
+	expectedFields := "name,email,last_used,use_count"
+
+	// Check that header is NOT present anywhere in the output
+	if strings.Contains(output, expectedFields) {
+		t.Errorf("expected no CSV header, but found '%s' in output", expectedFields)
+	}
+
+	// Check that both contacts are present
+	if !contains(output, "john@example.com") {
+		t.Error("expected john@example.com in CSV output")
+	}
+	if !contains(output, "jane@test.com") {
+		t.Error("expected jane@test.com in CSV output")
+	}
+
+	// Check that the first line is data, not header
+	lines := strings.Split(strings.TrimSpace(output), "\n")
+	if len(lines) < 2 {
+		t.Fatal("expected at least 2 lines in CSV output")
+	}
+	firstLine := lines[0]
+	if !strings.Contains(firstLine, "john@example.com") {
+		t.Errorf("expected first line to contain contact email, got '%s'", firstLine)
+	}
+}
+
 func TestEscapeCSV(t *testing.T) {
 	tests := []struct {
 		input    string
@@ -121,7 +170,7 @@ func TestExportToCSVWithSpecialChars(t *testing.T) {
 		},
 	}
 
-	data, err := exportToCSV(contacts)
+	data, err := exportToCSV(contacts, false)
 	if err != nil {
 		t.Fatalf("exportToCSV failed: %v", err)
 	}
@@ -192,7 +241,7 @@ func TestExportCSVToFile(t *testing.T) {
 		},
 	}
 
-	data, err := exportToCSV(contacts)
+	data, err := exportToCSV(contacts, false)
 	if err != nil {
 		t.Fatalf("exportToCSV failed: %v", err)
 	}

docs/docs/Features/CLI.md 🔗

@@ -144,6 +144,7 @@ matcha contacts export [flags]
 |------|-------------|
 | `-f` | Output format: `json` or `csv` (default: `json`) |
 | `-o` | Output file path. If omitted, prints to stdout |
+| `--no-header` | Omit CSV header row (CSV format only) |
 | `-h` | Show help |
 
 ### Examples
@@ -167,13 +168,19 @@ matcha contacts export -o ~/contacts.json
 matcha contacts export -f csv -o ~/contacts.csv
 ```
 
+**Export CSV without headers:**
+
+```bash
+matcha contacts export -f csv --no-header
+```
+
 If encryption is enabled, you will be prompted for your password before the contacts can be read.
 
 ### Output Format
 
 **JSON** exports an array of contact objects with `name`, `email`, `last_used`, and `use_count` fields.
 
-**CSV** exports a header row (`name,email,last_used,use_count`) followed by one row per contact.
+**CSV** exports a header row (`name,email,last_used,use_count`) followed by one row per contact. Use `--no-header` to omit the header row.
 
 ## matcha config