diff --git a/src/nodes/CommandConnections.ts b/src/nodes/CommandConnections.ts index e4f670ee..f388a3b9 100644 --- a/src/nodes/CommandConnections.ts +++ b/src/nodes/CommandConnections.ts @@ -65,7 +65,7 @@ class CommandAdd extends CommandPolykey { type: 'table', data: connections, options: { - headers: [ + columns: [ 'host', 'hostname', 'nodeIdEncoded', @@ -73,6 +73,7 @@ class CommandAdd extends CommandPolykey { 'timeout', 'usageCount', ], + includeHeaders: true, }, }); process.stdout.write(formattedOutput); diff --git a/src/types.ts b/src/types.ts index 9440893d..62b97d5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,7 +43,8 @@ type AgentChildProcessOutput = type TableRow = Record; interface TableOptions { - headers?: Array; + columns?: Array | Record; + includeHeaders?: boolean; includeRowCount?: boolean; } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 463cbde2..cf2b10c6 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -95,35 +95,61 @@ function encodeNonPrintable(str: string) { }); } -// Function to handle 'table' type output +/** + * Function to handle the `table` output format. + * @param rows + * @param options + * @param options.columns - Can either be an `Array` or `Record`. + * If it is `Record`, the `number` values will be used as the initial padding lengths. + * The object is also mutated if any cells exceed the inital padding lengths. + * This paramater can also be supplied to filter the columns that will be displayed. + * @param options.includeHeaders - Defaults to `True` + * @param options.includeRowCount - Defaults to `False`. + * @returns + */ function outputTableFormatter( - rowStream: Array, - options?: TableOptions, + rows: Array, + options: TableOptions = { + includeHeaders: true, + includeRowCount: false, + }, ): string { let output = ''; let rowCount = 0; + // Default includeHeaders to true + const includeHeaders = options.includeHeaders ?? true; const maxColumnLengths: Record = {}; + const optionColumns = + options?.columns != null + ? Array.isArray(options.columns) + ? options.columns + : Object.keys(options.columns) + : undefined; + // Initialize maxColumnLengths with header lengths if headers are provided - if (options?.headers) { - for (const header of options.headers) { - maxColumnLengths[header] = header.length; + if (optionColumns != null) { + for (const column of optionColumns) { + maxColumnLengths[column] = Math.max( + options?.columns?.[column] ?? 0, + column.length, + ); } } // Precompute max column lengths by iterating over the rows first - for (const row of rowStream) { - for (const key in options?.headers ?? row) { - if (row[key] != null) { - row[key] = encodeNonPrintable(row[key].toString()); + for (const row of rows) { + for (const column in options?.columns ?? row) { + if (row[column] != null) { + row[column] = encodeNonPrintable(row[column].toString()); } // Row[key] is definitely a string or null after this point due to encodeNonPrintable - const cellValue: string | null = row[key]; + const cellValue: string | null = row[column]; // Null or '' will both cause cellLength to be 3 const cellLength = cellValue == null || cellValue.length === 0 ? 3 : cellValue.length; // 3 is length of 'N/A' - maxColumnLengths[key] = Math.max( - maxColumnLengths[key] || 0, + maxColumnLengths[column] = Math.max( + maxColumnLengths[column] || 0, cellLength, // Use the length of the encoded value ); } @@ -131,25 +157,37 @@ function outputTableFormatter( // After this point, maxColumnLengths will have been filled with all the necessary keys. // Thus, the column keys can be derived from it. - const columnKeys = Object.keys(maxColumnLengths); + const columns = Object.keys(maxColumnLengths); // If headers are provided, add them to your output first - if (options?.headers) { - const headerRow = options.headers - .map((header) => header.padEnd(maxColumnLengths[header])) - .join('\t'); - output += headerRow + '\n'; + if (optionColumns != null) { + for (let i = 0; i < optionColumns.length; i++) { + const column = optionColumns[i]; + const maxColumnLength = maxColumnLengths[column]; + // Options.headers is definitely defined as optionHeaders != null + if (!Array.isArray(options!.columns)) { + options!.columns![column] = maxColumnLength; + } + if (includeHeaders) { + output += column.padEnd(maxColumnLength); + if (i !== optionColumns.length - 1) { + output += '\t'; + } else { + output += '\n'; + } + } + } } - for (const row of rowStream) { + for (const row of rows) { let formattedRow = ''; - if (options?.includeRowCount) { + if (options.includeRowCount) { formattedRow += `${++rowCount}\t`; } - for (const key of columnKeys) { + for (const column of columns) { // Assume row[key] has been already encoded as a string or null const cellValue = - row[key] == null || row[key].length === 0 ? 'N/A' : row[key]; - formattedRow += `${cellValue.padEnd(maxColumnLengths[key] || 0)}\t`; + row[column] == null || row[column].length === 0 ? 'N/A' : row[column]; + formattedRow += `${cellValue.padEnd(maxColumnLengths[column] || 0)}\t`; } output += formattedRow.trimEnd() + '\n'; } @@ -157,6 +195,12 @@ function outputTableFormatter( return output; } +/** + * Formats a message suitable for output. + * @param msg - The msg that needs to be formatted. + * @see {@link outputTableFormatter} for information regarding usage where `msg.type === 'table'`. + * @returns + */ function outputFormatter(msg: OutputObject): string | Uint8Array { let output = ''; if (msg.type === 'raw') { diff --git a/tests/utils.test.ts b/tests/utils.test.ts index ea60e791..7c915fa7 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -48,6 +48,9 @@ describe('bin/utils', () => { { key1: 'data1', key2: 'data2' }, { key1: null, key2: undefined }, ], + options: { + includeHeaders: true, + }, }); expect(tableOutput).toBe('value1\tvalue2\ndata1 \tdata2\nN/A \tN/A\n'); @@ -64,6 +67,40 @@ describe('bin/utils', () => { ); }, ); + testUtils.testIf(testUtils.isTestPlatformEmpty)( + 'table in human format for streaming usage', + async () => { + let tableOutput = ''; + const keys = { + key1: 7, + key2: 4, + }; + const generator = function* () { + yield [{ key1: 'value1', key2: 'value2' }]; + yield [{ key1: 'data1', key2: 'data2' }]; + yield [{ key1: null, key2: undefined }]; + }; + let i = 0; + for (const data of generator()) { + tableOutput += binUtils.outputFormatter({ + type: 'table', + data: data, + options: { + columns: keys, + includeHeaders: i === 0, + }, + }); + i++; + } + expect(keys).toStrictEqual({ + key1: 7, + key2: 6, + }); + expect(tableOutput).toBe( + 'key1 \tkey2 \nvalue1 \tvalue2\ndata1 \tdata2\nN/A \tN/A\n', + ); + }, + ); testUtils.testIf(testUtils.isTestPlatformEmpty)( 'dict in human and in json format', () => {