filemayor 3.6.0 → 4.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.
package/core/reporter.js CHANGED
@@ -1,783 +1,783 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * ═══════════════════════════════════════════════════════════════════
5
- * FILEMAYOR CORE — REPORTER
6
- * Multi-format output (table, JSON, CSV, minimal) with colors,
7
- * summary statistics, and machine-readable formats for piping.
8
- * ═══════════════════════════════════════════════════════════════════
9
- */
10
-
11
- 'use strict';
12
-
13
- const { formatBytes } = require('./scanner');
14
-
15
- // ─── ANSI Color Codes ─────────────────────────────────────────────
16
-
17
- const COLORS = {
18
- reset: '\x1b[0m',
19
- bold: '\x1b[1m',
20
- dim: '\x1b[2m',
21
- italic: '\x1b[3m',
22
- underline: '\x1b[4m',
23
-
24
- // Foreground
25
- black: '\x1b[30m',
26
- red: '\x1b[31m',
27
- green: '\x1b[32m',
28
- yellow: '\x1b[33m',
29
- blue: '\x1b[34m',
30
- magenta: '\x1b[35m',
31
- cyan: '\x1b[36m',
32
- white: '\x1b[37m',
33
- gray: '\x1b[90m',
34
-
35
- // Bright
36
- brightRed: '\x1b[91m',
37
- brightGreen: '\x1b[92m',
38
- brightYellow: '\x1b[93m',
39
- brightBlue: '\x1b[94m',
40
- brightMagenta: '\x1b[95m',
41
- brightCyan: '\x1b[96m',
42
- brightWhite: '\x1b[97m',
43
-
44
- // Background
45
- bgRed: '\x1b[41m',
46
- bgGreen: '\x1b[42m',
47
- bgYellow: '\x1b[43m',
48
- bgBlue: '\x1b[44m',
49
- bgMagenta: '\x1b[45m',
50
- bgCyan: '\x1b[46m',
51
- };
52
-
53
- let _colorsEnabled = true;
54
-
55
- function c(color, text) {
56
- if (!_colorsEnabled) return text;
57
- return `${COLORS[color]}${text}${COLORS.reset}`;
58
- }
59
-
60
- function setColors(enabled) {
61
- _colorsEnabled = enabled;
62
- }
63
-
64
- // ─── Symbols ──────────────────────────────────────────────────────
65
-
66
- const SYMBOLS = {
67
- check: '✓',
68
- cross: '✗',
69
- arrow: '→',
70
- bullet: '•',
71
- bar: '█',
72
- halfBar: '▓',
73
- lightBar: '░',
74
- folder: '📂',
75
- file: '📄',
76
- trash: '🗑️',
77
- sparkle: '✨',
78
- warning: '⚠️',
79
- error: '❌',
80
- info: 'ℹ️',
81
- clock: '⏱️',
82
- shield: '🛡️',
83
- eye: '👁️',
84
- };
85
-
86
- // ─── Table Formatter ──────────────────────────────────────────────
87
-
88
- /**
89
- * Format data as a table with aligned columns
90
- * @param {Object[]} data - Array of objects
91
- * @param {Object} options - Column config
92
- * @returns {string} Formatted table
93
- */
94
- function formatTable(data, options = {}) {
95
- if (!data || data.length === 0) return c('dim', ' (no data)');
96
-
97
- const {
98
- columns = null, // Column definitions [{key, label, width, align, format}]
99
- maxWidth = null, // Max column width
100
- indent = 2, // Left indent
101
- } = options;
102
-
103
- // Auto-detect columns from data
104
- const cols = columns || Object.keys(data[0]).map(key => ({
105
- key,
106
- label: key.charAt(0).toUpperCase() + key.slice(1),
107
- align: 'left'
108
- }));
109
-
110
- // Calculate column widths
111
- for (const col of cols) {
112
- if (!col.width) {
113
- const maxDataLen = Math.max(
114
- col.label.length,
115
- ...data.map(row => {
116
- const val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? '');
117
- return val.length;
118
- })
119
- );
120
- col.width = maxWidth ? Math.min(maxDataLen, maxWidth) : maxDataLen;
121
- }
122
- }
123
-
124
- const lines = [];
125
- const pad = ' '.repeat(indent);
126
-
127
- // Header
128
- const headerLine = cols.map(col => {
129
- const label = col.label.padEnd(col.width).slice(0, col.width);
130
- return c('bold', label);
131
- }).join(c('dim', ' '));
132
- lines.push(`${pad}${headerLine}`);
133
-
134
- // Separator
135
- const sep = cols.map(col => c('dim', '─'.repeat(col.width))).join(c('dim', '──'));
136
- lines.push(`${pad}${sep}`);
137
-
138
- // Rows
139
- for (const row of data) {
140
- const rowLine = cols.map(col => {
141
- let val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? '');
142
- const rawVal = val.replace(/\x1b\[[0-9;]*m/g, ''); // Strip ANSI for length calc
143
-
144
- if (col.align === 'right') {
145
- val = ' '.repeat(Math.max(0, col.width - rawVal.length)) + val;
146
- } else {
147
- val = val + ' '.repeat(Math.max(0, col.width - rawVal.length));
148
- }
149
- return val;
150
- }).join(' ');
151
- lines.push(`${pad}${rowLine}`);
152
- }
153
-
154
- return lines.join('\n');
155
- }
156
-
157
- // ─── Progress Bar ─────────────────────────────────────────────────
158
-
159
- /**
160
- * Render a progress bar string
161
- * @param {number} percent - 0-100
162
- * @param {number} width - Bar width in chars
163
- * @returns {string}
164
- */
165
- function progressBar(percent, width = 30) {
166
- const clamped = Math.max(0, Math.min(100, percent || 0));
167
- const filled = Math.round((clamped / 100) * width);
168
- const empty = width - filled;
169
- const bar = c('green', SYMBOLS.bar.repeat(filled)) +
170
- c('dim', SYMBOLS.lightBar.repeat(empty));
171
- return `${bar} ${c('bold', `${clamped}%`)}`;
172
- }
173
-
174
- // ─── Spinner ──────────────────────────────────────────────────────
175
-
176
- class Spinner {
177
- constructor(message = 'Working...') {
178
- this.message = message;
179
- this.frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
180
- this.frameIndex = 0;
181
- this.interval = null;
182
- this.stream = process.stderr;
183
- }
184
-
185
- start() {
186
- this.interval = setInterval(() => {
187
- const frame = c('cyan', this.frames[this.frameIndex]);
188
- this.stream.write(`\r${frame} ${this.message}`);
189
- this.frameIndex = (this.frameIndex + 1) % this.frames.length;
190
- }, 80);
191
- return this;
192
- }
193
-
194
- update(message) {
195
- this.message = message;
196
- }
197
-
198
- succeed(message) {
199
- this.stop();
200
- this.stream.write(`\r${c('green', SYMBOLS.check)} ${message || this.message}\n`);
201
- }
202
-
203
- fail(message) {
204
- this.stop();
205
- this.stream.write(`\r${c('red', SYMBOLS.cross)} ${message || this.message}\n`);
206
- }
207
-
208
- stop() {
209
- if (this.interval) {
210
- clearInterval(this.interval);
211
- this.interval = null;
212
- this.stream.write('\r' + ' '.repeat(this.message.length + 10) + '\r');
213
- }
214
- }
215
- }
216
-
217
- // ─── Scan Report ──────────────────────────────────────────────────
218
-
219
- /**
220
- * Format a scan result for display
221
- * @param {Object} scanResult - Result from scan()
222
- * @param {string} format - Output format
223
- * @returns {string}
224
- */
225
- function formatScanReport(scanResult, format = 'table') {
226
- switch (format) {
227
- case 'json':
228
- return JSON.stringify(scanResult, null, 2);
229
-
230
- case 'csv':
231
- return formatScanCSV(scanResult);
232
-
233
- case 'minimal':
234
- return scanResult.files.map(f => f.path).join('\n');
235
-
236
- case 'table':
237
- default:
238
- return formatScanTable(scanResult);
239
- }
240
- }
241
-
242
- function formatScanTable(result) {
243
- const lines = [];
244
-
245
- // Header
246
- lines.push('');
247
- lines.push(c('bold', ` ${SYMBOLS.folder} Scan Report: `) + c('cyan', result.root));
248
- lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
249
- lines.push(c('dim', ` ${'─'.repeat(60)}`));
250
-
251
- // Category summary
252
- const catData = Object.entries(result.stats.categories)
253
- .sort((a, b) => b[1] - a[1])
254
- .map(([cat, count]) => ({
255
- category: cat.charAt(0).toUpperCase() + cat.slice(1),
256
- count: String(count),
257
- }));
258
-
259
- if (catData.length > 0) {
260
- lines.push('');
261
- lines.push(c('bold', ' Categories'));
262
- lines.push(formatTable(catData, {
263
- columns: [
264
- { key: 'category', label: 'Category', width: 20 },
265
- { key: 'count', label: 'Files', width: 8, align: 'right' },
266
- ]
267
- }));
268
- }
269
-
270
- // File list (top 25)
271
- const fileData = result.files.slice(0, 25).map(f => ({
272
- name: f.name.length > 40 ? f.name.slice(0, 37) + '...' : f.name,
273
- category: f.category,
274
- size: f.sizeHuman,
275
- }));
276
-
277
- if (fileData.length > 0) {
278
- lines.push('');
279
- lines.push(c('bold', ' Files'));
280
- lines.push(formatTable(fileData, {
281
- columns: [
282
- { key: 'name', label: 'Name', width: 42 },
283
- { key: 'category', label: 'Category', width: 14 },
284
- { key: 'size', label: 'Size', width: 10, align: 'right' },
285
- ]
286
- }));
287
-
288
- if (result.files.length > 25) {
289
- lines.push(c('dim', ` ... and ${result.files.length - 25} more files`));
290
- }
291
- }
292
-
293
- // Summary
294
- lines.push('');
295
- lines.push(c('dim', ` ${'─'.repeat(60)}`));
296
- lines.push(` ${c('bold', 'Total:')} ${result.stats.filesFound} files, ${formatBytes(result.stats.totalSize)}`);
297
- lines.push(` ${c('bold', 'Scanned:')} ${result.stats.dirsScanned} directories in ${result.stats.durationHuman}`);
298
- if (result.stats.errors.length > 0) {
299
- lines.push(` ${c('yellow', SYMBOLS.warning)} ${result.stats.errors.length} errors`);
300
- }
301
- lines.push('');
302
-
303
- return lines.join('\n');
304
- }
305
-
306
- function formatScanCSV(result) {
307
- const header = 'name,path,category,size,modified,ext';
308
- const rows = result.files.map(f =>
309
- `"${f.name}","${f.path}","${f.category}",${f.size},"${f.modified.toISOString()}","${f.ext}"`
310
- );
311
- return [header, ...rows].join('\n');
312
- }
313
-
314
- // ─── Organize Report ──────────────────────────────────────────────
315
-
316
- function formatOrganizeReport(result, format = 'table') {
317
- // Defensive: handle empty/incomplete results
318
- if (!result || (!result.plan && !result.planSummary)) {
319
- return `\n ${c('green', SYMBOLS.check)} Nothing to organize — folder is already clean.\n`;
320
- }
321
- if (!result.plan) result.plan = [];
322
- if (!result.planSummary) result.planSummary = { categories: {}, totalFiles: 0, totalSizeHuman: '0 B' };
323
-
324
- switch (format) {
325
- case 'json':
326
- return JSON.stringify(result, null, 2);
327
- case 'csv':
328
- return formatOrganizeCSV(result);
329
- case 'minimal':
330
- return result.plan.map(p => `${p.source} ${SYMBOLS.arrow} ${p.destination}`).join('\n');
331
- case 'table':
332
- default:
333
- return formatOrganizeTable(result);
334
- }
335
- }
336
-
337
- function formatOrganizeTable(result) {
338
- const lines = [];
339
- const plan = result.plan;
340
- const isDryRun = result.dryRun;
341
-
342
- lines.push('');
343
- if (isDryRun) {
344
- lines.push(c('yellow', ` ${SYMBOLS.eye} DRY RUN — No files will be moved`));
345
- } else {
346
- lines.push(c('green', ` ${SYMBOLS.sparkle} Organization Complete`));
347
- }
348
- lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
349
- lines.push(c('dim', ` ${'─'.repeat(60)}`));
350
-
351
- // Category breakdown
352
- const catSummary = result.planSummary.categories;
353
- const catData = Object.entries(catSummary)
354
- .sort((a, b) => b[1].count - a[1].count)
355
- .map(([cat, info]) => ({
356
- category: cat,
357
- files: String(info.count),
358
- size: formatBytes(info.size)
359
- }));
360
-
361
- if (catData.length > 0) {
362
- lines.push('');
363
- lines.push(c('bold', ' Breakdown'));
364
- lines.push(formatTable(catData, {
365
- columns: [
366
- { key: 'category', label: 'Category', width: 20 },
367
- { key: 'files', label: 'Files', width: 8, align: 'right' },
368
- { key: 'size', label: 'Size', width: 10, align: 'right' },
369
- ]
370
- }));
371
- }
372
-
373
- // Move preview (top 15)
374
- const preview = plan.slice(0, 15).map(p => ({
375
- from: p.originalName.length > 30 ? p.originalName.slice(0, 27) + '...' : p.originalName,
376
- to: p.categoryLabel,
377
- action: p.action === 'move' ? c('green', 'move') :
378
- p.action === 'skip' ? c('yellow', 'skip') :
379
- p.action === 'rename' ? c('cyan', 'rename') :
380
- c('red', p.action)
381
- }));
382
-
383
- if (preview.length > 0) {
384
- lines.push('');
385
- lines.push(c('bold', ' Operations'));
386
- lines.push(formatTable(preview, {
387
- columns: [
388
- { key: 'from', label: 'File', width: 32 },
389
- { key: 'to', label: 'Destination', width: 16 },
390
- { key: 'action', label: 'Action', width: 10 },
391
- ]
392
- }));
393
-
394
- if (plan.length > 15) {
395
- lines.push(c('dim', ` ... and ${plan.length - 15} more operations`));
396
- }
397
- }
398
-
399
- // Summary
400
- lines.push('');
401
- lines.push(c('dim', ` ${'─'.repeat(60)}`));
402
- lines.push(` ${c('bold', 'Total:')} ${result.planSummary.totalFiles} files, ${result.planSummary.totalSizeHuman}`);
403
-
404
- if (!isDryRun && result.execSummary) {
405
- const exec = result.execSummary;
406
- lines.push(` ${c('green', SYMBOLS.check)} ${exec.succeeded} succeeded`);
407
- if (exec.failed > 0) lines.push(` ${c('red', SYMBOLS.cross)} ${exec.failed} failed`);
408
- if (exec.skipped > 0) lines.push(` ${c('yellow', '⊘')} ${exec.skipped} skipped`);
409
- } else if (isDryRun) {
410
- lines.push(c('dim', ` Run without --dry-run to execute these changes`));
411
- }
412
-
413
- lines.push('');
414
- return lines.join('\n');
415
- }
416
-
417
- function formatOrganizeCSV(result) {
418
- const header = 'source,destination,category,original_name,new_name,size,action';
419
- const rows = result.plan.map(p =>
420
- `"${p.source}","${p.destination}","${p.category}","${p.originalName}","${p.newName}",${p.size},"${p.action}"`
421
- );
422
- return [header, ...rows].join('\n');
423
- }
424
-
425
- // ─── Clean Report ─────────────────────────────────────────────────
426
-
427
- function formatCleanReport(result, format = 'table') {
428
- switch (format) {
429
- case 'json':
430
- return JSON.stringify(result, null, 2);
431
- case 'csv':
432
- return formatCleanCSV(result);
433
- case 'minimal':
434
- return result.junk.map(j => `${j.sizeHuman}\t${j.path}`).join('\n');
435
- case 'table':
436
- default:
437
- return formatCleanTable(result);
438
- }
439
- }
440
-
441
- function formatCleanTable(result) {
442
- const lines = [];
443
- const isDryRun = result.dryRun;
444
-
445
- lines.push('');
446
- if (isDryRun) {
447
- lines.push(c('yellow', ` ${SYMBOLS.eye} DRY RUN — No files will be deleted`));
448
- } else if (result.deleteResult) {
449
- lines.push(c('green', ` ${SYMBOLS.trash} Cleanup Complete`));
450
- } else {
451
- lines.push(c('cyan', ` ${SYMBOLS.trash} Junk Scan Results`));
452
- }
453
- lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
454
- lines.push(c('dim', ` ${'─'.repeat(60)}`));
455
-
456
- // Category breakdown
457
- const byCategory = result.stats.byCategory;
458
- const catData = Object.entries(byCategory)
459
- .sort((a, b) => b[1].size - a[1].size)
460
- .map(([cat, info]) => ({
461
- category: cat.charAt(0).toUpperCase() + cat.slice(1),
462
- items: String(info.count),
463
- size: formatBytes(info.size)
464
- }));
465
-
466
- if (catData.length > 0) {
467
- lines.push('');
468
- lines.push(c('bold', ' Junk Categories'));
469
- lines.push(formatTable(catData, {
470
- columns: [
471
- { key: 'category', label: 'Category', width: 20 },
472
- { key: 'items', label: 'Items', width: 8, align: 'right' },
473
- { key: 'size', label: 'Size', width: 10, align: 'right' },
474
- ]
475
- }));
476
- }
477
-
478
- // Largest items (top 10)
479
- const sorted = [...result.junk].sort((a, b) => b.size - a.size).slice(0, 10);
480
- const itemData = sorted.map(j => ({
481
- name: j.name.length > 35 ? j.name.slice(0, 32) + '...' : j.name,
482
- type: j.type === 'directory' ? c('blue', 'DIR') : c('dim', 'FILE'),
483
- size: j.sizeHuman,
484
- }));
485
-
486
- if (itemData.length > 0) {
487
- lines.push('');
488
- lines.push(c('bold', ' Largest Junk Items'));
489
- lines.push(formatTable(itemData, {
490
- columns: [
491
- { key: 'name', label: 'Name', width: 37 },
492
- { key: 'type', label: 'Type', width: 6 },
493
- { key: 'size', label: 'Size', width: 10, align: 'right' },
494
- ]
495
- }));
496
- }
497
-
498
- // Summary
499
- lines.push('');
500
- lines.push(c('dim', ` ${'─'.repeat(60)}`));
501
- lines.push(` ${c('bold', 'Total junk:')} ${result.stats.filesFound + result.stats.dirsFound} items, ${result.stats.totalSizeHuman}`);
502
-
503
- if (result.deleteResult) {
504
- const dr = result.deleteResult;
505
- lines.push(` ${c('green', SYMBOLS.check)} Deleted ${dr.deleted} items, freed ${dr.freedHuman}`);
506
- if (dr.errors.length > 0) {
507
- lines.push(` ${c('red', SYMBOLS.cross)} ${dr.errors.length} errors`);
508
- }
509
- } else if (isDryRun) {
510
- lines.push(c('dim', ` Run without --dry-run to clean these files`));
511
- }
512
-
513
- lines.push('');
514
- return lines.join('\n');
515
- }
516
-
517
- function formatCleanCSV(result) {
518
- const header = 'name,path,category,type,size';
519
- const rows = result.junk.map(j =>
520
- `"${j.name}","${j.path}","${j.category}","${j.type}",${j.size}`
521
- );
522
- return [header, ...rows].join('\n');
523
- }
524
-
525
- // ─── Generic Utilities ────────────────────────────────────────────
526
-
527
- /**
528
- * Print a header banner
529
- */
530
- function banner() {
531
- return [
532
- '',
533
- c('bold', ` ${SYMBOLS.sparkle} FileMayor`) + c('dim', ' — Your Digital Life Organizer'),
534
- c('dim', ' by Lehlohonolo Goodwill Nchefu (Chevza)'),
535
- c('dim', ` ${'─'.repeat(50)}`),
536
- ''
537
- ].join('\n');
538
- }
539
-
540
- /**
541
- * Print a success message
542
- */
543
- function success(msg) {
544
- return `${c('green', SYMBOLS.check)} ${msg}`;
545
- }
546
-
547
- /**
548
- * Print an error message
549
- */
550
- function error(msg) {
551
- return `${c('red', SYMBOLS.cross)} ${msg}`;
552
- }
553
-
554
- /**
555
- * Print a warning message
556
- */
557
- function warn(msg) {
558
- return `${c('yellow', SYMBOLS.warning)} ${msg}`;
559
- }
560
-
561
- /**
562
- * Print an info message
563
- */
564
- function info(msg) {
565
- return `${c('cyan', SYMBOLS.info)} ${msg}`;
566
- }
567
-
568
- // ─── Analyze Report ────────────────────────────────────────────────
569
-
570
- function formatAnalyzeReport(result, format = 'table') {
571
- if (format === 'json') return JSON.stringify(result, null, 2);
572
-
573
- const lines = [];
574
- lines.push('');
575
- lines.push(c('bold', ` ${SYMBOLS.eye} Deep Intelligence Report: `) + c('cyan', result.root));
576
- lines.push(c('dim', ` ${'─'.repeat(60)}`));
577
-
578
- // Summary Insights
579
- lines.push('');
580
- lines.push(` ${c('bold', 'Summary:')} ${result.summary.totalFiles} files detected. Total size: ${result.summary.totalSizeHuman}`);
581
- lines.push(` ${c('green', '⚡ Insight:')} You can reclaim ${c('bold', result.summary.potentialSavingsHuman)} today.`);
582
-
583
- // Duplicates Section
584
- if (result.duplicates.sets > 0) {
585
- lines.push('');
586
- lines.push(c('bold', ' Duplicate Bloat'));
587
- const dupeData = result.duplicates.details.map(d => ({
588
- name: d.files[0].name.length > 35 ? d.files[0].name.slice(0, 32) + '...' : d.files[0].name,
589
- count: String(d.files.length),
590
- wasted: d.wastedSpaceHuman
591
- }));
592
- lines.push(formatTable(dupeData, {
593
- columns: [
594
- { key: 'name', label: 'File Name', width: 37 },
595
- { key: 'count', label: 'Copies', width: 8, align: 'right' },
596
- { key: 'wasted', label: 'Wasted', width: 10, align: 'right' }
597
- ]
598
- }));
599
- lines.push(` ${c('dim', ` Found ${result.duplicates.sets} duplicate sets causing ${result.duplicates.totalWastedHuman} bloat.`)}`);
600
- }
601
-
602
- // Largest Folders Section
603
- if (result.largestDirs.length > 0) {
604
- lines.push('');
605
- lines.push(c('bold', ' Top Space Consumers (Bloat Map)'));
606
- const dirData = result.largestDirs.map(d => ({
607
- name: d.name.length > 37 ? d.name.slice(0, 34) + '...' : d.name,
608
- files: String(d.count),
609
- size: d.sizeHuman
610
- }));
611
- lines.push(formatTable(dirData, {
612
- columns: [
613
- { key: 'name', label: 'Directory', width: 40 },
614
- { key: 'files', label: 'Files', width: 8, align: 'right' },
615
- { key: 'size', label: 'Size', width: 10, align: 'right' }
616
- ]
617
- }));
618
- }
619
-
620
- // Junk Section
621
- if (result.junk.count > 0) {
622
- lines.push('');
623
- lines.push(c('bold', ' Recoverable Junk'));
624
- const junkData = Object.entries(result.junk.categories).map(([cat, info]) => ({
625
- category: cat.charAt(0).toUpperCase() + cat.slice(1),
626
- size: formatBytes(info.size)
627
- }));
628
- lines.push(formatTable(junkData, {
629
- columns: [
630
- { key: 'category', label: 'Category', width: 40 },
631
- { key: 'size', label: 'Savings', width: 18, align: 'right' }
632
- ]
633
- }));
634
- lines.push(` ${c('dim', ` Total Junk: ${result.junk.count} items / ${result.junk.totalSizeHuman}`)}`);
635
- }
636
-
637
- lines.push('');
638
- lines.push(c('yellow', ' Run \'filemayor organize\' or \'filemayor clean\' to restore order and reclaim space.'));
639
- lines.push('');
640
-
641
- return lines.join('\n');
642
- }
643
-
644
- // ─── Diagnostic (Explain) Report ───────────────────────────────────
645
-
646
- /**
647
- * Format an explain/diagnostic result
648
- * @param {Object} result - Result from ExplainEngine
649
- * @returns {string}
650
- */
651
- function formatExplainReport(result) {
652
- const lines = [];
653
- const health = result.health;
654
-
655
- lines.push('');
656
- lines.push(c('bold', ` ${health.score > 70 ? SYMBOLS.sparkle : SYMBOLS.warning} ${result.title}`));
657
- lines.push(c('dim', ` ${'─'.repeat(40)}`));
658
-
659
- lines.push(` ${c('bold', 'Target:')} ${c('cyan', result.path)}`);
660
-
661
- // Health Gauge
662
- const gauge = progressBar(health.score, 20);
663
- lines.push(` ${c('bold', 'Health:')} ${c('bold', health.label)} (${gauge})`);
664
-
665
- lines.push('');
666
- lines.push(c('bold', ' Issues Detected:'));
667
- if (result.insights.length === 0) {
668
- lines.push(` ${c('green', SYMBOLS.check)} No major issues identified. Clean as a whistle.`);
669
- } else {
670
- for (const insight of result.insights) {
671
- lines.push(` ${c('yellow', SYMBOLS.bullet)} ${insight}`);
672
- }
673
- }
674
-
675
- lines.push('');
676
- lines.push(c('bold', ' Suggested Treatment:'));
677
- lines.push(` ${c('dim', 'Run')} ${c('yellow', 'filemayor cure')} ${c('dim', 'to generate an AI-powered curative plan.')}`);
678
- lines.push('');
679
-
680
- return lines.join('\n');
681
- }
682
-
683
- /**
684
- * Format a curative plan for terminal display
685
- */
686
- function formatCureReport(plan) {
687
- const lines = [];
688
- lines.push('');
689
- lines.push(c('bold', ` ${SYMBOLS.shield} CURATIVE PLAN GENERATED`));
690
- lines.push(c('dim', ` ${'─'.repeat(40)}`));
691
-
692
- lines.push(` ${c('bold', 'Strategy:')} ${plan.narrative}`);
693
- lines.push(` ${c('bold', 'Confidence:')} ${plan.confidence}%`);
694
-
695
- lines.push('');
696
- lines.push(c('bold', ' Proposed Actions:'));
697
- const preview = plan.plan.slice(0, 10).map(p => ({
698
- file: p.source.split(/[\\/]/).pop(),
699
- to: p.destination.split(/[\\/]/).pop(),
700
- }));
701
-
702
- lines.push(formatTable(preview, {
703
- columns: [
704
- { key: 'file', label: 'File', width: 25 },
705
- { key: 'to', label: 'Move to', width: 25 },
706
- ]
707
- }));
708
-
709
- if (plan.plan.length > 10) {
710
- lines.push(` ${c('dim', `... and ${plan.plan.length - 10} more actions.`)}`);
711
- }
712
-
713
- lines.push('');
714
- lines.push(c('yellow', ` Run 'filemayor apply' to execute this plan.`));
715
- lines.push('');
716
-
717
- return lines.join('\n');
718
- }
719
-
720
- /**
721
- * Format a duplicate detection report
722
- */
723
- function formatDedupeReport(result) {
724
- const lines = [];
725
- lines.push('');
726
- lines.push(c('bold', ` ${SYMBOLS.trash} DUPLICATE ANALYSIS: `) + c('cyan', result.path));
727
- lines.push(c('dim', ` ${'─'.repeat(50)}`));
728
-
729
- if (result.sets === 0) {
730
- lines.push(` ${c('green', SYMBOLS.check)} No duplicates detected. Zero bloat.`);
731
- lines.push('');
732
- return lines.join('\n');
733
- }
734
-
735
- lines.push(` ${c('red', 'BLOAT DETECTED:')} ${result.sets} duplicate files found.`);
736
- lines.push(` ${c('yellow', 'SAVINGS POTENTIAL:')} ${result.totalWastedHuman}`);
737
- lines.push('');
738
-
739
- lines.push(c('bold', ' Duplicate Items:'));
740
- const preview = result.duplicates.slice(0, 10).map(d => ({
741
- name: d.duplicate.name.length > 35 ? d.duplicate.name.slice(0, 32) + '...' : d.duplicate.name,
742
- size: d.duplicate.sizeHuman,
743
- }));
744
-
745
- lines.push(formatTable(preview, {
746
- columns: [
747
- { key: 'name', label: 'File', width: 37 },
748
- { key: 'size', label: 'Wasted', width: 10, align: 'right' }
749
- ]
750
- }));
751
-
752
- if (result.duplicates.length > 10) {
753
- lines.push(` ${c('dim', `... and ${result.duplicates.length - 10} more duplicates.`)}`);
754
- }
755
-
756
- lines.push('');
757
- lines.push(c('yellow', ` Run 'filemayor dedupe' to remove these and reclaim space.`));
758
- lines.push('');
759
-
760
- return lines.join('\n');
761
- }
762
-
763
- module.exports = {
764
- COLORS,
765
- SYMBOLS,
766
- c,
767
- setColors,
768
- formatTable,
769
- progressBar,
770
- Spinner,
771
- formatScanReport,
772
- formatOrganizeReport,
773
- formatCleanReport,
774
- formatAnalyzeReport,
775
- formatExplainReport,
776
- formatCureReport,
777
- formatDedupeReport,
778
- banner,
779
- success,
780
- error,
781
- warn,
782
- info,
783
- };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — REPORTER
6
+ * Multi-format output (table, JSON, CSV, minimal) with colors,
7
+ * summary statistics, and machine-readable formats for piping.
8
+ * ═══════════════════════════════════════════════════════════════════
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { formatBytes } = require('./scanner');
14
+
15
+ // ─── ANSI Color Codes ─────────────────────────────────────────────
16
+
17
+ const COLORS = {
18
+ reset: '\x1b[0m',
19
+ bold: '\x1b[1m',
20
+ dim: '\x1b[2m',
21
+ italic: '\x1b[3m',
22
+ underline: '\x1b[4m',
23
+
24
+ // Foreground
25
+ black: '\x1b[30m',
26
+ red: '\x1b[31m',
27
+ green: '\x1b[32m',
28
+ yellow: '\x1b[33m',
29
+ blue: '\x1b[34m',
30
+ magenta: '\x1b[35m',
31
+ cyan: '\x1b[36m',
32
+ white: '\x1b[37m',
33
+ gray: '\x1b[90m',
34
+
35
+ // Bright
36
+ brightRed: '\x1b[91m',
37
+ brightGreen: '\x1b[92m',
38
+ brightYellow: '\x1b[93m',
39
+ brightBlue: '\x1b[94m',
40
+ brightMagenta: '\x1b[95m',
41
+ brightCyan: '\x1b[96m',
42
+ brightWhite: '\x1b[97m',
43
+
44
+ // Background
45
+ bgRed: '\x1b[41m',
46
+ bgGreen: '\x1b[42m',
47
+ bgYellow: '\x1b[43m',
48
+ bgBlue: '\x1b[44m',
49
+ bgMagenta: '\x1b[45m',
50
+ bgCyan: '\x1b[46m',
51
+ };
52
+
53
+ let _colorsEnabled = true;
54
+
55
+ function c(color, text) {
56
+ if (!_colorsEnabled) return text;
57
+ return `${COLORS[color]}${text}${COLORS.reset}`;
58
+ }
59
+
60
+ function setColors(enabled) {
61
+ _colorsEnabled = enabled;
62
+ }
63
+
64
+ // ─── Symbols ──────────────────────────────────────────────────────
65
+
66
+ const SYMBOLS = {
67
+ check: '✓',
68
+ cross: '✗',
69
+ arrow: '→',
70
+ bullet: '•',
71
+ bar: '█',
72
+ halfBar: '▓',
73
+ lightBar: '░',
74
+ folder: '📂',
75
+ file: '📄',
76
+ trash: '🗑️',
77
+ sparkle: '✨',
78
+ warning: '⚠️',
79
+ error: '❌',
80
+ info: 'ℹ️',
81
+ clock: '⏱️',
82
+ shield: '🛡️',
83
+ eye: '👁️',
84
+ };
85
+
86
+ // ─── Table Formatter ──────────────────────────────────────────────
87
+
88
+ /**
89
+ * Format data as a table with aligned columns
90
+ * @param {Object[]} data - Array of objects
91
+ * @param {Object} options - Column config
92
+ * @returns {string} Formatted table
93
+ */
94
+ function formatTable(data, options = {}) {
95
+ if (!data || data.length === 0) return c('dim', ' (no data)');
96
+
97
+ const {
98
+ columns = null, // Column definitions [{key, label, width, align, format}]
99
+ maxWidth = null, // Max column width
100
+ indent = 2, // Left indent
101
+ } = options;
102
+
103
+ // Auto-detect columns from data
104
+ const cols = columns || Object.keys(data[0]).map(key => ({
105
+ key,
106
+ label: key.charAt(0).toUpperCase() + key.slice(1),
107
+ align: 'left'
108
+ }));
109
+
110
+ // Calculate column widths
111
+ for (const col of cols) {
112
+ if (!col.width) {
113
+ const maxDataLen = Math.max(
114
+ col.label.length,
115
+ ...data.map(row => {
116
+ const val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? '');
117
+ return val.length;
118
+ })
119
+ );
120
+ col.width = maxWidth ? Math.min(maxDataLen, maxWidth) : maxDataLen;
121
+ }
122
+ }
123
+
124
+ const lines = [];
125
+ const pad = ' '.repeat(indent);
126
+
127
+ // Header
128
+ const headerLine = cols.map(col => {
129
+ const label = col.label.padEnd(col.width).slice(0, col.width);
130
+ return c('bold', label);
131
+ }).join(c('dim', ' '));
132
+ lines.push(`${pad}${headerLine}`);
133
+
134
+ // Separator
135
+ const sep = cols.map(col => c('dim', '─'.repeat(col.width))).join(c('dim', '──'));
136
+ lines.push(`${pad}${sep}`);
137
+
138
+ // Rows
139
+ for (const row of data) {
140
+ const rowLine = cols.map(col => {
141
+ let val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? '');
142
+ const rawVal = val.replace(/\x1b\[[0-9;]*m/g, ''); // Strip ANSI for length calc
143
+
144
+ if (col.align === 'right') {
145
+ val = ' '.repeat(Math.max(0, col.width - rawVal.length)) + val;
146
+ } else {
147
+ val = val + ' '.repeat(Math.max(0, col.width - rawVal.length));
148
+ }
149
+ return val;
150
+ }).join(' ');
151
+ lines.push(`${pad}${rowLine}`);
152
+ }
153
+
154
+ return lines.join('\n');
155
+ }
156
+
157
+ // ─── Progress Bar ─────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Render a progress bar string
161
+ * @param {number} percent - 0-100
162
+ * @param {number} width - Bar width in chars
163
+ * @returns {string}
164
+ */
165
+ function progressBar(percent, width = 30) {
166
+ const clamped = Math.max(0, Math.min(100, percent || 0));
167
+ const filled = Math.round((clamped / 100) * width);
168
+ const empty = width - filled;
169
+ const bar = c('green', SYMBOLS.bar.repeat(filled)) +
170
+ c('dim', SYMBOLS.lightBar.repeat(empty));
171
+ return `${bar} ${c('bold', `${clamped}%`)}`;
172
+ }
173
+
174
+ // ─── Spinner ──────────────────────────────────────────────────────
175
+
176
+ class Spinner {
177
+ constructor(message = 'Working...') {
178
+ this.message = message;
179
+ this.frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
180
+ this.frameIndex = 0;
181
+ this.interval = null;
182
+ this.stream = process.stderr;
183
+ }
184
+
185
+ start() {
186
+ this.interval = setInterval(() => {
187
+ const frame = c('cyan', this.frames[this.frameIndex]);
188
+ this.stream.write(`\r${frame} ${this.message}`);
189
+ this.frameIndex = (this.frameIndex + 1) % this.frames.length;
190
+ }, 80);
191
+ return this;
192
+ }
193
+
194
+ update(message) {
195
+ this.message = message;
196
+ }
197
+
198
+ succeed(message) {
199
+ this.stop();
200
+ this.stream.write(`\r${c('green', SYMBOLS.check)} ${message || this.message}\n`);
201
+ }
202
+
203
+ fail(message) {
204
+ this.stop();
205
+ this.stream.write(`\r${c('red', SYMBOLS.cross)} ${message || this.message}\n`);
206
+ }
207
+
208
+ stop() {
209
+ if (this.interval) {
210
+ clearInterval(this.interval);
211
+ this.interval = null;
212
+ this.stream.write('\r' + ' '.repeat(this.message.length + 10) + '\r');
213
+ }
214
+ }
215
+ }
216
+
217
+ // ─── Scan Report ──────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Format a scan result for display
221
+ * @param {Object} scanResult - Result from scan()
222
+ * @param {string} format - Output format
223
+ * @returns {string}
224
+ */
225
+ function formatScanReport(scanResult, format = 'table') {
226
+ switch (format) {
227
+ case 'json':
228
+ return JSON.stringify(scanResult, null, 2);
229
+
230
+ case 'csv':
231
+ return formatScanCSV(scanResult);
232
+
233
+ case 'minimal':
234
+ return scanResult.files.map(f => f.path).join('\n');
235
+
236
+ case 'table':
237
+ default:
238
+ return formatScanTable(scanResult);
239
+ }
240
+ }
241
+
242
+ function formatScanTable(result) {
243
+ const lines = [];
244
+
245
+ // Header
246
+ lines.push('');
247
+ lines.push(c('bold', ` ${SYMBOLS.folder} Scan Report: `) + c('cyan', result.root));
248
+ lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
249
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
250
+
251
+ // Category summary
252
+ const catData = Object.entries(result.stats.categories)
253
+ .sort((a, b) => b[1] - a[1])
254
+ .map(([cat, count]) => ({
255
+ category: cat.charAt(0).toUpperCase() + cat.slice(1),
256
+ count: String(count),
257
+ }));
258
+
259
+ if (catData.length > 0) {
260
+ lines.push('');
261
+ lines.push(c('bold', ' Categories'));
262
+ lines.push(formatTable(catData, {
263
+ columns: [
264
+ { key: 'category', label: 'Category', width: 20 },
265
+ { key: 'count', label: 'Files', width: 8, align: 'right' },
266
+ ]
267
+ }));
268
+ }
269
+
270
+ // File list (top 25)
271
+ const fileData = result.files.slice(0, 25).map(f => ({
272
+ name: f.name.length > 40 ? f.name.slice(0, 37) + '...' : f.name,
273
+ category: f.category,
274
+ size: f.sizeHuman,
275
+ }));
276
+
277
+ if (fileData.length > 0) {
278
+ lines.push('');
279
+ lines.push(c('bold', ' Files'));
280
+ lines.push(formatTable(fileData, {
281
+ columns: [
282
+ { key: 'name', label: 'Name', width: 42 },
283
+ { key: 'category', label: 'Category', width: 14 },
284
+ { key: 'size', label: 'Size', width: 10, align: 'right' },
285
+ ]
286
+ }));
287
+
288
+ if (result.files.length > 25) {
289
+ lines.push(c('dim', ` ... and ${result.files.length - 25} more files`));
290
+ }
291
+ }
292
+
293
+ // Summary
294
+ lines.push('');
295
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
296
+ lines.push(` ${c('bold', 'Total:')} ${result.stats.filesFound} files, ${formatBytes(result.stats.totalSize)}`);
297
+ lines.push(` ${c('bold', 'Scanned:')} ${result.stats.dirsScanned} directories in ${result.stats.durationHuman}`);
298
+ if (result.stats.errors.length > 0) {
299
+ lines.push(` ${c('yellow', SYMBOLS.warning)} ${result.stats.errors.length} errors`);
300
+ }
301
+ lines.push('');
302
+
303
+ return lines.join('\n');
304
+ }
305
+
306
+ function formatScanCSV(result) {
307
+ const header = 'name,path,category,size,modified,ext';
308
+ const rows = result.files.map(f =>
309
+ `"${f.name}","${f.path}","${f.category}",${f.size},"${f.modified.toISOString()}","${f.ext}"`
310
+ );
311
+ return [header, ...rows].join('\n');
312
+ }
313
+
314
+ // ─── Organize Report ──────────────────────────────────────────────
315
+
316
+ function formatOrganizeReport(result, format = 'table') {
317
+ // Defensive: handle empty/incomplete results
318
+ if (!result || (!result.plan && !result.planSummary)) {
319
+ return `\n ${c('green', SYMBOLS.check)} Nothing to organize — folder is already clean.\n`;
320
+ }
321
+ if (!result.plan) result.plan = [];
322
+ if (!result.planSummary) result.planSummary = { categories: {}, totalFiles: 0, totalSizeHuman: '0 B' };
323
+
324
+ switch (format) {
325
+ case 'json':
326
+ return JSON.stringify(result, null, 2);
327
+ case 'csv':
328
+ return formatOrganizeCSV(result);
329
+ case 'minimal':
330
+ return result.plan.map(p => `${p.source} ${SYMBOLS.arrow} ${p.destination}`).join('\n');
331
+ case 'table':
332
+ default:
333
+ return formatOrganizeTable(result);
334
+ }
335
+ }
336
+
337
+ function formatOrganizeTable(result) {
338
+ const lines = [];
339
+ const plan = result.plan;
340
+ const isDryRun = result.dryRun;
341
+
342
+ lines.push('');
343
+ if (isDryRun) {
344
+ lines.push(c('yellow', ` ${SYMBOLS.eye} DRY RUN — No files will be moved`));
345
+ } else {
346
+ lines.push(c('green', ` ${SYMBOLS.sparkle} Organization Complete`));
347
+ }
348
+ lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
349
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
350
+
351
+ // Category breakdown
352
+ const catSummary = result.planSummary.categories;
353
+ const catData = Object.entries(catSummary)
354
+ .sort((a, b) => b[1].count - a[1].count)
355
+ .map(([cat, info]) => ({
356
+ category: cat,
357
+ files: String(info.count),
358
+ size: formatBytes(info.size)
359
+ }));
360
+
361
+ if (catData.length > 0) {
362
+ lines.push('');
363
+ lines.push(c('bold', ' Breakdown'));
364
+ lines.push(formatTable(catData, {
365
+ columns: [
366
+ { key: 'category', label: 'Category', width: 20 },
367
+ { key: 'files', label: 'Files', width: 8, align: 'right' },
368
+ { key: 'size', label: 'Size', width: 10, align: 'right' },
369
+ ]
370
+ }));
371
+ }
372
+
373
+ // Move preview (top 15)
374
+ const preview = plan.slice(0, 15).map(p => ({
375
+ from: p.originalName.length > 30 ? p.originalName.slice(0, 27) + '...' : p.originalName,
376
+ to: p.categoryLabel,
377
+ action: p.action === 'move' ? c('green', 'move') :
378
+ p.action === 'skip' ? c('yellow', 'skip') :
379
+ p.action === 'rename' ? c('cyan', 'rename') :
380
+ c('red', p.action)
381
+ }));
382
+
383
+ if (preview.length > 0) {
384
+ lines.push('');
385
+ lines.push(c('bold', ' Operations'));
386
+ lines.push(formatTable(preview, {
387
+ columns: [
388
+ { key: 'from', label: 'File', width: 32 },
389
+ { key: 'to', label: 'Destination', width: 16 },
390
+ { key: 'action', label: 'Action', width: 10 },
391
+ ]
392
+ }));
393
+
394
+ if (plan.length > 15) {
395
+ lines.push(c('dim', ` ... and ${plan.length - 15} more operations`));
396
+ }
397
+ }
398
+
399
+ // Summary
400
+ lines.push('');
401
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
402
+ lines.push(` ${c('bold', 'Total:')} ${result.planSummary.totalFiles} files, ${result.planSummary.totalSizeHuman}`);
403
+
404
+ if (!isDryRun && result.execSummary) {
405
+ const exec = result.execSummary;
406
+ lines.push(` ${c('green', SYMBOLS.check)} ${exec.succeeded} succeeded`);
407
+ if (exec.failed > 0) lines.push(` ${c('red', SYMBOLS.cross)} ${exec.failed} failed`);
408
+ if (exec.skipped > 0) lines.push(` ${c('yellow', '⊘')} ${exec.skipped} skipped`);
409
+ } else if (isDryRun) {
410
+ lines.push(c('dim', ` Run without --dry-run to execute these changes`));
411
+ }
412
+
413
+ lines.push('');
414
+ return lines.join('\n');
415
+ }
416
+
417
+ function formatOrganizeCSV(result) {
418
+ const header = 'source,destination,category,original_name,new_name,size,action';
419
+ const rows = result.plan.map(p =>
420
+ `"${p.source}","${p.destination}","${p.category}","${p.originalName}","${p.newName}",${p.size},"${p.action}"`
421
+ );
422
+ return [header, ...rows].join('\n');
423
+ }
424
+
425
+ // ─── Clean Report ─────────────────────────────────────────────────
426
+
427
+ function formatCleanReport(result, format = 'table') {
428
+ switch (format) {
429
+ case 'json':
430
+ return JSON.stringify(result, null, 2);
431
+ case 'csv':
432
+ return formatCleanCSV(result);
433
+ case 'minimal':
434
+ return result.junk.map(j => `${j.sizeHuman}\t${j.path}`).join('\n');
435
+ case 'table':
436
+ default:
437
+ return formatCleanTable(result);
438
+ }
439
+ }
440
+
441
+ function formatCleanTable(result) {
442
+ const lines = [];
443
+ const isDryRun = result.dryRun;
444
+
445
+ lines.push('');
446
+ if (isDryRun) {
447
+ lines.push(c('yellow', ` ${SYMBOLS.eye} DRY RUN — No files will be deleted`));
448
+ } else if (result.deleteResult) {
449
+ lines.push(c('green', ` ${SYMBOLS.trash} Cleanup Complete`));
450
+ } else {
451
+ lines.push(c('cyan', ` ${SYMBOLS.trash} Junk Scan Results`));
452
+ }
453
+ lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
454
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
455
+
456
+ // Category breakdown
457
+ const byCategory = result.stats.byCategory;
458
+ const catData = Object.entries(byCategory)
459
+ .sort((a, b) => b[1].size - a[1].size)
460
+ .map(([cat, info]) => ({
461
+ category: cat.charAt(0).toUpperCase() + cat.slice(1),
462
+ items: String(info.count),
463
+ size: formatBytes(info.size)
464
+ }));
465
+
466
+ if (catData.length > 0) {
467
+ lines.push('');
468
+ lines.push(c('bold', ' Junk Categories'));
469
+ lines.push(formatTable(catData, {
470
+ columns: [
471
+ { key: 'category', label: 'Category', width: 20 },
472
+ { key: 'items', label: 'Items', width: 8, align: 'right' },
473
+ { key: 'size', label: 'Size', width: 10, align: 'right' },
474
+ ]
475
+ }));
476
+ }
477
+
478
+ // Largest items (top 10)
479
+ const sorted = [...result.junk].sort((a, b) => b.size - a.size).slice(0, 10);
480
+ const itemData = sorted.map(j => ({
481
+ name: j.name.length > 35 ? j.name.slice(0, 32) + '...' : j.name,
482
+ type: j.type === 'directory' ? c('blue', 'DIR') : c('dim', 'FILE'),
483
+ size: j.sizeHuman,
484
+ }));
485
+
486
+ if (itemData.length > 0) {
487
+ lines.push('');
488
+ lines.push(c('bold', ' Largest Junk Items'));
489
+ lines.push(formatTable(itemData, {
490
+ columns: [
491
+ { key: 'name', label: 'Name', width: 37 },
492
+ { key: 'type', label: 'Type', width: 6 },
493
+ { key: 'size', label: 'Size', width: 10, align: 'right' },
494
+ ]
495
+ }));
496
+ }
497
+
498
+ // Summary
499
+ lines.push('');
500
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
501
+ lines.push(` ${c('bold', 'Total junk:')} ${result.stats.filesFound + result.stats.dirsFound} items, ${result.stats.totalSizeHuman}`);
502
+
503
+ if (result.deleteResult) {
504
+ const dr = result.deleteResult;
505
+ lines.push(` ${c('green', SYMBOLS.check)} Deleted ${dr.deleted} items, freed ${dr.freedHuman}`);
506
+ if (dr.errors.length > 0) {
507
+ lines.push(` ${c('red', SYMBOLS.cross)} ${dr.errors.length} errors`);
508
+ }
509
+ } else if (isDryRun) {
510
+ lines.push(c('dim', ` Run without --dry-run to clean these files`));
511
+ }
512
+
513
+ lines.push('');
514
+ return lines.join('\n');
515
+ }
516
+
517
+ function formatCleanCSV(result) {
518
+ const header = 'name,path,category,type,size';
519
+ const rows = result.junk.map(j =>
520
+ `"${j.name}","${j.path}","${j.category}","${j.type}",${j.size}`
521
+ );
522
+ return [header, ...rows].join('\n');
523
+ }
524
+
525
+ // ─── Generic Utilities ────────────────────────────────────────────
526
+
527
+ /**
528
+ * Print a header banner
529
+ */
530
+ function banner() {
531
+ return [
532
+ '',
533
+ c('bold', ` ${SYMBOLS.sparkle} FileMayor`) + c('dim', ' — Your Digital Life Organizer'),
534
+ c('dim', ' by Lehlohonolo Goodwill Nchefu (Chevza)'),
535
+ c('dim', ` ${'─'.repeat(50)}`),
536
+ ''
537
+ ].join('\n');
538
+ }
539
+
540
+ /**
541
+ * Print a success message
542
+ */
543
+ function success(msg) {
544
+ return `${c('green', SYMBOLS.check)} ${msg}`;
545
+ }
546
+
547
+ /**
548
+ * Print an error message
549
+ */
550
+ function error(msg) {
551
+ return `${c('red', SYMBOLS.cross)} ${msg}`;
552
+ }
553
+
554
+ /**
555
+ * Print a warning message
556
+ */
557
+ function warn(msg) {
558
+ return `${c('yellow', SYMBOLS.warning)} ${msg}`;
559
+ }
560
+
561
+ /**
562
+ * Print an info message
563
+ */
564
+ function info(msg) {
565
+ return `${c('cyan', SYMBOLS.info)} ${msg}`;
566
+ }
567
+
568
+ // ─── Analyze Report ────────────────────────────────────────────────
569
+
570
+ function formatAnalyzeReport(result, format = 'table') {
571
+ if (format === 'json') return JSON.stringify(result, null, 2);
572
+
573
+ const lines = [];
574
+ lines.push('');
575
+ lines.push(c('bold', ` ${SYMBOLS.eye} Deep Intelligence Report: `) + c('cyan', result.root));
576
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
577
+
578
+ // Summary Insights
579
+ lines.push('');
580
+ lines.push(` ${c('bold', 'Summary:')} ${result.summary.totalFiles} files detected. Total size: ${result.summary.totalSizeHuman}`);
581
+ lines.push(` ${c('green', '⚡ Insight:')} You can reclaim ${c('bold', result.summary.potentialSavingsHuman)} today.`);
582
+
583
+ // Duplicates Section
584
+ if (result.duplicates.sets > 0) {
585
+ lines.push('');
586
+ lines.push(c('bold', ' Duplicate Bloat'));
587
+ const dupeData = result.duplicates.details.map(d => ({
588
+ name: d.files[0].name.length > 35 ? d.files[0].name.slice(0, 32) + '...' : d.files[0].name,
589
+ count: String(d.files.length),
590
+ wasted: d.wastedSpaceHuman
591
+ }));
592
+ lines.push(formatTable(dupeData, {
593
+ columns: [
594
+ { key: 'name', label: 'File Name', width: 37 },
595
+ { key: 'count', label: 'Copies', width: 8, align: 'right' },
596
+ { key: 'wasted', label: 'Wasted', width: 10, align: 'right' }
597
+ ]
598
+ }));
599
+ lines.push(` ${c('dim', ` Found ${result.duplicates.sets} duplicate sets causing ${result.duplicates.totalWastedHuman} bloat.`)}`);
600
+ }
601
+
602
+ // Largest Folders Section
603
+ if (result.largestDirs.length > 0) {
604
+ lines.push('');
605
+ lines.push(c('bold', ' Top Space Consumers (Bloat Map)'));
606
+ const dirData = result.largestDirs.map(d => ({
607
+ name: d.name.length > 37 ? d.name.slice(0, 34) + '...' : d.name,
608
+ files: String(d.count),
609
+ size: d.sizeHuman
610
+ }));
611
+ lines.push(formatTable(dirData, {
612
+ columns: [
613
+ { key: 'name', label: 'Directory', width: 40 },
614
+ { key: 'files', label: 'Files', width: 8, align: 'right' },
615
+ { key: 'size', label: 'Size', width: 10, align: 'right' }
616
+ ]
617
+ }));
618
+ }
619
+
620
+ // Junk Section
621
+ if (result.junk.count > 0) {
622
+ lines.push('');
623
+ lines.push(c('bold', ' Recoverable Junk'));
624
+ const junkData = Object.entries(result.junk.categories).map(([cat, info]) => ({
625
+ category: cat.charAt(0).toUpperCase() + cat.slice(1),
626
+ size: formatBytes(info.size)
627
+ }));
628
+ lines.push(formatTable(junkData, {
629
+ columns: [
630
+ { key: 'category', label: 'Category', width: 40 },
631
+ { key: 'size', label: 'Savings', width: 18, align: 'right' }
632
+ ]
633
+ }));
634
+ lines.push(` ${c('dim', ` Total Junk: ${result.junk.count} items / ${result.junk.totalSizeHuman}`)}`);
635
+ }
636
+
637
+ lines.push('');
638
+ lines.push(c('yellow', ' Run \'filemayor organize\' or \'filemayor clean\' to restore order and reclaim space.'));
639
+ lines.push('');
640
+
641
+ return lines.join('\n');
642
+ }
643
+
644
+ // ─── Diagnostic (Explain) Report ───────────────────────────────────
645
+
646
+ /**
647
+ * Format an explain/diagnostic result
648
+ * @param {Object} result - Result from ExplainEngine
649
+ * @returns {string}
650
+ */
651
+ function formatExplainReport(result) {
652
+ const lines = [];
653
+ const health = result.health;
654
+
655
+ lines.push('');
656
+ lines.push(c('bold', ` ${health.score > 70 ? SYMBOLS.sparkle : SYMBOLS.warning} ${result.title}`));
657
+ lines.push(c('dim', ` ${'─'.repeat(40)}`));
658
+
659
+ lines.push(` ${c('bold', 'Target:')} ${c('cyan', result.path)}`);
660
+
661
+ // Health Gauge
662
+ const gauge = progressBar(health.score, 20);
663
+ lines.push(` ${c('bold', 'Health:')} ${c('bold', health.label)} (${gauge})`);
664
+
665
+ lines.push('');
666
+ lines.push(c('bold', ' Issues Detected:'));
667
+ if (result.insights.length === 0) {
668
+ lines.push(` ${c('green', SYMBOLS.check)} No major issues identified. Clean as a whistle.`);
669
+ } else {
670
+ for (const insight of result.insights) {
671
+ lines.push(` ${c('yellow', SYMBOLS.bullet)} ${insight}`);
672
+ }
673
+ }
674
+
675
+ lines.push('');
676
+ lines.push(c('bold', ' Suggested Treatment:'));
677
+ lines.push(` ${c('dim', 'Run')} ${c('yellow', 'filemayor cure')} ${c('dim', 'to generate an AI-powered curative plan.')}`);
678
+ lines.push('');
679
+
680
+ return lines.join('\n');
681
+ }
682
+
683
+ /**
684
+ * Format a curative plan for terminal display
685
+ */
686
+ function formatCureReport(plan) {
687
+ const lines = [];
688
+ lines.push('');
689
+ lines.push(c('bold', ` ${SYMBOLS.shield} CURATIVE PLAN GENERATED`));
690
+ lines.push(c('dim', ` ${'─'.repeat(40)}`));
691
+
692
+ lines.push(` ${c('bold', 'Strategy:')} ${plan.narrative}`);
693
+ lines.push(` ${c('bold', 'Confidence:')} ${plan.confidence}%`);
694
+
695
+ lines.push('');
696
+ lines.push(c('bold', ' Proposed Actions:'));
697
+ const preview = plan.plan.slice(0, 10).map(p => ({
698
+ file: p.source.split(/[\\/]/).pop(),
699
+ to: p.destination.split(/[\\/]/).pop(),
700
+ }));
701
+
702
+ lines.push(formatTable(preview, {
703
+ columns: [
704
+ { key: 'file', label: 'File', width: 25 },
705
+ { key: 'to', label: 'Move to', width: 25 },
706
+ ]
707
+ }));
708
+
709
+ if (plan.plan.length > 10) {
710
+ lines.push(` ${c('dim', `... and ${plan.plan.length - 10} more actions.`)}`);
711
+ }
712
+
713
+ lines.push('');
714
+ lines.push(c('yellow', ` Run 'filemayor apply' to execute this plan.`));
715
+ lines.push('');
716
+
717
+ return lines.join('\n');
718
+ }
719
+
720
+ /**
721
+ * Format a duplicate detection report
722
+ */
723
+ function formatDedupeReport(result) {
724
+ const lines = [];
725
+ lines.push('');
726
+ lines.push(c('bold', ` ${SYMBOLS.trash} DUPLICATE ANALYSIS: `) + c('cyan', result.path));
727
+ lines.push(c('dim', ` ${'─'.repeat(50)}`));
728
+
729
+ if (result.sets === 0) {
730
+ lines.push(` ${c('green', SYMBOLS.check)} No duplicates detected. Zero bloat.`);
731
+ lines.push('');
732
+ return lines.join('\n');
733
+ }
734
+
735
+ lines.push(` ${c('red', 'BLOAT DETECTED:')} ${result.sets} duplicate files found.`);
736
+ lines.push(` ${c('yellow', 'SAVINGS POTENTIAL:')} ${result.totalWastedHuman}`);
737
+ lines.push('');
738
+
739
+ lines.push(c('bold', ' Duplicate Items:'));
740
+ const preview = result.duplicates.slice(0, 10).map(d => ({
741
+ name: d.duplicate.name.length > 35 ? d.duplicate.name.slice(0, 32) + '...' : d.duplicate.name,
742
+ size: d.duplicate.sizeHuman,
743
+ }));
744
+
745
+ lines.push(formatTable(preview, {
746
+ columns: [
747
+ { key: 'name', label: 'File', width: 37 },
748
+ { key: 'size', label: 'Wasted', width: 10, align: 'right' }
749
+ ]
750
+ }));
751
+
752
+ if (result.duplicates.length > 10) {
753
+ lines.push(` ${c('dim', `... and ${result.duplicates.length - 10} more duplicates.`)}`);
754
+ }
755
+
756
+ lines.push('');
757
+ lines.push(c('yellow', ` Run 'filemayor dedupe' to remove these and reclaim space.`));
758
+ lines.push('');
759
+
760
+ return lines.join('\n');
761
+ }
762
+
763
+ module.exports = {
764
+ COLORS,
765
+ SYMBOLS,
766
+ c,
767
+ setColors,
768
+ formatTable,
769
+ progressBar,
770
+ Spinner,
771
+ formatScanReport,
772
+ formatOrganizeReport,
773
+ formatCleanReport,
774
+ formatAnalyzeReport,
775
+ formatExplainReport,
776
+ formatCureReport,
777
+ formatDedupeReport,
778
+ banner,
779
+ success,
780
+ error,
781
+ warn,
782
+ info,
783
+ };