properties-comparator 1.0.4 → 1.0.5

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,520 @@
1
+ #!/usr/bin/env node
2
+
3
+ import chalk from "chalk";
4
+ import fs from "fs";
5
+ import path from "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]?.replace(/\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}">${
254
+ value === "N/A" ? "<em>N/A</em>" : value
255
+ }</td>`;
256
+ });
257
+
258
+ html += `\n </tr>`;
259
+ });
260
+
261
+ html += `\n </table>
262
+
263
+ <div class="summary ${mismatchCount === 0 ? "success" : "error"}">
264
+ <h2>Summary</h2>`;
265
+
266
+ if (mismatchCount === 0) {
267
+ html += `\n <p>All properties match across all files!</p>`;
268
+ } else {
269
+ html += `\n <p>${mismatchCount} key(s) have mismatched values.</p>
270
+ <p><strong>Mismatched keys:</strong> ${mismatchDetails
271
+ .filter((detail) => !detail.matched)
272
+ .map((detail) => detail.key)
273
+ .join(", ")}</p>`;
274
+ }
275
+
276
+ html += `\n </div>
277
+ </body>
278
+ </html>`;
279
+
280
+ return html;
281
+ }
282
+
283
+ /**
284
+ * Generates a Markdown report for the comparison results.
285
+ *
286
+ * @param {Array} filePaths - Array of file paths that were compared
287
+ * @param {Object} comparisonData - The output from compareFileData function
288
+ * @returns {string} - Markdown document as string
289
+ */
290
+ function generateMarkdownReport(filePaths, comparisonData) {
291
+ const { mismatchCount, mismatchDetails } = comparisonData;
292
+ const fileNames = filePaths.map((fp) => path.basename(fp));
293
+
294
+ let markdown = `# Properties Comparison Report\n\n`;
295
+
296
+ // Files compared
297
+ markdown += `## Files Compared\n\n`;
298
+ filePaths.forEach((fp, idx) => {
299
+ markdown += `${idx + 1}. ${fileNames[idx]} (${fp})\n`;
300
+ });
301
+
302
+ // Comparison results table
303
+ markdown += `\n## Comparison Results\n\n`;
304
+
305
+ // Table header
306
+ markdown += `| Key | Matched | ${fileNames
307
+ .map((name, idx) => `File ${idx + 1}: ${name}`)
308
+ .join(" | ")} |\n`;
309
+ markdown += `| --- | --- | ${fileNames.map(() => "---").join(" | ")} |\n`;
310
+
311
+ // Table content
312
+ mismatchDetails.forEach(({ key, values, matched }) => {
313
+ markdown += `| ${key} | ${matched ? "Yes" : "No"} | ${values
314
+ .map((v) => (v === "N/A" ? "*N/A*" : v))
315
+ .join(" | ")} |\n`;
316
+ });
317
+
318
+ // Summary
319
+ markdown += `\n## Summary\n\n`;
320
+ if (mismatchCount === 0) {
321
+ markdown += `✅ All properties match across all files!\n`;
322
+ } else {
323
+ markdown += `❌ ${mismatchCount} key(s) have mismatched values.\n\n`;
324
+ markdown += `**Mismatched keys:** ${mismatchDetails
325
+ .filter((detail) => !detail.matched)
326
+ .map((detail) => detail.key)
327
+ .join(", ")}\n`;
328
+ }
329
+
330
+ return markdown;
331
+ }
332
+
333
+ /**
334
+ * CLI function: compares properties/keys across multiple files,
335
+ * prints details to the console in a tabular format, and provides a summary.
336
+ *
337
+ * @param {string[]} filePaths - Array of file paths.
338
+ * @param {Object} options - Options for the comparison.
339
+ * @param {string} [options.format] - Output format ('console', 'html', or 'markdown').
340
+ * @param {string} [options.outputFile] - Path to save the report (for html and markdown).
341
+ */
342
+ function compareFiles(filePaths, options = {}) {
343
+ const format = options.format || "console";
344
+ const outputFile = options.outputFile;
345
+
346
+ const comparisonData = compareFileData(filePaths);
347
+
348
+ if (format === "console") {
349
+ console.log("Comparing properties/keys across files:\n");
350
+
351
+ // Prepare data for tabular output
352
+ const tableData = comparisonData.mismatchDetails.map(
353
+ ({ key, values, matched }) => {
354
+ const valueColumns = values.reduce((acc, value, idx) => {
355
+ acc[`File ${idx + 1}`] = value;
356
+ return acc;
357
+ }, {});
358
+ return {
359
+ Key: key,
360
+ Matched: matched ? "Yes" : "No",
361
+ ...valueColumns,
362
+ };
363
+ }
364
+ );
365
+
366
+ // Print the table
367
+ console.table(tableData);
368
+
369
+ // Custom print for mismatched rows
370
+ console.log("\n=== Highlighted Mismatched Rows ===");
371
+ comparisonData.mismatchDetails.forEach(({ key, values, matched }) => {
372
+ if (!matched) {
373
+ const coloredValues = values.map((value, idx) =>
374
+ chalk.red(`File ${idx + 1}: ${value}`)
375
+ );
376
+ console.log(
377
+ chalk.yellow(`Key: ${key}`),
378
+ "|",
379
+ coloredValues.join(" | ")
380
+ );
381
+ }
382
+ });
383
+
384
+ // Summary
385
+ console.log("\n=== Summary ===");
386
+ if (comparisonData.mismatchCount === 0) {
387
+ console.log("All properties match across all files!");
388
+ } else {
389
+ console.log(
390
+ `${comparisonData.mismatchCount} key(s) have mismatched values.`
391
+ );
392
+ const mismatchedKeys = comparisonData.mismatchDetails
393
+ .filter((detail) => !detail.matched)
394
+ .map((detail) => detail.key);
395
+ console.log("Mismatched keys:", mismatchedKeys.join(", "));
396
+ }
397
+ } else if (format === "html") {
398
+ const htmlReport = generateHtmlReport(filePaths, comparisonData);
399
+ if (outputFile) {
400
+ fs.writeFileSync(outputFile, htmlReport);
401
+ console.log(`HTML report saved to: ${outputFile}`);
402
+ } else {
403
+ console.log(htmlReport);
404
+ }
405
+ } else if (format === "markdown") {
406
+ const markdownReport = generateMarkdownReport(filePaths, comparisonData);
407
+ if (outputFile) {
408
+ fs.writeFileSync(outputFile, markdownReport);
409
+ console.log(`Markdown report saved to: ${outputFile}`);
410
+ } else {
411
+ console.log(markdownReport);
412
+ }
413
+ } else {
414
+ console.error(
415
+ `Unsupported format: ${format}. Using console output instead.`
416
+ );
417
+ compareFiles(filePaths); // Fallback to console output
418
+ }
419
+ }
420
+
421
+ /**
422
+ * CLI entry point for comparing .properties and .yml/.yaml files.
423
+ */
424
+ function run() {
425
+ const args = process.argv.slice(2);
426
+ const options = {
427
+ format: "console",
428
+ outputFile: null,
429
+ };
430
+
431
+ // Parse arguments for format and output file
432
+ const filePaths = [];
433
+ for (let i = 0; i < args.length; i++) {
434
+ if (args[i] === "--format" || args[i] === "-f") {
435
+ if (i + 1 < args.length) {
436
+ options.format = args[i + 1].toLowerCase();
437
+ i++; // Skip the next argument as it's the format value
438
+ }
439
+ } else if (args[i] === "--output" || args[i] === "-o") {
440
+ if (i + 1 < args.length) {
441
+ options.outputFile = args[i + 1];
442
+ i++; // Skip the next argument as it's the output file path
443
+ }
444
+ } else {
445
+ // Not an option, treat as file path
446
+ filePaths.push(path.resolve(args[i]));
447
+ }
448
+ }
449
+
450
+ if (filePaths.length === 0) {
451
+ console.error("Please provide file paths as command-line arguments.");
452
+ console.error(
453
+ "Usage: properties-comparator [options] file1 file2 [file3...]"
454
+ );
455
+ console.error("Options:");
456
+ console.error(
457
+ " --format, -f <format> Output format: console, html, or markdown"
458
+ );
459
+ console.error(
460
+ " --output, -o <file> Output file for html or markdown reports"
461
+ );
462
+ process.exit(1);
463
+ } else if (filePaths.length === 1) {
464
+ console.error("Please provide at least two file paths for comparison.");
465
+ process.exit(1);
466
+ }
467
+
468
+ const missing = filePaths.filter((fp) => !fs.existsSync(fp));
469
+ if (missing.length > 0) {
470
+ console.error(`The following file(s) do not exist: ${missing.join(", ")}`);
471
+ process.exit(1);
472
+ }
473
+
474
+ compareFiles(filePaths, options);
475
+ }
476
+
477
+ /**
478
+ * API function to compare properties between two files.
479
+ * @param {string} file1 - Path to the first file
480
+ * @param {string} file2 - Path to the second file
481
+ * @param {Object} options - Comparison options
482
+ * @returns {Object} Comparison results in a structured format
483
+ */
484
+ async function compareProperties(file1, file2, options = {}) {
485
+ const filePaths = [file1, file2];
486
+ const comparisonData = compareFileData(filePaths);
487
+
488
+ // Process the output based on options
489
+ if (options.output) {
490
+ if (options.json) {
491
+ fs.writeFileSync(options.output, JSON.stringify(comparisonData, null, 2));
492
+ } else {
493
+ const format = path.extname(options.output).toLowerCase() === '.md' ? 'markdown' : 'html';
494
+ const report = format === 'markdown'
495
+ ? generateMarkdownReport(filePaths, comparisonData)
496
+ : generateHtmlReport(filePaths, comparisonData);
497
+ fs.writeFileSync(options.output, report);
498
+ }
499
+
500
+ if (options.verbose) {
501
+ console.log(`Comparison report saved to ${options.output}`);
502
+ }
503
+ }
504
+
505
+ return comparisonData;
506
+ }
507
+
508
+ export {
509
+ parsePropertiesFile,
510
+ parseYamlFile,
511
+ parseFile,
512
+ compareFileData,
513
+ checkIfAllValuesMatch,
514
+ getMismatchFields,
515
+ compareFiles,
516
+ generateHtmlReport,
517
+ generateMarkdownReport,
518
+ compareProperties,
519
+ run,
520
+ };