001/*
002 * Copyright 2017-2022 Product Mog LLC, 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.lokalized;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.ThreadSafe;
023import java.util.ArrayList;
024import java.util.Collections;
025import java.util.List;
026import java.util.Locale;
027import java.util.Optional;
028
029import static java.util.Objects.requireNonNull;
030
031/**
032 * Diagnostic event for a lookup whose later locale candidate supplied a translation.
033 * <p>
034 * The event records every preceding candidate's own failure reason and cause, in attempt order. It contains no
035 * rendered translation or caller placeholder values. Collection state is unmodifiable; runtime causes are exposed
036 * by reference and are outside this class's thread-safety guarantee.
037 *
038 * @author <a href="https://revetkn.com">Mark Allen</a>
039 * @since 3.1.1
040 */
041@ThreadSafe
042public final class TranslationFallbackEvent {
043        @NonNull private final String key;
044        @NonNull private final Locale lookupLocale;
045        @Nullable private final LocaleMatchResult localeMatchResult;
046        @NonNull private final List<@NonNull Locale> attemptedLocales;
047        @NonNull private final Locale resolvedLocale;
048        @NonNull private final List<@NonNull PrecedingFailure> precedingFailures;
049
050        /**
051         * Constructs an event from a successful result and the failures preceding it. Custom {@link Strings}
052         * implementations can use this constructor to expose the same diagnostics.
053         * <p>
054         * The locale-match result and unmodifiable attempted-locales list are shared with {@code translationResult}. The supplied
055         * failure list is defensively copied. This event does not retain the rendered translation from {@code translationResult}.
056         *
057         * @param translationResult a translated result whose successful locale follows at least one failed candidate, not null
058         * @param precedingFailures failures for every preceding candidate, in attempt order, not null
059         * @throws IllegalArgumentException if the result is not translated, there are no preceding candidates, or the
060         *                                  failures do not correspond exactly to the preceding attempted locales
061         */
062        public TranslationFallbackEvent(@NonNull TranslationResult translationResult,
063                        @NonNull List<@NonNull PrecedingFailure> precedingFailures) {
064                requireNonNull(translationResult);
065                requireNonNull(precedingFailures);
066
067                if (translationResult.getStatus() != TranslationResultStatus.TRANSLATED)
068                        throw new IllegalArgumentException("A fallback event requires a translated result");
069
070                List<@NonNull Locale> attemptedLocales = translationResult.getAttemptedLocales();
071
072                if (precedingFailures.isEmpty() || precedingFailures.size() != attemptedLocales.size() - 1)
073                        throw new IllegalArgumentException("A fallback event requires one failure for each preceding locale candidate");
074
075                Locale resolvedLocale = translationResult.getResolvedLocale().orElseThrow(IllegalArgumentException::new);
076
077                if (!resolvedLocale.equals(attemptedLocales.get(attemptedLocales.size() - 1)))
078                        throw new IllegalArgumentException("The final attempted locale must supply the fallback translation");
079
080                List<@NonNull PrecedingFailure> precedingFailuresCopy = new ArrayList<>(precedingFailures.size());
081
082                for (int index = 0; index < precedingFailures.size(); ++index) {
083                        PrecedingFailure precedingFailure = requireNonNull(precedingFailures.get(index));
084
085                        if (!precedingFailure.getLocale().equals(attemptedLocales.get(index)))
086                                throw new IllegalArgumentException("Preceding failures must follow the attempted locale order");
087
088                        precedingFailuresCopy.add(precedingFailure);
089                }
090
091                this.key = translationResult.getKey();
092                this.lookupLocale = translationResult.getLookupLocale();
093                this.localeMatchResult = translationResult.getLocaleMatchResult().orElse(null);
094                this.attemptedLocales = attemptedLocales;
095                this.resolvedLocale = resolvedLocale;
096                this.precedingFailures = Collections.unmodifiableList(precedingFailuresCopy);
097        }
098
099        /**
100         * Gets the translation key.
101         *
102         * @return translation key, not null
103         */
104        @NonNull
105        public String getKey() {
106                return key;
107        }
108
109        /**
110         * Gets the locale used to begin per-key lookup.
111         *
112         * @return lookup locale, not null
113         */
114        @NonNull
115        public Locale getLookupLocale() {
116                return lookupLocale;
117        }
118
119        /**
120         * Gets the locale-negotiation diagnostics when available.
121         *
122         * @return locale-match result when available, otherwise empty, not null
123         */
124        @NonNull
125        public Optional<@NonNull LocaleMatchResult> getLocaleMatchResult() {
126                return Optional.ofNullable(localeMatchResult);
127        }
128
129        /**
130         * Gets all attempted locales through the successful candidate.
131         *
132         * @return attempted locales in attempt order, not null
133         */
134        @NonNull
135        public List<@NonNull Locale> getAttemptedLocales() {
136                return attemptedLocales;
137        }
138
139        /**
140         * Gets the locale that supplied the translation.
141         *
142         * @return resolved locale, not null
143         */
144        @NonNull
145        public Locale getResolvedLocale() {
146                return resolvedLocale;
147        }
148
149        /**
150         * Gets the failures preceding the successful candidate.
151         *
152         * @return preceding failures in attempt order, not null
153         */
154        @NonNull
155        public List<@NonNull PrecedingFailure> getPrecedingFailures() {
156                return precedingFailures;
157        }
158
159        /** @return diagnostic representation omitting translation text and throwable messages, not null */
160        @Override
161        @NonNull
162        public String toString() {
163                return Diagnostics.format("%s{key='%s', lookupLocale=%s, resolvedLocale=%s, precedingFailures=%s}",
164                                getClass().getSimpleName(), key, lookupLocale.toLanguageTag(), resolvedLocale.toLanguageTag(), precedingFailures);
165        }
166
167        /**
168         * Failure of one locale candidate preceding a successful fallback translation.
169         * <p>
170         * The runtime cause is exposed by reference and is outside this class's thread-safety guarantee.
171         *
172         * @since 3.1.1
173         */
174        @ThreadSafe
175        public static final class PrecedingFailure {
176                @NonNull private final Locale locale;
177                @NonNull private final TranslationFailureReason reason;
178                @Nullable private final Throwable cause;
179
180                /**
181                 * Constructs a preceding candidate failure.
182                 *
183                 * @param locale attempted locale, not null
184                 * @param reason reason the candidate failed, not null
185                 * @param cause runtime cause for a resolution failure, otherwise null
186                 * @throws IllegalArgumentException if the locale is malformed, a resolution failure has no cause, or another
187                 *                                  failure reason has a cause
188                 */
189                public PrecedingFailure(@NonNull Locale locale, @NonNull TranslationFailureReason reason, @Nullable Throwable cause) {
190                        this.locale = LocaleUtils.requireWellFormed(locale, "Attempted locale");
191                        this.reason = requireNonNull(reason);
192
193                        if ((reason == TranslationFailureReason.RESOLUTION_FAILURE) != (cause != null))
194                                throw new IllegalArgumentException("A preceding failure must carry a cause if and only if its reason is RESOLUTION_FAILURE");
195
196                        this.cause = cause;
197                }
198
199                /**
200                 * Gets the attempted locale.
201                 *
202                 * @return attempted locale, not null
203                 */
204                @NonNull
205                public Locale getLocale() {
206                        return locale;
207                }
208
209                /**
210                 * Gets the reason this candidate failed.
211                 *
212                 * @return failure reason, not null
213                 */
214                @NonNull
215                public TranslationFailureReason getReason() {
216                        return reason;
217                }
218
219                /**
220                 * Gets this candidate's runtime cause when present.
221                 *
222                 * @return runtime cause when present, otherwise empty, not null
223                 */
224                @NonNull
225                public Optional<@NonNull Throwable> getCause() {
226                        return Optional.ofNullable(cause);
227                }
228
229                /** @return diagnostic representation omitting throwable messages, not null */
230                @Override
231                @NonNull
232                public String toString() {
233                        return Diagnostics.format("%s{locale=%s, reason=%s}", getClass().getSimpleName(), locale.toLanguageTag(), reason);
234                }
235        }
236}