diff --git a/CLAUDE.md b/CLAUDE.md index 39c81d9..947bd61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,10 +65,14 @@ mvn clean package - **Environment**: Interface representing a configured environment - Core methods: `base()`, `binPaths()`, `launchArgs()` - Worker creation: `python()`, `groovy()`, `java()`, `service()` + - Status methods: `isUpToDate()`, `checkUpToDate()` - **Builder**: Interface for environment builders - Implementations: `PixiBuilder`, `MambaBuilder`, `UvBuilder`, `SimpleBuilder`, `DynamicBuilder` - Core terminator method: `build()` + - Status methods: `isUpToDate()`, `checkUpToDate()` - Subscription methods: `subscribeProgress()`, `subscribeOutput()`, `subscribeError()`, `logDebug()` +- **CheckResult**: Result of an environment up-to-date check + - Methods: `isUpToDate()`, `description()`, `verified()` - **BuilderFactory**: Factory for creating and discovering builders - Factory method: `createBuilder()` - Discovery methods: `name()`, `supportsScheme(scheme)`, `canWrap(File)`, `priority()` @@ -114,6 +118,20 @@ Builders are type-safe and builder-specific: - Default environment location: `~/.local/share/appose/` - Builders can wrap existing environments or create new ones +### Environment Status API + +Several related methods answer different questions about environment state: + +| Method | On | Question it answers | +|--------|----|---------------------| +| `BuilderFactory.canWrap(File)` | Factory | "Can this factory recognize this directory as a valid environment of its type?" | +| `Builder.wrap(File)` | Builder | "Create an Environment from this existing directory?" | +| `Builder.isUpToDate()` | Builder | "Has the builder's configuration changed since the last build?" (fast, reads `appose.json`) | +| `Builder.checkUpToDate()` | Builder | "Is the environment actually in sync with its declared configuration?" (may run tool verification) | +| `Builder.build()` | Builder | "Ensure the environment exists and is up-to-date, building if needed." | + +`isUpToDate()` is fast (<0.01s) — it compares the builder's configuration against the stored `appose.json` snapshot. `checkUpToDate()` returns a `CheckResult` that may additionally invoke tool-specific verification (e.g., `uv sync --dry-run`) to detect environment drift beyond configuration changes. When `CheckResult.verified()` is `true`, a real tool was invoked; when `false`, only the config comparison was done. + ### Worker Communication - **Request types**: EXECUTE (run script), CANCEL (stop execution) - **Response types**: LAUNCH, UPDATE, COMPLETION, CANCELATION, FAILURE, CRASH @@ -240,6 +258,16 @@ Environment env = Appose.wrap("/path/to/existing/env"); // System environment Environment env = Appose.system(); + +// Fast config check — skip rebuild if unchanged +if (!env.isUpToDate()) { env.rebuild(); } + +// Verified check with details +CheckResult result = env.checkUpToDate(); +if (!result.isUpToDate()) { + System.out.println(result.description()); + if (result.verified()) { /* tool confirmed drift */ } +} ``` ### Builder Discovery diff --git a/src/main/java/org/apposed/appose/Builder.java b/src/main/java/org/apposed/appose/Builder.java index a2b6203..a441948 100644 --- a/src/main/java/org/apposed/appose/Builder.java +++ b/src/main/java/org/apposed/appose/Builder.java @@ -112,6 +112,40 @@ default Environment rebuild() throws BuildException { */ void delete() throws IOException; + /** + * Checks whether the environment is up-to-date based on configuration state. + *

+ * This is a fast check that compares the builder's current configuration + * against the previously recorded state in {@code appose.json}. No build + * is triggered and no external tools are invoked. + *

+ *

+ * Returns {@code false} if no environment has been built yet, or if the + * builder's configuration has changed since the last build. + *

