Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,26 @@ because it turns other people's test suites red.

### Added

- **`csv` takes `columns`, so a table can be as narrow or as wide as the thing
you are testing.** Two to 32768, and six by default - which is the six
columns this tool has always written, so a `.csv` you already generate does
not change by a byte.

Fewer than six drops them from the middle: `id` stays first and
`description` stays last, because that is the field stretched to reach the
exact size you asked for. More than six adds `field_7`, `field_8` and so on
in front of the description.

**Above 16384 columns a spreadsheet quietly keeps the first 16384 and drops
the rest.** Measured with LibreOffice Calc: 16384 comes back whole, 16385
comes back with one column missing and no warning anywhere. The ceiling here
is deliberately past that, so you can build the set either side of the line
rather than only the last file that survives it.

The smallest file moves with the setting, the way it already does for row
endings and quoting - 36 B at two columns, 115 B at six, 5017 B at 256.
`tfg formats csv` prints what a given table needs.

- **A `targz` manifest says what its entries claim about themselves.** Two new
keys on the file entry, `entry_mode` and `entry_owner`, written every run
rather than only when you ask for them, so a harness never has to read a
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ recipe. `tfg formats <id>` prints the allowed range or list for each:
| `docx` | `paragraphs` |
| `xlsx` | `rows`, `columns` |
| `pptx` | `slides` |
| `csv` | `delimiter`, `line_ending`, `header`, `quote_style` |
| `csv` | `delimiter`, `line_ending`, `header`, `quote_style`, `columns` |
| `log` | `entry_format`, `timestamps`, `rate`, `methods`, `status_mix`, `level_mix`, `ip_version`, `line_ending` |
| `json`, `xml`, `html`, `md`, `txt`, `svg` | none |

Expand Down
131 changes: 86 additions & 45 deletions internal/format/csvfile/csv.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,25 +53,51 @@ const (
// Bounding it rather than guessing is what lets the closing row reach its
// length whatever row number it lands on.
maxRowDigits = 19

// fixedBeforeEnding is every byte of a row except the row number, the name
// (which also forms the address), the description, the quotes and the row
// ending. A constant expression, so it cannot drift away from the template
// above.
//
// The five separators count one byte each, which is a fact about the
// separators offered rather than an assumption: every one of them is a
// single byte, and dialect.go says so where they are declared.
fixedBeforeEnding = 5 /* separators */ + len(emailDomain) + amountWidth +
len(createdDate)
)

// fixedWidth is fixedBeforeEnding plus the quotes and the row ending, both of
// which the dialect decides. A CRLF row costs one byte more than an LF one on
// every row, and quoting every field costs two bytes per column, which is why
// the minimum moves with either setting.
// widestColumn is the most bytes the column at this position can take, for any
// draw. The description is not here: it is always last and is empty in the row
// the floor is made of.
//
// Measured rather than guessed only in the sense that every number in it is
// read off the template above. The positions match columnNamesFor, and a
// column past the ones this format started with holds a word.
func widestColumn(at int) int {
switch at {
case 0:
return maxRowDigits
case 1:
return longestWord
case 2:
return longestWord + len(emailDomain)
case 3:
return amountWidth
case 4:
return len(createdDate)
default:
return longestWord
}
}

// fixedWidth is the whole row except the description: every leading column at
// its widest, the separators between all of them, the quotes and the row
// ending.
//
// Three settings move it. A CRLF row costs one byte more than an LF one on
// every row, quoting every field costs two bytes a column, and the number of
// columns moves every term at once - which is why the minimum moves with any
// of the three.
//
// The separators count one byte each, which is a fact about the separators
// offered rather than an assumption: every one of them is a single byte, and
// dialect.go says so where they are declared.
func fixedWidth(d dialect) int64 {
return int64(fixedBeforeEnding + d.quotes.quoteBytes() + len(d.eol))
total := int64(d.columns-1) /* separators */ + int64(len(d.eol)) +
int64(d.quotes.quoteBytes(d.columns))
for at := 0; at < d.columns-1; at++ {
total += int64(widestColumn(at))
}
return total
}

