CodeCleanupApplication.java

package org.sandbox.jdt.core.cleanupapp;

/*-
 * #%L
 * Sandbox cleanup application
 * %%
 * Copyright (C) 2024 hammer
 * %%
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 * 
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the Eclipse
 * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
 * with the GNU Classpath Exception which is
 * available at https://www.gnu.org/software/classpath/license.html.
 * 
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 * #L%
 */


import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.core.runtime.Path;
import org.eclipse.equinox.app.IApplication;
import org.eclipse.equinox.app.IApplicationContext;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.internal.core.util.Util;
import org.eclipse.jdt.internal.corext.fix.CleanUpRefactoring;
import org.eclipse.jdt.internal.ui.JavaPlugin;
import org.eclipse.jdt.ui.cleanup.CleanUpOptions;
import org.eclipse.jdt.ui.cleanup.ICleanUp;
import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.RefactoringStatus;

public class CodeCleanupApplication implements IApplication {
	private static final File[] FILES = new File[0];

	private static final String ARG_CONFIG = "-config"; //$NON-NLS-1$

	private static final String ARG_HELP = "-help"; //$NON-NLS-1$

	private static final String ARG_QUIET = "-quiet"; //$NON-NLS-1$

	private static final String ARG_VERBOSE = "-verbose"; //$NON-NLS-1$

	private static final String ARG_MODE = "--mode"; //$NON-NLS-1$

	private static final String ARG_SOURCE = "--source"; //$NON-NLS-1$

	private static final String ARG_SCOPE = "--scope"; //$NON-NLS-1$

	private static final String ARG_PATCH = "--patch"; //$NON-NLS-1$

	private static final String ARG_REPORT = "--report"; //$NON-NLS-1$

	/** Exit code: success, no changes needed (check) or applied (apply). */
	static final int EXIT_OK = 0;

	/** Exit code: error (parsing, IO, config invalid, etc.). */
	static final int EXIT_ERROR = 1;

	/** Exit code: changes detected/needed (check/diff mode). */
	static final int EXIT_CHANGES = 2;

	/**
	 * Execution mode for the cleanup application.
	 */
	enum CleanupMode {
		/** Apply changes to files (default, backwards compatible). */
		APPLY,
		/** Check for changes without modifying files; exit 2 if changes needed. */
		CHECK,
		/** Generate unified diff output without modifying files. */
		DIFF
	}

	/**
	 * Scope filter for source files.
	 */
	enum CleanupScope {
		MAIN, TEST, BOTH
	}

	private String configName;

	private Map<String, String> options = null;

	private static final String PDE_LAUNCH = "-pdelaunch"; //$NON-NLS-1$

	private boolean quiet = false;

	private boolean verbose = false;

	CleanupMode cleanupMode = CleanupMode.APPLY;

	CleanupScope cleanupScope = CleanupScope.BOTH;

	String patchFile = null;

	String reportFile = null;

	private static final int INITIALSIZE = 1;

	private static final int DEFAULT_PARSE_MODE = 0;

	private static final int CONFIG_PARSE_MODE = 1;

	private static final int MODE_PARSE_MODE = 2;

	private static final int SOURCE_PARSE_MODE = 3;

	private static final int SCOPE_PARSE_MODE = 4;

	private static final int PATCH_PARSE_MODE = 5;

	private static final int REPORT_PARSE_MODE = 6;

	private final List<String> changedFiles = new ArrayList<>();

	private final StringBuilder patchContent = new StringBuilder();

	private int filesProcessed = 0;


