diff --git a/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevel.java b/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevel.java new file mode 100644 index 00000000..582c68d0 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevel.java @@ -0,0 +1,163 @@ +package io.jenkins.plugins.forensics.git.miner; + +import edu.hm.hafner.util.Generated; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Collections; +import java.util.IntSummaryStatistics; +import java.util.List; +import java.util.Objects; +import java.util.StringJoiner; + +/** + * Aggregates the logical indentation levels of all non-blank lines in a source file into a statistical summary. + * Indentation is used as a language-agnostic proxy for code complexity. + * + *

Stores summary statistics such as the number of lines, total, mean, standard deviation, and maximum + * indentation level. Instances are created by {@link IndentationLevelCalculator}, which defines how logical + * indentation levels are computed.

+ * + * @author Akash Manna + * @see IndentationLevelCalculator + */ +public final class IndentationLevel implements Serializable { + @Serial + private static final long serialVersionUID = 1L; // since 1.x.x + + /** A constant for a file that does not contain any (non-blank) lines. */ + static final IndentationLevel EMPTY = new IndentationLevel(Collections.emptyList()); + + private final int numberOfLines; + private final int total; + private final int maximum; + private final double mean; + private final double standardDeviation; + + /** + * Creates a new instance of {@link IndentationLevel} by aggregating the specified per-line indentation levels. + * + * @param indentationLevelsPerLine + * the logical indentation level of every non-blank line of the analyzed file, in any order + */ + IndentationLevel(final List indentationLevelsPerLine) { + Objects.requireNonNull(indentationLevelsPerLine, "indentationLevelsPerLine must not be null"); + + numberOfLines = indentationLevelsPerLine.size(); + if (numberOfLines == 0) { + total = 0; + maximum = 0; + mean = 0.0; + standardDeviation = 0.0; + + return; + } + + IntSummaryStatistics statistics = indentationLevelsPerLine.stream() + .mapToInt(Integer::intValue) + .summaryStatistics(); + total = (int) statistics.getSum(); + maximum = statistics.getMax(); + mean = statistics.getAverage(); + + double sumOfSquaredDeviations = indentationLevelsPerLine.stream() + .mapToDouble(level -> Math.pow(level - mean, 2)) + .sum(); + standardDeviation = Math.sqrt(sumOfSquaredDeviations / numberOfLines); + } + + /** + * Returns the number of non-blank lines that have been used to compute this summary. Blank (or + * whitespace-only) lines are not considered, since they do not carry any indentation signal. + * + * @return the number of analyzed lines, i.e. {@code n} in the original recipe + */ + public int getNumberOfLines() { + return numberOfLines; + } + + /** + * Returns the sum of the indentation levels of all analyzed lines. + * + * @return the total indentation, i.e. {@code total} in the original recipe + */ + public int getTotal() { + return total; + } + + /** + * Returns the highest indentation level that has been found in any of the analyzed lines. + * + * @return the maximum indentation level, i.e. {@code max} in the original recipe + */ + public int getMaximum() { + return maximum; + } + + /** + * Returns the average indentation level of the analyzed lines. + * + * @return the mean indentation level, i.e. {@code mean} in the original recipe, or {@code 0.0} if there are + * no analyzed lines + */ + public double getMean() { + return mean; + } + + /** + * Returns the (population) standard deviation of the indentation levels of the analyzed lines. A high standard + * deviation together with a high mean is a strong indicator of a file that mixes very simple and excessively + * nested code, which is a typical refactoring candidate. + * + * @return the standard deviation, i.e. {@code sd} in the original recipe, or {@code 0.0} if there are no + * analyzed lines + */ + public double getStandardDeviation() { + return standardDeviation; + } + + /** + * Returns whether this summary does not contain any analyzed lines, e.g. because the file was empty, contained + * only blank lines, or could not be read. + * + * @return {@code true} if there are no analyzed lines, {@code false} otherwise + */ + public boolean isEmpty() { + return numberOfLines == 0; + } + + @Override + @Generated + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + var that = (IndentationLevel) o; + return numberOfLines == that.numberOfLines + && total == that.total + && maximum == that.maximum + && Double.compare(that.mean, mean) == 0 + && Double.compare(that.standardDeviation, standardDeviation) == 0; + } + + @Override + @Generated + public int hashCode() { + return Objects.hash(numberOfLines, total, maximum, mean, standardDeviation); + } + + @Override + @Generated + public String toString() { + return new StringJoiner(", ", IndentationLevel.class.getSimpleName() + "[", "]") + .add("numberOfLines=" + numberOfLines) + .add("total=" + total) + .add("maximum=" + maximum) + .add("mean=" + mean) + .add("standardDeviation=" + standardDeviation) + .toString(); + } +} \ No newline at end of file diff --git a/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCalculator.java b/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCalculator.java new file mode 100644 index 00000000..a8ae644d --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCalculator.java @@ -0,0 +1,96 @@ +package io.jenkins.plugins.forensics.git.miner; + +import java.util.ArrayList; +import java.util.List; + +/** + * Computes the {@link IndentationLevel} metric for the textual content of a single file. + * + *

Determines the logical indentation level of each non-blank line and aggregates the results into an + * {@link IndentationLevel}.

+ * + * @author Akash Manna + * @see IndentationLevel + */ +public class IndentationLevelCalculator { + /** The number of consecutive leading space characters that count as a single logical indentation level. */ + static final int SPACES_PER_LEVEL = 4; + + private static final char TAB_CHARACTER = '\t'; + private static final char SPACE_CHARACTER = ' '; + + /** + * Computes the {@link IndentationLevel} for the specified lines of a file. + * + * @param lines + * the lines of the file, in their original order; must not be {@code null}, but may be empty + * + * @return the aggregated indentation level for the specified lines + */ + public IndentationLevel compute(final Iterable lines) { + List levelsOfNonBlankLines = new ArrayList<>(); + for (String line : lines) { + if (isBlank(line)) { + continue; + } + levelsOfNonBlankLines.add(countIndentationLevel(line)); + } + return new IndentationLevel(levelsOfNonBlankLines); + } + + /** + * Computes the {@link IndentationLevel} for the specified textual content of a file. The content is split into + * lines using {@code \n}, {@code \r\n}, or {@code \r} as line separator. + * + * @param content + * the full textual content of the file; may be {@code null} or empty, in which case an + * {@link IndentationLevel#isEmpty() empty} result is returned + * + * @return the aggregated indentation level for the specified content + */ + public IndentationLevel compute(final String content) { + if (content == null || content.isEmpty()) { + return compute(List.of()); + } + return compute(List.of(content.split("\r\n|\r|\n", -1))); + } + + private boolean isBlank(final String line) { + return line == null || line.isBlank(); + } + + /** + * Determines the logical indentation level of a single line, i.e. the number of complete indentation units + * (one tab, or four consecutive spaces) found at the start of the line, before the first non-whitespace + * character (or the end of the line) is reached. + * + * @param line + * a single, non-blank line of a file + * + * @return the logical indentation level of the line, i.e. a value {@code >= 0} + */ + int countIndentationLevel(final String line) { + int pendingSpaces = 0; + int level = 0; + + for (int i = 0; i < line.length(); i++) { + char character = line.charAt(i); + if (character == TAB_CHARACTER) { + level++; + pendingSpaces = 0; + } + else if (character == SPACE_CHARACTER) { + pendingSpaces++; + if (pendingSpaces == SPACES_PER_LEVEL) { + level++; + pendingSpaces = 0; + } + } + else { + break; // the leading whitespace prefix of the line ends here + } + } + + return level; + } +} \ No newline at end of file diff --git a/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCollector.java b/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCollector.java new file mode 100644 index 00000000..e507ea01 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCollector.java @@ -0,0 +1,155 @@ +package io.jenkins.plugins.forensics.git.miner; + +import org.eclipse.jgit.errors.LargeObjectException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.TreeWalk; + +import edu.hm.hafner.util.FilteredLog; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Collects the {@link IndentationLevel} metric for each regular (non-binary) file in a Git commit. + * + *

Unlike {@link DiffsCollector}, which analyzes only modified lines, this collector reads the complete + * contents of each file because indentation is a property of the entire file. Binary, unreadable, or oversized + * files are skipped, and the reason is recorded in the provided {@link FilteredLog}.

+ * + * @author Akash Manna + * @see IndentationLevelCalculator + */ +public class IndentationLevelCollector { + /** + * The maximum number of bytes that will be read into memory for a single file. Files larger than this limit are + * skipped, in order to avoid excessive memory usage when mining large (e.g. binary or generated) files. + */ + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024L; // 5 MB + + /** The number of leading bytes that are inspected by the binary-file heuristic. */ + private static final int BINARY_DETECTION_BUFFER_SIZE = 8000; + + private final IndentationLevelCalculator calculator = new IndentationLevelCalculator(); + + /** + * Collects the {@link IndentationLevel} for every regular file that is part of the specified commit. + * + * @param repository + * the repository to read the file contents from + * @param commitId + * the ID of the commit whose snapshot should be analyzed + * @param logger + * the logger used to report skipped or unreadable files + * + * @return a mapping of repository-relative file path to the {@link IndentationLevel} of that file; iteration + * order follows the order in which the files are visited in the tree + */ + public Map collect(final Repository repository, final ObjectId commitId, + final FilteredLog logger) { + Map indentationLevelsByFile = new LinkedHashMap<>(); + + try (var revWalk = new RevWalk(repository); var reader = repository.newObjectReader()) { + RevCommit commit = revWalk.parseCommit(commitId); + RevTree tree = commit.getTree(); + + try (var treeWalk = new TreeWalk(repository, reader)) { + treeWalk.addTree(tree); + treeWalk.setRecursive(true); + + while (treeWalk.next()) { + if (!isRegularFile(treeWalk.getFileMode(0))) { + continue; + } + + String path = treeWalk.getPathString(); + ObjectId blobId = treeWalk.getObjectId(0); + + readIndentationLevel(reader, path, blobId, logger) + .ifPresent(level -> indentationLevelsByFile.put(path, level)); + } + } + } + catch (IOException exception) { + logger.logException(exception, + "Can't compute indentation levels for commit '%s'", commitId.getName()); + } + + return indentationLevelsByFile; + } + + private Optional readIndentationLevel(final ObjectReader reader, final String path, + final ObjectId blobId, final FilteredLog logger) { + try { + ObjectLoader loader = reader.open(blobId); + if (loader.getSize() > MAX_FILE_SIZE) { + logger.logInfo("Skipping file '%s': size %d bytes exceeds the limit of %d bytes", + path, loader.getSize(), MAX_FILE_SIZE); + + return Optional.empty(); + } + + byte[] content = loader.getCachedBytes(Math.toIntExact(MAX_FILE_SIZE)); + if (isBinary(content)) { + return Optional.empty(); + } + + String text = new String(content, StandardCharsets.UTF_8); + + return Optional.of(calculator.compute(text)); + } + catch (MissingObjectException exception) { + logger.logException(exception, "Can't find content of file '%s'", path); + } + catch (LargeObjectException exception) { + logger.logInfo("Skipping file '%s': %s", path, exception.getMessage()); + } + catch (IOException exception) { + logger.logException(exception, "Can't read content of file '%s'", path); + } + + return Optional.empty(); + } + + /** + * Returns whether the specified file mode represents a regular (non-symlink, non-submodule) file, i.e. a file + * whose content can meaningfully be analyzed for indentation. + * + * @param fileMode + * the file mode as reported by the tree walk + * + * @return {@code true} if the file is a regular file (executable or not), {@code false} otherwise + */ + private boolean isRegularFile(final FileMode fileMode) { + return fileMode.equals(FileMode.REGULAR_FILE) || fileMode.equals(FileMode.EXECUTABLE_FILE); + } + + /** + * Applies the same heuristic that Git itself uses to distinguish text from binary files: a file is considered + * binary if a {@code NUL} byte is found within the first {@link #BINARY_DETECTION_BUFFER_SIZE} bytes. + * + * @param content + * the bytes to inspect + * + * @return {@code true} if the content looks like a binary file, {@code false} otherwise + */ + private boolean isBinary(final byte[] content) { + int limit = Math.min(content.length, BINARY_DETECTION_BUFFER_SIZE); + for (int i = 0; i < limit; i++) { + if (content[i] == 0) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/package-info.java b/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/package-info.java index 22ce7f97..250a0ed9 100644 --- a/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/package-info.java +++ b/plugin/src/main/java/io/jenkins/plugins/forensics/git/miner/package-info.java @@ -19,6 +19,8 @@ *
  • last modification time
  • *
  • lines of code (from the commit details)
  • *
  • code churn (changed lines since created)
  • + *
  • indentation level (a language-agnostic proxy for the complexity of a file, see + * {@link io.jenkins.plugins.forensics.git.miner.IndentationLevel})
  • * */ @DefaultAnnotation(NonNull.class) diff --git a/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCalculatorTest.java b/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCalculatorTest.java new file mode 100644 index 00000000..e9f72fde --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCalculatorTest.java @@ -0,0 +1,150 @@ +package io.jenkins.plugins.forensics.git.miner; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +/** + * Tests the class {@link IndentationLevelCalculator}. + * + * @author Akash Manna + */ +class IndentationLevelCalculatorTest { + private static final double TOLERANCE = 1e-9; + + private final IndentationLevelCalculator calculator = new IndentationLevelCalculator(); + + @Test + void shouldReturnEmptyResultForEmptyContent() { + assertThat(calculator.compute("")).satisfies(IndentationLevelCalculatorTest::assertIsEmpty); + assertThat(calculator.compute((String) null)).satisfies(IndentationLevelCalculatorTest::assertIsEmpty); + assertThat(calculator.compute(List.of())).satisfies(IndentationLevelCalculatorTest::assertIsEmpty); + } + + @Test + void shouldIgnoreBlankAndWhitespaceOnlyLines() { + var level = calculator.compute(List.of("", " ", "\t", "\t \t ")); + + assertIsEmpty(level); + } + + @Test + void shouldCountSingleLineWithoutIndentationAsLevelZero() { + var level = calculator.compute(List.of("no indentation at all")); + + assertThat(level.getNumberOfLines()).isEqualTo(1); + assertThat(level.getTotal()).isZero(); + assertThat(level.getMaximum()).isZero(); + assertThat(level.getMean()).isEqualTo(0.0, within(TOLERANCE)); + assertThat(level.getStandardDeviation()).isEqualTo(0.0, within(TOLERANCE)); + assertThat(level.isEmpty()).isFalse(); + } + + @ParameterizedTest(name = "[{index}] line=\"{0}\" -> level={1}") + @CsvSource({ + "'foo()', 0", + "'\tfoo()', 1", + "'\t\tfoo()', 2", + "'\t\t\tfoo()', 3", + "' foo()', 1", // exactly 4 spaces + "' foo()', 2", // exactly 8 spaces + "' foo()', 0", // 3 spaces: not enough for a level + "' foo()', 1", // 5 spaces: 4 counted, 1 leftover ignored + "'\t foo()', 2", // 1 tab + 4 spaces + "' \tfoo()', 2", // 4 spaces + 1 tab + "' \tfoo()', 1", // 2 spaces (not enough) + 1 tab + "'\t\t foo()', 3" // 2 tabs + 4 spaces + }) + void shouldCountIndentationLevelOfSingleLine(final String line, final int expectedLevel) { + assertThat(calculator.countIndentationLevel(line)).isEqualTo(expectedLevel); + } + + @Test + void shouldTreatRunsOfSpacesIndependently() { + // 9 spaces: two full groups of four (=> level 2), one leftover space is ignored + assertThat(calculator.countIndentationLevel(" foo")).isEqualTo(2); + } + + @Test + void shouldStopCountingAtFirstNonWhitespaceCharacter() { + // indentation in the middle of the line (after the first token) must not be counted + assertThat(calculator.countIndentationLevel("if (x) { nested(); }")).isZero(); + } + + @Test + void shouldAggregateMultipleLinesCorrectly() { + var level = calculator.compute(List.of( + "level0();", // 0 + "\tlevel1();", // 1 + "\t\tlevel2();", // 2 + "\t\t\tlevel3();", // 3 + "" // blank line - ignored + )); + + assertThat(level.getNumberOfLines()).isEqualTo(4); + assertThat(level.getTotal()).isEqualTo(6); // 0 + 1 + 2 + 3 + assertThat(level.getMaximum()).isEqualTo(3); + assertThat(level.getMean()).isEqualTo(1.5, within(TOLERANCE)); + + // population standard deviation of [0, 1, 2, 3] with mean 1.5 + double expectedVariance = (Math.pow(1.5, 2) + Math.pow(0.5, 2) + Math.pow(0.5, 2) + Math.pow(1.5, 2)) / 4; + assertThat(level.getStandardDeviation()).isEqualTo(Math.sqrt(expectedVariance), within(TOLERANCE)); + } + + @Test + void shouldComputeStandardDeviationOfZeroForUniformIndentation() { + var level = calculator.compute(List.of("\tfoo();", "\tbar();", "\tbaz();")); + + assertThat(level.getNumberOfLines()).isEqualTo(3); + assertThat(level.getTotal()).isEqualTo(3); + assertThat(level.getMean()).isEqualTo(1.0, within(TOLERANCE)); + assertThat(level.getStandardDeviation()).isEqualTo(0.0, within(TOLERANCE)); + assertThat(level.getMaximum()).isEqualTo(1); + } + + @Test + void shouldSplitContentOnDifferentLineSeparators() { + var unix = calculator.compute("\tfoo();\n\t\tbar();\n"); + var windows = calculator.compute("\tfoo();\r\n\t\tbar();\r\n"); + var mac = calculator.compute("\tfoo();\r\t\tbar();\r"); + + for (var level : List.of(unix, windows, mac)) { + assertThat(level.getNumberOfLines()).isEqualTo(2); + assertThat(level.getTotal()).isEqualTo(3); + assertThat(level.getMaximum()).isEqualTo(2); + } + } + + @Test + void shouldHandleRealisticJavaSnippet() { + var content = String.join("\n", List.of( + "public class Example {", // 0 + " public void run() {", // 1 (4 spaces) + " if (isValid()) {", // 2 (8 spaces) + " doSomething();", // 3 (12 spaces) + " }", // 2 + " }", // 1 + "}" // 0 + )); + + var level = calculator.compute(content); + + assertThat(level.getNumberOfLines()).isEqualTo(7); + assertThat(level.getTotal()).isEqualTo(0 + 1 + 2 + 3 + 2 + 1 + 0); + assertThat(level.getMaximum()).isEqualTo(3); + assertThat(level.getMean()).isEqualTo(9.0 / 7.0, within(TOLERANCE)); + } + + private static void assertIsEmpty(final IndentationLevel level) { + assertThat(level.isEmpty()).isTrue(); + assertThat(level.getNumberOfLines()).isZero(); + assertThat(level.getTotal()).isZero(); + assertThat(level.getMaximum()).isZero(); + assertThat(level.getMean()).isEqualTo(0.0, within(TOLERANCE)); + assertThat(level.getStandardDeviation()).isEqualTo(0.0, within(TOLERANCE)); + } +} \ No newline at end of file diff --git a/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCollectorITest.java b/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCollectorITest.java new file mode 100644 index 00000000..62324f40 --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelCollectorITest.java @@ -0,0 +1,163 @@ +package io.jenkins.plugins.forensics.git.miner; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import edu.hm.hafner.util.FilteredLog; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; + +/** + * Integration tests for the class {@link IndentationLevelCollector}. + * + * @author Akash Manna + */ +class IndentationLevelCollectorITest { + private static final PersonIdent AUTHOR = new PersonIdent("Test User", "test@example.com"); + + private final IndentationLevelCollector collector = new IndentationLevelCollector(); + + private Git git; + private Repository repository; + + @BeforeEach + void initializeRepository(@TempDir final File baseDirectory) throws IOException, GitAPIException { + git = Git.init().setDirectory(baseDirectory).call(); + repository = git.getRepository(); + } + + @AfterEach + void closeRepository() { + git.close(); + repository.close(); + } + + @Test + void shouldComputeIndentationLevelForSingleFile() throws IOException, GitAPIException { + writeFile("Sample.java", String.join("\n", List.of( + "public class Sample {", // level 0 + " void run() {", // level 1 + " doSomething();", // level 2 + " }", // level 1 + "}" // level 0 + ))); + ObjectId commitId = commitAll("Add Sample.java"); + + var logger = new FilteredLog("Errors"); + Map result = collector.collect(repository, commitId, logger); + + assertThat(result).containsKey("Sample.java"); + var level = result.get("Sample.java"); + assertThat(level.getNumberOfLines()).isEqualTo(5); + assertThat(level.getTotal()).isEqualTo(0 + 1 + 2 + 1 + 0); + assertThat(level.getMaximum()).isEqualTo(2); + assertThat(logger.getErrorMessages()).isEmpty(); + } + + @Test + void shouldCollectMultipleFilesIncludingNestedOnes() throws IOException, GitAPIException { + writeFile("a.txt", "no indentation\n"); + writeFile("nested/b.txt", "\tindented once\n\t\tindented twice\n"); + ObjectId commitId = commitAll("Add files"); + + Map result = collector.collect(repository, commitId, new FilteredLog("Errors")); + + assertThat(result).containsOnlyKeys("a.txt", "nested/b.txt"); + assertThat(result.get("a.txt").getMaximum()).isZero(); + assertThat(result.get("nested/b.txt").getMaximum()).isEqualTo(2); + assertThat(result.get("nested/b.txt").getNumberOfLines()).isEqualTo(2); + } + + @Test + void shouldIgnoreEmptyFile() throws IOException, GitAPIException { + writeFile("empty.txt", ""); + ObjectId commitId = commitAll("Add empty file"); + + Map result = collector.collect(repository, commitId, new FilteredLog("Errors")); + + assertThat(result).containsKey("empty.txt"); + assertThat(result.get("empty.txt").isEmpty()).isTrue(); + } + + @Test + void shouldSkipBinaryFiles() throws IOException, GitAPIException { + writeBinaryFile("image.png", new byte[] {0x00, 0x01, 0x02, 0x03, 0x04}); + writeFile("readme.txt", " not binary\n"); + ObjectId commitId = commitAll("Add binary and text file"); + + var logger = new FilteredLog("Errors"); + Map result = collector.collect(repository, commitId, logger); + + assertThat(result).doesNotContainKey("image.png"); + assertThat(result).containsKey("readme.txt"); + } + + @Test + void shouldUpdateResultsAcrossCommits() throws IOException, GitAPIException { + writeFile("Growing.java", "level0();\n"); + ObjectId firstCommit = commitAll("Initial version"); + + writeFile("Growing.java", "level0();\n\tlevel1();\n\t\tlevel2();\n"); + ObjectId secondCommit = commitAll("Add more nesting"); + + var firstResult = collector.collect(repository, firstCommit, new FilteredLog("Errors")); + var secondResult = collector.collect(repository, secondCommit, new FilteredLog("Errors")); + + assertThat(firstResult.get("Growing.java").getMaximum()).isZero(); + assertThat(secondResult.get("Growing.java").getMaximum()).isEqualTo(2); + assertThat(secondResult.get("Growing.java").getNumberOfLines()).isEqualTo(3); + } + + @Test + void shouldReturnEmptyMapAndLogErrorForUnknownCommit() { + var logger = new FilteredLog("Errors"); + + Map result = collector.collect(repository, ObjectId.zeroId(), logger); + + assertThat(result).isEmpty(); + assertThat(logger.getErrorMessages()).isNotEmpty(); + } + + private void writeFile(final String relativePath, final String content) throws IOException { + Path file = resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + private void writeBinaryFile(final String relativePath, final byte[] content) throws IOException { + Path file = resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.write(file, content); + } + + private Path resolve(final String relativePath) { + return repository.getWorkTree().toPath().resolve(relativePath); + } + + private ObjectId commitAll(final String message) throws GitAPIException { + git.add().addFilepattern(".").call(); + RevCommit commit = git.commit() + .setMessage(message) + .setAuthor(AUTHOR) + .setCommitter(AUTHOR) + .call(); + + return commit.getId(); + } +} \ No newline at end of file diff --git a/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelTest.java b/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelTest.java new file mode 100644 index 00000000..dbac07bc --- /dev/null +++ b/plugin/src/test/java/io/jenkins/plugins/forensics/git/miner/IndentationLevelTest.java @@ -0,0 +1,82 @@ +package io.jenkins.plugins.forensics.git.miner; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +/** + * Tests the class {@link IndentationLevel}. + * + * @author Akash Manna + */ +class IndentationLevelTest { + private static final double TOLERANCE = 1e-9; + + @Test + void shouldCreateEmptyResultForEmptyInput() { + var level = new IndentationLevel(List.of()); + + assertThat(level.isEmpty()).isTrue(); + assertThat(level.getNumberOfLines()).isZero(); + assertThat(level.getTotal()).isZero(); + assertThat(level.getMaximum()).isZero(); + assertThat(level.getMean()).isEqualTo(0.0, within(TOLERANCE)); + assertThat(level.getStandardDeviation()).isEqualTo(0.0, within(TOLERANCE)); + } + + @Test + void shouldAggregateSingleValue() { + var level = new IndentationLevel(List.of(4)); + + assertThat(level.isEmpty()).isFalse(); + assertThat(level.getNumberOfLines()).isEqualTo(1); + assertThat(level.getTotal()).isEqualTo(4); + assertThat(level.getMaximum()).isEqualTo(4); + assertThat(level.getMean()).isEqualTo(4.0, within(TOLERANCE)); + assertThat(level.getStandardDeviation()).isEqualTo(0.0, within(TOLERANCE)); + } + + @Test + void shouldAggregateMultipleValues() { + var level = new IndentationLevel(List.of(0, 2, 4, 6)); + + assertThat(level.getNumberOfLines()).isEqualTo(4); + assertThat(level.getTotal()).isEqualTo(12); + assertThat(level.getMaximum()).isEqualTo(6); + assertThat(level.getMean()).isEqualTo(3.0, within(TOLERANCE)); + + // population standard deviation of [0, 2, 4, 6] with mean 3.0 + double expectedVariance = (9.0 + 1.0 + 1.0 + 9.0) / 4.0; + assertThat(level.getStandardDeviation()).isEqualTo(Math.sqrt(expectedVariance), within(TOLERANCE)); + } + + @Test + void shouldRejectNullInput() { + assertThatNullPointerException() + .isThrownBy(() -> new IndentationLevel(null)); + } + + @Test + void shouldHaveSharedEmptyConstant() { + assertThat(IndentationLevel.EMPTY.isEmpty()).isTrue(); + assertThat(IndentationLevel.EMPTY).isEqualTo(new IndentationLevel(List.of())); + } + + @Test + void shouldObeyEqualsAndHashCodeContract() { + EqualsVerifier.simple().forClass(IndentationLevel.class).verify(); + } + + @Test + void shouldProvideReadableToString() { + var level = new IndentationLevel(List.of(1, 2, 3)); + + assertThat(level.toString()) + .contains("numberOfLines=3") + .contains("total=6") + .contains("maximum=3"); + } +} \ No newline at end of file