ScanCommand.java
package org.egothor.methodatlas.command;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.egothor.methodatlas.AiResultCache;
import org.egothor.methodatlas.ClassificationOverride;
import org.egothor.methodatlas.CliConfig;
import org.egothor.methodatlas.OutputMode;
import org.egothor.methodatlas.TestMethodSink;
import org.egothor.methodatlas.ai.AiSuggestionEngine;
import org.egothor.methodatlas.api.TestDiscovery;
import org.egothor.methodatlas.api.TestDiscoveryConfig;
import org.egothor.methodatlas.emit.OutputEmitter;
/**
* CLI command handler for the default CSV and {@code -plain} output modes.
*
* <p>
* Scans one or more source roots, optionally enriches the output with AI
* suggestions, and emits test-method records incrementally to the supplied
* writer.
* </p>
*
* @see org.egothor.methodatlas.emit.OutputEmitter
* @see SarifCommand
* @see GitHubAnnotationsCommand
*/
public final class ScanCommand implements Command {
private static final Logger LOG = Logger.getLogger(ScanCommand.class.getName());
private final CliConfig cliConfig;
private final TestDiscoveryConfig discoveryConfig;
private final AiSuggestionEngine aiEngine;
private final ClassificationOverride override;
private final AiResultCache aiCache;
/**
* Creates a new scan command.
*
* @param cliConfig full parsed CLI configuration
* @param discoveryConfig discovery configuration forwarded to providers
* @param aiEngine AI engine providing suggestions; {@code null} when
* AI is disabled
* @param override human classification overrides
* @param aiCache AI result cache
*/
public ScanCommand(CliConfig cliConfig, TestDiscoveryConfig discoveryConfig,
AiSuggestionEngine aiEngine, ClassificationOverride override,
AiResultCache aiCache) {
this.cliConfig = cliConfig;
this.discoveryConfig = discoveryConfig;
this.aiEngine = aiEngine;
this.override = override;
this.aiCache = aiCache;
}
/**
* Runs the scan and emits output incrementally.
*
* @param out writer that receives all emitted output
* @return {@code 0} if all files were processed successfully, {@code 1} if
* any file produced a parse or processing error
* @throws IOException if traversing a file tree fails
*/
@Override
@SuppressWarnings("PMD.NPathComplexity") // combinatorial expansion of optional CSV columns; inherent to the format
public int execute(PrintWriter out) throws IOException {
boolean aiEnabled = aiEngine != null;
boolean confidenceEnabled = aiEnabled && cliConfig.aiOptions().confidence();
boolean contentHashEnabled = cliConfig.contentHash();
List<Path> roots = cliConfig.paths().isEmpty() ? List.of(Paths.get(".")) : cliConfig.paths();
OutputEmitter emitter = new OutputEmitter(out, aiEnabled, confidenceEnabled, contentHashEnabled,
cliConfig.driftDetect(), cliConfig.emitSourceRoot());
if (cliConfig.emitMetadata()) {
String version = ScanCommand.class.getPackage().getImplementationVersion();
String taxonomyInfo = CommandSupport.resolveTaxonomyInfo(cliConfig.aiOptions(), aiEnabled);
emitter.emitMetadata(version != null ? version : "dev", Instant.now().toString(), taxonomyInfo);
}
emitter.emitCsvHeader(cliConfig.outputMode());
final OutputMode mode = cliConfig.outputMode();
final boolean emitSourceRoot = cliConfig.emitSourceRoot();
// Scan each root with its own sink so the source_root value can be captured
// per root. When emitSourceRoot is false, sourceRoot is null and the column
// is omitted from the output.
List<TestDiscovery> providers = CommandSupport.loadProviders(discoveryConfig);
boolean hadErrors = false;
try {
for (Path root : roots) {
String sourceRoot = emitSourceRoot ? CommandSupport.computeFilePrefix(List.of(root)) : null;
TestMethodSink rootSink = (fqcn, method, beginLine, loc, contentHash, tags, displayName, suggestion) ->
emitter.emit(mode, fqcn, method, loc, contentHash, tags, displayName, suggestion, sourceRoot);
if (CommandSupport.runDiscovery(root, providers, cliConfig.aiOptions(), aiEngine,
CommandSupport.filterSink(rootSink, cliConfig.securityOnly()),
cliConfig.contentHash(), override, aiCache)) {
hadErrors = true;
}
}
} finally {
CommandSupport.closeAll(providers);
}
if (aiCache.isActive() && LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "AI cache: {0} hit(s), {1} miss(es)",
new Object[] { aiCache.hits(), aiCache.misses() });
}
return hadErrors ? 1 : 0;
}
}