From 553e97f5cc6e179b1b6ebe5e3f8acc833fbce9e1 Mon Sep 17 00:00:00 2001 From: Ramkumar Ethiraj <64294490+rmkr-dev@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:58:28 +0000 Subject: [PATCH] Add data transform CLI scaffold Introduce YAML/JSON/CSV conversion commands, fixtures, unit tests, and CI. --- .github/workflows/ci.yml | 22 +++++ .gitignore | 11 +++ CONTRIBUTING.md | 21 +++++ LICENSE | 21 +++++ package-lock.json | 57 +++++++++++++ package.json | 46 +++++++++++ src/cli.js | 146 +++++++++++++++++++++++++++++++++ src/convert.js | 98 ++++++++++++++++++++++ src/csv.js | 165 ++++++++++++++++++++++++++++++++++++++ src/index.js | 14 ++++ test/cli.test.js | 88 ++++++++++++++++++++ test/convert.test.js | 89 ++++++++++++++++++++ test/csv.test.js | 50 ++++++++++++ test/fixtures/sample.csv | 4 + test/fixtures/sample.json | 12 +++ test/fixtures/sample.yaml | 8 ++ 16 files changed, 852 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.js create mode 100644 src/convert.js create mode 100644 src/csv.js create mode 100644 src/index.js create mode 100644 test/cli.test.js create mode 100644 test/convert.test.js create mode 100644 test/csv.test.js create mode 100644 test/fixtures/sample.csv create mode 100644 test/fixtures/sample.json create mode 100644 test/fixtures/sample.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0626a11 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f776ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +*.tgz +coverage/ +.nyc_output/ +dist/ +.idea/ +.vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3f5eef7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +## Setup + +- Use Node 20 or newer +- Install dependencies with the package manager +- Run the test script from package.json + +## Guidelines + +- Keep the CLI ESM-only (type module). +- Prefer small, focused changes with tests for round-trips and edge cases. +- Do not add company names or personal contacts in committed files. +- Match existing style: errors on stderr, exit code 1 on failure, UTF-8 I/O. + +## Pull requests + +1. Fork and branch from main. +2. Add or update tests under test/. +3. Ensure the test script passes. +4. Open a PR with a short summary of the change and why. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a085a9c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rmkr-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d6b8270 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,57 @@ +{ + "name": "data-transform-cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "data-transform-cli", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "data-transform": "src/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "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/sponsors/puzrin" + }, + { + "type": "github", + "url": "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/sponsors/nodeca" + } + ], + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..63f0a06 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "data-transform-cli", + "version": "0.1.0", + "description": "Streaming-friendly CLI for YAML ↔ JSON ↔ CSV transforms for pipelines and shell use", + "type": "module", + "bin": { + "data-transform": "./src/cli.js" + }, + "main": "./src/index.js", + "exports": { + ".": "./src/index.js" + }, + "files": [ + "src", + "LICENSE", + "README.md" + ], + "scripts": { + "test": "node --test test/*.test.js", + "start": "node src/cli.js" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "cli", + "yaml", + "json", + "csv", + "transform", + "pipeline" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "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/rmkr-dev/data-transform-cli.git" + }, + "bugs": { + "url": "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/rmkr-dev/data-transform-cli/issues" + }, + "homepage": "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/rmkr-dev/data-transform-cli#readme", + "dependencies": { + "commander": "^12.1.0", + "js-yaml": "^4.1.0" + } +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..77abf58 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; +import { stdin as stdinStream } from 'node:process'; +import { Command } from 'commander'; +import { + convert, + detectFormat, + inferFormat, + parse, + serialize, +} from './convert.js'; + +const program = new Command(); + +program + .name('data-transform') + .description('YAML ↔ JSON ↔ CSV transforms for pipelines and shell use') + .version('0.1.0'); + +/** + * Read all of stdin as UTF-8 text. + * @returns {Promise} + */ +function readStdin() { + return new Promise((resolve, reject) => { + const chunks = []; + stdinStream.setEncoding('utf8'); + stdinStream.on('data', (c) => chunks.push(c)); + stdinStream.on('end', () => resolve(chunks.join(''))); + stdinStream.on('error', reject); + }); +} + +/** + * Load input from file path or stdin when path is "-" / omitted. + * @param {string | undefined} file + * @returns {Promise<{ text: string, name?: string }>} + */ +async function loadInput(file) { + if (!file || file === '-') { + if (stdinStream.isTTY) { + throw new Error('No input file and stdin is a TTY. Pass a file or pipe data.'); + } + return { text: await readStdin(), name: undefined }; + } + return { text: readFileSync(file, 'utf8'), name: file }; +} + +/** + * Resolve source format from flag, filename, or content. + * @param {string} text + * @param {string | undefined} name + * @param {string | undefined} fromFlag + */ +function resolveFrom(text, name, fromFlag) { + if (fromFlag) return fromFlag; + return detectFormat(name) ?? inferFormat(text); +} + +/** + * Write output to file or stdout. + * @param {string} out + * @param {string | undefined} outputPath + */ +function writeOutput(out, outputPath) { + if (outputPath && outputPath !== '-') { + writeFileSync(outputPath, out, 'utf8'); + } else { + process.stdout.write(out); + } +} + +/** + * Shared options for transform commands. + * @param {Command} cmd + */ +function addIOOptions(cmd) { + return cmd + .argument('[file]', 'input file (default: stdin)') + .option('-o, --output ', 'write to file instead of stdout') + .option('-f, --from ', 'input format: json | yaml | csv'); +} + +function makeTransformCommand(name, description, toFormat) { + const cmd = program.command(name).description(description); + addIOOptions(cmd); + if (toFormat === 'json') { + cmd.option('--minify', 'emit compact JSON', false); + } + cmd.action(async (file, opts) => { + try { + const { text, name } = await loadInput(file); + const from = resolveFrom(text, name, opts.from); + const out = convert(text, from, toFormat, { + pretty: !opts.minify, + minify: Boolean(opts.minify), + }); + writeOutput(out, opts.output); + } catch (err) { + console.error(`error: ${err.message}`); + process.exitCode = 1; + } + }); +} + +makeTransformCommand('to-json', 'Convert input to JSON', 'json'); +makeTransformCommand('to-yaml', 'Convert input to YAML', 'yaml'); +makeTransformCommand('to-csv', 'Convert input to CSV (array of objects/rows)', 'csv'); + +program + .command('pretty') + .description('Pretty-print JSON (or convert to pretty JSON)') + .argument('[file]', 'input file (default: stdin)') + .option('-o, --output ', 'write to file instead of stdout') + .option('-f, --from ', 'input format: json | yaml | csv') + .action(async (file, opts) => { + try { + const { text, name } = await loadInput(file); + const from = resolveFrom(text, name, opts.from); + const data = parse(text, from); + writeOutput(serialize(data, 'json', { pretty: true }), opts.output); + } catch (err) { + console.error(`error: ${err.message}`); + process.exitCode = 1; + } + }); + +program + .command('minify') + .description('Minify JSON (or convert to compact JSON)') + .argument('[file]', 'input file (default: stdin)') + .option('-o, --output ', 'write to file instead of stdout') + .option('-f, --from ', 'input format: json | yaml | csv') + .action(async (file, opts) => { + try { + const { text, name } = await loadInput(file); + const from = resolveFrom(text, name, opts.from); + const data = parse(text, from); + writeOutput(serialize(data, 'json', { minify: true }), opts.output); + } catch (err) { + console.error(`error: ${err.message}`); + process.exitCode = 1; + } + }); + +program.parseAsync(process.argv); diff --git a/src/convert.js b/src/convert.js new file mode 100644 index 0000000..e5a2c4e --- /dev/null +++ b/src/convert.js @@ -0,0 +1,98 @@ +import yaml from 'js-yaml'; +import { parseCsv, stringifyCsv } from './csv.js'; + +/** + * Detect format from filename extension. + * @param {string} [filename] + * @returns {'json' | 'yaml' | 'csv' | null} + */ +export function detectFormat(filename) { + if (!filename) return null; + const lower = filename.toLowerCase(); + if (lower.endsWith('.json')) return 'json'; + if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'; + if (lower.endsWith('.csv')) return 'csv'; + return null; +} + +/** + * Parse input text as the given format. + * @param {string} text + * @param {'json' | 'yaml' | 'csv'} format + * @returns {unknown} + */ +export function parse(text, format) { + switch (format) { + case 'json': + return JSON.parse(text); + case 'yaml': + return yaml.load(text); + case 'csv': + return parseCsv(text); + default: + throw new Error(`Unsupported parse format: ${format}`); + } +} + +/** + * Serialize data to the given format. + * @param {unknown} data + * @param {'json' | 'yaml' | 'csv'} format + * @param {{ pretty?: boolean, minify?: boolean }} [opts] + * @returns {string} + */ +export function serialize(data, format, opts = {}) { + switch (format) { + case 'json': { + if (opts.minify) return JSON.stringify(data); + const space = opts.pretty === false ? 0 : 2; + return JSON.stringify(data, null, space) + (space ? '\n' : ''); + } + case 'yaml': + return yaml.dump(data, { lineWidth: -1, noRefs: true }); + case 'csv': { + if (!Array.isArray(data)) { + throw new Error('CSV output requires an array (of objects or rows)'); + } + return stringifyCsv(data); + } + default: + throw new Error(`Unsupported serialize format: ${format}`); + } +} + +/** + * Convert between formats. + * @param {string} text + * @param {'json' | 'yaml' | 'csv'} from + * @param {'json' | 'yaml' | 'csv'} to + * @param {{ pretty?: boolean, minify?: boolean }} [opts] + * @returns {string} + */ +export function convert(text, from, to, opts = {}) { + const data = parse(text, from); + return serialize(data, to, opts); +} + +/** + * Infer source format from content heuristics when extension is unknown. + * @param {string} text + * @returns {'json' | 'yaml' | 'csv'} + */ +export function inferFormat(text) { + const trimmed = text.trimStart(); + if (!trimmed) return 'json'; + if (trimmed[0] === '{' || trimmed[0] === '[') { + try { + JSON.parse(text); + return 'json'; + } catch { + // fall through + } + } + const firstLine = trimmed.split(/\r?\n/, 1)[0] ?? ''; + if (firstLine.includes(',') && !trimmed.startsWith('---') && !/:\s/.test(firstLine)) { + return 'csv'; + } + return 'yaml'; +} diff --git a/src/csv.js b/src/csv.js new file mode 100644 index 0000000..538af58 --- /dev/null +++ b/src/csv.js @@ -0,0 +1,165 @@ +/** + * Simple, robust CSV parse/stringify (RFC 4180-ish). + * Supports quoted fields, escaped quotes, and newlines inside quotes. + */ + +/** + * Parse a CSV string into an array of row objects (header row required) + * or an array of arrays if noHeader is true. + * @param {string} text + * @param {{ noHeader?: boolean }} [opts] + * @returns {object[] | string[][]} + */ +export function parseCsv(text, opts = {}) { + const rows = parseCsvRows(text); + if (rows.length === 0) return []; + + if (opts.noHeader) return rows; + + const headers = rows[0].map((h) => h.trim()); + return rows.slice(1).map((cells) => { + const obj = {}; + for (let i = 0; i < headers.length; i++) { + obj[headers[i]] = cells[i] ?? ''; + } + return obj; + }); +} + +/** + * Parse CSV into raw rows (arrays of strings). + * @param {string} text + * @returns {string[][]} + */ +export function parseCsvRows(text) { + const rows = []; + let row = []; + let field = ''; + let i = 0; + let inQuotes = false; + const s = String(text).replace(/^\uFEFF/, ''); + + while (i < s.length) { + const c = s[i]; + + if (inQuotes) { + if (c === '"') { + if (s[i + 1] === '"') { + field += '"'; + i += 2; + continue; + } + inQuotes = false; + i += 1; + continue; + } + field += c; + i += 1; + continue; + } + + if (c === '"') { + inQuotes = true; + i += 1; + continue; + } + + if (c === ',') { + row.push(field); + field = ''; + i += 1; + continue; + } + + if (c === '\r') { + i += 1; + continue; + } + + if (c === '\n') { + row.push(field); + rows.push(row); + row = []; + field = ''; + i += 1; + continue; + } + + field += c; + i += 1; + } + + // trailing field / row (including empty trailing newline handled above) + if (field.length > 0 || row.length > 0 || (s.length > 0 && !s.endsWith('\n') && !s.endsWith('\r'))) { + row.push(field); + rows.push(row); + } else if (row.length > 0) { + row.push(field); + rows.push(row); + } + + // Drop a single trailing empty row produced by a final newline + if (rows.length > 0) { + const last = rows[rows.length - 1]; + if (last.length === 1 && last[0] === '' && text.match(/\r?\n$/)) { + rows.pop(); + } + } + + return rows; +} + +/** + * Escape a single CSV field. + * @param {unknown} value + * @returns {string} + */ +export function escapeCsvField(value) { + if (value === null || value === undefined) return ''; + const str = typeof value === 'object' ? JSON.stringify(value) : String(value); + if (/[",\r\n]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +/** + * Stringify data to CSV. + * Accepts array of objects (uses union of keys as header) or array of arrays. + * @param {object[] | unknown[][]} data + * @param {{ headers?: string[] }} [opts] + * @returns {string} + */ +export function stringifyCsv(data, opts = {}) { + if (!Array.isArray(data) || data.length === 0) { + if (opts.headers?.length) { + return opts.headers.map(escapeCsvField).join(',') + '\n'; + } + return ''; + } + + const first = data[0]; + const isObjects = first !== null && typeof first === 'object' && !Array.isArray(first); + + if (isObjects) { + const headers = + opts.headers ?? + Array.from( + data.reduce((set, row) => { + Object.keys(row ?? {}).forEach((k) => set.add(k)); + return set; + }, new Set()) + ); + const lines = [headers.map(escapeCsvField).join(',')]; + for (const row of data) { + lines.push(headers.map((h) => escapeCsvField(row?.[h])).join(',')); + } + return lines.join('\n') + '\n'; + } + + // array of arrays + const lines = data.map((row) => + (Array.isArray(row) ? row : [row]).map(escapeCsvField).join(',') + ); + return lines.join('\n') + '\n'; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..b601fa6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,14 @@ +export { + detectFormat, + parse, + serialize, + convert, + inferFormat, +} from './convert.js'; + +export { + parseCsv, + parseCsvRows, + stringifyCsv, + escapeCsvField, +} from './csv.js'; diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..21e014e --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,88 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const cli = join(root, 'src', 'cli.js'); +const fixture = (name) => join(__dirname, 'fixtures', name); + +/** + * Run the CLI with args and optional stdin. + * @param {string[]} args + * @param {string} [stdinText] + * @returns {Promise<{ code: number|null, stdout: string, stderr: string }>} + */ +function runCli(args, stdinText) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, ...args], { + cwd: root, + env: process.env, + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + if (stdinText !== undefined) { + child.stdin.end(stdinText); + } else { + child.stdin.end(); + } + }); +} + +describe('cli', () => { + it('to-json from yaml file', async () => { + const { code, stdout, stderr } = await runCli(['to-json', fixture('sample.yaml')]); + assert.equal(code, 0, stderr); + const data = JSON.parse(stdout); + assert.equal(data[0].name, 'alpha'); + }); + + it('to-yaml from json file', async () => { + const { code, stdout, stderr } = await runCli(['to-yaml', fixture('sample.json')]); + assert.equal(code, 0, stderr); + assert.match(stdout, /name:\s*alpha/); + }); + + it('to-csv from json via stdin', async () => { + const input = readFileSync(fixture('sample.json'), 'utf8'); + const { code, stdout, stderr } = await runCli(['to-csv', '-f', 'json'], input); + assert.equal(code, 0, stderr); + assert.match(stdout, /^id,name,note/m); + assert.match(stdout, /hello, world/); + }); + + it('pretty formats compact json from stdin', async () => { + const { code, stdout, stderr } = await runCli(['pretty', '-f', 'json'], '{"a":1}'); + assert.equal(code, 0, stderr); + assert.equal(stdout, '{\n "a": 1\n}\n'); + }); + + it('minify collapses json', async () => { + const { code, stdout, stderr } = await runCli( + ['minify', '-f', 'json'], + '{\n "a": 1\n}\n' + ); + assert.equal(code, 0, stderr); + assert.equal(stdout, '{"a":1}'); + }); + + it('to-json --minify emits compact output', async () => { + const { code, stdout, stderr } = await runCli([ + 'to-json', + '--minify', + fixture('sample.yaml'), + ]); + assert.equal(code, 0, stderr); + assert.ok(!stdout.includes('\n ')); + assert.ok(JSON.parse(stdout)); + }); +}); diff --git a/test/convert.test.js b/test/convert.test.js new file mode 100644 index 0000000..bdc5257 --- /dev/null +++ b/test/convert.test.js @@ -0,0 +1,89 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + convert, + detectFormat, + inferFormat, + parse, + serialize, +} from '../src/convert.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = (name) => readFileSync(join(__dirname, 'fixtures', name), 'utf8'); + +describe('detectFormat / inferFormat', () => { + it('detects by extension', () => { + assert.equal(detectFormat('a.json'), 'json'); + assert.equal(detectFormat('a.YAML'), 'yaml'); + assert.equal(detectFormat('a.yml'), 'yaml'); + assert.equal(detectFormat('a.csv'), 'csv'); + assert.equal(detectFormat('a.txt'), null); + }); + + it('infers from content', () => { + assert.equal(inferFormat('{"a":1}'), 'json'); + assert.equal(inferFormat('[1,2]'), 'json'); + assert.equal(inferFormat('a: 1\nb: 2\n'), 'yaml'); + assert.equal(inferFormat('id,name\n1,a\n'), 'csv'); + }); +}); + +describe('convert round-trips', () => { + it('json → yaml → json preserves data', () => { + const jsonText = fixture('sample.json'); + const data = parse(jsonText, 'json'); + const yamlText = serialize(data, 'yaml'); + const back = parse(yamlText, 'yaml'); + assert.deepEqual(back, data); + }); + + it('yaml → json → yaml preserves data', () => { + const yamlText = fixture('sample.yaml'); + const data = parse(yamlText, 'yaml'); + const jsonText = serialize(data, 'json', { pretty: true }); + const back = parse(jsonText, 'json'); + assert.deepEqual(back, data); + }); + + it('json → csv → json preserves stringified fields', () => { + // CSV is stringly-typed; compare via CSV object shape + const data = parse(fixture('sample.json'), 'json'); + const asCsvObjects = data.map((row) => ({ + id: String(row.id), + name: row.name, + note: row.note, + })); + const csv = serialize(asCsvObjects, 'csv'); + const back = parse(csv, 'csv'); + assert.deepEqual(back, asCsvObjects); + }); + + it('csv fixture parses and re-serializes', () => { + const csvText = fixture('sample.csv'); + const data = parse(csvText, 'csv'); + const again = serialize(data, 'csv'); + assert.deepEqual(parse(again, 'csv'), data); + }); + + it('convert() helper json to yaml', () => { + const out = convert('{"x":1}', 'json', 'yaml'); + assert.match(out, /x:\s*1/); + }); + + it('pretty and minify json', () => { + const data = { a: 1, b: [2] }; + const pretty = serialize(data, 'json', { pretty: true }); + const mini = serialize(data, 'json', { minify: true }); + assert.ok(pretty.includes('\n')); + assert.equal(mini, '{"a":1,"b":[2]}'); + assert.deepEqual(JSON.parse(pretty), data); + assert.deepEqual(JSON.parse(mini), data); + }); + + it('csv serialize rejects non-arrays', () => { + assert.throws(() => serialize({ a: 1 }, 'csv'), /array/i); + }); +}); diff --git a/test/csv.test.js b/test/csv.test.js new file mode 100644 index 0000000..667d4af --- /dev/null +++ b/test/csv.test.js @@ -0,0 +1,50 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseCsv, parseCsvRows, stringifyCsv, escapeCsvField } from '../src/csv.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = (name) => readFileSync(join(__dirname, 'fixtures', name), 'utf8'); + +describe('csv', () => { + it('escapeCsvField quotes special characters', () => { + assert.equal(escapeCsvField('plain'), 'plain'); + assert.equal(escapeCsvField('a,b'), '"a,b"'); + assert.equal(escapeCsvField('say "hi"'), '"say ""hi"""'); + assert.equal(escapeCsvField('a\nb'), '"a\nb"'); + assert.equal(escapeCsvField(null), ''); + }); + + it('parses quoted commas and newlines', () => { + const rows = parseCsv(fixture('sample.csv')); + assert.equal(rows.length, 2); + assert.equal(rows[0].name, 'alpha'); + assert.equal(rows[0].note, 'hello, world'); + assert.equal(rows[1].note, 'line1\nline2'); + }); + + it('round-trips object rows', () => { + const data = [ + { id: '1', name: 'alpha', note: 'hello, world' }, + { id: '2', name: 'beta', note: 'line1\nline2' }, + ]; + const csv = stringifyCsv(data); + const parsed = parseCsv(csv); + assert.deepEqual(parsed, data); + }); + + it('parses raw rows without header', () => { + const rows = parseCsvRows('a,b\n1,2\n'); + assert.deepEqual(rows, [ + ['a', 'b'], + ['1', '2'], + ]); + }); + + it('handles empty input', () => { + assert.deepEqual(parseCsv(''), []); + assert.equal(stringifyCsv([]), ''); + }); +}); diff --git a/test/fixtures/sample.csv b/test/fixtures/sample.csv new file mode 100644 index 0000000..6c74fcf --- /dev/null +++ b/test/fixtures/sample.csv @@ -0,0 +1,4 @@ +id,name,note +1,alpha,"hello, world" +2,beta,"line1 +line2" diff --git a/test/fixtures/sample.json b/test/fixtures/sample.json new file mode 100644 index 0000000..2cd47f7 --- /dev/null +++ b/test/fixtures/sample.json @@ -0,0 +1,12 @@ +[ + { + "id": 1, + "name": "alpha", + "note": "hello, world" + }, + { + "id": 2, + "name": "beta", + "note": "line1\nline2" + } +] diff --git a/test/fixtures/sample.yaml b/test/fixtures/sample.yaml new file mode 100644 index 0000000..0135234 --- /dev/null +++ b/test/fixtures/sample.yaml @@ -0,0 +1,8 @@ +- id: 1 + name: alpha + note: hello, world +- id: 2 + name: beta + note: |- + line1 + line2