diff --git a/cmd/bd/export.go b/cmd/bd/export.go index ac432414..e2cd7455 100644 --- a/cmd/bd/export.go +++ b/cmd/bd/export.go @@ -306,11 +306,25 @@ Output to stdout by default, or use -o flag for file output.`, // Set appropriate file permissions (0600: rw-------) if err := os.Chmod(finalPath, 0600); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to set file permissions: %v\n", err) + fmt.Fprintf(os.Stderr, "Warning: failed to set file permissions: %v\n", err) } - } - // Output statistics if JSON format requested + // Verify JSONL file integrity after export + actualCount, err := countIssuesInJSONL(finalPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: Export verification failed: %v\n", err) + os.Exit(1) + } + if actualCount != len(exportedIDs) { + fmt.Fprintf(os.Stderr, "Error: Export verification failed\n") + fmt.Fprintf(os.Stderr, " Expected: %d issues\n", len(exportedIDs)) + fmt.Fprintf(os.Stderr, " JSONL file: %d lines\n", actualCount) + fmt.Fprintf(os.Stderr, " Mismatch indicates export failed to write all issues\n") + os.Exit(1) + } + } + + // Output statistics if JSON format requested if jsonOutput { stats := map[string]interface{}{ "success": true, diff --git a/cmd/bd/export_test.go b/cmd/bd/export_test.go index 5490b031..7a387771 100644 --- a/cmd/bd/export_test.go +++ b/cmd/bd/export_test.go @@ -257,4 +257,67 @@ func TestExportCommand(t *testing.T) { t.Errorf("JSONL file was modified! Expected 2 issues, got %d", countAfter) } }) + + t.Run("verify JSONL line count matches exported count", func(t *testing.T) { + exportPath := filepath.Join(tmpDir, "export_verify.jsonl") + + // Clear export hashes to force re-export + if err := s.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("Failed to clear export hashes: %v", err) + } + + store = s + dbPath = testDB + exportCmd.Flags().Set("output", exportPath) + exportCmd.Run(exportCmd, []string{}) + + // Verify the exported file has exactly 2 lines + actualCount, err := countIssuesInJSONL(exportPath) + if err != nil { + t.Fatalf("Failed to count issues in JSONL: %v", err) + } + if actualCount != 2 { + t.Errorf("Expected 2 issues in JSONL, got %d", actualCount) + } + + // Simulate corrupted export by truncating file + corruptedPath := filepath.Join(tmpDir, "export_corrupted.jsonl") + + // First export normally + if err := s.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("Failed to clear export hashes: %v", err) + } + store = s + exportCmd.Flags().Set("output", corruptedPath) + exportCmd.Run(exportCmd, []string{}) + + // Now manually corrupt it by removing one line + file, err := os.Open(corruptedPath) + if err != nil { + t.Fatalf("Failed to open file for corruption: %v", err) + } + scanner := bufio.NewScanner(file) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + file.Close() + + // Write back only first line (simulating partial write) + corruptedFile, err := os.Create(corruptedPath) + if err != nil { + t.Fatalf("Failed to create corrupted file: %v", err) + } + corruptedFile.WriteString(lines[0] + "\n") + corruptedFile.Close() + + // Verify countIssuesInJSONL detects the corruption + count, err := countIssuesInJSONL(corruptedPath) + if err != nil { + t.Fatalf("Failed to count corrupted file: %v", err) + } + if count != 1 { + t.Errorf("Expected 1 line in corrupted file, got %d", count) + } + }) }