diff --git a/java/com/google/dotprompt/helpers/Helpers.java b/java/com/google/dotprompt/helpers/Helpers.java index 82b24f98b..86d36ef7f 100644 --- a/java/com/google/dotprompt/helpers/Helpers.java +++ b/java/com/google/dotprompt/helpers/Helpers.java @@ -77,8 +77,7 @@ public static Object json(Object context, Options options) throws IOException { Integer indent = options.hash("indent", null); if (indent != null) { - DefaultPrettyPrinter printer = - new DefaultPrettyPrinter(); + DefaultPrettyPrinter printer = new DefaultPrettyPrinter(); printer.indentObjectsWith(new DefaultIndenter(" ", "\n")); localMapper = @@ -90,16 +89,15 @@ public static Object json(Object context, Options options) throws IOException { public DefaultPrettyPrinter createInstance() { return new DefaultPrettyPrinter(this) { @Override - public void writeObjectFieldValueSeparator( - JsonGenerator g) throws IOException { + public void writeObjectFieldValueSeparator(JsonGenerator g) + throws IOException { g.writeRaw(": "); } }; } @Override - public void writeObjectFieldValueSeparator( - JsonGenerator g) throws IOException { + public void writeObjectFieldValueSeparator(JsonGenerator g) throws IOException { g.writeRaw(": "); } }) diff --git a/java/com/google/dotprompt/resolvers/SchemaResolver.java b/java/com/google/dotprompt/resolvers/SchemaResolver.java index 330c6ee22..22c8961e7 100644 --- a/java/com/google/dotprompt/resolvers/SchemaResolver.java +++ b/java/com/google/dotprompt/resolvers/SchemaResolver.java @@ -20,6 +20,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.function.Function; /** * Resolves a provided schema name to an underlying JSON schema. @@ -86,8 +87,7 @@ public interface SchemaResolver { * corresponding JSON Schema, or {@code null}. * @return An async {@link SchemaResolver} wrapping the synchronous function. */ - static SchemaResolver fromSync( - java.util.function.Function> syncResolver) { + static SchemaResolver fromSync(Function> syncResolver) { return schemaName -> CompletableFuture.completedFuture(syncResolver.apply(schemaName)); } } diff --git a/java/com/google/dotprompt/store/BUILD.bazel b/java/com/google/dotprompt/store/BUILD.bazel index eb49e4492..8ab8603c0 100644 --- a/java/com/google/dotprompt/store/BUILD.bazel +++ b/java/com/google/dotprompt/store/BUILD.bazel @@ -19,11 +19,49 @@ load("@rules_java//java:defs.bzl", "java_library") java_library( name = "store", srcs = [ + "DirStore.java", + "DirStoreOptions.java", + "DirStoreSync.java", "PromptStore.java", + "PromptStoreSync.java", "PromptStoreWritable.java", + "PromptStoreWritableSync.java", + "StoreUtils.java", ], visibility = ["//visibility:public"], deps = [ "//java/com/google/dotprompt/models", ], ) + +java_test( + name = "DirStoreSyncTest", + srcs = ["DirStoreSyncTest.java"], + deps = [ + ":store", + "//java/com/google/dotprompt/models", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "DirStoreTest", + srcs = ["DirStoreTest.java"], + deps = [ + ":store", + "//java/com/google/dotprompt/models", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "StoreUtilsTest", + srcs = ["StoreUtilsTest.java"], + deps = [ + ":store", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/java/com/google/dotprompt/store/DirStore.java b/java/com/google/dotprompt/store/DirStore.java new file mode 100644 index 000000000..bb3a8791a --- /dev/null +++ b/java/com/google/dotprompt/store/DirStore.java @@ -0,0 +1,117 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import com.google.dotprompt.models.DeletePromptOrPartialOptions; +import com.google.dotprompt.models.ListPartialsOptions; +import com.google.dotprompt.models.ListPromptsOptions; +import com.google.dotprompt.models.LoadPartialOptions; +import com.google.dotprompt.models.LoadPromptOptions; +import com.google.dotprompt.models.PaginatedPartials; +import com.google.dotprompt.models.PaginatedPrompts; +import com.google.dotprompt.models.PromptData; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; + +/** + * Asynchronous filesystem-based prompt store implementation. + * + *

Reads and writes prompts and partials from/to the local file system within a specified + * directory using asynchronous operations backed by {@link CompletableFuture}. + * + *

File Naming Conventions

+ * + * + * + *

Usage Example

+ * + *
{@code
+ * DirStore store = new DirStore(DirStoreOptions.of("/path/to/prompts"));
+ *
+ * // List prompts asynchronously
+ * store.list(null).thenAccept(prompts -> {
+ *     prompts.prompts().forEach(p -> System.out.println(p.name()));
+ * });
+ *
+ * // Load a specific prompt
+ * PromptData data = store.load("my_prompt", null).join();
+ * }
+ * + *

This class wraps {@link DirStoreSync} to provide async operations. All operations are executed + * on a configurable {@link Executor}. + */ +public class DirStore implements PromptStoreWritable { + + private final DirStoreSync syncStore; + private final Executor executor; + + /** + * Creates a new DirStore instance using the common ForkJoinPool. + * + * @param options Configuration options including the base directory. + */ + public DirStore(DirStoreOptions options) { + this(options, ForkJoinPool.commonPool()); + } + + /** + * Creates a new DirStore instance with a custom executor. + * + * @param options Configuration options including the base directory. + * @param executor The executor to use for async operations. + */ + public DirStore(DirStoreOptions options, Executor executor) { + this.syncStore = new DirStoreSync(options); + this.executor = executor; + } + + @Override + public CompletableFuture list(ListPromptsOptions options) { + return CompletableFuture.supplyAsync(() -> syncStore.list(options), executor); + } + + @Override + public CompletableFuture listPartials(ListPartialsOptions options) { + return CompletableFuture.supplyAsync(() -> syncStore.listPartials(options), executor); + } + + @Override + public CompletableFuture load(String name, LoadPromptOptions options) { + return CompletableFuture.supplyAsync(() -> syncStore.load(name, options), executor); + } + + @Override + public CompletableFuture loadPartial(String name, LoadPartialOptions options) { + return CompletableFuture.supplyAsync(() -> syncStore.loadPartial(name, options), executor); + } + + @Override + public CompletableFuture save(PromptData prompt) { + return CompletableFuture.runAsync(() -> syncStore.save(prompt), executor); + } + + @Override + public CompletableFuture delete(String name, DeletePromptOrPartialOptions options) { + return CompletableFuture.runAsync(() -> syncStore.delete(name, options), executor); + } +} diff --git a/java/com/google/dotprompt/store/DirStoreOptions.java b/java/com/google/dotprompt/store/DirStoreOptions.java new file mode 100644 index 000000000..f552638c0 --- /dev/null +++ b/java/com/google/dotprompt/store/DirStoreOptions.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import java.nio.file.Path; + +/** + * Configuration options for directory-based prompt stores. + * + * @param directory The base directory where prompt files are stored. + */ +public record DirStoreOptions(Path directory) { + + /** + * Creates options from a string path. + * + * @param directory The directory path as a string. + * @return A new DirStoreOptions instance. + */ + public static DirStoreOptions of(String directory) { + return new DirStoreOptions(Path.of(directory)); + } +} diff --git a/java/com/google/dotprompt/store/DirStoreSync.java b/java/com/google/dotprompt/store/DirStoreSync.java new file mode 100644 index 000000000..dda7d926b --- /dev/null +++ b/java/com/google/dotprompt/store/DirStoreSync.java @@ -0,0 +1,267 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import com.google.dotprompt.models.DeletePromptOrPartialOptions; +import com.google.dotprompt.models.ListPartialsOptions; +import com.google.dotprompt.models.ListPromptsOptions; +import com.google.dotprompt.models.LoadPartialOptions; +import com.google.dotprompt.models.LoadPromptOptions; +import com.google.dotprompt.models.PaginatedPartials; +import com.google.dotprompt.models.PaginatedPrompts; +import com.google.dotprompt.models.PartialRef; +import com.google.dotprompt.models.PromptData; +import com.google.dotprompt.models.PromptRef; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Synchronous filesystem-based prompt store implementation. + * + *

Reads and writes prompts and partials from/to the local file system within a specified + * directory using synchronous operations. + * + *

File Naming Conventions

+ * + * + * + *

Usage Example

+ * + *
{@code
+ * DirStoreSync store = new DirStoreSync(DirStoreOptions.of("/path/to/prompts"));
+ *
+ * // List prompts
+ * PaginatedPrompts prompts = store.list(null);
+ *
+ * // Load a specific prompt
+ * PromptData data = store.load("my_prompt", null);
+ *
+ * // Save a prompt
+ * store.save(new PromptData("new_prompt", null, null, "---\nmodel: gemini\n---\nHello"));
+ * }
+ */ +public class DirStoreSync implements PromptStoreWritableSync { + + private final Path directory; + + /** + * Creates a new DirStoreSync instance. + * + * @param options Configuration options including the base directory. + */ + public DirStoreSync(DirStoreOptions options) { + this.directory = options.directory(); + } + + @Override + public PaginatedPrompts list(ListPromptsOptions options) { + try { + List files = StoreUtils.scanDirectory(directory); + List prompts = new ArrayList<>(); + + for (String relativePath : files) { + String filename = Path.of(relativePath).getFileName().toString(); + if (StoreUtils.isPartial(filename)) { + continue; + } + + StoreUtils.ParsedFilename parsed = StoreUtils.parsePromptFilename(filename); + Path filePath = directory.resolve(relativePath); + String content = Files.readString(filePath, StandardCharsets.UTF_8); + String version = StoreUtils.calculateVersion(content); + + // Include subdirectory in the name + String dirPart = + relativePath.contains("/") || relativePath.contains("\\") + ? relativePath.substring(0, relativePath.lastIndexOf(filename)) + : ""; + String fullName = dirPart + parsed.name(); + // Normalize path separators + fullName = fullName.replace('\\', '/'); + if (fullName.endsWith("/")) { + fullName = fullName.substring(0, fullName.length() - 1) + "/" + parsed.name(); + } else if (!dirPart.isEmpty()) { + fullName = dirPart.replace('\\', '/') + parsed.name(); + } else { + fullName = parsed.name(); + } + + prompts.add(new PromptRef(fullName, parsed.variant(), version)); + } + + return new PaginatedPrompts(prompts, null); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public PaginatedPartials listPartials(ListPartialsOptions options) { + try { + List files = StoreUtils.scanDirectory(directory); + List partials = new ArrayList<>(); + + for (String relativePath : files) { + String filename = Path.of(relativePath).getFileName().toString(); + if (!StoreUtils.isPartial(filename)) { + continue; + } + + StoreUtils.ParsedFilename parsed = StoreUtils.parsePromptFilename(filename); + Path filePath = directory.resolve(relativePath); + String content = Files.readString(filePath, StandardCharsets.UTF_8); + String version = StoreUtils.calculateVersion(content); + + // Include subdirectory in the name + String dirPart = ""; + if (relativePath.contains("/") || relativePath.contains("\\")) { + int idx = Math.max(relativePath.lastIndexOf('/'), relativePath.lastIndexOf('\\')); + dirPart = relativePath.substring(0, idx + 1).replace('\\', '/'); + } + String fullName = dirPart + parsed.name(); + + partials.add(new PartialRef(fullName, parsed.variant(), version)); + } + + return new PaginatedPartials(partials, null); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public PromptData load(String name, LoadPromptOptions options) { + String variant = options != null ? options.variant() : null; + String requestedVersion = options != null ? options.version() : null; + + String filename = StoreUtils.buildFilename(name, variant, false); + Path filePath = directory.resolve(filename); + + try { + String content = Files.readString(filePath, StandardCharsets.UTF_8); + String version = StoreUtils.calculateVersion(content); + + if (requestedVersion != null && !requestedVersion.equals(version)) { + throw new IllegalArgumentException( + "Version mismatch: requested " + requestedVersion + " but found " + version); + } + + return new PromptData(name, variant, version, content); + } catch (NoSuchFileException e) { + throw new UncheckedIOException( + new IOException("Prompt not found: " + name + (variant != null ? "." + variant : ""))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public PromptData loadPartial(String name, LoadPartialOptions options) { + String variant = options != null ? options.variant() : null; + String requestedVersion = options != null ? options.version() : null; + + String filename = StoreUtils.buildFilename(name, variant, true); + Path filePath = directory.resolve(filename); + + try { + String content = Files.readString(filePath, StandardCharsets.UTF_8); + String version = StoreUtils.calculateVersion(content); + + if (requestedVersion != null && !requestedVersion.equals(version)) { + throw new IllegalArgumentException( + "Version mismatch: requested " + requestedVersion + " but found " + version); + } + + return new PromptData(name, variant, version, content); + } catch (NoSuchFileException e) { + throw new UncheckedIOException( + new IOException("Partial not found: " + name + (variant != null ? "." + variant : ""))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public void save(PromptData prompt) { + if (prompt.name() == null || prompt.name().isEmpty()) { + throw new IllegalArgumentException("Prompt name is required"); + } + if (prompt.source() == null) { + throw new IllegalArgumentException("Prompt source is required"); + } + + // Determine if this is a partial (name starts with _) + boolean isPartial = prompt.name().startsWith("_"); + String name = isPartial ? prompt.name().substring(1) : prompt.name(); + + String filename = StoreUtils.buildFilename(name, prompt.variant(), isPartial); + Path filePath = directory.resolve(filename); + + try { + Files.createDirectories(filePath.getParent()); + Files.writeString(filePath, prompt.source(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public void delete(String name, DeletePromptOrPartialOptions options) { + String variant = options != null ? options.variant() : null; + + // Try deleting as a prompt first + String promptFilename = StoreUtils.buildFilename(name, variant, false); + Path promptPath = directory.resolve(promptFilename); + + if (Files.exists(promptPath)) { + try { + Files.delete(promptPath); + return; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // Try deleting as a partial + String partialFilename = StoreUtils.buildFilename(name, variant, true); + Path partialPath = directory.resolve(partialFilename); + + if (Files.exists(partialPath)) { + try { + Files.delete(partialPath); + return; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + throw new UncheckedIOException( + new IOException( + "Neither prompt nor partial found: " + name + (variant != null ? "." + variant : ""))); + } +} diff --git a/java/com/google/dotprompt/store/DirStoreSyncTest.java b/java/com/google/dotprompt/store/DirStoreSyncTest.java new file mode 100644 index 000000000..327f49e74 --- /dev/null +++ b/java/com/google/dotprompt/store/DirStoreSyncTest.java @@ -0,0 +1,256 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.dotprompt.models.DeletePromptOrPartialOptions; +import com.google.dotprompt.models.LoadPromptOptions; +import com.google.dotprompt.models.PaginatedPartials; +import com.google.dotprompt.models.PaginatedPrompts; +import com.google.dotprompt.models.PromptData; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link DirStoreSync}. */ +@RunWith(JUnit4.class) +public class DirStoreSyncTest { + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private Path baseDir; + private DirStoreSync store; + + @Before + public void setUp() throws IOException { + baseDir = tempFolder.newFolder("prompts").toPath(); + store = new DirStoreSync(new DirStoreOptions(baseDir)); + } + + @Test + public void list_shouldReturnEmptyForEmptyDirectory() { + PaginatedPrompts result = store.list(null); + + assertThat(result.prompts()).isEmpty(); + assertThat(result.cursor()).isNull(); + } + + @Test + public void list_shouldReturnPromptsExcludingPartials() throws IOException { + Files.writeString(baseDir.resolve("greet.prompt"), "Hello", StandardCharsets.UTF_8); + Files.writeString(baseDir.resolve("goodbye.prompt"), "Bye", StandardCharsets.UTF_8); + Files.writeString(baseDir.resolve("_header.prompt"), "Partial", StandardCharsets.UTF_8); + + PaginatedPrompts result = store.list(null); + + assertThat(result.prompts()).hasSize(2); + assertThat(result.prompts().stream().map(p -> p.name())).containsExactly("greet", "goodbye"); + } + + @Test + public void list_shouldIncludeVersions() throws IOException { + Files.writeString(baseDir.resolve("greet.prompt"), "Hello", StandardCharsets.UTF_8); + + PaginatedPrompts result = store.list(null); + + assertThat(result.prompts().get(0).version()).isNotNull(); + assertThat(result.prompts().get(0).version()).hasLength(8); + } + + @Test + public void list_shouldHandleVariants() throws IOException { + Files.writeString( + baseDir.resolve("greet.formal.prompt"), "Formal greeting", StandardCharsets.UTF_8); + + PaginatedPrompts result = store.list(null); + + assertThat(result.prompts()).hasSize(1); + assertThat(result.prompts().get(0).name()).isEqualTo("greet"); + assertThat(result.prompts().get(0).variant()).isEqualTo("formal"); + } + + @Test + public void listPartials_shouldReturnOnlyPartials() throws IOException { + Files.writeString(baseDir.resolve("greet.prompt"), "Hello", StandardCharsets.UTF_8); + Files.writeString(baseDir.resolve("_header.prompt"), "Header", StandardCharsets.UTF_8); + Files.writeString( + baseDir.resolve("_footer.dark.prompt"), "Dark Footer", StandardCharsets.UTF_8); + + PaginatedPartials result = store.listPartials(null); + + assertThat(result.partials()).hasSize(2); + assertThat(result.partials().stream().map(p -> p.name())).containsExactly("header", "footer"); + } + + @Test + public void load_shouldLoadPromptContent() throws IOException { + String content = "---\nmodel: gemini\n---\nHello {{name}}"; + Files.writeString(baseDir.resolve("greet.prompt"), content, StandardCharsets.UTF_8); + + PromptData result = store.load("greet", null); + + assertThat(result.name()).isEqualTo("greet"); + assertThat(result.source()).isEqualTo(content); + assertThat(result.variant()).isNull(); + assertThat(result.version()).isNotNull(); + } + + @Test + public void load_shouldLoadPromptWithVariant() throws IOException { + String content = "Formal greeting"; + Files.writeString(baseDir.resolve("greet.formal.prompt"), content, StandardCharsets.UTF_8); + + PromptData result = store.load("greet", new LoadPromptOptions("formal", null)); + + assertThat(result.name()).isEqualTo("greet"); + assertThat(result.variant()).isEqualTo("formal"); + assertThat(result.source()).isEqualTo(content); + } + + @Test + public void load_shouldVerifyVersionIfProvided() throws IOException { + String content = "Hello"; + Files.writeString(baseDir.resolve("greet.prompt"), content, StandardCharsets.UTF_8); + String expectedVersion = StoreUtils.calculateVersion(content); + + PromptData result = store.load("greet", new LoadPromptOptions(null, expectedVersion)); + + assertThat(result.version()).isEqualTo(expectedVersion); + } + + @Test(expected = IllegalArgumentException.class) + public void load_shouldThrowOnVersionMismatch() throws IOException { + Files.writeString(baseDir.resolve("greet.prompt"), "Hello", StandardCharsets.UTF_8); + + store.load("greet", new LoadPromptOptions(null, "badversion")); + } + + @Test(expected = UncheckedIOException.class) + public void load_shouldThrowForNonexistentPrompt() { + store.load("nonexistent", null); + } + + @Test + public void loadPartial_shouldLoadPartialContent() throws IOException { + String content = "Header content"; + Files.writeString(baseDir.resolve("_header.prompt"), content, StandardCharsets.UTF_8); + + PromptData result = store.loadPartial("header", null); + + assertThat(result.name()).isEqualTo("header"); + assertThat(result.source()).isEqualTo(content); + } + + @Test + public void save_shouldWritePromptToFile() throws IOException { + PromptData prompt = new PromptData("greet", null, null, "Hello {{name}}"); + + store.save(prompt); + + Path file = baseDir.resolve("greet.prompt"); + assertThat(Files.exists(file)).isTrue(); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo("Hello {{name}}"); + } + + @Test + public void save_shouldWritePromptWithVariant() throws IOException { + PromptData prompt = new PromptData("greet", "formal", null, "Formal greeting"); + + store.save(prompt); + + Path file = baseDir.resolve("greet.formal.prompt"); + assertThat(Files.exists(file)).isTrue(); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo("Formal greeting"); + } + + @Test + public void save_shouldWritePartial() throws IOException { + PromptData prompt = new PromptData("_header", null, null, "Header content"); + + store.save(prompt); + + Path file = baseDir.resolve("_header.prompt"); + assertThat(Files.exists(file)).isTrue(); + } + + @Test + public void save_shouldCreateSubdirectories() throws IOException { + PromptData prompt = new PromptData("group/greet", null, null, "Hello"); + + store.save(prompt); + + Path file = baseDir.resolve("group/greet.prompt"); + assertThat(Files.exists(file)).isTrue(); + } + + @Test(expected = IllegalArgumentException.class) + public void save_shouldThrowForMissingName() { + store.save(new PromptData(null, null, null, "content")); + } + + @Test(expected = IllegalArgumentException.class) + public void save_shouldThrowForMissingSource() { + store.save(new PromptData("greet", null, null, null)); + } + + @Test + public void delete_shouldDeletePromptFile() throws IOException { + Path file = baseDir.resolve("greet.prompt"); + Files.writeString(file, "Hello", StandardCharsets.UTF_8); + assertThat(Files.exists(file)).isTrue(); + + store.delete("greet", null); + + assertThat(Files.exists(file)).isFalse(); + } + + @Test + public void delete_shouldDeletePromptWithVariant() throws IOException { + Path file = baseDir.resolve("greet.formal.prompt"); + Files.writeString(file, "Formal", StandardCharsets.UTF_8); + + store.delete("greet", new DeletePromptOrPartialOptions("formal")); + + assertThat(Files.exists(file)).isFalse(); + } + + @Test + public void delete_shouldDeletePartialIfPromptNotFound() throws IOException { + Path file = baseDir.resolve("_header.prompt"); + Files.writeString(file, "Header", StandardCharsets.UTF_8); + + store.delete("header", null); + + assertThat(Files.exists(file)).isFalse(); + } + + @Test(expected = UncheckedIOException.class) + public void delete_shouldThrowForNonexistentFile() { + store.delete("nonexistent", null); + } +} diff --git a/java/com/google/dotprompt/store/DirStoreTest.java b/java/com/google/dotprompt/store/DirStoreTest.java new file mode 100644 index 000000000..a0fb53e76 --- /dev/null +++ b/java/com/google/dotprompt/store/DirStoreTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.dotprompt.models.PaginatedPrompts; +import com.google.dotprompt.models.PromptData; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.ExecutionException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link DirStore}. */ +@RunWith(JUnit4.class) +public class DirStoreTest { + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private Path baseDir; + private DirStore store; + + @Before + public void setUp() throws IOException { + baseDir = tempFolder.newFolder("prompts").toPath(); + store = new DirStore(new DirStoreOptions(baseDir)); + } + + @Test + public void list_shouldReturnFutureWithPrompts() + throws IOException, ExecutionException, InterruptedException { + Files.writeString(baseDir.resolve("greet.prompt"), "Hello", StandardCharsets.UTF_8); + + PaginatedPrompts result = store.list(null).get(); + + assertThat(result.prompts()).hasSize(1); + assertThat(result.prompts().get(0).name()).isEqualTo("greet"); + } + + @Test + public void load_shouldReturnFutureWithPromptData() + throws IOException, ExecutionException, InterruptedException { + String content = "Hello {{name}}"; + Files.writeString(baseDir.resolve("greet.prompt"), content, StandardCharsets.UTF_8); + + PromptData result = store.load("greet", null).get(); + + assertThat(result.name()).isEqualTo("greet"); + assertThat(result.source()).isEqualTo(content); + } + + @Test + public void save_shouldCompleteFutureAfterWriting() + throws IOException, ExecutionException, InterruptedException { + PromptData prompt = new PromptData("greet", null, null, "Hello"); + + store.save(prompt).get(); + + Path file = baseDir.resolve("greet.prompt"); + assertThat(Files.exists(file)).isTrue(); + assertThat(Files.readString(file, StandardCharsets.UTF_8)).isEqualTo("Hello"); + } + + @Test + public void delete_shouldCompleteFutureAfterDeleting() + throws IOException, ExecutionException, InterruptedException { + Path file = baseDir.resolve("greet.prompt"); + Files.writeString(file, "Hello", StandardCharsets.UTF_8); + + store.delete("greet", null).get(); + + assertThat(Files.exists(file)).isFalse(); + } + + @Test + public void operations_shouldBeAsync() throws IOException { + Files.writeString(baseDir.resolve("greet.prompt"), "Hello", StandardCharsets.UTF_8); + + // These should return immediately with CompletableFutures + var listFuture = store.list(null); + var loadFuture = store.load("greet", null); + + assertThat(listFuture).isNotNull(); + assertThat(loadFuture).isNotNull(); + + // And the futures should complete with correct data + assertThat(listFuture.join().prompts()).hasSize(1); + assertThat(loadFuture.join().source()).isEqualTo("Hello"); + } +} diff --git a/java/com/google/dotprompt/store/PromptStore.java b/java/com/google/dotprompt/store/PromptStore.java index 26747bdd6..8b6638352 100644 --- a/java/com/google/dotprompt/store/PromptStore.java +++ b/java/com/google/dotprompt/store/PromptStore.java @@ -27,7 +27,34 @@ import com.google.dotprompt.models.PromptData; import java.util.concurrent.CompletableFuture; -/** Interface for reading prompts and partials from a store. */ +/** + * Asynchronous interface for reading prompts and partials from a store. + * + *

A prompt store provides methods for listing and loading prompt templates and partials. Prompts + * are template files containing prompt definitions with optional frontmatter configuration, while + * partials are reusable fragments that can be included in prompts using Handlebars syntax. + * + *

All methods return {@link CompletableFuture} for non-blocking operations. For synchronous + * access, use {@link PromptStoreSync} instead. + * + *

Core Operations

+ * + *
    + *
  • {@link #list} - List all available prompts with their versions + *
  • {@link #listPartials} - List all available partials + *
  • {@link #load} - Load a specific prompt by name + *
  • {@link #loadPartial} - Load a specific partial by name + *
+ * + *

Implementations

+ * + *
    + *
  • {@link DirStore} - Filesystem-based implementation + *
+ * + * @see PromptStoreSync for synchronous operations + * @see PromptStoreWritable for write operations (save, delete) + */ public interface PromptStore { /** * Lists prompts in the store. diff --git a/java/com/google/dotprompt/store/PromptStoreSync.java b/java/com/google/dotprompt/store/PromptStoreSync.java new file mode 100644 index 000000000..dd56c2cf1 --- /dev/null +++ b/java/com/google/dotprompt/store/PromptStoreSync.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import com.google.dotprompt.models.ListPartialsOptions; +import com.google.dotprompt.models.ListPromptsOptions; +import com.google.dotprompt.models.LoadPartialOptions; +import com.google.dotprompt.models.LoadPromptOptions; +import com.google.dotprompt.models.PaginatedPartials; +import com.google.dotprompt.models.PaginatedPrompts; +import com.google.dotprompt.models.PromptData; + +/** + * Synchronous interface for reading prompts and partials from a store. + * + *

This is the synchronous counterpart to {@link PromptStore}. Use this interface when blocking + * operations are acceptable and you don't need async handling. + * + * @see PromptStore + * @see PromptStoreWritableSync + */ +public interface PromptStoreSync { + + /** + * Lists prompts in the store. + * + * @param options Options for listing prompts. + * @return The paginated results. + */ + PaginatedPrompts list(ListPromptsOptions options); + + /** + * Lists partials available in the store. + * + * @param options Options for listing partials. + * @return The paginated results. + */ + PaginatedPartials listPartials(ListPartialsOptions options); + + /** + * Loads a prompt from the store. + * + * @param name The name of the prompt. + * @param options Options for loading the prompt. + * @return The prompt data. + */ + PromptData load(String name, LoadPromptOptions options); + + /** + * Loads a partial from the store. + * + * @param name The name of the partial. + * @param options Options for loading the partial. + * @return The prompt data representing the partial. + */ + PromptData loadPartial(String name, LoadPartialOptions options); +} diff --git a/java/com/google/dotprompt/store/PromptStoreWritableSync.java b/java/com/google/dotprompt/store/PromptStoreWritableSync.java new file mode 100644 index 000000000..3b851a1a6 --- /dev/null +++ b/java/com/google/dotprompt/store/PromptStoreWritableSync.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import com.google.dotprompt.models.DeletePromptOrPartialOptions; +import com.google.dotprompt.models.PromptData; + +/** + * Synchronous interface for reading, writing, and deleting prompts and partials. + * + *

This is the synchronous counterpart to {@link PromptStoreWritable}. Use this interface when + * blocking operations are acceptable and you don't need async handling. + * + * @see PromptStoreWritable + * @see PromptStoreSync + */ +public interface PromptStoreWritableSync extends PromptStoreSync { + + /** + * Saves a prompt in the store. + * + * @param prompt The prompt data to save. + */ + void save(PromptData prompt); + + /** + * Deletes a prompt from the store. + * + * @param name The name of the prompt to delete. + * @param options Options for deleting the prompt. + */ + void delete(String name, DeletePromptOrPartialOptions options); +} diff --git a/java/com/google/dotprompt/store/StoreUtils.java b/java/com/google/dotprompt/store/StoreUtils.java new file mode 100644 index 000000000..a1ed441b1 --- /dev/null +++ b/java/com/google/dotprompt/store/StoreUtils.java @@ -0,0 +1,180 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Utility methods for directory-based prompt stores. + * + *

This class provides common functionality for parsing prompt filenames, calculating versions, + * and scanning directories for prompt files. + */ +public final class StoreUtils { + + /** The file extension for prompt files. */ + public static final String PROMPT_EXTENSION = ".prompt"; + + /** Private constructor to prevent instantiation. */ + private StoreUtils() {} + + /** + * Calculates a version hash for the given content. + * + *

Uses SHA-1 and returns the first 8 characters of the hex-encoded hash. This matches the + * version calculation in the JavaScript and Python implementations for cross-language + * compatibility. + * + * @param content The content to hash. + * @return An 8-character hex string representing the version. + */ + public static String calculateVersion(String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (int i = 0; i < 4; i++) { // 4 bytes = 8 hex chars + String hex = Integer.toHexString(0xff & hash[i]); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-1 not available", e); + } + } + + /** + * Parses a prompt filename to extract name and optional variant. + * + *

Expected format: {@code name[.variant].prompt} or {@code _name[.variant].prompt} for + * partials. + * + * @param filename The filename to parse (without any directory prefix). + * @return A ParsedFilename containing the name and variant. + * @throws IllegalArgumentException If the filename doesn't match expected format. + */ + public static ParsedFilename parsePromptFilename(String filename) { + if (!filename.endsWith(PROMPT_EXTENSION)) { + throw new IllegalArgumentException("Filename must end with " + PROMPT_EXTENSION); + } + + // Remove the leading underscore for partials + String baseName = filename; + if (baseName.startsWith("_")) { + baseName = baseName.substring(1); + } + + // Remove the .prompt extension + baseName = baseName.substring(0, baseName.length() - PROMPT_EXTENSION.length()); + + // Check for variant (format: name.variant) + int lastDot = baseName.lastIndexOf('.'); + if (lastDot > 0) { + String name = baseName.substring(0, lastDot); + String variant = baseName.substring(lastDot + 1); + return new ParsedFilename(name, variant); + } + + return new ParsedFilename(baseName, null); + } + + /** + * Checks if a filename represents a partial prompt. + * + *

Partials are identified by a leading underscore in the filename. + * + * @param filename The filename to check. + * @return true if the filename starts with '_', false otherwise. + */ + public static boolean isPartial(String filename) { + return filename.startsWith("_"); + } + + /** + * Scans a directory for prompt files. + * + * @param baseDir The base directory to scan. + * @return A list of relative paths to all .prompt files found. + * @throws IOException If an error occurs while scanning. + */ + public static List scanDirectory(Path baseDir) throws IOException { + List results = new ArrayList<>(); + if (!Files.exists(baseDir) || !Files.isDirectory(baseDir)) { + return results; + } + + try (Stream paths = Files.walk(baseDir)) { + paths + .filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(PROMPT_EXTENSION)) + .forEach( + p -> { + Path relativePath = baseDir.relativize(p); + results.add(relativePath.toString()); + }); + } + return results; + } + + /** + * Builds the filename for a prompt. + * + * @param name The prompt name (can include subdirectory paths). + * @param variant The optional variant. + * @param isPartial Whether this is a partial. + * @return The constructed filename. + */ + public static String buildFilename(String name, String variant, boolean isPartial) { + StringBuilder sb = new StringBuilder(); + + // Handle subdirectories in the name + int lastSlash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + String dir = ""; + String baseName = name; + if (lastSlash >= 0) { + dir = name.substring(0, lastSlash + 1); + baseName = name.substring(lastSlash + 1); + } + + sb.append(dir); + if (isPartial) { + sb.append("_"); + } + sb.append(baseName); + if (variant != null && !variant.isEmpty()) { + sb.append(".").append(variant); + } + sb.append(PROMPT_EXTENSION); + return sb.toString(); + } + + /** Result of parsing a prompt filename. */ + public record ParsedFilename(String name, String variant) {} +} diff --git a/java/com/google/dotprompt/store/StoreUtilsTest.java b/java/com/google/dotprompt/store/StoreUtilsTest.java new file mode 100644 index 000000000..f2294f789 --- /dev/null +++ b/java/com/google/dotprompt/store/StoreUtilsTest.java @@ -0,0 +1,173 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.dotprompt.store; + +import static com.google.common.truth.Truth.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link StoreUtils}. */ +@RunWith(JUnit4.class) +public class StoreUtilsTest { + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void calculateVersion_shouldReturnConsistentHash() { + String content = "Hello, World!"; + String version1 = StoreUtils.calculateVersion(content); + String version2 = StoreUtils.calculateVersion(content); + + assertThat(version1).isEqualTo(version2); + assertThat(version1).hasLength(8); + } + + @Test + public void calculateVersion_shouldReturnDifferentHashForDifferentContent() { + String version1 = StoreUtils.calculateVersion("Hello"); + String version2 = StoreUtils.calculateVersion("World"); + + assertThat(version1).isNotEqualTo(version2); + } + + @Test + public void parsePromptFilename_shouldExtractNameWithoutVariant() { + StoreUtils.ParsedFilename result = StoreUtils.parsePromptFilename("greet.prompt"); + + assertThat(result.name()).isEqualTo("greet"); + assertThat(result.variant()).isNull(); + } + + @Test + public void parsePromptFilename_shouldExtractNameAndVariant() { + StoreUtils.ParsedFilename result = StoreUtils.parsePromptFilename("greet.formal.prompt"); + + assertThat(result.name()).isEqualTo("greet"); + assertThat(result.variant()).isEqualTo("formal"); + } + + @Test + public void parsePromptFilename_shouldHandlePartialPrefix() { + StoreUtils.ParsedFilename result = StoreUtils.parsePromptFilename("_header.prompt"); + + assertThat(result.name()).isEqualTo("header"); + assertThat(result.variant()).isNull(); + } + + @Test + public void parsePromptFilename_shouldHandlePartialWithVariant() { + StoreUtils.ParsedFilename result = StoreUtils.parsePromptFilename("_footer.dark.prompt"); + + assertThat(result.name()).isEqualTo("footer"); + assertThat(result.variant()).isEqualTo("dark"); + } + + @Test(expected = IllegalArgumentException.class) + public void parsePromptFilename_shouldThrowForInvalidExtension() { + StoreUtils.parsePromptFilename("greet.txt"); + } + + @Test + public void isPartial_shouldReturnTrueForPartials() { + assertThat(StoreUtils.isPartial("_header.prompt")).isTrue(); + assertThat(StoreUtils.isPartial("_footer.dark.prompt")).isTrue(); + } + + @Test + public void isPartial_shouldReturnFalseForPrompts() { + assertThat(StoreUtils.isPartial("greet.prompt")).isFalse(); + assertThat(StoreUtils.isPartial("greet.formal.prompt")).isFalse(); + } + + @Test + public void buildFilename_shouldBuildPromptFilename() { + String result = StoreUtils.buildFilename("greet", null, false); + assertThat(result).isEqualTo("greet.prompt"); + } + + @Test + public void buildFilename_shouldBuildPromptFilenameWithVariant() { + String result = StoreUtils.buildFilename("greet", "formal", false); + assertThat(result).isEqualTo("greet.formal.prompt"); + } + + @Test + public void buildFilename_shouldBuildPartialFilename() { + String result = StoreUtils.buildFilename("header", null, true); + assertThat(result).isEqualTo("_header.prompt"); + } + + @Test + public void buildFilename_shouldBuildPartialFilenameWithVariant() { + String result = StoreUtils.buildFilename("footer", "dark", true); + assertThat(result).isEqualTo("_footer.dark.prompt"); + } + + @Test + public void buildFilename_shouldHandleSubdirectory() { + String result = StoreUtils.buildFilename("group/greet", "formal", false); + assertThat(result).isEqualTo("group/greet.formal.prompt"); + } + + @Test + public void scanDirectory_shouldFindPromptFiles() throws IOException { + Path baseDir = tempFolder.newFolder("prompts").toPath(); + Files.writeString(baseDir.resolve("greet.prompt"), "content", StandardCharsets.UTF_8); + Files.writeString(baseDir.resolve("_header.prompt"), "partial", StandardCharsets.UTF_8); + Files.writeString(baseDir.resolve("other.txt"), "text", StandardCharsets.UTF_8); + + List results = StoreUtils.scanDirectory(baseDir); + + assertThat(results).hasSize(2); + assertThat(results).contains("greet.prompt"); + assertThat(results).contains("_header.prompt"); + } + + @Test + public void scanDirectory_shouldFindFilesInSubdirectories() throws IOException { + Path baseDir = tempFolder.newFolder("prompts").toPath(); + Path subDir = baseDir.resolve("group"); + Files.createDirectories(subDir); + Files.writeString(subDir.resolve("greet.prompt"), "content", StandardCharsets.UTF_8); + + List results = StoreUtils.scanDirectory(baseDir); + + assertThat(results).hasSize(1); + assertThat(results.get(0)).contains("greet.prompt"); + assertThat(results.get(0)).contains("group"); + } + + @Test + public void scanDirectory_shouldReturnEmptyForNonexistentDir() throws IOException { + Path baseDir = Path.of(tempFolder.getRoot().getPath(), "nonexistent"); + + List results = StoreUtils.scanDirectory(baseDir); + + assertThat(results).isEmpty(); + } +} diff --git a/java/com/google/dotprompt/store/package-info.java b/java/com/google/dotprompt/store/package-info.java new file mode 100644 index 000000000..6be6ab35c --- /dev/null +++ b/java/com/google/dotprompt/store/package-info.java @@ -0,0 +1,148 @@ +/* + * Copyright 2025 Google LLC + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Prompt storage and retrieval implementations for Dotprompt. + * + *

This package provides interfaces and implementations for storing, retrieving, and managing + * prompt templates and partials. A prompt store is responsible for persisting prompts and their + * associated metadata, enabling prompt reuse across applications. + * + *

Key Concepts

+ * + *
    + *
  • Prompts: Template files containing prompt definitions with optional frontmatter + * configuration + *
  • Partials: Reusable prompt fragments that can be included in other prompts using + * Handlebars partial syntax ({@code {{> partialName}}}) + *
  • Variants: Alternative versions of the same prompt (e.g., {@code greeting.formal} vs + * {@code greeting.casual}) + *
  • Versions: Content-based hashes (SHA-1) for tracking prompt changes + *
+ * + *

Architecture

+ * + *

The package follows a layered interface design: + * + *

+ *                    ┌─────────────────────────────────────┐
+ *                    │      PromptStoreWritable            │
+ *                    │  (async: save, delete + read ops)   │
+ *                    └─────────────────┬───────────────────┘
+ *                                      │ extends
+ *                    ┌─────────────────▼───────────────────┐
+ *                    │         PromptStore                 │
+ *                    │  (async: list, listPartials,        │
+ *                    │          load, loadPartial)         │
+ *                    └─────────────────────────────────────┘
+ *
+ *                    ┌─────────────────────────────────────┐
+ *                    │    PromptStoreWritableSync          │
+ *                    │  (sync: save, delete + read ops)    │
+ *                    └─────────────────┬───────────────────┘
+ *                                      │ extends
+ *                    ┌─────────────────▼───────────────────┐
+ *                    │       PromptStoreSync               │
+ *                    │  (sync: list, listPartials,         │
+ *                    │         load, loadPartial)          │
+ *                    └─────────────────────────────────────┘
+ * 
+ * + *

Available Implementations

+ * + *
    + *
  • {@link com.google.dotprompt.store.DirStore}: Asynchronous filesystem-based store using + * {@link java.util.concurrent.CompletableFuture} + *
  • {@link com.google.dotprompt.store.DirStoreSync}: Synchronous filesystem-based store for + * blocking operations + *
+ * + *

File Naming Conventions

+ * + *

Directory-based stores organize prompts using the following conventions: + * + *

    + *
  • Prompts: {@code [name].prompt} or {@code [name].[variant].prompt} + *
  • Partials: {@code _[name].prompt} or {@code _[name].[variant].prompt} + *
  • Directory structure forms part of the prompt/partial name (e.g., {@code group/greeting}) + *
+ * + *

Usage Examples

+ * + *

Async Usage (DirStore)

+ * + *
{@code
+ * // Create a store
+ * DirStore store = new DirStore(DirStoreOptions.of("/path/to/prompts"));
+ *
+ * // List all prompts
+ * store.list(null).thenAccept(result -> {
+ *     result.prompts().forEach(p ->
+ *         System.out.println(p.name() + " v" + p.version())
+ *     );
+ * });
+ *
+ * // Load a prompt
+ * PromptData data = store.load("greeting", null).join();
+ * System.out.println(data.source());
+ *
+ * // Load a variant
+ * PromptData formal = store.load("greeting",
+ *     new LoadPromptOptions("formal", null)).join();
+ *
+ * // Save a new prompt
+ * store.save(new PromptData("welcome", null, null,
+ *     "---\nmodel: gemini\n---\nHello {{name}}!")).join();
+ *
+ * // Delete a prompt
+ * store.delete("old_prompt", null).join();
+ * }
+ * + *

Sync Usage (DirStoreSync)

+ * + *
{@code
+ * // Create a sync store
+ * DirStoreSync store = new DirStoreSync(DirStoreOptions.of("/path/to/prompts"));
+ *
+ * // List all prompts
+ * PaginatedPrompts result = store.list(null);
+ * result.prompts().forEach(p -> System.out.println(p.name()));
+ *
+ * // Load a prompt
+ * PromptData data = store.load("greeting", null);
+ *
+ * // Save a prompt
+ * store.save(new PromptData("welcome", null, null, "Hello!"));
+ * }
+ * + *

Version Calculation

+ * + *

Versions are calculated as the first 8 characters of the SHA-1 hash of the prompt content. + * This provides: + * + *

    + *
  • Deterministic versioning across all language implementations (Java, JS, Python) + *
  • Content-based change detection + *
  • Version verification on load to ensure content integrity + *
+ * + * @see com.google.dotprompt.store.PromptStore + * @see com.google.dotprompt.store.DirStore + * @see com.google.dotprompt.store.DirStoreSync + */ +package com.google.dotprompt.store;