src / tools / dataTools.ts
src / tools / dataTools.ts
/**
* Data Format Tools
* Provides CSV/JSON/YAML conversion, schema validation, merging, and config diffing.
*/
import * as csvParserModule from 'csv-parser';
const csvParser = (csvParserModule as any).default || csvParserModule;
import yaml from 'js-yaml';
import { z } from 'zod';
import fs from 'fs/promises';
import * as fsNode from 'fs';
import path from 'path';
import { validatePath } from '../pathSecurity';
function safePath(value: string, workspaceRoot: string): string {
return validatePath(workspaceRoot, workspaceRoot, value);
}
// Zod schemas for validation
const csvToJsonSchema = z.object({
csv_content: z.string().min(1),
});
const jsonToCsvSchema = z.object({
json_data: z.any(),
options: z.object({
headers: z.array(z.string()).optional(),
delimiter: z.string().optional().default(','),
}).optional(),
});
const csvValidateSchema = z.object({
file_path: z.string().min(1),
schema: z.any(),
});
const csvMergeSchema = z.object({
files: z.array(z.string().min(1)),
output_path: z.string().min(1),
});
const yamlParseSchema = z.object({
path: z.string().min(1),
});
const yamlGenerateSchema = z.object({
data: z.any(),
path: z.string().min(1),
});
const configDiffSchema = z.object({
file1: z.string().min(1),
file2: z.string().min(1),
});
/**
* Convert CSV content to JSON.
*/
export async function csv_to_json_stream(csv_content: string): Promise<{ rows: any[]; headers: string[] }> {
const validated = csvToJsonSchema.parse({ csv_content });
return new Promise((resolve, reject) => {
const rows: any[] = [];
let headers: string[] = [];
csvParser()(validated.csv_content)
.on('headers', (h: string[]) => {
headers = h;
})
.on('data', (row: Record<string, any>) => {
rows.push(row);
})
.on('end', () => {
resolve({ rows, headers });
})
.on('error', (err: Error) => {
reject(new Error(`CSV parsing failed: ${err.message}`));
});
});
}
/**
* Convert JSON data to CSV.
*/
export async function json_to_csv_convert(json_data: any, options?: { headers?: string[]; delimiter?: string }): Promise<string> {
const validated = jsonToCsvSchema.parse({ json_data, options });
if (!Array.isArray(validated.json_data) || validated.json_data.length === 0) {
throw new Error('JSON data must be a non-empty array of objects.');
}
// Determine headers from first object or use provided headers
const headers = validated.options?.headers || Object.keys(validated.json_data[0]);
let csvContent = '';
// Header row
csvContent += headers.join(validated.options?.delimiter || ',') + '\n';
// Data rows
for (const obj of validated.json_data) {
const values = headers.map(key => {
const val = obj[key];
if (val === null || val === undefined) return '';
if (typeof val === 'string' && val.includes(',')) return `"${val}"`;
return String(val);
});
csvContent += values.join(validated.options?.delimiter || ',') + '\n';
}
return csvContent;
}
/**
* Validate CSV against a schema.
*/
export async function csv_validate_schema(file_path: string, schema: any, workspaceRoot: string = process.cwd()): Promise<{ valid: boolean; errors: Array<{ row: number; column: string; error: string }> }> {
const validated = csvValidateSchema.parse({ file_path: safePath(file_path, workspaceRoot), schema });
try {
await fs.access(validated.file_path);
} catch (error) {
throw new Error(`File not found: ${validated.file_path}`);
}
const errors: Array<{ row: number; column: string; error: string }> = [];
// Simple schema validation (check required columns and types)
if (!Array.isArray(schema.columns)) {
return { valid: false, errors: [{ row: -1, column: 'schema', error: 'Schema must have a "columns" array' }] };
}
let currentRow = 0;
try {
const stream = fsNode.createReadStream(validated.file_path);
for await (const row of stream.pipe(csvParser())) {
currentRow++;
for (const colDef of schema.columns) {
const value = row[colDef.name as keyof typeof row];
// Check required columns
if (colDef.required && (!value || value.trim() === '')) {
errors.push({ row: currentRow, column: colDef.name, error: 'Required field is empty' });
}
// Type validation
if (value !== undefined && colDef.type) {
switch (colDef.type) {
case 'string':
if (typeof value !== 'string') {
errors.push({ row: currentRow, column: colDef.name, error: `Expected string, got ${typeof value}` });
}
break;
case 'number':
if (isNaN(Number(value))) {
errors.push({ row: currentRow, column: colDef.name, error: 'Expected number' });
}
break;
case 'boolean':
if (!['true', 'false'].includes(String(value).toLowerCase())) {
errors.push({ row: currentRow, column: colDef.name, error: 'Expected boolean (true/false)' });
}
break;
}
}
}
}
return { valid: errors.length === 0, errors };
} catch (error) {
throw new Error(`CSV validation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Merge multiple CSV files by header matching.
*/
export async function csv_merge_files(files: string[], output_path: string, workspaceRoot: string = process.cwd()): Promise<{ merged_rows: number; total_columns: number }> {
const validated = csvMergeSchema.parse({
files: files.map(file => safePath(file, workspaceRoot)),
output_path: safePath(output_path, workspaceRoot),
});
try {
// Ensure output directory exists
await fs.mkdir(path.dirname(validated.output_path), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
let allHeaders: string[] = [];
const allRows: any[][] = [];
try {
for (const file of validated.files) {
await fs.access(file);
// Read CSV and extract headers/rows
const content = await fs.readFile(file, 'utf-8');
const rows = await csv_to_json_stream(content);
if (allHeaders.length === 0) {
allHeaders = rows.headers;
} else {
// Merge headers - add new columns from subsequent files
for (const header of rows.headers) {
if (!allHeaders.includes(header)) {
allHeaders.push(header);
}
}
}
// Normalize rows to have all headers
for (const row of rows.rows) {
const normalizedRow: any = {};
for (const header of allHeaders) {
normalizedRow[header] = row[header] || '';
}
allRows.push(normalizedRow);
}
}
// Write merged CSV
let csvContent = allHeaders.join(',') + '\n';
for (const row of allRows) {
const values = allHeaders.map(header => {
const val = (row as Record<string, any>)[header];
if (val === null || val === undefined) return '';
if (typeof val === 'string' && val.includes(',')) return `"${val}"`;
return String(val);
});
csvContent += values.join(',') + '\n';
}
await fs.writeFile(validated.output_path, csvContent);
return { merged_rows: allRows.length, total_columns: allHeaders.length };
} catch (error) {
throw new Error(`CSV merge failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Parse a YAML file.
*/
export async function yaml_parse_file(path_str: string, workspaceRoot: string = process.cwd()): Promise<any> {
const validated = yamlParseSchema.parse({ path: safePath(path_str, workspaceRoot) });
try {
await fs.access(validated.path);
} catch (error) {
throw new Error(`File not found: ${validated.path}`);
}
try {
const content = await fs.readFile(validated.path, 'utf-8');
return yaml.load(content);
} catch (error) {
throw new Error(`YAML parsing failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Generate a YAML file from data.
*/
export async function yaml_generate_file(data: any, path_str: string, workspaceRoot: string = process.cwd()): Promise<string> {
const validated = yamlGenerateSchema.parse({ data, path: safePath(path_str, workspaceRoot) });
try {
// Ensure output directory exists
await fs.mkdir(path.dirname(validated.path), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
try {
const yamlContent = yaml.dump(validated.data, { lineWidth: -1 }); // No line wrapping
await fs.writeFile(validated.path, yamlContent);
return validated.path;
} catch (error) {
throw new Error(`YAML generation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Deep diff two YAML files.
*/
export async function config_diff_yaml(file1: string, file2: string, workspaceRoot: string = process.cwd()): Promise<{ differences: Array<{ path: string; old_val?: any; new_val?: any }> }> {
const validated = configDiffSchema.parse({
file1: safePath(file1, workspaceRoot),
file2: safePath(file2, workspaceRoot),
});
let obj1: any;
let obj2: any;
try {
await fs.access(validated.file1);
await fs.access(validated.file2);
const content1 = await fs.readFile(validated.file1, 'utf-8');
const content2 = await fs.readFile(validated.file2, 'utf-8');
obj1 = yaml.load(content1);
obj2 = yaml.load(content2);
} catch (error) {
throw new Error(`YAML file access failed: ${error instanceof Error ? error.message : String(error)}`);
}
const differences: Array<{ path: string; old_val?: any; new_val?: any }> = [];
// Simple deep diff implementation
function compareObjects(obj1: any, obj2: any, prefix: string = '') {
if (obj1 === null || obj2 === null) {
differences.push({ path: prefix || 'root', old_val: obj1, new_val: obj2 });
return;
}
if (typeof obj1 !== typeof obj2) {
differences.push({ path: prefix || 'root', old_val: obj1, new_val: obj2 });
return;
}
if (Array.isArray(obj1)) {
if (!Array.isArray(obj2)) {
differences.push({ path: prefix || 'root', old_val: obj1, new_val: obj2 });
return;
}
const maxLen = Math.max(obj1.length, obj2.length);
for (let i = 0; i < maxLen; i++) {
compareObjects(
obj1[i] !== undefined ? obj1[i] : null,
obj2[i] !== undefined ? obj2[i] : null,
`${prefix}[${i}]`
);
}
return;
}
if (typeof obj1 === 'object') {
const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
for (const key of keys) {
const newPath = prefix ? `${prefix}.${key}` : key;
if (!(key in obj1)) {
differences.push({ path: newPath, old_val: undefined, new_val: obj2[key] });
} else if (!(key in obj2)) {
differences.push({ path: newPath, old_val: obj1[key], new_val: undefined });
} else if (obj1[key] !== obj2[key]) {
compareObjects(obj1[key], obj2[key], newPath);
}
}
} else if (obj1 !== obj2) {
differences.push({ path: prefix || 'root', old_val: obj1, new_val: obj2 });
}
}
compareObjects(obj1, obj2);
return { differences };
}
/**
* Convert YAML content to JSON string.
*/
export async function yaml_to_json(yaml_content: string): Promise<string> {
try {
const parsed = yaml.load(yaml_content);
return JSON.stringify(parsed, null, 2);
} catch (error) {
throw new Error(`YAML to JSON conversion failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Data Format Tools
* Provides CSV/JSON/YAML conversion, schema validation, merging, and config diffing.
*/
import * as csvParserModule from 'csv-parser';
const csvParser = (csvParserModule as any).default || csvParserModule;
import yaml from 'js-yaml';
import { z } from 'zod';
import fs from 'fs/promises';
import * as fsNode from 'fs';
import path from 'path';
import { validatePath } from '../pathSecurity';
function safePath(value: string, workspaceRoot: string): string {
return validatePath(workspaceRoot, workspaceRoot, value);
}
// Zod schemas for validation
const csvToJsonSchema = z.object({
csv_content: z.string().min(1),
});
const jsonToCsvSchema = z.object({
json_data: z.any(),
options: z.object({
headers: z.array(z.string()).optional(),
delimiter: z.string().optional().default(','),
}).optional(),
});
const csvValidateSchema = z.object({
file_path: z.string().min(1),
schema: z.any(),
});
const csvMergeSchema = z.object({
files: z.array(z.string().min(1)),
output_path: z.string().min(1),
});
const yamlParseSchema = z.object({
path: z.string().min(1),
});
const yamlGenerateSchema = z.object({
data: z.any(),
path: z.string().min(1),
});
const configDiffSchema = z.object({
file1: z.string().min(1),
file2: z.string().min(1),
});
/**
* Convert CSV content to JSON.
*/
export async function csv_to_json_stream(csv_content: string): Promise<{ rows: any[]; headers: string[] }> {
const validated = csvToJsonSchema.parse({ csv_content });
return new Promise((resolve, reject) => {
const rows: any[] = [];
let headers: string[] = [];
csvParser()(validated.csv_content)
.on('headers', (h: string[]) => {
headers = h;
})
.on('data', (row: Record<string, any>) => {
rows.push(row);
})
.on('end', () => {
resolve({ rows, headers });
})
.on('error', (err: Error) => {
reject(new Error(`CSV parsing failed: ${err.message}`));
});
});
}
/**
* Convert JSON data to CSV.
*/
export async function json_to_csv_convert(json_data: any, options?: { headers?: string[]; delimiter?: string }): Promise<string> {
const validated = jsonToCsvSchema.parse({ json_data, options });
if (!Array.isArray(validated.json_data) || validated.json_data.length === 0) {
throw new Error('JSON data must be a non-empty array of objects.');
}
// Determine headers from first object or use provided headers
const headers = validated.options?.headers || Object.keys(validated.json_data[0]);
let csvContent = '';
// Header row
csvContent += headers.join(validated.options?.delimiter || ',') + '\n';
// Data rows
for (const obj of validated.json_data) {
const values = headers.map(key => {
const val = obj[key];
if (val === null || val === undefined) return '';
if (typeof val === 'string' && val.includes(',')) return `"${val}"`;
return String(val);
});
csvContent += values.join(validated.options?.delimiter || ',') + '\n';
}
return csvContent;
}
/**
* Validate CSV against a schema.
*/
export async function csv_validate_schema(file_path: string, schema: any, workspaceRoot: string = process.cwd()): Promise<{ valid: boolean; errors: Array<{ row: number; column: string; error: string }> }> {
const validated = csvValidateSchema.parse({ file_path: safePath(file_path, workspaceRoot), schema });
try {
await fs.access(validated.file_path);
} catch (error) {
throw new Error(`File not found: ${validated.file_path}`);
}
const errors: Array<{ row: number; column: string; error: string }> = [];
// Simple schema validation (check required columns and types)
if (!Array.isArray(schema.columns)) {
return { valid: false, errors: [{ row: -1, column: 'schema', error: 'Schema must have a "columns" array' }] };
}
let currentRow = 0;
try {
const stream = fsNode.createReadStream(validated.file_path);
for await (const row of stream.pipe(csvParser())) {
currentRow++;
for (const colDef of schema.columns) {
const value = row[colDef.name as keyof typeof row];
// Check required columns
if (colDef.required && (!value || value.trim() === '')) {
errors.push({ row: currentRow, column: colDef.name, error: 'Required field is empty' });
}
// Type validation
if (value !== undefined && colDef.type) {
switch (colDef.type) {
case 'string':
if (typeof value !== 'string') {
errors.push({ row: currentRow, column: colDef.name, error: `Expected string, got ${typeof value}` });
}
break;
case 'number':
if (isNaN(Number(value))) {
errors.push({ row: currentRow, column: colDef.name, error: 'Expected number' });
}
break;
case 'boolean':
if (!['true', 'false'].includes(String(value).toLowerCase())) {
errors.push({ row: currentRow, column: colDef.name, error: 'Expected boolean (true/false)' });
}
break;
}
}
}
}
return { valid: errors.length === 0, errors };
} catch (error) {
throw new Error(`CSV validation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Merge multiple CSV files by header matching.
*/
export async function csv_merge_files(files: string[], output_path: string, workspaceRoot: string = process.cwd()): Promise<{ merged_rows: number; total_columns: number }> {
const validated = csvMergeSchema.parse({
files: files.map(file => safePath(file, workspaceRoot)),
output_path: safePath(output_path, workspaceRoot),
});
try {
// Ensure output directory exists
await fs.mkdir(path.dirname(validated.output_path), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
let allHeaders: string[] = [];
const allRows: any[][] = [];
try {
for (const file of validated.files) {
await fs.access(file);
// Read CSV and extract headers/rows
const content = await fs.readFile(file, 'utf-8');
const rows = await csv_to_json_stream(content);
if (allHeaders.length === 0) {
allHeaders = rows.headers;
} else {
// Merge headers - add new columns from subsequent files
for (const header of rows.headers) {
if (!allHeaders.includes(header)) {
allHeaders.push(header);
}
}
}
// Normalize rows to have all headers
for (const row of rows.rows) {
const normalizedRow: any = {};
for (const header of allHeaders) {
normalizedRow[header] = row[header] || '';
}
allRows.push(normalizedRow);
}
}
// Write merged CSV
let csvContent = allHeaders.join(',') + '\n';
for (const row of allRows) {
const values = allHeaders.map(header => {
const val = (row as Record<string, any>)[header];
if (val === null || val === undefined) return '';
if (typeof val === 'string' && val.includes(',')) return `"${val}"`;
return String(val);
});
csvContent += values.join(',') + '\n';
}
await fs.writeFile(validated.output_path, csvContent);
return { merged_rows: allRows.length, total_columns: allHeaders.length };
} catch (error) {
throw new Error(`CSV merge failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Parse a YAML file.
*/
export async function yaml_parse_file(path_str: string, workspaceRoot: string = process.cwd()): Promise<any> {
const validated = yamlParseSchema.parse({ path: safePath(path_str, workspaceRoot) });
try {
await fs.access(validated.path);
} catch (error) {
throw new Error(`File not found: ${validated.path}`);
}
try {
const content = await fs.readFile(validated.path, 'utf-8');
return yaml.load(content);
} catch (error) {
throw new Error(`YAML parsing failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Generate a YAML file from data.
*/
export async function yaml_generate_file(data: any, path_str: string, workspaceRoot: string = process.cwd()): Promise<string> {
const validated = yamlGenerateSchema.parse({ data, path: safePath(path_str, workspaceRoot) });
try {
// Ensure output directory exists
await fs.mkdir(path.dirname(validated.path), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
try {
const yamlContent = yaml.dump(validated.data, { lineWidth: -1 }); // No line wrapping
await fs.writeFile(validated.path, yamlContent);
return validated.path;
} catch (error) {
throw new Error(`YAML generation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Deep diff two YAML files.
*/
export async function config_diff_yaml(file1: string, file2: string, workspaceRoot: string = process.cwd()): Promise<{ differences: Array<{ path: string; old_val?: any; new_val?: any }> }> {
const validated = configDiffSchema.parse({
file1: safePath(file1, workspaceRoot),
file2: safePath(file2, workspaceRoot),
});
let obj1: any;
let obj2: any;
try {
await fs.access(validated.file1);
await fs.access(validated.file2);
const content1 = await fs.readFile(validated.file1, 'utf-8');
const content2 = await fs.readFile(validated.file2, 'utf-8');
obj1 = yaml.load(content1);
obj2 = yaml.load(content2);
} catch (error) {
throw new Error(`YAML file access failed: ${error instanceof Error ? error.message : String(error)}`);
}
const differences: Array<{ path: string; old_val?: any; new_val?: any }> = [];
// Simple deep diff implementation
function compareObjects(obj1: any, obj2: any, prefix: string = '') {
if (obj1 === null || obj2 === null) {
differences.push({ path: prefix || 'root', old_val: obj1, new_val: obj2 });
return;
}
if (typeof obj1 !== typeof obj2) {
differences.push({ path: prefix || 'root', old_val: obj1, new_val: obj2 });
return;
}
if (Array.isArray(obj1)) {
if (!Array.isArray(obj2)) {
differences.push({ path: prefix || 'root', old_val: obj1, new_val: obj2 });
return;
}
const maxLen = Math.max(obj1.length, obj2.length);
for (let i = 0; i < maxLen; i++) {
compareObjects(
obj1[i] !== undefined ? obj1[i] : null,
obj2[i] !== undefined ? obj2[i] : null,
`${prefix}[${i}]`
);
}
return;
}
if (typeof obj1 === 'object') {
const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
for (const key of keys) {
const newPath = prefix ? `${prefix}.${key}` : key;
if (!(key in obj1)) {
differences.push({ path: newPath, old_val: undefined, new_val: obj2[key] });
} else if (!(key in obj2)) {
differences.push({ path: newPath, old_val: obj1[key], new_val: undefined });
} else if (obj1[key] !== obj2[key]) {
compareObjects(obj1[key], obj2[key], newPath);
}
}
} else if (obj1 !== obj2) {
differences.push({ path: prefix || 'root', old_val: obj1, new_val: obj2 });
}
}
compareObjects(obj1, obj2);
return { differences };
}
/**
* Convert YAML content to JSON string.
*/
export async function yaml_to_json(yaml_content: string): Promise<string> {
try {
const parsed = yaml.load(yaml_content);
return JSON.stringify(parsed, null, 2);
} catch (error) {
throw new Error(`YAML to JSON conversion failed: ${error instanceof Error ? error.message : String(error)}`);
}
}