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