Add support for custom quota-exceeded messages

Extend the quota configuration with a new
`sizeLimitExceededUnknownRequestSizeMsg` and
`sizeLimitExceededKnownRequestSizeMsg` allowing administrators to define
custom error messages when a repository exceeds its size quota, for the
case when the size requested was unknown or known upfront, respectively.

The message supports the placeholders `${project}`, `${available}`,
`${maximum}` and `${requested}` (when available) which are expanded at
runtime using the new QuotaSizeMessageInterpolator.

MaxRepositorySizeQuota is updated to return an AvailableSizeResponse
instead of a raw Long so that both the available space and the
corresponding maximum limit can be propagated.

The most restrictive quota (the smallest available size) is selected,
and its custom message is used when constructing a client-facing
QuotaResponse objects.

A default message template is provided for quota sections that do not
define their own message:

When requested size quota is unknown:

"Project ${project} exceeds quota: max=${maximum} bytes,
available=${available} bytes."

When requested size quota is known (as it was before this change):

"Requested space [${requested}] is bigger then available [${available}]
for repository ${project}"

Bug: Issue 459520484
Depends-On: I0e9e58734d6ebf9d669c1e70cf51b16aaa5a3bf0
Change-Id: Iecb608b85d6c4547d219bf00b7186ca0c3c3eaec
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.");
+  }
+}