	/**
	 * Clean up the given Java source file. In CHECK/DIFF modes, detects changes
	 * without writing them to disk.
	 */
	private void cleanFile(final File file) {
		try {
			if (this.verbose) {
				System.out.println(Messages.bind(Messages.CommandLineCleaning, file.getAbsolutePath()));
			}

			IPath filePath = Path.fromOSString(file.getAbsolutePath());
			IFile iFile = ResourcesPlugin.getWorkspace().getRoot().getFileForLocation(filePath);

			if (iFile == null || !iFile.exists()) {
				if (!this.quiet) {
					System.err.println(Messages.bind(Messages.FileOutsideWorkspace, file.getAbsolutePath()));
				}
				return;
			}

			ICompilationUnit cu = JavaCore.createCompilationUnitFrom(iFile);
			if (cu == null) {
				if (!this.quiet) {
					System.err.println(Messages.bind(Messages.CleanupProblem, file.getAbsolutePath()));
				}
				return;
			}

			this.filesProcessed++;

			// Snapshot original content for change detection in CHECK/DIFF modes
			byte[] originalContent = null;
			if (this.cleanupMode == CleanupMode.CHECK || this.cleanupMode == CleanupMode.DIFF) {
				originalContent = Files.readAllBytes(file.toPath());
			}

			CleanUpRefactoring refactoring = new CleanUpRefactoring();
			refactoring.addCompilationUnit(cu);

			ICleanUp[] cleanUps = JavaPlugin.getDefault().getCleanUpRegistry().createCleanUps();
			if (this.options != null && cleanUps.length > 0) {
				CleanUpOptions cleanUpOptions = new CleanUpOptions();
				for (Map.Entry<String, String> entry : this.options.entrySet()) {
					cleanUpOptions.setOption(entry.getKey(), entry.getValue());
				}
				for (ICleanUp cleanUp : cleanUps) {
					cleanUp.setOptions(cleanUpOptions);
					refactoring.addCleanUp(cleanUp);
				}
			} else {
				refactoring.setUseOptionsFromProfile(true);
				for (ICleanUp cleanUp : cleanUps) {
					refactoring.addCleanUp(cleanUp);
				}
			}

			RefactoringStatus status = refactoring.checkAllConditions(new NullProgressMonitor());
			if (status.hasFatalError()) {
				if (!this.quiet) {
					System.err.println(Messages.bind(Messages.CleanupFatalError, file.getAbsolutePath(), status.getMessageMatchingSeverity(RefactoringStatus.FATAL)));
				}
				return;
			}

			Change change = refactoring.createChange(new NullProgressMonitor());
			if (change != null) {
				if (this.cleanupMode == CleanupMode.CHECK || this.cleanupMode == CleanupMode.DIFF) {
					// Perform the change, then compare and restore
					change.perform(new NullProgressMonitor());
					cu.save(new NullProgressMonitor(), true);
					iFile.refreshLocal(1, new NullProgressMonitor());

					byte[] newContent = Files.readAllBytes(file.toPath());
					boolean changed = !MessageDigest.isEqual(
							computeHash(originalContent), computeHash(newContent));

					if (changed) {
						this.changedFiles.add(file.getAbsolutePath());

						String origStr = new String(originalContent, StandardCharsets.UTF_8);
						String newStr = new String(newContent, StandardCharsets.UTF_8);

						if (this.cleanupMode == CleanupMode.DIFF && !this.quiet) {
							printUnifiedDiff(file.getAbsolutePath(), origStr, newStr);
						}

						// Capture diff for patch file
						if (this.patchFile != null) {
							appendUnifiedDiff(file.getAbsolutePath(), origStr, newStr);
						}
					}

					// Restore original content (dry-run)
					Files.write(file.toPath(), originalContent);
					iFile.refreshLocal(1, new NullProgressMonitor());
				} else {
					// APPLY mode – snapshot before, apply, compare
					byte[] beforeContent = Files.readAllBytes(file.toPath());
					change.perform(new NullProgressMonitor());
					cu.save(new NullProgressMonitor(), true);
					iFile.refreshLocal(1, new NullProgressMonitor());

					byte[] afterContent = Files.readAllBytes(file.toPath());
					if (!MessageDigest.isEqual(computeHash(beforeContent), computeHash(afterContent))) {
						this.changedFiles.add(file.getAbsolutePath());
					}
				}
			}

		} catch (CoreException e) {
			final String errorMessage = Messages.bind(Messages.CaughtException, "CoreException", e.getLocalizedMessage()); //$NON-NLS-1$
			Util.log(e, errorMessage);
			System.err.println(Messages.bind(Messages.ExceptionSkip, errorMessage));
		} catch (Exception e) {
			final String errorMessage = Messages.bind(Messages.CaughtException, e.getClass().getSimpleName(), e.getLocalizedMessage());
			Util.log(e, errorMessage);
			System.err.println(Messages.bind(Messages.ExceptionSkip, errorMessage));
		}
	}

