properties-comparator 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env node
2
+
3
+ import chalk from "chalk";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import yaml from "js-yaml";
7
+
8
+ /**
9
+ * Parses a .properties file into an object.
10
+ * Handles any file read/parse errors gracefully.
11
+ * @param {string} filePath - The path to the properties file.
12
+ * @returns {Object} - Key-value pairs, or {} on error.
13
+ */
14
+ function parsePropertiesFile(filePath) {
15
+ try {
16
+ const content = fs.readFileSync(filePath, "utf-8");
17
+ const lines = content.split(/\r?\n/);
18
+ const result = {};
19
+
20
+ for (let line of lines) {
21
+ let trimmedLine = line.trim();
22
+
23
+ // 1) Skip empty lines or lines that *start* with '#'
24
+ if (!trimmedLine || trimmedLine.startsWith("#")) {
25
+ continue;
26
+ }
27
+
28
+ // 2) Remove inline comment: anything after the first '#'
29
+ const hashIndex = trimmedLine.indexOf("#");
30
+ if (hashIndex !== -1) {
31
+ trimmedLine = trimmedLine.slice(0, hashIndex).trim();
32
+ }
33
+
34
+ // 3) Split on the *first* '=' only
35
+ const eqIndex = trimmedLine.indexOf("=");
36
+ if (eqIndex === -1) {
37
+ // No '=' => Not a valid key-value line
38
+ continue;
39
+ }
40
+
41
+ const key = trimmedLine.slice(0, eqIndex).trim();
42
+ const value = trimmedLine.slice(eqIndex + 1).trim();
43
+
44
+ if (key) {
45
+ result[key] = value;
46
+ }
47
+ }
48
+
49
+ return result;
50
+ } catch (err) {
51
+ console.error(
52
+ `Error reading/parsing .properties file (${filePath}):`,
53
+ err.message
54
+ );
55
+ return {};
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Flattens a nested object into a single-level object using dot-notation for nested keys.
61
+ * @param {Object} obj - The object to flatten.
62
+ * @param {string} [parentKey=''] - The current parent key (used in recursion).
63
+ * @param {Object} [res={}] - The accumulator object.
64
+ * @returns {Object} - A flattened key-value map.
65
+ */
66
+ function flattenObject(obj, parentKey = "", res = {}) {
67
+ for (const [key, value] of Object.entries(obj || {})) {
68
+ const newKey = parentKey ? `${parentKey}.${key}` : key;
69
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
70
+ flattenObject(value, newKey, res);
71
+ } else {
72
+ // Ensure all values are strings for consistent comparison
73
+ res[newKey] = String(value);
74
+ }
75
+ }
76
+ return res;
77
+ }
78
+
79
+ /**
80
+ * Parses a .yml or .yaml file into a flat key-value map.
81
+ * Handles any file read/parse errors gracefully.
82
+ * @param {string} filePath - The path to the YAML file.
83
+ * @returns {Object} - A flattened key-value map, or {} on error.
84
+ */
85
+ function parseYamlFile(filePath) {
86
+ try {
87
+ const fileContents = fs.readFileSync(filePath, "utf-8");
88
+ const data = yaml.load(fileContents);
89
+ return flattenObject(data);
90
+ } catch (err) {
91
+ console.error(
92
+ `Error reading/parsing YAML file (${filePath}):`,
93
+ err.message
94
+ );
95
+ return {};
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Detects file extension and parses the file content into an object.
101
+ * Currently supports .properties, .yaml, and .yml.
102
+ * If extension is unsupported, logs a warning and returns {}.
103
+ * @param {string} filePath - The path to the file.
104
+ * @returns {Object} - Parsed content as a key-value map, or {} if unsupported.
105
+ */
106
+ function parseFile(filePath) {
107
+ const ext = path.extname(filePath).toLowerCase();
108
+
109
+ switch (ext) {
110
+ case ".properties":
111
+ return parsePropertiesFile(filePath);
112
+ case ".yml":
113
+ case ".yaml":
114
+ return parseYamlFile(filePath);
115
+ default:
116
+ console.error(
117
+ `Warning: Unsupported file extension "${ext}" for file "${filePath}". ` +
118
+ `Only .properties, .yml, or .yaml are supported. This file will be treated as empty.`
119
+ );
120
+ return {};
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Internal helper that compares key-value data from multiple files
126
+ * and returns a structured result (without printing to console).
127
+ *
128
+ * @param {string[]} filePaths - Array of file paths.
129
+ * @returns {{
130
+ * mismatchCount: number,
131
+ * mismatchDetails: {
132
+ * key: string,
133
+ * values: string[],
134
+ * matched: boolean
135
+ * }[]
136
+ * }}
137
+ */
138
+ function compareFileData(filePaths) {
139
+ // Parse each file
140
+ const parsedObjects = filePaths.map(parseFile);
141
+
142
+ // Collect all unique keys
143
+ const allKeys = new Set(parsedObjects.flatMap((obj) => Object.keys(obj)));
144
+
145
+ const mismatchDetails = [];
146
+
147
+ // Compare values for each key across files
148
+ allKeys.forEach((key) => {
149
+ const values = parsedObjects.map(
150
+ (obj) => obj[key]?.replaceAll(/\s+/g, "") || "N/A"
151
+ );
152
+ const matched = values.every((value) => value === values[0]);
153
+ mismatchDetails.push({ key, values, matched });
154
+ });
155
+
156
+ // Count mismatches
157
+ const mismatchCount = mismatchDetails.filter((d) => !d.matched).length;
158
+ return { mismatchCount, mismatchDetails };
159
+ }
160
+
161
+ /**
162
+ * Helper function: checks if all values match across the provided files.
163
+ *
164
+ * @param {string[]} filePaths - Array of file paths.
165
+ * @returns {boolean} - True if all properties match across all files, false otherwise.
166
+ */
167
+ function checkIfAllValuesMatch(filePaths) {
168
+ const { mismatchCount } = compareFileData(filePaths);
169
+ return mismatchCount === 0;
170
+ }
171
+
172
+ /**
173
+ * Helper function: returns a list of fields (keys) that do not match.
174
+ *
175
+ * @param {string[]} filePaths - Array of file paths.
176
+ * @returns {string[]} - List of mismatched keys.
177
+ */
178
+ function getMismatchFields(filePaths) {
179
+ const { mismatchDetails } = compareFileData(filePaths);
180
+ return mismatchDetails
181
+ .filter((detail) => !detail.matched)
182
+ .map((detail) => detail.key);
183
+ }
184
+
185
+ /**
186
+ * Generates an HTML report for the comparison results.
187
+ *
188
+ * @param {Array} filePaths - Array of file paths that were compared
189
+ * @param {Object} comparisonData - The output from compareFileData function
190
+ * @returns {string} - HTML document as string
191
+ */
192
+ function generateHtmlReport(filePaths, comparisonData) {
193
+ const { mismatchCount, mismatchDetails } = comparisonData;
194
+ const fileNames = filePaths.map((fp) => path.basename(fp));
195
+
196
+ // Start HTML document
197
+ let html = `<!DOCTYPE html>
198
+ <html lang="en">
199
+ <head>
200
+ <meta charset="UTF-8">
201
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
202
+ <title>Properties Comparison Report</title>
203
+ <style>
204
+ body { font-family: Arial, sans-serif; margin: 20px; line-height: 1.6; color: #333; }
205
+ h1, h2 { color: #0066cc; }
206
+ table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
207
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
208
+ th { background-color: #f2f2f2; }
209
+ tr:nth-child(even) { background-color: #f9f9f9; }
210
+ tr:hover { background-color: #f2f2f2; }
211
+ .mismatch { background-color: #ffe6e6; }
212
+ .matched { background-color: #e6ffe6; }
213
+ .value-mismatch { color: #cc0000; font-weight: bold; }
214
+ .summary { margin: 20px 0; padding: 15px; border-radius: 5px; }
215
+ .summary.success { background-color: #e6ffe6; border: 1px solid #99cc99; }
216
+ .summary.error { background-color: #ffe6e6; border: 1px solid #cc9999; }
217
+ .file-list { margin-bottom: 20px; }
218
+ </style>
219
+ </head>
220
+ <body>
221
+ <h1>Properties Comparison Report</h1>
222
+
223
+ <div class="file-list">
224
+ <h2>Files Compared:</h2>
225
+ <ol>
226
+ ${fileNames
227
+ .map(
228
+ (name, idx) => `<li>${name} <small>(${filePaths[idx]})</small></li>`
229
+ )
230
+ .join("\n ")}
231
+ </ol>
232
+ </div>
233
+
234
+ <h2>Comparison Results</h2>
235
+ <table>
236
+ <tr>
237
+ <th>Key</th>
238
+ <th>Matched</th>
239
+ ${fileNames
240
+ .map((name, idx) => `<th>File ${idx + 1}: ${name}</th>`)
241
+ .join("\n ")}
242
+ </tr>`;
243
+
244
+ // Add table rows for each key
245
+ mismatchDetails.forEach(({ key, values, matched }) => {
246
+ html += `\n <tr class="${matched ? "matched" : "mismatch"}">
247
+ <td>${key}</td>
248
+ <td>${matched ? "Yes" : "No"}</td>`;
249
+
250
+ // Add values from each file
251
+ values.forEach((value, idx) => {
252
+ const cellClass = matched ? "" : "value-mismatch";
253
+ html += `\n <td class="${cellClass}">${value === "N/A" ? "<em>N/A</em>" : value
254
+ }</td>`;
255
+ });
256
+
257
+ html += `\n </tr>`;
258
+ });
259
+
260
+ html += `\n </table>
261
+
262
+ <div class="summary ${mismatchCount === 0 ? "success" : "error"}">
263
+ <h2>Summary</h2>`;
264
+
265
+ if (mismatchCount === 0) {
266
+ html += `\n <p>All properties match across all files!</p>`;
267
+ } else {
268
+ html += `\n <p>${mismatchCount} key(s) have mismatched values.</p>
269
+ <p><strong>Mismatched keys:</strong> ${mismatchDetails
270
+ .filter((detail) => !detail.matched)
271
+ .map((detail) => detail.key)
272
+ .join(", ")}</p>`;
273
+ }
274
+
275
+ html += `\n </div>
276
+ </body>
277
+ </html>`;
278
+
279
+ return html;
280
+ }
281
+
282
+ /**
283
+ * Generates a Markdown report for the comparison results.
284
+ *
285
+ * @param {Array} filePaths - Array of file paths that were compared
286
+ * @param {Object} comparisonData - The output from compareFileData function
287
+ * @returns {string} - Markdown document as string
288
+ */
289
+ function generateMarkdownReport(filePaths, comparisonData) {
290
+ const { mismatchCount, mismatchDetails } = comparisonData;
291
+ const fileNames = filePaths.map((fp) => path.basename(fp));
292
+
293
+ let markdown = `# Properties Comparison Report\n\n`;
294
+
295
+ // Files compared
296
+ markdown += `## Files Compared\n\n`;
297
+ filePaths.forEach((fp, idx) => {
298
+ markdown += `${idx + 1}. ${fileNames[idx]} (${fp})\n`;
299
+ });
300
+
301
+ // Comparison results table
302
+ markdown += `\n## Comparison Results\n\n`;
303
+
304
+ // Table header
305
+ markdown += `| Key | Matched | ${fileNames
306
+ .map((name, idx) => `File ${idx + 1}: ${name}`)
307
+ .join(" | ")} |\n`;
308
+ markdown += `| --- | --- | ${fileNames.map(() => "---").join(" | ")} |\n`;
309
+
310
+ // Table content
311
+ mismatchDetails.forEach(({ key, values, matched }) => {
312
+ markdown += `| ${key} | ${matched ? "Yes" : "No"} | ${values
313
+ .map((v) => (v === "N/A" ? "*N/A*" : v))
314
+ .join(" | ")} |\n`;
315
+ });
316
+
317
+ // Summary
318
+ markdown += `\n## Summary\n\n`;
319
+ if (mismatchCount === 0) {
320
+ markdown += `✅ All properties match across all files!\n`;
321
+ } else {
322
+ markdown += `❌ ${mismatchCount} key(s) have mismatched values.\n\n`;
323
+ markdown += `**Mismatched keys:** ${mismatchDetails
324
+ .filter((detail) => !detail.matched)
325
+ .map((detail) => detail.key)
326
+ .join(", ")}\n`;
327
+ }
328
+
329
+ return markdown;
330
+ }
331
+
332
+ /**
333
+ * CLI function: compares properties/keys across multiple files,
334
+ * prints details to the console in a tabular format, and provides a summary.
335
+ *
336
+ * @param {string[]} filePaths - Array of file paths.
337
+ * @param {Object} options - Options for the comparison.
338
+ * @param {string} [options.format] - Output format ('console', 'html', or 'markdown').
339
+ * @param {string} [options.outputFile] - Path to save the report (for html and markdown).
340
+ */
341
+ function compareFiles(filePaths, options = {}) {
342
+ const format = options.format || "console";
343
+ const outputFile = options.outputFile;
344
+
345
+ const comparisonData = compareFileData(filePaths);
346
+
347
+ if (format === "console") {
348
+ console.log("Comparing properties/keys across files:\n");
349
+
350
+ // Prepare data for tabular output
351
+ const tableData = comparisonData.mismatchDetails.map(
352
+ ({ key, values, matched }) => {
353
+ const valueColumns = values.reduce((acc, value, idx) => {
354
+ acc[`File ${idx + 1}`] = value;
355
+ return acc;
356
+ }, {});
357
+ return {
358
+ Key: key,
359
+ Matched: matched ? "Yes" : "No",
360
+ ...valueColumns,
361
+ };
362
+ }
363
+ );
364
+
365
+ // Print the table
366
+ console.table(tableData);
367
+
368
+ // Custom print for mismatched rows
369
+ console.log("\n=== Highlighted Mismatched Rows ===");
370
+ comparisonData.mismatchDetails.forEach(({ key, values, matched }) => {
371
+ if (!matched) {
372
+ const coloredValues = values.map((value, idx) =>
373
+ chalk.red(`File ${idx + 1}: ${value}`)
374
+ );
375
+ console.log(
376
+ chalk.yellow(`Key: ${key}`),
377
+ "|",
378
+ coloredValues.join(" | ")
379
+ );
380
+ }
381
+ });
382
+
383
+ // Summary
384
+ console.log("\n=== Summary ===");
385
+ if (comparisonData.mismatchCount === 0) {
386
+ console.log("All properties match across all files!");
387
+ } else {
388
+ console.log(
389
+ `${comparisonData.mismatchCount} key(s) have mismatched values.`
390
+ );
391
+ const mismatchedKeys = comparisonData.mismatchDetails
392
+ .filter((detail) => !detail.matched)
393
+ .map((detail) => detail.key);
394
+ console.log("Mismatched keys:", mismatchedKeys.join(", "));
395
+ }
396
+ } else if (format === "html") {
397
+ const htmlReport = generateHtmlReport(filePaths, comparisonData);
398
+ if (outputFile) {
399
+ fs.writeFileSync(outputFile, htmlReport);
400
+ console.log(`HTML report saved to: ${outputFile}`);
401
+ } else {
402
+ console.log(htmlReport);
403
+ }
404
+ } else if (format === "markdown") {
405
+ const markdownReport = generateMarkdownReport(filePaths, comparisonData);
406
+ if (outputFile) {
407
+ fs.writeFileSync(outputFile, markdownReport);
408
+ console.log(`Markdown report saved to: ${outputFile}`);
409
+ } else {
410
+ console.log(markdownReport);
411
+ }
412
+ } else {
413
+ console.error(
414
+ `Unsupported format: ${format}. Using console output instead.`
415
+ );
416
+ compareFiles(filePaths); // Fallback to console output
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Prints the usage information for the CLI.
422
+ */
423
+ function printUsage() {
424
+ console.error("Please provide file paths as command-line arguments.");
425
+ console.error(
426
+ "Usage: properties-comparator [options] file1 file2 [file3...]"
427
+ );
428
+ console.error("Options:");
429
+ console.error(
430
+ " --format, -f <format> Output format: console, html, or markdown"
431
+ );
432
+ console.error(
433
+ " --output, -o <file> Output file for html or markdown reports"
434
+ );
435
+ }
436
+
437
+ /**
438
+ * Parses command-line arguments.
439
+ * @param {string[]} args - Array of command-line arguments.
440
+ * @returns {{ filePaths: string[], options: Object }} - Parsed paths and options.
441
+ */
442
+ function parseArgs(args) {
443
+ const options = {
444
+ format: "console",
445
+ outputFile: null,
446
+ };
447
+ const filePaths = [];
448
+
449
+ let i = 0;
450
+ while (i < args.length) {
451
+ if (args[i] === "--format" || args[i] === "-f") {
452
+ if (i + 1 < args.length) {
453
+ options.format = args[i + 1].toLowerCase();
454
+ i += 2;
455
+ } else {
456
+ i++;
457
+ }
458
+ } else if (args[i] === "--output" || args[i] === "-o") {
459
+ if (i + 1 < args.length) {
460
+ options.outputFile = args[i + 1];
461
+ i += 2;
462
+ } else {
463
+ i++;
464
+ }
465
+ } else {
466
+ filePaths.push(path.resolve(args[i]));
467
+ i++;
468
+ }
469
+ }
470
+
471
+ return { filePaths, options };
472
+ }
473
+
474
+ /**
475
+ * CLI entry point for comparing .properties and .yml/.yaml files.
476
+ */
477
+ function run() {
478
+ const { filePaths, options } = parseArgs(process.argv.slice(2));
479
+
480
+ if (filePaths.length === 0) {
481
+ printUsage();
482
+ process.exit(1);
483
+ } else if (filePaths.length === 1) {
484
+ console.error("Please provide at least two file paths for comparison.");
485
+ process.exit(1);
486
+ }
487
+
488
+ const missing = filePaths.filter((fp) => !fs.existsSync(fp));
489
+ if (missing.length > 0) {
490
+ console.error(`The following file(s) do not exist: ${missing.join(", ")}`);
491
+ process.exit(1);
492
+ }
493
+
494
+ compareFiles(filePaths, options);
495
+ }
496
+
497
+ /**
498
+ * API function to compare properties between two files.
499
+ * @param {string} file1 - Path to the first file
500
+ * @param {string} file2 - Path to the second file
501
+ * @param {Object} options - Comparison options
502
+ * @returns {Object} Comparison results in a structured format
503
+ */
504
+ function compareProperties(file1, file2, options = {}) {
505
+ const filePaths = [file1, file2];
506
+ const comparisonData = compareFileData(filePaths);
507
+
508
+ // Process the output based on options
509
+ if (options.output) {
510
+ if (options.json) {
511
+ fs.writeFileSync(options.output, JSON.stringify(comparisonData, null, 2));
512
+ } else {
513
+ const format = path.extname(options.output).toLowerCase() === '.md' ? 'markdown' : 'html';
514
+ const report = format === 'markdown'
515
+ ? generateMarkdownReport(filePaths, comparisonData)
516
+ : generateHtmlReport(filePaths, comparisonData);
517
+ fs.writeFileSync(options.output, report);
518
+ }
519
+
520
+ if (options.verbose) {
521
+ console.log(`Comparison report saved to ${options.output}`);
522
+ }
523
+ }
524
+
525
+ return comparisonData;
526
+ }
527
+
528
+ export {
529
+ parsePropertiesFile,
530
+ parseYamlFile,
531
+ parseFile,
532
+ compareFileData,
533
+ checkIfAllValuesMatch,
534
+ getMismatchFields,
535
+ compareFiles,
536
+ generateHtmlReport,
537
+ generateMarkdownReport,
538
+ compareProperties,
539
+ run,
540
+ };