} project property's value,
+ * accepting {@code true}/{@code false} case-insensitively.
+ */
+ private static boolean parseForceRewrite(String value) {
+ String normalized = value.trim().toLowerCase(Locale.ROOT);
+ if ("true".equals(normalized)) {
+ return true;
+ }
+ if ("false".equals(normalized)) {
+ return false;
+ }
+ throw new GradleException(
+ "gherkinToAsciidoc: invalid value '" + value + "' for -P"
+ + GherkinToAsciidocExtension.FORCE_REWRITE_OVERRIDE_PROPERTY
+ + "; expected 'true' or 'false'");
+ }
+
/**
* Wires the task's {@code sourceDirs}/{@code sourceFile} from this project's own extension, or -
* when neither is configured locally and a root extension exists - from the root project's own
diff --git a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java
index ab9d5ee..a041273 100644
--- a/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java
+++ b/gherkin-to-asciidoc/src/main/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexer.java
@@ -7,7 +7,10 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -15,11 +18,19 @@
* Numbers {@code Feature}/{@code Scenario} titles directly in the source {@code .feature} files
* according to a configured {@link IndexingMode}.
*
- * Every run first strips any numbering left over from a previous run (recognised by the
- * {@code - } prefix this class itself adds), then - unless the mode is
- * {@link IndexingMode#OFF} - applies fresh numbering. This makes the operation idempotent and
- * makes switching between modes (including back to {@code OFF}) simply undo the previous
- * numbering rather than requiring any state to be tracked between runs.
+ * A line whose existing number already matches the format {@code mode} would itself produce -
+ * a single integer for a {@code Feature}, or for a {@code Scenario}/{@code Scenario Outline}
+ * either a single integer ({@link IndexingMode#SCENARIO}) or {@code .}
+ * ({@link IndexingMode#ALL}, matched against that line's own feature's resolved number) - is left
+ * completely untouched: its number is "pinned". Every other numbered line (wrong format, or a
+ * leftover from a different mode, or {@link IndexingMode#OFF} itself never expecting a number at
+ * all) is stripped and, if {@code mode} numbers lines of that kind, assigned a fresh number one
+ * past the highest pinned number seen so far (or from 1, if none are pinned yet) - so a newly
+ * added file that happens to sort alphabetically before already-numbered files never bumps their
+ * numbers, and fresh numbers always read as a continuation of the existing sequence rather than
+ * backfilling a gap earlier in it. Passing {@code forceRewrite = true} instead ignores existing
+ * numbers entirely and renumbers everything from scratch, exactly as if every file were being
+ * numbered for the first time.
*
* Never called with {@link IndexingMode#CI}: the caller skips invoking this class entirely for
* that mode, since {@code CI} means the feature files must be left completely untouched, not even
@@ -30,68 +41,150 @@ public class FeatureIndexer {
private static final Pattern KEYWORD_LINE =
Pattern.compile("^(\\s*)(Feature|Scenario Outline|Scenario):(\\s*)(.*)$");
private static final Pattern EXISTING_INDEX = Pattern.compile("^\\d+(?:\\.\\d+)? - (.*)$");
+ private static final Pattern SINGLE_INDEX = Pattern.compile("^(\\d+) - (.*)$");
/** Creates a new {@code FeatureIndexer}. */
public FeatureIndexer() {}
/**
- * Rewrites every file in {@code featureFiles} in place: strips any {@code Feature}/
- * {@code Scenario} numbering added by a previous run, then applies numbering per
- * {@code mode}. Files are numbered in the order they appear in {@code featureFiles} - the
- * caller is responsible for ordering that list the way numbers should be assigned. A file is
- * only rewritten on disk when its content actually changes.
+ * Rewrites every file in {@code featureFiles} in place per {@code mode} and
+ * {@code forceRewrite} - see the class documentation for exactly what changes and what's left
+ * alone. Files are numbered in the order they appear in {@code featureFiles} - the caller is
+ * responsible for ordering that list the way numbers should be assigned. A file is only
+ * rewritten on disk when its content actually changes.
*
* @param featureFiles the feature files collected for this run, in the order to number them in
* @param mode the indexing mode to apply
+ * @param forceRewrite when {@code true}, ignores existing numbers and renumbers everything from
+ * scratch; when {@code false}, leaves already-correctly-numbered lines alone
*/
- public void reindex(List featureFiles, IndexingMode mode) {
- int featureNumber = 0;
- int scenarioNumber = 0;
+ public void reindex(List featureFiles, IndexingMode mode, boolean forceRewrite) {
+ List parsedFiles = new ArrayList<>();
for (File featureFile : featureFiles) {
- featureNumber++;
- int scenarioInFeature = 0;
- List lines = readLines(featureFile);
- List rewritten = new ArrayList<>(lines.size());
- boolean changed = false;
-
- for (String line : lines) {
- Matcher matcher = KEYWORD_LINE.matcher(line);
- if (!matcher.matches()) {
- rewritten.add(line);
+ parsedFiles.add(parse(featureFile));
+ }
+
+ boolean numberFeatures = mode == IndexingMode.FEATURE || mode == IndexingMode.ALL;
+ if (numberFeatures) {
+ List allFeatures = new ArrayList<>();
+ for (ParsedFile parsedFile : parsedFiles) {
+ allFeatures.addAll(parsedFile.featureMatches);
+ }
+ resolveSequential(allFeatures, forceRewrite, SINGLE_INDEX);
+ }
+
+ if (mode == IndexingMode.SCENARIO) {
+ List allScenarios = new ArrayList<>();
+ for (ParsedFile parsedFile : parsedFiles) {
+ allScenarios.addAll(parsedFile.scenarioMatches);
+ }
+ resolveSequential(allScenarios, forceRewrite, SINGLE_INDEX);
+ } else if (mode == IndexingMode.ALL) {
+ for (ParsedFile parsedFile : parsedFiles) {
+ if (parsedFile.featureMatches.isEmpty()) {
continue;
}
+ Integer featureNumber = parsedFile.featureMatches.get(0).number;
+ Pattern perFeatureIndex = Pattern.compile("^" + featureNumber + "\\.(\\d+) - (.*)$");
+ resolveSequential(parsedFile.scenarioMatches, forceRewrite, perFeatureIndex);
+ }
+ }
+
+ for (ParsedFile parsedFile : parsedFiles) {
+ applyAndWrite(parsedFile, mode);
+ }
+ }
- String indent = matcher.group(1);
- String keyword = matcher.group(2);
- String gap = matcher.group(3);
- String name = stripExistingIndex(matcher.group(4));
-
- String newName;
- if ("Feature".equals(keyword)) {
- newName = (mode == IndexingMode.FEATURE || mode == IndexingMode.ALL)
- ? featureNumber + " - " + name
- : name;
- } else if (mode == IndexingMode.SCENARIO) {
- scenarioNumber++;
- newName = scenarioNumber + " - " + name;
- } else if (mode == IndexingMode.ALL) {
- scenarioInFeature++;
- newName = featureNumber + "." + scenarioInFeature + " - " + name;
- } else {
- newName = name;
+ /**
+ * Determines the number for every entry in {@code matches}: entries whose {@link
+ * LineMatch#rawName} already matches {@code pinPattern} (and {@code forceRewrite} is
+ * {@code false}) keep that number ("pinned"); every other entry is assigned a fresh number,
+ * counting up from one past the highest pinned number (or from 1, if none are pinned), in list
+ * order. Fresh numbers are never lower than an already-pinned one, so they read as a
+ * continuation of the existing sequence rather than backfilling a gap earlier in it.
+ */
+ private void resolveSequential(List matches, boolean forceRewrite, Pattern pinPattern) {
+ Set taken = new HashSet<>();
+ if (!forceRewrite) {
+ for (LineMatch match : matches) {
+ Matcher matcher = pinPattern.matcher(match.rawName);
+ if (matcher.matches()) {
+ match.number = Integer.parseInt(matcher.group(1));
+ taken.add(match.number);
}
+ }
+ }
+ int next = taken.isEmpty() ? 1 : Collections.max(taken) + 1;
+ for (LineMatch match : matches) {
+ if (match.number != null) {
+ continue;
+ }
+ while (taken.contains(next)) {
+ next++;
+ }
+ match.number = next;
+ taken.add(next);
+ next++;
+ }
+ }
- String newLine = indent + keyword + ":" + gap + newName;
- changed |= !newLine.equals(line);
- rewritten.add(newLine);
+ private ParsedFile parse(File file) {
+ List lines = readLines(file);
+ ParsedFile parsedFile = new ParsedFile(file, new ArrayList<>(lines));
+ for (int lineIndex = 0; lineIndex < lines.size(); lineIndex++) {
+ Matcher matcher = KEYWORD_LINE.matcher(lines.get(lineIndex));
+ if (!matcher.matches()) {
+ continue;
+ }
+ LineMatch match = new LineMatch(
+ lineIndex, matcher.group(1), matcher.group(2), matcher.group(3), matcher.group(4));
+ if ("Feature".equals(match.keyword)) {
+ parsedFile.featureMatches.add(match);
+ } else {
+ parsedFile.scenarioMatches.add(match);
}
+ }
+ return parsedFile;
+ }
- if (changed) {
- writeLines(featureFile, rewritten);
+ private void applyAndWrite(ParsedFile parsedFile, IndexingMode mode) {
+ boolean changed = false;
+ for (LineMatch match : parsedFile.featureMatches) {
+ String cleanName = stripExistingIndex(match.rawName);
+ String newName = match.number != null ? match.number + " - " + cleanName : cleanName;
+ changed |= applyLine(parsedFile.lines, match, newName);
+ }
+
+ Integer featureNumber = parsedFile.featureMatches.isEmpty()
+ ? null : parsedFile.featureMatches.get(0).number;
+ for (LineMatch match : parsedFile.scenarioMatches) {
+ String cleanName = stripExistingIndex(match.rawName);
+ String newName;
+ if (mode == IndexingMode.SCENARIO && match.number != null) {
+ newName = match.number + " - " + cleanName;
+ } else if (mode == IndexingMode.ALL && match.number != null && featureNumber != null) {
+ newName = featureNumber + "." + match.number + " - " + cleanName;
+ } else {
+ newName = cleanName;
}
+ changed |= applyLine(parsedFile.lines, match, newName);
+ }
+
+ if (changed) {
+ writeLines(parsedFile.file, parsedFile.lines);
}
}
+ private boolean applyLine(List lines, LineMatch match, String newName) {
+ String newLine = match.indent + match.keyword + ":" + match.gap + newName;
+ String oldLine = lines.get(match.lineIndex);
+ if (newLine.equals(oldLine)) {
+ return false;
+ }
+ lines.set(match.lineIndex, newLine);
+ return true;
+ }
+
private String stripExistingIndex(String name) {
Matcher matcher = EXISTING_INDEX.matcher(name);
return matcher.matches() ? matcher.group(1) : name;
@@ -112,4 +205,41 @@ private void writeLines(File file, List lines) {
throw new GradleException("gherkinToAsciidoc: could not update feature file: " + file, e);
}
}
+
+ /** A single file's lines, plus its parsed {@code Feature}/{@code Scenario} keyword lines. */
+ private static final class ParsedFile {
+ final File file;
+ final List lines;
+ final List featureMatches = new ArrayList<>();
+ final List scenarioMatches = new ArrayList<>();
+
+ ParsedFile(File file, List lines) {
+ this.file = file;
+ this.lines = lines;
+ }
+ }
+
+ /**
+ * A single parsed {@code Feature}/{@code Scenario}/{@code Scenario Outline} line. Deliberately
+ * not a record: instances are used as mutable carriers for the resolved {@link #number}, and
+ * must never be treated as equal to another instance with coincidentally identical field
+ * values (e.g. two different files' first scenario, both unnumbered) - only the default
+ * identity-based {@link Object#equals(Object)} is safe here.
+ */
+ private static final class LineMatch {
+ final int lineIndex;
+ final String indent;
+ final String keyword;
+ final String gap;
+ final String rawName;
+ Integer number;
+
+ LineMatch(int lineIndex, String indent, String keyword, String gap, String rawName) {
+ this.lineIndex = lineIndex;
+ this.indent = indent;
+ this.keyword = keyword;
+ this.gap = gap;
+ this.rawName = rawName;
+ }
+ }
}
diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java
index 54719aa..5558d79 100644
--- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java
+++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocMultiProjectPluginTest.java
@@ -250,6 +250,27 @@ void subProjectIndexingOverridesRoot() {
assertThat(extension(sub).getIndexing().get()).isEqualTo(IndexingMode.OFF);
}
+ @Test
+ @DisplayName("sub-project without its own configuration inherits forceRewrite from the root project")
+ void subProjectInheritsForceRewriteFromRoot() {
+ Project root = rootProject();
+ extension(root).getForceRewrite().set(true);
+ Project sub = subProject(root, "sub");
+
+ assertThat(extension(sub).getForceRewrite().get()).isTrue();
+ }
+
+ @Test
+ @DisplayName("sub-project's own forceRewrite takes precedence over the root project's")
+ void subProjectForceRewriteOverridesRoot() {
+ Project root = rootProject();
+ extension(root).getForceRewrite().set(true);
+ Project sub = subProject(root, "sub");
+ extension(sub).getForceRewrite().set(false);
+
+ assertThat(extension(sub).getForceRewrite().get()).isFalse();
+ }
+
// --- helpers ---
private Project rootProject() {
diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java
index e443461..c9666ef 100644
--- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java
+++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/GherkinToAsciidocPluginTest.java
@@ -159,6 +159,15 @@ void extensionDefaultIndexingIsOff() {
assertThat(ext.getIndexing().get()).isEqualTo(IndexingMode.OFF);
}
+ @Test
+ @DisplayName("extension default: forceRewrite is false")
+ void extensionDefaultForceRewriteIsFalse() {
+ Project project = projectWithPlugin();
+ GherkinToAsciidocExtension ext = extension(project);
+
+ assertThat(ext.getForceRewrite().get()).isFalse();
+ }
+
@Test
@DisplayName("generates features.adoc from a flat source directory")
void generatesAsciidocFromFlatDirectory() throws IOException {
@@ -1020,6 +1029,128 @@ void cliPropertyInvalidValueThrowsDescriptiveError() throws IOException {
+ "expected one of: off, feature, scenario, all, ci");
}
+ @Test
+ @DisplayName("forceRewrite default (false): a new alphabetically-earlier feature file added on a later "
+ + "run does not renumber an already-numbered file")
+ void forceRewriteDefaultDoesNotRenumberOnLaterRun() throws IOException {
+ Project project = projectWithPlugin();
+ File featuresDir = new File(tempDir.toFile(), "features");
+ featuresDir.mkdirs();
+ writeFeatureFile(featuresDir, "z.feature",
+ "Feature: Z Feature\n\n Scenario: Z scenario\n Given z\n");
+ File outputDir = new File(tempDir.toFile(), "output");
+
+ GenerateFeatureDocsTask firstRun = task(project);
+ firstRun.getSourceDirs().from(featuresDir);
+ firstRun.getIndexing().set(IndexingMode.FEATURE);
+ firstRun.getOutputDir().set(outputDir);
+ firstRun.getProjectDirectory().set(project.getLayout().getProjectDirectory());
+ firstRun.generate();
+ assertThat(Files.readString(featuresDir.toPath().resolve("z.feature")))
+ .contains("Feature: 1 - Z Feature");
+
+ // A new file that sorts alphabetically before z.feature is added on a later run.
+ writeFeatureFile(featuresDir, "a.feature",
+ "Feature: A Feature\n\n Scenario: A scenario\n Given a\n");
+
+ GenerateFeatureDocsTask secondRun = task(project);
+ secondRun.getSourceDirs().from(featuresDir);
+ secondRun.getIndexing().set(IndexingMode.FEATURE);
+ secondRun.getOutputDir().set(outputDir);
+ secondRun.getProjectDirectory().set(project.getLayout().getProjectDirectory());
+ secondRun.generate();
+
+ assertThat(Files.readString(featuresDir.toPath().resolve("z.feature")))
+ .contains("Feature: 1 - Z Feature");
+ assertThat(Files.readString(featuresDir.toPath().resolve("a.feature")))
+ .contains("Feature: 2 - A Feature");
+ }
+
+ @Test
+ @DisplayName("forceRewrite true: a new alphabetically-earlier feature file added on a later run "
+ + "renumbers the already-numbered file to fit alphabetical order")
+ void forceRewriteTrueRenumbersOnLaterRun() throws IOException {
+ Project project = projectWithPlugin();
+ File featuresDir = new File(tempDir.toFile(), "features");
+ featuresDir.mkdirs();
+ writeFeatureFile(featuresDir, "z.feature",
+ "Feature: Z Feature\n\n Scenario: Z scenario\n Given z\n");
+ File outputDir = new File(tempDir.toFile(), "output");
+
+ GenerateFeatureDocsTask firstRun = task(project);
+ firstRun.getSourceDirs().from(featuresDir);
+ firstRun.getIndexing().set(IndexingMode.FEATURE);
+ firstRun.getForceRewrite().set(true);
+ firstRun.getOutputDir().set(outputDir);
+ firstRun.getProjectDirectory().set(project.getLayout().getProjectDirectory());
+ firstRun.generate();
+
+ writeFeatureFile(featuresDir, "a.feature",
+ "Feature: A Feature\n\n Scenario: A scenario\n Given a\n");
+
+ GenerateFeatureDocsTask secondRun = task(project);
+ secondRun.getSourceDirs().from(featuresDir);
+ secondRun.getIndexing().set(IndexingMode.FEATURE);
+ secondRun.getForceRewrite().set(true);
+ secondRun.getOutputDir().set(outputDir);
+ secondRun.getProjectDirectory().set(project.getLayout().getProjectDirectory());
+ secondRun.generate();
+
+ assertThat(Files.readString(featuresDir.toPath().resolve("a.feature")))
+ .contains("Feature: 1 - A Feature");
+ assertThat(Files.readString(featuresDir.toPath().resolve("z.feature")))
+ .contains("Feature: 2 - Z Feature");
+ }
+
+ @Test
+ @DisplayName("the -PgherkinToAsciidoc.forceRewrite project property overrides forceRewrite regardless "
+ + "of the configured value")
+ void cliPropertyOverridesConfiguredForceRewrite() throws IOException {
+ Files.writeString(tempDir.resolve("gradle.properties"), "gherkinToAsciidoc.forceRewrite=true\n");
+ Project project = projectWithPlugin();
+ File featuresDir = new File(tempDir.toFile(), "features");
+ featuresDir.mkdirs();
+ writeFeatureFile(featuresDir, "z.feature",
+ "Feature: 1 - Z Feature\n\n Scenario: Z scenario\n Given z\n");
+ writeFeatureFile(featuresDir, "a.feature",
+ "Feature: A Feature\n\n Scenario: A scenario\n Given a\n");
+
+ GenerateFeatureDocsTask task = task(project);
+ task.getSourceDirs().from(featuresDir);
+ task.getIndexing().set(IndexingMode.FEATURE);
+ // Configured to false, but the CLI override must win.
+ extension(project).getForceRewrite().set(false);
+ task.getOutputDir().set(new File(tempDir.toFile(), "output"));
+ task.getProjectDirectory().set(project.getLayout().getProjectDirectory());
+ task.generate();
+
+ assertThat(task.getForceRewrite().get()).isTrue();
+ assertThat(Files.readString(featuresDir.toPath().resolve("a.feature")))
+ .contains("Feature: 1 - A Feature");
+ assertThat(Files.readString(featuresDir.toPath().resolve("z.feature")))
+ .contains("Feature: 2 - Z Feature");
+ }
+
+ @Test
+ @DisplayName("an invalid -PgherkinToAsciidoc.forceRewrite value throws a descriptive GradleException")
+ void cliPropertyInvalidForceRewriteValueThrowsDescriptiveError() throws IOException {
+ Files.writeString(tempDir.resolve("gradle.properties"), "gherkinToAsciidoc.forceRewrite=maybe\n");
+ Project project = projectWithPlugin();
+ File featuresDir = new File(tempDir.toFile(), "features");
+ featuresDir.mkdirs();
+ writeFeatureFile(featuresDir, "sample.feature", "Feature: Sample\n\n Scenario: A scenario\n Given g\n");
+
+ GenerateFeatureDocsTask task = task(project);
+ task.getSourceDirs().from(featuresDir);
+ task.getOutputDir().set(new File(tempDir.toFile(), "output"));
+ task.getProjectDirectory().set(project.getLayout().getProjectDirectory());
+
+ assertThatThrownBy(task::generate)
+ .hasRootCauseInstanceOf(org.gradle.api.GradleException.class)
+ .hasRootCauseMessage("gherkinToAsciidoc: invalid value 'maybe' for "
+ + "-PgherkinToAsciidoc.forceRewrite; expected 'true' or 'false'");
+ }
+
// --- helpers ---
private Project projectWithPlugin() {
diff --git a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java
index e1daaf7..2847353 100644
--- a/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java
+++ b/gherkin-to-asciidoc/src/test/java/com/arc_e_tect/gradle/gherkin/indexing/FeatureIndexerTest.java
@@ -27,7 +27,7 @@ void offModeLeavesTitlesUntouched() throws IOException {
File file = writeFeature("authentication.feature",
"Feature: User authentication\n\n Scenario: User logs in\n Given a user\n");
- indexer.reindex(List.of(file), IndexingMode.OFF);
+ indexer.reindex(List.of(file), IndexingMode.OFF, false);
assertThat(content(file))
.contains("Feature: User authentication")
@@ -42,7 +42,7 @@ void featureModeNumbersFeaturesInGivenOrder() throws IOException {
File invoice = writeFeature("invoice.feature",
"Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n");
- indexer.reindex(List.of(auth, invoice), IndexingMode.FEATURE);
+ indexer.reindex(List.of(auth, invoice), IndexingMode.FEATURE, false);
assertThat(content(auth))
.contains("Feature: 1 - User authentication")
@@ -67,7 +67,7 @@ void scenarioModeNumbersScenariosContinuously() throws IOException {
File invoice = writeFeature("invoice.feature",
"Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n");
- indexer.reindex(List.of(auth, invoice), IndexingMode.SCENARIO);
+ indexer.reindex(List.of(auth, invoice), IndexingMode.SCENARIO, false);
assertThat(content(auth))
.contains("Feature: User authentication")
@@ -93,7 +93,7 @@ void allModeNumbersFeaturesAndScenariosPerFeature() throws IOException {
File invoice = writeFeature("invoice.feature",
"Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n");
- indexer.reindex(List.of(auth, invoice), IndexingMode.ALL);
+ indexer.reindex(List.of(auth, invoice), IndexingMode.ALL, false);
assertThat(content(auth))
.contains("Feature: 1 - User authentication")
@@ -113,7 +113,7 @@ void numbersFilesInGivenOrderNotAlphabetically() throws IOException {
"Feature: A Feature\n\n Scenario: A scenario\n Given a\n");
// Given in z-then-a order: the caller (not the indexer) is responsible for ordering.
- indexer.reindex(List.of(zFile, aFile), IndexingMode.FEATURE);
+ indexer.reindex(List.of(zFile, aFile), IndexingMode.FEATURE, false);
assertThat(content(zFile)).contains("Feature: 1 - Z Feature");
assertThat(content(aFile)).contains("Feature: 2 - A Feature");
@@ -133,7 +133,7 @@ void numbersScenarioOutline() throws IOException {
| admin |
""");
- indexer.reindex(List.of(file), IndexingMode.ALL);
+ indexer.reindex(List.of(file), IndexingMode.ALL, false);
assertThat(content(file)).contains("Scenario Outline: 1.1 - User logs in with ");
}
@@ -150,7 +150,7 @@ void numbersScenariosInsideRule() throws IOException {
Given a premium user
""");
- indexer.reindex(List.of(file), IndexingMode.SCENARIO);
+ indexer.reindex(List.of(file), IndexingMode.SCENARIO, false);
assertThat(content(file)).contains(" Scenario: 1 - Premium user views protected page");
}
@@ -162,9 +162,9 @@ void switchingToOffRemovesNumbering() throws IOException {
"Feature: User authentication\n\n Scenario: User logs in\n Given a user\n");
File invoice = writeFeature("invoice.feature",
"Feature: Invoice payment\n\n Scenario: User pays an invoice\n Given an invoice\n");
- indexer.reindex(List.of(auth, invoice), IndexingMode.ALL);
+ indexer.reindex(List.of(auth, invoice), IndexingMode.ALL, false);
- indexer.reindex(List.of(auth, invoice), IndexingMode.OFF);
+ indexer.reindex(List.of(auth, invoice), IndexingMode.OFF, false);
assertThat(content(auth))
.contains("Feature: User authentication")
@@ -181,10 +181,10 @@ void switchingToOffRemovesNumbering() throws IOException {
void switchingModesReplacesNumbering() throws IOException {
File auth = writeFeature("authentication.feature",
"Feature: User authentication\n\n Scenario: User logs in\n Given a user\n");
- indexer.reindex(List.of(auth), IndexingMode.SCENARIO);
+ indexer.reindex(List.of(auth), IndexingMode.SCENARIO, false);
assertThat(content(auth)).contains("Scenario: 1 - User logs in");
- indexer.reindex(List.of(auth), IndexingMode.FEATURE);
+ indexer.reindex(List.of(auth), IndexingMode.FEATURE, false);
assertThat(content(auth))
.contains("Feature: 1 - User authentication")
@@ -197,10 +197,10 @@ void switchingModesReplacesNumbering() throws IOException {
void reindexingWithSameModeIsIdempotent() throws IOException {
File auth = writeFeature("authentication.feature",
"Feature: User authentication\n\n Scenario: User logs in\n Given a user\n");
- indexer.reindex(List.of(auth), IndexingMode.ALL);
+ indexer.reindex(List.of(auth), IndexingMode.ALL, false);
String firstPass = content(auth);
- indexer.reindex(List.of(auth), IndexingMode.ALL);
+ indexer.reindex(List.of(auth), IndexingMode.ALL, false);
assertThat(content(auth)).isEqualTo(firstPass);
}
@@ -215,13 +215,130 @@ void doesNotTouchUnrelatedLines() throws IOException {
Given a user with role "Scenario: not a keyword"
""");
- indexer.reindex(List.of(file), IndexingMode.SCENARIO);
+ indexer.reindex(List.of(file), IndexingMode.SCENARIO, false);
assertThat(content(file))
.contains("Scenario: 1 - User logs in")
.contains("Given a user with role \"Scenario: not a keyword\"");
}
+ // --- forceRewrite = false (default): preserve already-correctly-numbered lines ---
+
+ @Test
+ @DisplayName("forceRewrite false: a new alphabetically-earlier file does not steal an already-numbered "
+ + "file's number")
+ void unpinnedFileDoesNotStealAlreadyNumberedFilesNumber() throws IOException {
+ File zFile = writeFeature("z.feature",
+ "Feature: 1 - Z Feature\n\n Scenario: Z scenario\n Given z\n");
+ File aFile = writeFeature("a.feature",
+ "Feature: A Feature\n\n Scenario: A scenario\n Given a\n");
+
+ // Given in correct alphabetical order: a before z.
+ indexer.reindex(List.of(aFile, zFile), IndexingMode.FEATURE, false);
+
+ assertThat(content(zFile)).contains("Feature: 1 - Z Feature");
+ assertThat(content(aFile)).contains("Feature: 2 - A Feature");
+ }
+
+ @Test
+ @DisplayName("forceRewrite true: an already-numbered file is renumbered to fit alphabetical order, "
+ + "same as before this property existed")
+ void forceRewriteTrueRenumbersEverythingFromScratch() throws IOException {
+ File zFile = writeFeature("z.feature",
+ "Feature: 1 - Z Feature\n\n Scenario: Z scenario\n Given z\n");
+ File aFile = writeFeature("a.feature",
+ "Feature: A Feature\n\n Scenario: A scenario\n Given a\n");
+
+ indexer.reindex(List.of(aFile, zFile), IndexingMode.FEATURE, true);
+
+ assertThat(content(aFile)).contains("Feature: 1 - A Feature");
+ assertThat(content(zFile)).contains("Feature: 2 - Z Feature");
+ }
+
+ @Test
+ @DisplayName("forceRewrite false: a scenario number in the wrong format for the current mode is "
+ + "renumbered, e.g. switching from SCENARIO to ALL")
+ void scenarioNumberNotMatchingNewModeFormatIsRenumbered() throws IOException {
+ File file = writeFeature("authentication.feature",
+ "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n");
+ indexer.reindex(List.of(file), IndexingMode.SCENARIO, false);
+ assertThat(content(file)).contains("Scenario: 1 - User logs in");
+
+ indexer.reindex(List.of(file), IndexingMode.ALL, false);
+
+ assertThat(content(file))
+ .contains("Feature: 1 - User authentication")
+ .contains("Scenario: 1.1 - User logs in")
+ .doesNotContain("Scenario: 1 -");
+ }
+
+ @Test
+ @DisplayName("forceRewrite false: a feature number left over from a mode that doesn't number features "
+ + "is stripped, e.g. switching from ALL to SCENARIO")
+ void featureNumberNotExpectedByNewModeIsStripped() throws IOException {
+ File file = writeFeature("authentication.feature",
+ "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n");
+ indexer.reindex(List.of(file), IndexingMode.ALL, false);
+ assertThat(content(file)).contains("Feature: 1 - User authentication");
+
+ indexer.reindex(List.of(file), IndexingMode.SCENARIO, false);
+
+ assertThat(content(file))
+ .contains("Feature: User authentication")
+ .contains("Scenario: 1 - User logs in");
+ }
+
+ @Test
+ @DisplayName("forceRewrite false: an already-numbered scenario keeps its number; only the unnumbered "
+ + "one gets a fresh number, not colliding with the pinned one")
+ void pinnedScenarioKeepsItsNumberUnnumberedOneGetsNextAvailable() throws IOException {
+ File file = writeFeature("sample.feature", """
+ Feature: Sample
+
+ Scenario: 5 - Already numbered
+ Given a user
+
+ Scenario: Not yet numbered
+ Given a user
+ """);
+
+ indexer.reindex(List.of(file), IndexingMode.SCENARIO, false);
+
+ assertThat(content(file))
+ .contains("Scenario: 5 - Already numbered")
+ .contains("Scenario: 6 - Not yet numbered");
+ }
+
+ @Test
+ @DisplayName("forceRewrite false: re-running the same mode twice is idempotent, same as before this "
+ + "property existed")
+ void forceRewriteFalseIsIdempotentAcrossRuns() throws IOException {
+ File auth = writeFeature("authentication.feature",
+ "Feature: User authentication\n\n Scenario: User logs in\n Given a user\n");
+ indexer.reindex(List.of(auth), IndexingMode.ALL, false);
+ String firstPass = content(auth);
+
+ indexer.reindex(List.of(auth), IndexingMode.ALL, false);
+
+ assertThat(content(auth)).isEqualTo(firstPass);
+ }
+
+ @Test
+ @DisplayName("forceRewrite false: an ALL-mode scenario number matching a different feature number than "
+ + "its own resolved one is renumbered")
+ void scenarioNumberMismatchedWithOwnFeatureNumberIsRenumbered() throws IOException {
+ // "3.1" would only be pinned if this feature's own resolved number were 3 - it isn't (no
+ // other feature is numbered here, so this one resolves to 1), so it must be renumbered.
+ File file = writeFeature("sample.feature",
+ "Feature: Sample\n\n Scenario: 3.1 - Stale scenario\n Given g\n");
+
+ indexer.reindex(List.of(file), IndexingMode.ALL, false);
+
+ assertThat(content(file))
+ .contains("Feature: 1 - Sample")
+ .contains("Scenario: 1.1 - Stale scenario");
+ }
+
private File writeFeature(String name, String content) throws IOException {
File file = tempDir.resolve(name).toFile();
Files.writeString(file.toPath(), content, StandardCharsets.UTF_8);