	File[] processCommandLine(final String[] argsArray) {

		int index = 0;
		final int argCount = argsArray.length;

		int parseMode = DEFAULT_PARSE_MODE;

		int fileCounter = 0;

		File[] filesToCleanup = new File[INITIALSIZE];

		loop: while (index < argCount) {
			final String currentArg = argsArray[index++];

			switch (parseMode) {
				default:
					break;
				case DEFAULT_PARSE_MODE:
					if (PDE_LAUNCH.equals(currentArg)) {
						continue loop;
					}
					if (ARG_HELP.equals(currentArg) || "--help".equals(currentArg)) { //$NON-NLS-1$
						displayHelp();
						return FILES;
					}
					if (ARG_VERBOSE.equals(currentArg) || "--verbose".equals(currentArg)) { //$NON-NLS-1$
						this.verbose = true;
						continue loop;
					}
					if (ARG_QUIET.equals(currentArg) || "--quiet".equals(currentArg)) { //$NON-NLS-1$
						this.quiet = true;
						continue loop;
					}
					if (ARG_CONFIG.equals(currentArg) || "--config".equals(currentArg)) { //$NON-NLS-1$
						parseMode = CONFIG_PARSE_MODE;
						continue loop;
					}
					if (ARG_MODE.equals(currentArg)) {
						parseMode = MODE_PARSE_MODE;
						continue loop;
					}
					if (ARG_SOURCE.equals(currentArg)) {
						parseMode = SOURCE_PARSE_MODE;
						continue loop;
					}
					if (ARG_SCOPE.equals(currentArg)) {
						parseMode = SCOPE_PARSE_MODE;
						continue loop;
					}
					if (ARG_PATCH.equals(currentArg)) {
						parseMode = PATCH_PARSE_MODE;
						continue loop;
					}
					if (ARG_REPORT.equals(currentArg)) {
						parseMode = REPORT_PARSE_MODE;
						continue loop;
					}
					// the current arg should be a file or a directory name
					final File file = new File(currentArg);
					if (file.exists()) {
						if (filesToCleanup.length == fileCounter) {
							System.arraycopy(filesToCleanup, 0, filesToCleanup = new File[fileCounter * 2], 0, fileCounter);
						}
						filesToCleanup[fileCounter++] = file;
					} else {
						String canonicalPath;
						try {
							canonicalPath = file.getCanonicalPath();
						} catch (IOException e2) {
							canonicalPath = file.getAbsolutePath();
						}
						final String errorMsg = file.isAbsolute() ?
								Messages.bind(Messages.CommandLineErrorFile, canonicalPath) :
								Messages.bind(Messages.CommandLineErrorFileTryFullPath, canonicalPath);
						displayHelp(errorMsg);
						return FILES;
					}
					break;
				case CONFIG_PARSE_MODE:
					this.configName = currentArg;
					this.options = readConfig(currentArg);
					if (this.options == null) {
						displayHelp(Messages.bind(Messages.CommandLineErrorConfig, currentArg));
						return FILES;
					}
					parseMode = DEFAULT_PARSE_MODE;
					continue loop;
				case MODE_PARSE_MODE:
					try {
						this.cleanupMode = CleanupMode.valueOf(currentArg.toUpperCase());
					} catch (@SuppressWarnings("unused") IllegalArgumentException e) {
						displayHelp(Messages.bind(Messages.CommandLineErrorInvalidMode, currentArg));
						return FILES;
					}
					parseMode = DEFAULT_PARSE_MODE;
					continue loop;
				case SOURCE_PARSE_MODE: {
					final File sourceDir = new File(currentArg);
					if (sourceDir.exists()) {
						if (filesToCleanup.length == fileCounter) {
							System.arraycopy(filesToCleanup, 0, filesToCleanup = new File[fileCounter * 2], 0, fileCounter);
						}
						filesToCleanup[fileCounter++] = sourceDir;
					} else {
						displayHelp(Messages.bind(Messages.CommandLineErrorFile, currentArg));
						return FILES;
					}
					parseMode = DEFAULT_PARSE_MODE;
					continue loop;
				}
				case SCOPE_PARSE_MODE:
					try {
						this.cleanupScope = CleanupScope.valueOf(currentArg.toUpperCase());
					} catch (@SuppressWarnings("unused") IllegalArgumentException e) {
						displayHelp(Messages.bind(Messages.CommandLineErrorInvalidScope, currentArg));
						return FILES;
					}
					parseMode = DEFAULT_PARSE_MODE;
					continue loop;
				case PATCH_PARSE_MODE:
					this.patchFile = currentArg;
					parseMode = DEFAULT_PARSE_MODE;
					continue loop;
				case REPORT_PARSE_MODE:
					this.reportFile = currentArg;
					parseMode = DEFAULT_PARSE_MODE;
					continue loop;
			}
		}

		if (parseMode == CONFIG_PARSE_MODE || this.options == null) {
			displayHelp(Messages.bind(Messages.CommandLineErrorNoConfigFile));
			return null;
		}
		if (this.quiet && this.verbose) {
			displayHelp(
				Messages.bind(
					Messages.CommandLineErrorQuietVerbose,
					new String[] { ARG_QUIET, ARG_VERBOSE }
				));
			return null;
		}
		if (fileCounter == 0) {
			displayHelp(Messages.bind(Messages.CommandLineErrorFileDir));
			return null;
		}
		if (filesToCleanup.length != fileCounter) {
			System.arraycopy(filesToCleanup, 0, filesToCleanup = new File[fileCounter], 0, fileCounter);
		}
		return filesToCleanup;
	}