func init() {
Expand Down Expand Up @@ -150,7 +176,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
"separator": string(d.sep),
"header": d.header,
"quote_style": d.quotes.id,
"columns": len(columnNames),
"columns": d.columns,
// Stated even though it is always false here, so a test can assert
// on it without knowing which formats carry a label internally.
format.PropertyLabelEmbedded: false,
Expand Down Expand Up @@ -202,10 +228,10 @@ type rows struct {
}

// Shortest is the smallest row this builder can close a file with: the widest
// row number, the longest name in both the name and the address, and an empty
// description. It has to hold for every draw rather than for the lucky one.
// row number, the longest word wherever a word goes, and an empty description.
// It has to hold for every draw rather than for the lucky one.
func (r *rows) Shortest() int64 {
return int64(maxRowDigits+2*longestWord) + fixedWidth(r.dia)
return fixedWidth(r.dia)
}

func (r *rows) Append(dst []byte, rng *rand.Rand) []byte {
Expand Down Expand Up @@ -244,36 +270,51 @@ func (r *rows) append(dst []byte, rng *rand.Rand, want int64) []byte {
sep := r.dia.sep
q := r.dia.quotes

dst = q.mark(dst)
dst = strconv.AppendInt(dst, r.next, 10)
dst = q.mark(dst)
dst = append(dst, sep)
dst = q.mark(dst)
dst = append(dst, name...)
dst = q.mark(dst)
dst = append(dst, sep)
dst = q.mark(dst)
dst = append(dst, name...)
dst = append(dst, emailDomain...)
dst = q.mark(dst)
dst = append(dst, sep)
dst = q.mark(dst)
dst = strconv.AppendInt(dst, int64(whole), 10)
dst = append(dst, '.')
if cents < 10 {
dst = append(dst, '0')
// Every column but the description, in the order columnNamesFor names
// them. The draws above happen once and outside this loop, which is what
// keeps a six column table byte for byte what it always was: a narrower
// table draws exactly the same values and writes fewer of them, and a wider
// one draws its extra words only when it reaches them.
for at := 0; at < r.dia.columns-1; at++ {
dst = q.mark(dst)
dst = r.appendValue(dst, rng, at, name, whole, cents)
dst = q.mark(dst)
dst = append(dst, sep)
}
dst = strconv.AppendInt(dst, int64(cents), 10)
dst = q.mark(dst)
dst = append(dst, sep)
dst = q.mark(dst)
dst = append(dst, createdDate...)
dst = q.mark(dst)
dst = append(dst, sep)

return r.appendDescription(dst, rng, want, int64(len(dst)-start))
}

// appendValue writes the value of the column at this position.
//
// The name is drawn once by the caller and used twice, in the name column and
// inside the address, which is how this table has always read. The positions
// match widestColumn above, and the two are the pair that has to stay in step -
// a value wider than its column would make the closing row overshoot a length
// it was handed.
func (r *rows) appendValue(dst []byte, rng *rand.Rand, at int, name string, whole, cents int) []byte {
switch at {
case 0:
return strconv.AppendInt(dst, r.next, 10)
case 1:
return append(dst, name...)
case 2:
dst = append(dst, name...)
return append(dst, emailDomain...)
case 3:
dst = strconv.AppendInt(dst, int64(whole), 10)
dst = append(dst, '.')
if cents < 10 {
dst = append(dst, '0')
}
return strconv.AppendInt(dst, int64(cents), 10)
case 4:
return append(dst, createdDate...)
default:
return append(dst, words[rng.IntN(len(words))]...)
}
}

// appendDescription writes the last field and ends the row.
//
// want below zero means a natural row, any other value is the exact length the
Expand Down
121 changes: 109 additions & 12 deletions internal/format/csvfile/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ package csvfile

import (
"bytes"
"fmt"
"strconv"
"strings"

"github.com/donislawdev/TestingFilesGenerator/internal/format"
Expand All @@ -26,6 +28,37 @@ const (
LineEnding = "line_ending"
Header = "header"
QuoteStyle = "quote_style"
Columns = "columns"
)

const (
// minColumns is two, and it is a decision rather than a limit of the
// writer. A table of one column has no separator in it anywhere, so a file
// written with the wrong one is byte for byte a file written with the right
// one - our own structural check refuses such a file for exactly that
// reason, measured 2026-09-03. It would also leave the delimiter setting
// doing nothing at all, which this project treats as a refusal rather than
// a silence. And a single column of values is a txt file, which this tool
// already writes.
minColumns = 2

// defaultColumns is the six this format has always had. Leaving it alone
// produces the same bytes it always did.
defaultColumns = 6

// maxColumns is deliberately ABOVE the width of a spreadsheet, and that is
// the whole reason for the number.
//
// Measured 2026-09-03 with LibreOffice Calc 26.2.5.2 headless: a table of
// 16384 columns comes back whole, and one of 16385 comes back with 16384 -
// the last column is dropped without a word. 32768 columns came back as
// 16384 too, in three seconds, so the clamp is quiet and cheap rather than
// an error anybody would see.
//
// A tester needs to stand on BOTH sides of that line, because building the
// set around a boundary is what this tool is for. Stopping at 16384 would
// offer the last table that survives and not the first that does not.
maxColumns = 32768
)

// delimiters are the separators offered, by name rather than by character.
Expand Down Expand Up @@ -118,12 +151,13 @@ func (q quoting) wraps(description []byte, sep byte) bool {
//
// That row has an empty description, and an empty field carries no separator,
// so minimal leaves it bare and pays nothing. Only "all" pays, and it pays for
// every column.
func (q quoting) quoteBytes() int {
// every column - so the number of them is asked for rather than assumed to be
// the six this format started with.
func (q quoting) quoteBytes(columns int) int {
if !q.everyField {
return 0
}
return 2 * len(columnNames)
return 2 * columns
}

// mark writes the quote that wraps a plain field, which only "all" has.
Expand All @@ -146,6 +180,8 @@ type dialect struct {
header bool

quotes quoting

columns int
}

func defaultDialect() dialect {
Expand All @@ -156,10 +192,11 @@ func defaultDialect() dialect {
eol: "\n",
header: true,
quotes: quoteStyles["minimal"],
columns: defaultColumns,
}
}

// parseDialect reads the three settings.
// parseDialect reads the five settings.
//
// A value that is not in the declared set has already been refused by the
// registry, which checks it against the declaration for every format at once.
Expand Down Expand Up @@ -211,17 +248,70 @@ func parseDialect(props map[string]string) (dialect, error) {
d.quotes = q
}

n, err := columnsFrom(props, d.columns)
if err != nil {
return dialect{}, err
}
d.columns = n

return d, nil
}

// columnsFrom reads the one setting here that is a number rather than a word.
//
// Its own function because the four above are each three lines and this is
// seven, and because parseDialect had reached the point where one more setting
// took it past the branching the code shape guard allows. Splitting it was the
// answer rather than raising that number, which is the rule this project keeps:
// the crowd counter only goes down.
func columnsFrom(props map[string]string, fallback int) (int, error) {
v, ok := props[Columns]
if !ok || v == "" {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, badValue(Columns, v, "it has to be a whole number")
}
if n < minColumns || n > maxColumns {
return 0, badValue(Columns, v,
fmt.Sprintf("it has to be between %d and %d", minColumns, maxColumns))
}
return n, nil
}

func badValue(key, val, why string) error {
return &format.PropertyValueError{Format: "csv", Key: key, Value: val, Reason: why}
}

// columnNames are the columns this table has always had. The last one is the
// description, which is the field the closing row stretches to reach an exact
// length, so it stays last whatever else changes.
var columnNames = []string{"id", "name", "email", "amount", "created", "description"}
// baseColumns are the columns this table has always had, without the
// description. It is last in every table and is not in this list for that
// reason - it is the field the closing row stretches to reach an exact length,
// so it stays last whatever else changes.
var baseColumns = []string{"id", "name", "email", "amount", "created"}

// columnNamesFor is the header of a table this wide.
//
// The leading names are taken from baseColumns in order and the description
// closes the row, so asking for fewer columns drops them from the MIDDLE
// rather than from either end. Six gives exactly the six this format has
// always written, which is what keeps the default byte for byte what it was.
//
// Past those, the names say where they are rather than what they hold. Column
// types - a name, an address, a telephone number - are a separate piece of
// work with its own place in the backlog, and inventing half of it here would
// be the harder half to take back.
func columnNamesFor(columns int) []string {
out := make([]string, 0, columns)
for i := 0; i < columns-1; i++ {
if i < len(baseColumns) {
out = append(out, baseColumns[i])
continue
}
out = append(out, "field_"+strconv.Itoa(i+1))
}
return append(out, "description")
}

// headerLine is the first row, built from the dialect rather than written out,
// so the separator in it cannot disagree with the separator in the rows below.
Expand All @@ -233,12 +323,13 @@ var columnNames = []string{"id", "name", "email", "amount", "created", "descript
// well, so a header left bare would be the one row disagreeing with the
// setting that produced it.
func (d dialect) headerLine() string {
names := columnNames
names := columnNamesFor(d.columns)
if d.quotes.everyField {
names = make([]string, 0, len(columnNames))
for _, column := range columnNames {
names = append(names, string(quoteMark)+column+string(quoteMark))
quoted := make([]string, 0, len(names))
for _, column := range names {
quoted = append(quoted, string(quoteMark)+column+string(quoteMark))
}
names = quoted
}
return strings.Join(names, string(d.sep)) + d.eol
}
Expand Down Expand Up @@ -276,5 +367,11 @@ func properties() []format.Property {
Choices: quoteStyleIDs, Default: "minimal",
Detail: "Which fields carry quotes. With none the description stops carrying separators as well, because an unquoted field cannot hold one.",
},
{
Name: Columns, Kind: format.PropertyInt,
Min: minColumns, Max: maxColumns, Unit: "columns",
Default: strconv.Itoa(defaultColumns),
Detail: "How many columns each row has. Above 16384 a spreadsheet may show only the first 16384 and drop the rest without a word.",
},
}
}
Loading
Loading