Merge "Add support for custom quota-exceeded messages"
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/MaxRepositorySizeQuota.java b/src/main/java/com/googlesource/gerrit/plugins/quota/MaxRepositorySizeQuota.java
index 304b092..2450760 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/MaxRepositorySizeQuota.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/MaxRepositorySizeQuota.java
@@ -24,6 +24,7 @@
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.Ordering;
 import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.cache.CacheModule;
@@ -47,6 +48,7 @@
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.ExecutionException;
@@ -92,13 +94,13 @@
     this.projectCache = projectCache;
   }
 
-  protected Optional<Long> getMaxPackSize(Project.NameKey project) {
+  protected Optional<AvailableSizeResponse> getMaxPackSize(Project.NameKey project) {
     return getMaxPackSize(project, true);
   }
 
-  protected Optional<Long> getMaxPackSize(
+  protected Optional<AvailableSizeResponse> getMaxPackSize(
       Project.NameKey project, boolean requireProjectExistence) {
-    List<Long> maxPackCandidates = new ArrayList<>();
+    List<AvailableSizeResponse> maxPackCandidates = new ArrayList<>();
     getMaxPackSize(quotaFinder.firstMatching(project), project, requireProjectExistence)
         .ifPresent(maxPackCandidates::add);
     getMaxPackSize(quotaFinder.getGlobalNamespacedQuota(), project, requireProjectExistence)
@@ -106,10 +108,12 @@
 
     return maxPackCandidates.isEmpty()
         ? Optional.empty()
-        : Optional.of(Collections.min(maxPackCandidates));
+        : Optional.of(
+            Collections.min(
+                maxPackCandidates, Comparator.comparingLong(AvailableSizeResponse::availableSize)));
   }
 
-  protected Optional<Long> getMaxPackSize(
+  protected Optional<AvailableSizeResponse> getMaxPackSize(
       QuotaSection quotaSection, Project.NameKey project, boolean requireProjectExistence) {
     if (quotaSection == null) {
       return Optional.empty();
@@ -142,8 +146,22 @@
         maxPackSize2 = Math.max(0, maxTotalSize - totalSize);
       }
 
-      return Optional.ofNullable(
-          Ordering.<Long>natural().nullsLast().min(maxPackSize1, maxPackSize2));
+      Long chosenAvailable = Ordering.<Long>natural().nullsLast().min(maxPackSize1, maxPackSize2);
+
+      if (chosenAvailable == null) {
+        return Optional.empty();
+      }
+
+      Long maximumSize;
+      if (chosenAvailable.equals(maxPackSize1)) {
+        maximumSize = maxRepoSize;
+      } else {
+        maximumSize = maxTotalSize;
+      }
+
+      return Optional.of(
+          new AvailableSizeResponse(quotaSection, project, chosenAvailable, maximumSize));
+
     } catch (ExecutionException e) {
       log.warn("Couldn't calculate maxPackSize for {}", project, e);
       return Optional.empty();
@@ -261,7 +279,7 @@
 
     return ctx.project()
         .flatMap(p -> getMaxPackSize(p, false))
-        .map(v -> requestQuota(ctx, numTokens, v, false))
+        .map(v -> requestQuota(numTokens, v, false))
         .orElse(noOp());
   }
 
@@ -289,8 +307,8 @@
     }
 
     return ctx.project()
-        .flatMap(p -> getMaxPackSize(p))
-        .map(v -> requestQuota(ctx, numTokens, v, true))
+        .flatMap(this::getMaxPackSize)
+        .map(v -> requestQuota(numTokens, v, true))
         .orElse(noOp());
   }
 
@@ -299,13 +317,16 @@
     if (!REPOSITORY_SIZE_GROUP.equals(quotaGroup)) {
       return noOp();
     }
-    return ctx.project().flatMap(p -> getMaxPackSize(p)).map(v -> ok(v)).orElse(noOp());
+    return ctx.project()
+        .flatMap(this::getMaxPackSize)
+        .map(v -> ok(v.availableSize(), v.exceededSizeMessage()))
+        .orElse(noOp());
   }
 
   private QuotaResponse requestQuota(
-      QuotaRequestContext ctx, long requested, Long availableSpace, boolean deduct) {
-    Project.NameKey r = ctx.project().get();
-    if (availableSpace >= requested) {
+      long requested, AvailableSizeResponse availableSizeResponse, boolean deduct) {
+    Project.NameKey r = availableSizeResponse.project();
+    if (availableSizeResponse.availableSize() >= requested) {
       if (deduct) {
         try {
           cache.get(r).getAndAdd(requested);
@@ -318,9 +339,29 @@
       return ok();
     }
 
-    return error(
-        String.format(
-            "Requested space [%d] is bigger then available [%d] for repository %s",
-            requested, availableSpace, r));
+    return error(availableSizeResponse.withRequested(requested).exceededSizeMessage());
+  }
+
+  protected record AvailableSizeResponse(
+      QuotaSection quotaSection,
+      Project.NameKey project,
+      long availableSize,
+      long maximumSize,
+      @Nullable Long requested) {
+
+    public AvailableSizeResponse(
+        QuotaSection quotaSection, Project.NameKey project, long availableSize, long maximumSize) {
+      this(quotaSection, project, availableSize, maximumSize, null);
+    }
+
+    public AvailableSizeResponse withRequested(long newRequested) {
+      return new AvailableSizeResponse(
+          quotaSection, project, availableSize, maximumSize, newRequested);
+    }
+
+    public String exceededSizeMessage() {
+      return quotaSection()
+          .quotaSizeExceededMessage(project, availableSize, maximumSize, requested);
+    }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
index 8de2ff8..8df6099 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.quota;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import java.util.Arrays;
 import java.util.List;
@@ -24,6 +25,13 @@
   String KEY_MAX_PROJECTS = "maxProjects";
   String KEY_MAX_REPO_SIZE = "maxRepoSize";
   String KEY_MAX_TOTAL_SIZE = "maxTotalSize";
+  String KEY_SIZE_EXCEEDED_KNOWN_REQUEST_SIZE_MSG = "sizeLimitExceededKnownRequestSizeMsg";
+  String DEFAULT_SIZE_MSG_KNOWN_REQUEST_SIZE_TEMPLATE =
+      "Requested space [${requested}] is bigger then available [${available}] for repository"
+          + " ${project}";
+  String KEY_SIZE_EXCEEDED_UNKNOWN_REQUEST_SIZE_MSG = "sizeLimitExceededUnknownRequestSizeMsg";
+  String DEFAULT_SIZE_MSG_UNKNOWN_REQUEST_SIZE_TEMPLATE =
+      "Project ${project} exceeds quota: max=${maximum} bytes, available=${available} bytes.";
 
   String getNamespace();
 
@@ -60,6 +68,34 @@
         .toList();
   }
 
+  default String quotaSizeExceededMessage(
+      Project.NameKey project, long availableSize, long maximumSize, @Nullable Long requested) {
+
+    return QuotaSizeMessageInterpolator.interpolate(
+        getQuotaSizeExceededMessageTemplate(requested),
+        project,
+        availableSize,
+        maximumSize,
+        requested);
+  }
+
+  private String getQuotaSizeExceededMessageTemplate(@Nullable Long requested) {
+    boolean unknown = (requested == null);
+    String msgTemplate =
+        unknown
+            ? KEY_SIZE_EXCEEDED_UNKNOWN_REQUEST_SIZE_MSG
+            : KEY_SIZE_EXCEEDED_KNOWN_REQUEST_SIZE_MSG;
+    String defaultMsgTemplate =
+        unknown
+            ? DEFAULT_SIZE_MSG_UNKNOWN_REQUEST_SIZE_TEMPLATE
+            : DEFAULT_SIZE_MSG_KNOWN_REQUEST_SIZE_TEMPLATE;
+    String tpl = cfg().getString(section(), subSection(), msgTemplate);
+    if (tpl == null || tpl.trim().isEmpty()) {
+      return defaultMsgTemplate;
+    }
+    return tpl;
+  }
+
   default boolean isFallbackQuota() {
     return false;
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolator.java b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolator.java
new file mode 100644
index 0000000..31095ab
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolator.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utility class responsible for expanding variables in size-quota-exceeded message templates.
+ *
+ * <p>This interpolator replaces simple {@code ${var}} placeholders inside a message template with
+ * runtime values derived from size quota evaluation. It currently supports exactly three variables:
+ *
+ * <ul>
+ *   <li>{@code ${project}} — expanded to the project name (e.g. {@code "foo/bar"}).
+ *   <li>{@code ${available}} — the number of remaining bytes before quota is exceeded.
+ *   <li>{@code ${maximum}} — the configured maximum allowed value for the applicable quota.
+ *   <li>{@code ${requestedSize}} — requested size
+ * </ul>
+ *
+ * <p>Unknown variables are left untouched, allowing safe forward-compatibility and making it easier
+ * to spot configuration mistakes.
+ */
+class QuotaSizeMessageInterpolator {
+  private static final Pattern VAR_PATTERN = Pattern.compile("\\$\\{([^}]+)}");
+
+  /**
+   * Expands variables inside the quota-exceeded message template.
+   *
+   * <p>Supported variables:
+   *
+   * <ul>
+   *   <li>{@code ${project}} — project name
+   *   <li>{@code ${available}} — remaining quota in bytes/tokens
+   *   <li>{@code ${maximum}} — configured upper limit for the quota
+   *   <li>{@code ${requestedSize}} — requested size
+   * </ul>
+   *
+   * <p>Unknown placeholders are preserved unchanged, e.g. {@code ${foo}} remains {@code ${foo}}.
+   *
+   * @param template raw template read from configuration
+   * @param project the affected project
+   * @param availableSize remaining size/tokens before exceeding quota
+   * @param maximumSize configured maximum for the quota
+   * @param requestedSize requested size
+   * @return the template with variables replaced
+   */
+  static String interpolate(
+      String template,
+      Project.NameKey project,
+      long availableSize,
+      long maximumSize,
+      @Nullable Long requestedSize) {
+
+    Map<String, String> vars =
+        Map.of(
+            "project", project.get(),
+            "available", String.valueOf(availableSize),
+            "requested", Objects.toString(requestedSize, ""),
+            "maximum", String.valueOf(maximumSize));
+
+    Matcher m = VAR_PATTERN.matcher(template);
+    StringBuilder sb = new StringBuilder();
+
+    while (m.find()) {
+      String var = m.group(1);
+      String replacement = vars.getOrDefault(var, m.group(0));
+      m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
+    }
+
+    m.appendTail(sb);
+    return sb.toString();
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index f2ceb15..2d3a925 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -22,6 +22,35 @@
     maxTotalSize = 200 m
 ```
 
+<a id="sizeLimitExceededUnknownRequestSizeMsg" />
+`quota.<namespace>.sizeLimitExceededUnknownRequestSizeMsg`
+: Optional custom message returned when a size quota in this namespace is exceeded **and the
+requested size is not known in advance**.
+This situation occurs when the quota is used as a *pre-flight* check, but the exact number
+of bytes to be written is not yet available.
+For example, when Gerrit receives a commit, its size cannot be determined until the transfer
+completes.
+
+<a id="sizeLimitExceededKnownRequestSizeMsg" />
+`quota.<namespace>.sizeLimitExceededKnownRequestSizeMsg`
+: Optional custom message returned when a size quota in this namespace is exceeded **and the
+requested size is known upfront**.
+This applies when the quota is evaluated after the size is already determined, such as during
+a post-receive hook or when creating a repository.
+
+The message may contain the following placeholders, which will be replaced at
+runtime when constructing the client-facing error message:
+
+* `${project}` — the name of the affected project
+* `${available}` — remaining quota (in bytes) available before exceeding the limit
+* `${maximum}` — the configured maximum size (in bytes) enforced by the quota
+* `${requested}` — The requested size (in bytes), if known.
+
+Unknown placeholders are left unchanged. If no message is configured, a
+default message is used. When multiple quota enforcers contribute to a quota
+calculation, the message from the most restrictive enforcer (the one with the
+lowest remaining quota) is selected.
+
 <a id="maxProjects" />
 `quota.<namespace>.maxProjects`
 : The maximum number of projects that can be created in this namespace.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/GlobalQuotaSectionTest.java b/src/test/java/com/googlesource/gerrit/plugins/quota/GlobalQuotaSectionTest.java
new file mode 100644
index 0000000..4740fa3
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/GlobalQuotaSectionTest.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import org.eclipse.jgit.lib.Config;
+
+public class GlobalQuotaSectionTest extends QuotaSectionBase {
+
+  @Override
+  protected QuotaSection createQuotaSection(Config cfg) {
+    return new GlobalQuotaSection(cfg);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSectionTest.java b/src/test/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSectionTest.java
new file mode 100644
index 0000000..7623b12
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSectionTest.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import org.eclipse.jgit.lib.Config;
+
+public class NamespacedQuotaSectionTest extends QuotaSectionBase {
+
+  @Override
+  protected QuotaSection createQuotaSection(Config cfg) {
+    return new NamespacedQuotaSection(cfg, "test/*");
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSectionBase.java b/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSectionBase.java
new file mode 100644
index 0000000..c1b7204
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSectionBase.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class QuotaSectionBase {
+
+  protected static final Project.NameKey PROJECT = Project.nameKey("foo/bar");
+
+  protected abstract QuotaSection createQuotaSection(Config cfg);
+
+  @Test
+  public void fallsBackToDefaultExceededMessageUnknownRequestSizeWhenNotConfigured() {
+    Config cfg = new Config();
+    QuotaSection section = createQuotaSection(cfg);
+
+    String message = section.quotaSizeExceededMessage(PROJECT, 100L, 200L, null);
+
+    assertThat(message)
+        .isEqualTo("Project foo/bar exceeds quota: max=200 bytes, available=100 bytes.");
+  }
+
+  @Test
+  public void usesSizeExceededMessageUnknownRequestSizeWhenConfigured() {
+    Config cfg = new Config();
+    cfg.setString(
+        createQuotaSection(cfg).section(),
+        createQuotaSection(cfg).subSection(),
+        QuotaSection.KEY_SIZE_EXCEEDED_UNKNOWN_REQUEST_SIZE_MSG,
+        "Custom for ${project}: max=${maximum}, avail=${available}");
+    QuotaSection section = createQuotaSection(cfg);
+
+    String message = section.quotaSizeExceededMessage(PROJECT, 10L, 20L, null);
+
+    assertThat(message).isEqualTo("Custom for foo/bar: max=20, avail=10");
+  }
+
+  @Test
+  public void fallsBackToDefaultExceededMessageKnownRequestSizeWhenNotConfigured() {
+    Config cfg = new Config();
+    QuotaSection section = createQuotaSection(cfg);
+
+    String message = section.quotaSizeExceededMessage(PROJECT, 100L, 200L, 300L);
+
+    assertThat(message)
+        .isEqualTo("Requested space [300] is bigger then available [100] for repository foo/bar");
+  }
+
+  @Test
+  public void usesSizeExceededMessageKnownRequestSizeWhenConfigured() {
+    Config cfg = new Config();
+    cfg.setString(
+        createQuotaSection(cfg).section(),
+        createQuotaSection(cfg).subSection(),
+        QuotaSection.KEY_SIZE_EXCEEDED_KNOWN_REQUEST_SIZE_MSG,
+        "Custom for ${project}: max=${maximum}, avail=${available}, requested=${requested}");
+    QuotaSection section = createQuotaSection(cfg);
+
+    String message = section.quotaSizeExceededMessage(PROJECT, 10L, 20L, 30L);
+
+    assertThat(message).isEqualTo("Custom for foo/bar: max=20, avail=10, requested=30");
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolatorTest.java b/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolatorTest.java
new file mode 100644
index 0000000..d1ea706
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/QuotaSizeMessageInterpolatorTest.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import org.junit.Test;
+
+public class QuotaSizeMessageInterpolatorTest {
+
+  private static final Project.NameKey PROJECT = Project.nameKey("foo/bar");
+
+  @Test
+  public void interpolateReplacesAllVariables() {
+    String template =
+        "Project ${project} exceeds quota: maximum=${maximum}, available=${available}.";
+
+    String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 100L, 200L, null);
+
+    assertThat(result).isEqualTo("Project foo/bar exceeds quota: maximum=200, available=100.");
+  }
+
+  @Test
+  public void interpolateHandlesRepeatedVariables() {
+    String template =
+        "Project ${project}, project=${project}, available=${available}, maximum=${maximum}.";
+
+    String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 42L, 1337L, null);
+
+    assertThat(result).isEqualTo("Project foo/bar, project=foo/bar, available=42, maximum=1337.");
+  }
+
+  @Test
+  public void interpolateLeavesUnknownVariablesUntouched() {
+    String template = "Project ${project} exceeded quota ${unknown} with available=${available}.";
+
+    String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 10L, 20L, null);
+
+    assertThat(result).isEqualTo("Project foo/bar exceeded quota ${unknown} with available=10.");
+  }
+
+  @Test
+  public void interpolateWorksWithEmptyTemplate() {
+    String template = "";
+
+    String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 10L, 20L, null);
+
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void interpolateWorksWhenNumbersAreZero() {
+    String template = "Project ${project} has maximum=${maximum} and available=${available}.";
+
+    String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 0L, 0L, null);
+
+    assertThat(result).isEqualTo("Project foo/bar has maximum=0 and available=0.");
+  }
+
+  @Test
+  public void interpolateRequestedWhenAvailable() {
+    String template =
+        "Project ${project} has maximum=${maximum} and available=${available}, but ${requested} was"
+            + " requested.";
+
+    String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 0L, 0L, 100L);
+
+    assertThat(result)
+        .isEqualTo("Project foo/bar has maximum=0 and available=0, but 100 was requested.");
+  }
+
+  @Test
+  public void interpolateRequestedToEmptyStringWhenNotAvailable() {
+    String template =
+        "Project ${project} has maximum=${maximum} and available=${available}, but ${requested} was"
+            + " requested.";
+
+    String result = QuotaSizeMessageInterpolator.interpolate(template, PROJECT, 0L, 0L, 100L);
+
+    assertThat(result)
+        .isEqualTo("Project foo/bar has maximum=0 and available=0, but 100 was requested.");
+  }
+}