	/**
	 * Return a Java Properties file representing the options that are in the
	 * specified configuration file.
	 */
	private static Map<String, String> readConfig(final String filename) {
		final File configFile = new File(filename);
		try (BufferedInputStream stream = new BufferedInputStream(new FileInputStream(configFile))) {
			final Properties formatterOptions = new Properties();
			formatterOptions.load(stream);
			Map<String, String> optionsMap = new HashMap<>();
			for (String key : formatterOptions.stringPropertyNames()) {
				optionsMap.put(key, formatterOptions.getProperty(key));
			}
			return optionsMap;
		} catch (IOException e) {
			String canonicalPath = null;
			try {
				canonicalPath = configFile.getCanonicalPath();
			} catch (IOException e2) {
				canonicalPath = configFile.getAbsolutePath();
			}
			final String errorMessage;
			if (!configFile.exists() && !configFile.isAbsolute()) {
				errorMessage = Messages.bind(Messages.ConfigFileNotFoundErrorTryFullPath, new Object[] {
					canonicalPath,
					System.getProperty("user.dir") //$NON-NLS-1$
				});

			} else {
				errorMessage = Messages.bind(Messages.ConfigFileReadingError, canonicalPath);
			}
			Util.log(e, errorMessage);
			System.err.println(errorMessage);
		}
		return null;
	}