+ * + * @return {@code true} if the environment directory exists and its recorded + * state matches the current builder configuration. + * @throws BuildException if the environment directory cannot be resolved. + */ + boolean isUpToDate() throws BuildException; + + /** + * Checks whether the environment is up-to-date, optionally using + * tool-specific verification to detect environment drift beyond + * configuration changes (e.g., manually modified packages, corrupted builds). + *

+ * If the underlying tool supports a verification command (e.g., + * {@code uv sync --dry-run}), it will be invoked and + * {@link CheckResult#verified()} will return {@code true}. + * Otherwise, falls back to the fast config-level check. + *

+ * + * @return A {@link CheckResult} describing the staleness state. + * @throws BuildException if the environment directory cannot be resolved. + */ + CheckResult checkUpToDate() throws BuildException; + /** * Wraps an existing environment directory, detecting and using any * configuration files present for future rebuild() calls. diff --git a/src/main/java/org/apposed/appose/CheckResult.java b/src/main/java/org/apposed/appose/CheckResult.java new file mode 100644 index 0000000..1707d0f --- /dev/null +++ b/src/main/java/org/apposed/appose/CheckResult.java @@ -0,0 +1,93 @@ +/*- + * #%L + * Appose: multi-language interprocess cooperation with shared memory. + * %% + * Copyright (C) 2023 - 2026 Appose developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.apposed.appose; + +/** + * Result of an environment up-to-date check. + * + * @see Builder#checkUpToDate() + * @see Environment#checkUpToDate() + */ +public interface CheckResult { + + /** + * Whether the environment is up-to-date. + */ + boolean isUpToDate(); + + /** + * Human-readable description of the check result. + * Explains why the environment is (or is not) up-to-date. + */ + String description(); + + /** + * Whether a real tool verification was performed + * (as opposed to a fast config-level comparison). + *

+ * When {@code true}, the underlying package manager was actually invoked + * to verify the environment is in sync. When {@code false}, only a fast + * comparison of the builder configuration against the stored state was done. + *

+ */ + boolean verified(); + + // -- Factory methods -- + + /** + * Creates a CheckResult indicating the environment is up-to-date. + * + * @param description Human-readable explanation of the check result. + * @param verified Whether a real tool verification was performed. + * @return A CheckResult with {@link #isUpToDate()} returning {@code true}. + */ + static CheckResult upToDate(final String description, final boolean verified) { + return new CheckResult() { + @Override public boolean isUpToDate() { return true; } + @Override public String description() { return description; } + @Override public boolean verified() { return verified; } + }; + } + + /** + * Creates a CheckResult indicating the environment is stale. + * + * @param description Human-readable explanation of why the environment is stale. + * @param verified Whether a real tool verification was performed. + * @return A CheckResult with {@link #isUpToDate()} returning {@code false}. + */ + static CheckResult stale(final String description, final boolean verified) { + return new CheckResult() { + @Override public boolean isUpToDate() { return false; } + @Override public String description() { return description; } + @Override public boolean verified() { return verified; } + }; + } +} diff --git a/src/main/java/org/apposed/appose/Environment.java b/src/main/java/org/apposed/appose/Environment.java index f60dee7..8bbfce8 100644 --- a/src/main/java/org/apposed/appose/Environment.java +++ b/src/main/java/org/apposed/appose/Environment.java @@ -122,6 +122,29 @@ default Environment delete() throws BuildException { return this; } + /** + * Checks whether this environment's configuration is up-to-date. + * Delegates to {@link Builder#isUpToDate()}. + * + * @return {@code true} if the environment is up-to-date. + * @throws BuildException if the check cannot be performed. + */ + default boolean isUpToDate() throws BuildException { + return builder().isUpToDate(); + } + + /** + * Checks whether this environment is up-to-date, optionally using + * tool-specific verification to detect environment drift. + * Delegates to {@link Builder#checkUpToDate()}. + * + * @return A {@link CheckResult} describing the staleness state. + * @throws BuildException if the check cannot be performed. + */ + default CheckResult checkUpToDate() throws BuildException { + return builder().checkUpToDate(); + } + /** * Creates a Python script service. *

diff --git a/src/main/java/org/apposed/appose/builder/BaseBuilder.java b/src/main/java/org/apposed/appose/builder/BaseBuilder.java index 3e13fbb..f2634d5 100644 --- a/src/main/java/org/apposed/appose/builder/BaseBuilder.java +++ b/src/main/java/org/apposed/appose/builder/BaseBuilder.java @@ -31,6 +31,7 @@ import org.apposed.appose.BuildException; import org.apposed.appose.Builder; +import org.apposed.appose.CheckResult; import org.apposed.appose.Environment; import org.apposed.appose.Scheme; import org.apposed.appose.util.Environments; @@ -83,6 +84,53 @@ public void delete() throws IOException { if (dir.exists()) FilePaths.deleteRecursively(dir); } + @Override + public boolean isUpToDate() throws BuildException { + File dir = resolveEnvDir(); + if (dir == null || !dir.isDirectory()) return false; + try { + return isUpToDate(dir); + } + catch (IOException e) { + throw new BuildException(this, e); + } + } + + @Override + public CheckResult checkUpToDate() throws BuildException { + File dir = resolveEnvDir(); + if (dir == null || !dir.isDirectory()) { + return CheckResult.stale( + "Environment directory does not exist: " + dir, false); + } + try { + if (!isUpToDate(dir)) { + return CheckResult.stale( + "Configuration has changed since last build", false); + } + return verifyUpToDate(dir); + } + catch (IOException e) { + return CheckResult.stale("Check failed: " + e.getMessage(), false); + } + } + + /** + * Performs a tool-specific verification that the environment is up-to-date. + * Subclasses should override this to invoke their tool's dry-run or check command. + * The default implementation returns a config-level-only result. + * + * @param envDir The environment directory to check. + * @return A CheckResult indicating whether the environment is up-to-date. + * @throws IOException If the check command fails. + */ + protected CheckResult verifyUpToDate(File envDir) throws IOException { + return CheckResult.upToDate( + "Config-level check passed; " + envType() + + " does not support tool-level verification", + false); + } + @Override public Environment wrap(File envDir) throws BuildException { try { diff --git a/src/main/java/org/apposed/appose/builder/DynamicBuilder.java b/src/main/java/org/apposed/appose/builder/DynamicBuilder.java index 2b73a51..9531ded 100644 --- a/src/main/java/org/apposed/appose/builder/DynamicBuilder.java +++ b/src/main/java/org/apposed/appose/builder/DynamicBuilder.java @@ -32,6 +32,7 @@ import org.apposed.appose.BuildException; import org.apposed.appose.Builder; import org.apposed.appose.BuilderFactory; +import org.apposed.appose.CheckResult; import org.apposed.appose.Environment; import org.apposed.appose.Scheme; @@ -79,6 +80,20 @@ public Environment rebuild() throws BuildException { return delegate.rebuild(); } + @Override + public boolean isUpToDate() throws BuildException { + Builder delegate = createBuilder(); + copyConfigToDelegate(delegate); + return delegate.isUpToDate(); + } + + @Override + public CheckResult checkUpToDate() throws BuildException { + Builder delegate = createBuilder(); + copyConfigToDelegate(delegate); + return delegate.checkUpToDate(); + } + // -- Helper methods -- private void copyConfigToDelegate(Builder delegate) { diff --git a/src/main/java/org/apposed/appose/builder/MambaBuilder.java b/src/main/java/org/apposed/appose/builder/MambaBuilder.java index 59b2097..977a936 100644 --- a/src/main/java/org/apposed/appose/builder/MambaBuilder.java +++ b/src/main/java/org/apposed/appose/builder/MambaBuilder.java @@ -30,6 +30,7 @@ package org.apposed.appose.builder; import org.apposed.appose.BuildException; +import org.apposed.appose.CheckResult; import org.apposed.appose.Environment; import org.apposed.appose.util.FilePaths; import org.apposed.appose.scheme.Schemes; @@ -158,6 +159,13 @@ public Environment wrap(File envDir) throws BuildException { return build(); } + @Override + protected CheckResult verifyUpToDate(File envDir) throws IOException { + // Micromamba has no dry-run for env update. Fallback to config-level. + return CheckResult.upToDate( + "Config-level check passed; mamba does not support tool-level verification", false); + } + private Environment createEnvironment(Mamba mamba, File envDir) { String base = envDir.getAbsolutePath(); List launchArgs = Arrays.asList(mamba.command, "run", "-p", base); diff --git a/src/main/java/org/apposed/appose/builder/PixiBuilder.java b/src/main/java/org/apposed/appose/builder/PixiBuilder.java index 572901c..6fe701f 100644 --- a/src/main/java/org/apposed/appose/builder/PixiBuilder.java +++ b/src/main/java/org/apposed/appose/builder/PixiBuilder.java @@ -30,6 +30,7 @@ package org.apposed.appose.builder; import org.apposed.appose.BuildException; +import org.apposed.appose.CheckResult; import org.apposed.appose.Environment; import org.apposed.appose.util.FilePaths; import org.apposed.appose.scheme.Schemes; @@ -254,6 +255,13 @@ public Environment wrap(File envDir) throws BuildException { // -- Helper methods -- + @Override + protected CheckResult verifyUpToDate(File envDir) throws IOException { + // Pixi has no --dry-run flag for install. Fallback to config-level. + return CheckResult.upToDate( + "Config-level check passed; pixi does not support tool-level verification", false); + } + /** Returns a new list with an additional flag appended. */ private static List withFlag(List flags, String flag) { List result = new ArrayList<>(flags); diff --git a/src/main/java/org/apposed/appose/builder/SimpleBuilder.java b/src/main/java/org/apposed/appose/builder/SimpleBuilder.java index a8f7ec6..aa751f8 100644 --- a/src/main/java/org/apposed/appose/builder/SimpleBuilder.java +++ b/src/main/java/org/apposed/appose/builder/SimpleBuilder.java @@ -31,6 +31,7 @@ import org.apposed.appose.BuildException; import org.apposed.appose.Builder; +import org.apposed.appose.CheckResult; import org.apposed.appose.Environment; import org.apposed.appose.util.Environments; @@ -122,6 +123,17 @@ public String envType() { return "custom"; } + @Override + public boolean isUpToDate() throws BuildException { + return true; + } + + @Override + public CheckResult checkUpToDate() throws BuildException { + return CheckResult.upToDate( + "Simple environments have no package management", false); + } + @Override public Environment build() throws BuildException { File base = resolveEnvDir(); diff --git a/src/main/java/org/apposed/appose/builder/UvBuilder.java b/src/main/java/org/apposed/appose/builder/UvBuilder.java index 95306aa..c5ac561 100644 --- a/src/main/java/org/apposed/appose/builder/UvBuilder.java +++ b/src/main/java/org/apposed/appose/builder/UvBuilder.java @@ -30,6 +30,7 @@ package org.apposed.appose.builder; import org.apposed.appose.BuildException; +import org.apposed.appose.CheckResult; import org.apposed.appose.Environment; import org.apposed.appose.util.FilePaths; import org.apposed.appose.util.Platforms; @@ -238,6 +239,32 @@ public Environment wrap(File envDir) throws BuildException { // -- Helper methods -- + @Override + protected CheckResult verifyUpToDate(File envDir) throws IOException { + // Only pyproject.toml-based projects support uv sync --dry-run. + File pyprojectToml = new File(envDir, "pyproject.toml"); + if (!pyprojectToml.isFile()) { + return CheckResult.upToDate( + "Config-level check passed; uv tool-level verification " + + "requires pyproject.toml", false); + } + Uv uv = new Uv(); + try { + uv.syncDryRun(envDir); + return CheckResult.upToDate( + "uv sync --dry-run: environment is in sync", true); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return CheckResult.stale( + "uv sync --dry-run was interrupted", true); + } + catch (IOException e) { + return CheckResult.stale( + "uv sync --dry-run detected changes needed: " + e.getMessage(), true); + } + } + private Environment createEnvironment(File envDir) { String base = envDir.getAbsolutePath(); diff --git a/src/main/java/org/apposed/appose/tool/Uv.java b/src/main/java/org/apposed/appose/tool/Uv.java index 9a325c6..ed79929 100644 --- a/src/main/java/org/apposed/appose/tool/Uv.java +++ b/src/main/java/org/apposed/appose/tool/Uv.java @@ -273,4 +273,20 @@ public void sync(final File projectDir, String pythonVersion) throws IOException // Run uv sync with working directory set to projectDir. exec(projectDir, args.toArray(new String[0])); } + + /** + * Checks whether a project's dependencies are in sync using {@code uv sync --dry-run}. + *

+ * This performs a dry run that does not modify the environment. If the + * environment is out of sync, throws {@link IOException} with the tool's output. + *

+ * + * @param projectDir The project directory containing pyproject.toml. + * @throws IOException If an I/O error occurs or the environment is out of sync. + * @throws InterruptedException If the current thread is interrupted. + * @throws IllegalStateException if uv has not been installed + */ + public void syncDryRun(final File projectDir) throws IOException, InterruptedException { + exec(projectDir, "sync", "--dry-run"); + } } diff --git a/src/test/java/org/apposed/appose/DumpApi.java b/src/test/java/org/apposed/appose/DumpApi.java index 8559335..9f9a8ca 100644 --- a/src/test/java/org/apposed/appose/DumpApi.java +++ b/src/test/java/org/apposed/appose/DumpApi.java @@ -100,6 +100,7 @@ public class DumpApi { PACKAGE_TO_MODULE.put("org.apposed.appose.BuildException", "appose/builder/__init__.api"); PACKAGE_TO_MODULE.put("org.apposed.appose.Builder", "appose/builder/__init__.api"); PACKAGE_TO_MODULE.put("org.apposed.appose.BuilderFactory", "appose/builder/__init__.api"); + PACKAGE_TO_MODULE.put("org.apposed.appose.CheckResult", "appose/builder/__init__.api"); PACKAGE_TO_MODULE.put("org.apposed.appose.builder.BaseBuilder", "appose/builder/__init__.api"); PACKAGE_TO_MODULE.put("org.apposed.appose.builder.SimpleBuilder", "appose/builder/__init__.api"); PACKAGE_TO_MODULE.put("org.apposed.appose.builder.DynamicBuilder", "appose/builder/__init__.api"); diff --git a/src/test/java/org/apposed/appose/builder/CheckResultTest.java b/src/test/java/org/apposed/appose/builder/CheckResultTest.java new file mode 100644 index 0000000..5f099da --- /dev/null +++ b/src/test/java/org/apposed/appose/builder/CheckResultTest.java @@ -0,0 +1,301 @@ +/*- + * #%L + * Appose: multi-language interprocess cooperation with shared memory. + * %% + * Copyright (C) 2023 - 2026 Appose developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.apposed.appose.builder; + +import org.apposed.appose.Appose; +import org.apposed.appose.CheckResult; +import org.apposed.appose.Environment; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the environment staleness checking API: + * {@link org.apposed.appose.Builder#isUpToDate()}, + * {@link org.apposed.appose.Builder#checkUpToDate()}, + * {@link org.apposed.appose.Environment#isUpToDate()}, + * {@link org.apposed.appose.Environment#checkUpToDate()}, + * and {@link CheckResult}. + */ +public class CheckResultTest { + + // -- CheckResult factory tests -- + + @Test + public void testCheckResultsUpToDateVerified() { + CheckResult result = upToDateResult(true); + assertTrue(result.isUpToDate()); + assertTrue(result.verified()); + assertNotNull(result.description()); + } + + @Test + public void testCheckResultsUpToDateNotVerified() { + CheckResult result = upToDateResult(false); + assertTrue(result.isUpToDate()); + assertFalse(result.verified()); + } + + @Test + public void testCheckResultsStaleVerified() { + CheckResult result = staleResult(true); + assertFalse(result.isUpToDate()); + assertTrue(result.verified()); + } + + @Test + public void testCheckResultsStaleNotVerified() { + CheckResult result = staleResult(false); + assertFalse(result.isUpToDate()); + assertFalse(result.verified()); + } + + // -- SimpleBuilder tests -- + + @Test + public void testSimpleBuilderAlwaysUpToDate() throws Exception { + SimpleBuilder builder = new SimpleBuilder(); + assertTrue(builder.isUpToDate()); + } + + @Test + public void testSimpleBuilderCheckUpToDate() throws Exception { + SimpleBuilder builder = new SimpleBuilder(); + CheckResult result = builder.checkUpToDate(); + assertTrue(result.isUpToDate()); + assertFalse(result.verified()); + } + + @Test + public void testSimpleEnvironmentIsUpToDate() throws Exception { + Environment env = Appose.custom().build(); + assertTrue(env.isUpToDate()); + } + + @Test + public void testSimpleEnvironmentCheckUpToDate() throws Exception { + Environment env = Appose.custom().build(); + CheckResult result = env.checkUpToDate(); + assertTrue(result.isUpToDate()); + assertFalse(result.verified()); + } + + // -- BaseBuilder isUpToDate tests -- + + @Test + public void testUvBuilderNotUpToDateWhenNoEnvDir() throws Exception { + UvBuilder builder = new UvBuilder(); + builder.name("nonexistent-check-result-test"); + assertFalse(builder.isUpToDate()); + } + + @Test + public void testPixiBuilderNotUpToDateWhenNoEnvDir() throws Exception { + PixiBuilder builder = new PixiBuilder(); + builder.name("nonexistent-check-result-test"); + assertFalse(builder.isUpToDate()); + } + + @Test + public void testUvBuilderNotUpToDateWhenNoApposeJson() throws Exception { + File tempDir = new File("target/check-result-test-no-json"); + tempDir.mkdirs(); + UvBuilder builder = new UvBuilder(); + builder.base(tempDir); + // Directory exists but no appose.json. + assertFalse(builder.isUpToDate()); + } + + @Test + public void testUvBuilderUpToDateAfterBuild() throws Exception { + File tempDir = new File("target/check-result-test-up-to-date"); + cleanDir(tempDir); + + String requirements = "appose\n"; + + UvBuilder builder = new UvBuilder(); + builder.base(tempDir); + builder.content(requirements); + builder.scheme("requirements.txt"); + + // Before build, not up to date. + assertFalse(builder.isUpToDate()); + + // Build the environment. + Environment env = builder.build(); + + // After build, the same builder config should be up to date. + assertTrue(builder.isUpToDate()); + + // Environment convenience method should also work. + assertTrue(env.isUpToDate()); + + // Clean up. + env.delete(); + } + + @Test + public void testUvBuilderStaleAfterConfigChange() throws Exception { + File tempDir = new File("target/check-result-test-stale"); + cleanDir(tempDir); + + String requirements = "appose\n"; + + UvBuilder builder = new UvBuilder(); + builder.base(tempDir); + builder.content(requirements); + builder.scheme("requirements.txt"); + builder.build(); + + // Up to date with current config. + assertTrue(builder.isUpToDate()); + + // Change config. + builder.content("appose\nnumpy\n"); + + // Now stale. + assertFalse(builder.isUpToDate()); + + // Clean up. + builder.delete(); + } + + @Test + public void testUvBuilderCheckUpToDateVerified() throws Exception { + File tempDir = new File("target/check-result-test-verified"); + cleanDir(tempDir); + + String pyproject = "[project]\nname = \"check-test\"\nversion = \"0.1.0\"\ndependencies = [\"appose\"]\n"; + + UvBuilder builder = new UvBuilder(); + builder.base(tempDir); + builder.content(pyproject); + builder.scheme("pyproject.toml"); + Environment env = builder.build(); + + CheckResult result = builder.checkUpToDate(); + assertTrue(result.isUpToDate()); + assertTrue(result.verified(), "pyproject.toml environments should have verified=true"); + + // Environment convenience method. + CheckResult envResult = env.checkUpToDate(); + assertTrue(envResult.isUpToDate()); + + // Clean up. + env.delete(); + } + + @Test + public void testUvBuilderCheckUpToDateNotVerifiedForRequirementsTxt() throws Exception { + File tempDir = new File("target/check-result-test-not-verified"); + cleanDir(tempDir); + + String requirements = "appose\n"; + + UvBuilder builder = new UvBuilder(); + builder.base(tempDir); + builder.content(requirements); + builder.scheme("requirements.txt"); + Environment env = builder.build(); + + CheckResult result = builder.checkUpToDate(); + assertTrue(result.isUpToDate()); + assertFalse(result.verified(), "requirements.txt environments should have verified=false"); + + // Clean up. + env.delete(); + } + + @Test + public void testCheckUpToDateStaleWhenNoEnv() throws Exception { + File tempDir = new File("target/check-result-test-no-env"); + cleanDir(tempDir); + + UvBuilder builder = new UvBuilder(); + builder.base(tempDir); + + CheckResult result = builder.checkUpToDate(); + assertFalse(result.isUpToDate()); + assertFalse(result.verified()); + } + + // -- DynamicBuilder delegation tests -- + + @Test + public void testDynamicBuilderDelegatesIsUpToDate() throws Exception { + // DynamicBuilder with SimpleBuilder via system. + Environment env = Appose.system(); + assertTrue(env.isUpToDate()); + } + + // -- Helper methods -- + + /** Creates an up-to-date CheckResult for testing. */ + private static CheckResult upToDateResult(final boolean verified) { + return CheckResult.upToDate("test up-to-date", verified); + } + + /** Creates a stale CheckResult for testing. */ + private static CheckResult staleResult(final boolean verified) { + return CheckResult.stale("test stale", verified); + } + + private static void cleanDir(File dir) throws IOException { + if (dir.exists()) { + String[] entries = dir.list(); + if (entries != null) { + for (String entry : entries) { + deleteRecursively(new File(dir, entry)); + } + } + } + dir.mkdirs(); + } + + private static void deleteRecursively(File file) throws IOException { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + Files.deleteIfExists(file.toPath()); + } +}