	/**
	 * Runs the Java code cleanup application
	 */
	@Override
	public Object start(final IApplicationContext context) throws Exception {
		Instant startTime = Instant.now();
		final File[] filesToCleanup = processCommandLine((String[]) context.getArguments().get(IApplicationContext.APPLICATION_ARGS));

		if (filesToCleanup == null) {
			return IApplication.EXIT_OK;
		}

		if (!this.quiet) {
			if (this.configName != null) {
				System.out.println(Messages.bind(Messages.CommandLineConfigFile, this.configName));
			}
			System.out.println(Messages.bind(Messages.CommandLineStart));
			if (this.cleanupMode != CleanupMode.APPLY) {
				System.out.println(Messages.bind(Messages.CommandLineMode, this.cleanupMode.name().toLowerCase()));
			}
		}

		// clean up the list of files and/or directories
		for (final File file : filesToCleanup) {
			if (file.isDirectory()) {
				cleanDirTree(file);
			} else if (Util.isJavaLikeFileName(file.getPath())) {
				cleanFile(file);
			}
		}

		Instant endTime = Instant.now();

		// Write patch file if requested
		if (this.patchFile != null && !this.changedFiles.isEmpty()) {
			writePatchFile(filesToCleanup);
		}

		// Write JSON report if requested
		if (this.reportFile != null) {
			writeJsonReport(startTime, endTime);
		}

		if (!this.quiet) {
			System.out.println(Messages.bind(Messages.CommandLineDone));
			if (!this.changedFiles.isEmpty()) {
				System.out.println(Messages.bind(Messages.CommandLineChangedFiles,
						String.valueOf(this.changedFiles.size())));
			}
		}

		// Determine exit code based on mode
		if (this.cleanupMode == CleanupMode.CHECK || this.cleanupMode == CleanupMode.DIFF) {
			return this.changedFiles.isEmpty() ? Integer.valueOf(EXIT_OK) : Integer.valueOf(EXIT_CHANGES);
		}
		return IApplication.EXIT_OK;
	}

	@Override
	public void stop() {
		// do nothing
	}

	/**
	 * Display the command line usage message.
	 */
	private static void displayHelp() {
		System.out.println(Messages.bind(Messages.CommandLineUsage));
	}

	private static void displayHelp(final String message) {
		System.err.println(message);
		System.out.println();
		displayHelp();
	}

	/**
	 * Recursively clean up the Java source code that is contained in the
	 * directory rooted at dir.
	 */
	private void cleanDirTree(final File dir) {

		final File[] files = dir.listFiles();
		if (files == null) {
			return;
		}

		for (final File file : files) {
			if (file.isDirectory()) {
				if (shouldProcessDirectory(file)) {
					cleanDirTree(file);
				}
			} else if (Util.isJavaLikeFileName(file.getPath())) {
				cleanFile(file);
			}
		}
	}

	/**
	 * Check if a directory should be processed based on the scope setting.
	 */
	private boolean shouldProcessDirectory(final File dir) {
		if (this.cleanupScope == CleanupScope.BOTH) {
			return true;
		}
		String name = dir.getName();
		if (this.cleanupScope == CleanupScope.MAIN) {
			return !"test".equals(name) && !"tests".equals(name); //$NON-NLS-1$ //$NON-NLS-2$
		}
		// TEST scope: only process test directories and their parents
		return "test".equals(name) || "tests".equals(name); //$NON-NLS-1$ //$NON-NLS-2$
	}

	/**
	 * Compute SHA-256 hash for content comparison.
	 */
	private static byte[] computeHash(byte[] content) {
		try {
			return MessageDigest.getInstance("SHA-256").digest(content); //$NON-NLS-1$
		} catch (NoSuchAlgorithmException e) {
			// SHA-256 is always available in Java
			throw new AssertionError(e);
		}
	}

	/**
	 * Print a simple unified diff between original and new content.
	 */
	private static void printUnifiedDiff(String filePath, String original, String modified) {
		System.out.println("--- a/" + filePath); //$NON-NLS-1$
		System.out.println("+++ b/" + filePath); //$NON-NLS-1$
		String[] origLines = original.split("\n", -1); //$NON-NLS-1$
		String[] newLines = modified.split("\n", -1); //$NON-NLS-1$
		// Simple line-by-line diff (hunk-based)
		int maxLen = Math.max(origLines.length, newLines.length);
		int hunkStart = -1;
		List<String> hunkLines = new ArrayList<>();
		for (int i = 0; i < maxLen; i++) {
			String origLine = i < origLines.length ? origLines[i] : ""; //$NON-NLS-1$
			String newLine = i < newLines.length ? newLines[i] : ""; //$NON-NLS-1$
			if (!origLine.equals(newLine)) {
				if (hunkStart == -1) {
					hunkStart = i + 1;
				}
				if (i < origLines.length) {
					hunkLines.add("-" + origLine); //$NON-NLS-1$
				}
				if (i < newLines.length) {
					hunkLines.add("+" + newLine); //$NON-NLS-1$
				}
			} else {
				if (!hunkLines.isEmpty()) {
					System.out.println("@@ -" + hunkStart + " @@"); //$NON-NLS-1$ //$NON-NLS-2$
					hunkLines.forEach(System.out::println);
					hunkLines.clear();
					hunkStart = -1;
				}
			}
		}
		if (!hunkLines.isEmpty()) {
			System.out.println("@@ -" + hunkStart + " @@"); //$NON-NLS-1$ //$NON-NLS-2$
			hunkLines.forEach(System.out::println);
		}
	}

	/**
	 * Append unified diff content to the patchContent buffer.
	 */
	private void appendUnifiedDiff(String filePath, String original, String modified) {
		this.patchContent.append("--- a/").append(filePath).append('\n'); //$NON-NLS-1$
		this.patchContent.append("+++ b/").append(filePath).append('\n'); //$NON-NLS-1$
		String[] origLines = original.split("\n", -1); //$NON-NLS-1$
		String[] newLines = modified.split("\n", -1); //$NON-NLS-1$
		int maxLen = Math.max(origLines.length, newLines.length);
		int hunkStart = -1;
		List<String> hunkLines = new ArrayList<>();
		for (int i = 0; i < maxLen; i++) {
			String origLine = i < origLines.length ? origLines[i] : ""; //$NON-NLS-1$
			String newLine = i < newLines.length ? newLines[i] : ""; //$NON-NLS-1$
			if (!origLine.equals(newLine)) {
				if (hunkStart == -1) {
					hunkStart = i + 1;
				}
				if (i < origLines.length) {
					hunkLines.add("-" + origLine); //$NON-NLS-1$
				}
				if (i < newLines.length) {
					hunkLines.add("+" + newLine); //$NON-NLS-1$
				}
			} else {
				if (!hunkLines.isEmpty()) {
					this.patchContent.append("@@ -").append(hunkStart).append(" @@\n"); //$NON-NLS-1$ //$NON-NLS-2$
					for (String line : hunkLines) {
						this.patchContent.append(line).append('\n');
					}
					hunkLines.clear();
					hunkStart = -1;
				}
			}
		}
		if (!hunkLines.isEmpty()) {
			this.patchContent.append("@@ -").append(hunkStart).append(" @@\n"); //$NON-NLS-1$ //$NON-NLS-2$
			for (String line : hunkLines) {
				this.patchContent.append(line).append('\n');
			}
		}
	}

	/**
	 * Write unified diff patch file for all changed files.
	 */
	private void writePatchFile(final File[] sourceRoots) {
		try (PrintWriter writer = new PrintWriter(
				new OutputStreamWriter(Files.newOutputStream(new File(this.patchFile).toPath()),
						StandardCharsets.UTF_8))) {
			writer.print(this.patchContent.toString());
			if (this.verbose) {
				System.out.println(Messages.bind(Messages.CommandLinePatchWritten, this.patchFile));
			}
		} catch (IOException e) {
			System.err.println(Messages.bind(Messages.CommandLinePatchError, this.patchFile));
		}
	}

	/**
	 * Write a JSON report file with cleanup results.
	 */
	private void writeJsonReport(Instant startTime, Instant endTime) {
		try (PrintWriter writer = new PrintWriter(
				new OutputStreamWriter(Files.newOutputStream(new File(this.reportFile).toPath()),
						StandardCharsets.UTF_8))) {
			Map<String, Object> report = new LinkedHashMap<>();
			report.put("tool", "sandbox-cleanup"); //$NON-NLS-1$ //$NON-NLS-2$
			report.put("version", getToolVersion()); //$NON-NLS-1$
			report.put("mode", this.cleanupMode.name().toLowerCase()); //$NON-NLS-1$
			report.put("scope", this.cleanupScope.name().toLowerCase()); //$NON-NLS-1$
			report.put("startTime", startTime.toString()); //$NON-NLS-1$
			report.put("endTime", endTime.toString()); //$NON-NLS-1$
			report.put("durationMs", endTime.toEpochMilli() - startTime.toEpochMilli()); //$NON-NLS-1$
			report.put("filesProcessed", this.filesProcessed); //$NON-NLS-1$
			report.put("filesChanged", this.changedFiles.size()); //$NON-NLS-1$
			report.put("changedFiles", this.changedFiles); //$NON-NLS-1$

			// Write JSON manually to avoid adding dependencies
			writeJsonObject(writer, report, 0);
			writer.println();

			if (this.verbose) {
				System.out.println(Messages.bind(Messages.CommandLineReportWritten, this.reportFile));
			}
		} catch (IOException e) {
			System.err.println(Messages.bind(Messages.CommandLineReportError, this.reportFile));
		}
	}

	/**
	 * Simple JSON writer without external dependencies.
	 */
	@SuppressWarnings("unchecked")
	private static void writeJsonObject(PrintWriter writer, Map<String, Object> map, int indent) {
		String pad = "  ".repeat(indent); //$NON-NLS-1$
		String innerPad = "  ".repeat(indent + 1); //$NON-NLS-1$
		writer.println("{"); //$NON-NLS-1$
		int i = 0;
		for (Map.Entry<String, Object> entry : map.entrySet()) {
			writer.print(innerPad + "\"" + escapeJson(entry.getKey()) + "\": "); //$NON-NLS-1$ //$NON-NLS-2$
			Object val = entry.getValue();
			if (val instanceof String s) {
				writer.print("\"" + escapeJson(s) + "\""); //$NON-NLS-1$ //$NON-NLS-2$
			} else if (val instanceof Number || val instanceof Boolean) {
				writer.print(val);
			} else if (val instanceof List<?> list) {
				writeJsonArray(writer, list, indent + 1);
			} else if (val instanceof Map<?, ?> m) {
				writeJsonObject(writer, (Map<String, Object>) m, indent + 1);
			} else {
				writer.print("null"); //$NON-NLS-1$
			}
			if (i++ < map.size() - 1) {
				writer.print(","); //$NON-NLS-1$
			}
			writer.println();
		}
		writer.print(pad + "}"); //$NON-NLS-1$
	}

	private static void writeJsonArray(PrintWriter writer, List<?> list, int indent) {
		if (list.isEmpty()) {
			writer.print("[]"); //$NON-NLS-1$
			return;
		}
		String innerPad = "  ".repeat(indent + 1); //$NON-NLS-1$
		String pad = "  ".repeat(indent); //$NON-NLS-1$
		writer.println("["); //$NON-NLS-1$
		for (int i = 0; i < list.size(); i++) {
			Object item = list.get(i);
			if (item instanceof String s) {
				writer.print(innerPad + "\"" + escapeJson(s) + "\""); //$NON-NLS-1$ //$NON-NLS-2$
			} else {
				writer.print(innerPad + item);
			}
			if (i < list.size() - 1) {
				writer.print(","); //$NON-NLS-1$
			}
			writer.println();
		}
		writer.print(pad + "]"); //$NON-NLS-1$
	}

	private static String escapeJson(String s) {
		return s.replace("\\", "\\\\") //$NON-NLS-1$ //$NON-NLS-2$
				.replace("\"", "\\\"") //$NON-NLS-1$ //$NON-NLS-2$
				.replace("\n", "\\n") //$NON-NLS-1$ //$NON-NLS-2$
				.replace("\r", "\\r") //$NON-NLS-1$ //$NON-NLS-2$
				.replace("\t", "\\t"); //$NON-NLS-1$ //$NON-NLS-2$
	}

	private static String getToolVersion() {
		// Read version from bundle or fallback
		return System.getProperty("sandbox.cleanup.version", "1.2.6-SNAPSHOT"); //$NON-NLS-1$ //$NON-NLS-2$
	}
}