diff --git a/app.yml b/app.yml index f2f9a3a..ff4e30d 100644 --- a/app.yml +++ b/app.yml @@ -1,6 +1,7 @@ dependencies: eu.maveniverse.maven.mima:context: "2.4.33" eu.maveniverse.maven.mima.runtime:standalone-static: "2.4.33" + org.apache.maven.indexer:search-backend-smo: "7.1.6" info.picocli:picocli: "4.7.7" org.yaml:snakeyaml: "2.4" org.jline:jline-console-ui: "3.30.5" diff --git a/pom.xml b/pom.xml index 51a9d2a..48b02e6 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ 11 org.codejive.jpm.Main 2.4.34 + 7.1.6 4.7.7 2.4 3.30.5 @@ -55,6 +56,11 @@ standalone-static ${version.mima} + + org.apache.maven.indexer + search-backend-smo + ${version.search-backend} + info.picocli picocli @@ -126,11 +132,21 @@ maven-compiler-plugin 3.14.0 + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.1 + org.apache.maven.plugins maven-surefire-plugin 3.5.3 + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.3 + org.apache.maven.plugins maven-jar-plugin @@ -210,6 +226,38 @@ + + org.codehaus.mojo + build-helper-maven-plugin + + + add-test-source + process-resources + + add-test-source + + + + src/it/java + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + integration-test + + integration-test + verify + + integration-test + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/src/it/java/org/codejive/jpm/MainIT.java b/src/it/java/org/codejive/jpm/MainIT.java new file mode 100644 index 0000000..e370d58 --- /dev/null +++ b/src/it/java/org/codejive/jpm/MainIT.java @@ -0,0 +1,161 @@ +package org.codejive.jpm; + +import static org.assertj.core.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +/** Integration tests for the Main class, focusing on the new 'do' command and aliases. */ +class MainIT { + + @TempDir Path tempDir; + + private String originalDir; + + @BeforeEach + void setUp() { + originalDir = System.getProperty("user.dir"); + System.setProperty("user.dir", tempDir.toString()); + System.setProperty("picocli.ansi", "false"); + } + + @AfterEach + void tearDown() { + System.setProperty("user.dir", originalDir); + } + + /** + * Helper method to capture stdout/stderr for tests that need to check command output. Only use + * this for tests that check jpm's own output, not for tests that execute system commands. + */ + private TestOutputCapture captureOutput() { + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setErr(new PrintStream(errContent)); + + return new TestOutputCapture(originalOut, originalErr, outContent, errContent); + } + + /** Helper class to manage output capture and restoration */ + private static class TestOutputCapture implements AutoCloseable { + private final PrintStream originalOut; + private final PrintStream originalErr; + private final ByteArrayOutputStream outContent; + private final ByteArrayOutputStream errContent; + + TestOutputCapture( + PrintStream originalOut, + PrintStream originalErr, + ByteArrayOutputStream outContent, + ByteArrayOutputStream errContent) { + this.originalOut = originalOut; + this.originalErr = originalErr; + this.outContent = outContent; + this.errContent = errContent; + } + + String getOut() { + return outContent.toString(); + } + + String getErr() { + return errContent.toString(); + } + + @Override + public void close() { + System.setOut(originalOut); + System.setErr(originalErr); + } + } + + @Test + void testDoAliasWithArgs() throws IOException { + createAppYmlWithRepositories(); + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute("build", "--foo", "bar"); + + assertThat(exitCode).isEqualTo(0); + String output = capture.getOut(); + // The run action should execute and include the classpath in the output + assertThat(output).contains("javac", "jfiglet", "--foo bar"); + } + } + + @Test + void testCopyCommandWithRepositoryOptions() throws IOException { + // Test copy command with --repo options + CommandLine cmd = Main.getCommandLine(); + int exitCode = + cmd.execute( + "copy", + "--repo", + "central=https://repo1.maven.org/maven2", + "--repo", + "https://jcenter.bintray.com", + "com.google.guava:guava:31.1-jre"); + + // The command should execute successfully (even if dependency resolution might fail) + assertThat(exitCode >= 0).isTrue(); + } + + @Test + void testInstallCommandWithRepositoryOptions() throws IOException { + CommandLine cmd = Main.getCommandLine(); + int exitCode = + cmd.execute( + "install", + "--repo", + "central=https://repo1.maven.org/maven2", + "com.google.guava:guava:31.1-jre"); + + // The command should execute successfully (even if dependency resolution might fail) + assertThat(exitCode >= 0).isTrue(); + } + + @Test + void testPathCommandWithRepositoryOptionsAndAppYml() throws IOException { + // Create app.yml with repositories + createAppYmlWithRepositories(); + + try (TestOutputCapture capture = captureOutput()) { + CommandLine cmd = Main.getCommandLine(); + int exitCode = + cmd.execute( + "path", + "--repo", + "jcenter=https://jcenter.bintray.com", + "com.google.guava:guava:31.1-jre"); + + // The command should execute (even if dependency resolution might fail) + assertThat(exitCode >= 0).isTrue(); + } + } + + private void createAppYmlWithRepositories() throws IOException { + String yamlContent = + "dependencies:\n" + + " com.github.lalyos:jfiglet: \"0.0.9\"\n" + + "\n" + + "repositories:\n" + + " central: \"https://repo1.maven.org/maven2\"\n" + + " custom: \"https://my.custom.repo/maven2\"\n" + + "\n" + + "actions:\n" + + " build: \"echo javac -cp {{deps}} *.java\"\n" + + " test: \"echo java -cp {{deps}} TestRunner\"\n"; + Files.writeString(tempDir.resolve("app.yml"), yamlContent); + } +} diff --git a/src/it/java/org/codejive/jpm/search/SearchIT.java b/src/it/java/org/codejive/jpm/search/SearchIT.java new file mode 100644 index 0000000..ab17bb4 --- /dev/null +++ b/src/it/java/org/codejive/jpm/search/SearchIT.java @@ -0,0 +1,54 @@ +package org.codejive.jpm.search; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import org.codejive.jpm.search.Search; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.FieldSource; + +public class SearchIT { + @ParameterizedTest + @EnumSource(Search.Backends.class) + void testSearchSingleTerm(Search.Backends backend) throws IOException { + Search s = Search.getBackend(backend); + Search.SearchResult res = s.findArtifacts("httpclient", 10); + assertThat(res.count).isGreaterThan(1); + assertThat(res.artifacts).isNotEmpty(); + res = s.findNextArtifacts(res); + assertThat(res.count).isGreaterThan(1); + assertThat(res.artifacts).isNotEmpty(); + } + + @ParameterizedTest + @EnumSource(Search.Backends.class) + void testSearchDoubleTerm(Search.Backends backend) throws IOException { + Search s = Search.getBackend(backend); + Search.SearchResult res = s.findArtifacts("apache:httpclient", 10); + assertThat(res.count).isGreaterThan(1); + assertThat(res.artifacts).isNotEmpty(); + res = s.findNextArtifacts(res); + assertThat(res.count).isGreaterThan(1); + assertThat(res.artifacts).isNotEmpty(); + } + + @ParameterizedTest + @EnumSource(Search.Backends.class) + void testSearchTripleTerm(Search.Backends backend) throws IOException { + Search s = Search.getBackend(backend); + Search.SearchResult res = s.findArtifacts("org.apache.httpcomponents:httpclient:", 10); + assertThat(res.count).isGreaterThan(1); + assertThat(res.artifacts).isNotEmpty(); + assertThat(res.artifacts).allMatch(a -> "org.apache.httpcomponents".equals(a.getGroupId())); + assertThat(res.artifacts).allMatch(a -> "httpclient".equals(a.getArtifactId())); + res = s.findNextArtifacts(res); + assertThat(res.count).isGreaterThan(1); + assertThat(res.artifacts).isNotEmpty(); + assertThat(res.artifacts).allMatch(a -> "org.apache.httpcomponents".equals(a.getGroupId())); + assertThat(res.artifacts).allMatch(a -> "httpclient".equals(a.getArtifactId())); + } +} diff --git a/src/main/java/org/codejive/jpm/Jpm.java b/src/main/java/org/codejive/jpm/Jpm.java index 8ea5f0e..856c0bc 100644 --- a/src/main/java/org/codejive/jpm/Jpm.java +++ b/src/main/java/org/codejive/jpm/Jpm.java @@ -5,6 +5,7 @@ import java.util.*; import java.util.stream.Collectors; import org.codejive.jpm.config.AppInfo; +import org.codejive.jpm.search.Search; import org.codejive.jpm.util.*; import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.resolution.DependencyResolutionException; @@ -120,12 +121,26 @@ public SyncResult copy(String[] artifactNames, Map repos, boolea * @throws IOException If an error occurred during the search. */ public String[] search(String artifactPattern, int count) throws IOException { + return search(artifactPattern, count, null); + } + + /** + * Searches for artifacts matching the given pattern. + * + * @param artifactPattern The pattern to search for. + * @param count The maximum number of results to return. + * @return An array of artifact names matching the given pattern. + * @throws IOException If an error occurred during the search. + */ + public String[] search(String artifactPattern, int count, Search.Backends backend) + throws IOException { List artifacts = new ArrayList<>(); int max = count <= 0 || count > 200 ? 200 : count; - SearchResult result = SearchUtils.findArtifacts(artifactPattern, max); + Search s = Search.getBackend(backend); + Search.SearchResult result = s.findArtifacts(artifactPattern, max); while (result != null) { artifacts.addAll(result.artifacts); - result = count <= 0 ? SearchUtils.findNextArtifacts(result) : null; + result = count <= 0 ? s.findNextArtifacts(result) : null; } return artifacts.stream().map(Jpm::artifactGav).toArray(String[]::new); } diff --git a/src/main/java/org/codejive/jpm/Main.java b/src/main/java/org/codejive/jpm/Main.java index e17ec4d..2670097 100644 --- a/src/main/java/org/codejive/jpm/Main.java +++ b/src/main/java/org/codejive/jpm/Main.java @@ -1,11 +1,13 @@ // spotless:off Dependencies for JBang //DEPS eu.maveniverse.maven.mima:context:2.4.34 eu.maveniverse.maven.mima.runtime:standalone-static:2.4.34 +//DEPS org.apache.maven.indexer:search-backend-smo:7.1.6 //DEPS info.picocli:picocli:4.7.7 //DEPS org.yaml:snakeyaml:2.4 //DEPS org.jline:jline-console-ui:3.30.5 org.jline:jline-terminal-jni:3.30.5 //DEPS org.slf4j:slf4j-api:2.0.17 org.slf4j:slf4j-simple:2.0.17 -//SOURCES Jpm.java config/AppInfo.java util/CommandsParser.java util/FileUtils.java util/Resolver.java -//SOURCES util/ScriptUtils.java util/SearchResult.java util/SearchUtils.java util/SyncResult.java util/Version.java +//SOURCES Jpm.java config/AppInfo.java search/Search.java search/SearchSmoRestImpl.java search/SearchSmoApiImpl.java +//SOURCES util/CommandsParser.java util/FileUtils.java util/Resolver.java util/ScriptUtils.java util/SyncResult.java +//SOURCES util/Version.java // spotless:on package org.codejive.jpm; @@ -17,6 +19,7 @@ import java.util.*; import java.util.concurrent.Callable; import java.util.stream.Collectors; +import org.codejive.jpm.search.Search.Backends; import org.codejive.jpm.util.SyncResult; import org.codejive.jpm.util.Version; import org.jline.consoleui.elements.InputValue; @@ -45,16 +48,16 @@ versionProvider = Version.class, description = "Simple command line tool for managing Maven artifacts", subcommands = { - Main.Copy.class, Main.Search.class, Main.Install.class, + Main.Copy.class, Main.PrintPath.class, - Main.Exec.class, Main.Do.class, Main.Clean.class, Main.Build.class, Main.Run.class, - Main.Test.class + Main.Test.class, + Main.Exec.class }) public class Main { @@ -118,6 +121,12 @@ static class Search implements Callable { description = "Maximum number of results to return") private Integer max; + @Option( + names = {"-b", "--backend"}, + description = + "The search backend to use. Supported values: ${COMPLETION-CANDIDATES}") + private Backends backend; + @Parameters( paramLabel = "artifactPattern", description = "Partial or full artifact name to search for.", @@ -194,7 +203,7 @@ String[] search(String artifactPattern) { .directory(depsMixin.directory) .noLinks(depsMixin.noLinks) .build() - .search(artifactPattern, Math.min(max, 200)); + .search(artifactPattern, Math.min(max, 200), backend); } catch (IOException e) { throw new UncheckedIOException(e); } diff --git a/src/main/java/org/codejive/jpm/search/Search.java b/src/main/java/org/codejive/jpm/search/Search.java new file mode 100644 index 0000000..e9db3cd --- /dev/null +++ b/src/main/java/org/codejive/jpm/search/Search.java @@ -0,0 +1,96 @@ +package org.codejive.jpm.search; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.eclipse.aether.artifact.Artifact; + +public interface Search { + + /** + * Find artifacts matching the given pattern. This will return the first page of results. If the + * pattern to search for is a simple name (there are no colons in the string), the search will + * match any part of an artifact's group or name. If there's a single colon, the search will + * match any part of the group id and artifact id separately. If there are two colons, the + * search will match the group id and artifact id exactly, and will return the artifact's + * versions. + * + * @param artifactPattern The pattern to search for. + * @param count The maximum number of results to return. + * @return The search result as an instance of {@link SearchResult}. + * @throws IOException If an error occurred during the search. + */ + SearchResult findArtifacts(String artifactPattern, int count) throws IOException; + + /** + * Find the next page of artifacts. This takes a {@link SearchResult} returned by a previous + * call to {@link #findArtifacts(String, int)} and returns the next page of results. + * + * @param prevResult The previous search result. + * @return The next search result as an instance of {@link SearchResult}. + * @throws IOException If an error occurred during the search. + */ + SearchResult findNextArtifacts(SearchResult prevResult) throws IOException; + + enum Backends { + rest_smo, + rest_csc; + // smo_smo, + // smo_csc; + } + + static Search getBackend(Backends backend) { + if (backend != null) { + switch (backend) { + case rest_smo: + return SearchSolrRestImpl.createSmo(); + case rest_csc: + return SearchSolrRestImpl.createCsc(); + // case smo_smo: + // return SearchSmoApiImpl.createSmo(); + // case smo_csc: + // return SearchSmoApiImpl.createCsc(); + } + } + return SearchSolrRestImpl.createSmo(); + } + + /** + * Hold the result of a search while also functioning as a kind of bookmark for paging purposes. + */ + class SearchResult { + /** The artifacts that matched the search query. */ + public final List artifacts; + + /** The search query that produced this result. */ + public final String query; + + /** The index of the first artifact in this result relative to the total result set. */ + public final int start; + + /** The maximum number of results to return */ + public final int count; + + /** The total number of artifacts that matched the search query. */ + public final int total; + + /** + * Create a new search result. + * + * @param artifacts The artifacts that matched the search query. + * @param query The search query that produced this result. + * @param start The index of the first artifact in this result relative to the total result + * set. + * @param count The maximum number of results to return. + * @param total The total number of artifacts that matched the search query. + */ + public SearchResult( + List artifacts, String query, int start, int count, int total) { + this.artifacts = Collections.unmodifiableList(artifacts); + this.query = query; + this.start = start; + this.count = count; + this.total = total; + } + } +} diff --git a/src/main/java/org/codejive/jpm/search/SearchSmoApiImpl.java b/src/main/java/org/codejive/jpm/search/SearchSmoApiImpl.java new file mode 100644 index 0000000..56dc6a7 --- /dev/null +++ b/src/main/java/org/codejive/jpm/search/SearchSmoApiImpl.java @@ -0,0 +1,92 @@ +package org.codejive.jpm.search; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.maven.search.api.MAVEN; +import org.apache.maven.search.api.Record; +import org.apache.maven.search.api.SearchRequest; +import org.apache.maven.search.api.request.BooleanQuery; +import org.apache.maven.search.api.request.FieldQuery; +import org.apache.maven.search.api.request.Paging; +import org.apache.maven.search.api.request.Query; +import org.apache.maven.search.backend.smo.SmoSearchBackend; +import org.apache.maven.search.backend.smo.SmoSearchBackendFactory; +import org.apache.maven.search.backend.smo.SmoSearchResponse; +import org.eclipse.aether.artifact.DefaultArtifact; + +public class SearchSmoApiImpl implements Search { + private final SmoSearchBackend backend; + + public static Search createSmo() { + return new SearchSmoApiImpl(SmoSearchBackendFactory.createSmo()); + } + + public static Search createCsc() { + return new SearchSmoApiImpl(SmoSearchBackendFactory.createCsc()); + } + + private SearchSmoApiImpl(SmoSearchBackend backend) { + this.backend = backend; + } + + @Override + public SearchResult findArtifacts(String query, int count) throws IOException { + return select(query, 0, count); + } + + @Override + public SearchResult findNextArtifacts(SearchResult prevResult) throws IOException { + return select(prevResult.query, prevResult.start + 1, prevResult.count); + } + + private SearchResult select(String query, int start, int count) throws IOException { + String[] parts = query.split(":", -1); + Query q; + if (parts.length >= 3) { + // Exact group/artifact match for retrieving versions + q = + BooleanQuery.and( + FieldQuery.fieldQuery(MAVEN.GROUP_ID, parts[0]), + FieldQuery.fieldQuery(MAVEN.ARTIFACT_ID, parts[1])); + } else if (parts.length == 2) { + // Partial group/artifact match, we will filter the results + // to remove those that match an inverted artifact/group + q = Query.query(String.format("%s %s", parts[0], parts[1])); + } else { + // Simple partial match + q = Query.query(query); + } + SearchRequest req = new SearchRequest(new Paging(count, start), q); + SmoSearchResponse res = backend.search(req); + List artifacts = + res.getPage().stream() + .filter(r -> acceptRecord(r, parts)) + .map(SearchSmoApiImpl::toArtifact) + .collect(Collectors.toList()); + return new SearchResult( + artifacts, + query, + req.getPaging().getPageOffset(), + res.getCurrentHits(), + res.getTotalHits()); + } + + private static boolean acceptRecord(Record r, String[] parts) { + String grp = r.getValue(MAVEN.GROUP_ID); + String art = r.getValue(MAVEN.ARTIFACT_ID); + String pkg = r.getValue(MAVEN.PACKAGING); + return pkg != null + && grp != null + && art != null + && (pkg.equals("jar") || pkg.equals("bundle")) + && (parts.length != 2 || (grp.contains(parts[0]) && art.contains(parts[1]))); + } + + private static DefaultArtifact toArtifact(Record r) { + String grp = r.getValue(MAVEN.GROUP_ID); + String art = r.getValue(MAVEN.ARTIFACT_ID); + String ver = r.getValue(MAVEN.VERSION); + return new DefaultArtifact(grp, art, "", ver); + } +} diff --git a/src/main/java/org/codejive/jpm/util/SearchUtils.java b/src/main/java/org/codejive/jpm/search/SearchSolrRestImpl.java similarity index 57% rename from src/main/java/org/codejive/jpm/util/SearchUtils.java rename to src/main/java/org/codejive/jpm/search/SearchSolrRestImpl.java index bd0c1c0..6ae69a5 100644 --- a/src/main/java/org/codejive/jpm/util/SearchUtils.java +++ b/src/main/java/org/codejive/jpm/search/SearchSolrRestImpl.java @@ -1,4 +1,4 @@ -package org.codejive.jpm.util; +package org.codejive.jpm.search; import java.io.IOException; import java.io.InputStream; @@ -10,6 +10,7 @@ import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; +import org.codejive.jpm.util.Version; import org.eclipse.aether.artifact.DefaultArtifact; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.LoaderOptions; @@ -18,7 +19,22 @@ import org.yaml.snakeyaml.representer.Representer; /** Utility class for searching Maven artifacts. */ -public class SearchUtils { +public class SearchSolrRestImpl implements Search { + private final String baseSearchUrl; + private final boolean offsetInPages; + + public static SearchSolrRestImpl createSmo() { + return new SearchSolrRestImpl("https://search.maven.org/solrsearch/select", false); + } + + public static SearchSolrRestImpl createCsc() { + return new SearchSolrRestImpl("https://central.sonatype.com/solrsearch/select", true); + } + + private SearchSolrRestImpl(String baseSearchUrl, boolean offsetInPages) { + this.baseSearchUrl = baseSearchUrl; + this.offsetInPages = offsetInPages; + } /** * Find artifacts matching the given pattern. This will return the first page of results. If the @@ -30,31 +46,38 @@ public class SearchUtils { * * @param artifactPattern The pattern to search for. * @param count The maximum number of results to return. - * @return The search result as an instance of {@link SearchResult}. + * @return The search result as an instance of {@link Search.SearchResult}. * @throws IOException If an error occurred during the search. */ - public static SearchResult findArtifacts(String artifactPattern, int count) throws IOException { + public Search.SearchResult findArtifacts(String artifactPattern, int count) throws IOException { return select(artifactPattern, 0, count); } /** - * Find the next page of artifacts. This takes a {@link SearchResult} returned by a previous - * call to {@link #findArtifacts(String, int)} and returns the next page of results. + * Find the next page of artifacts. This takes a {@link Search.SearchResult} returned by a + * previous call to {@link #findArtifacts(String, int)} and returns the next page of results. * * @param prevResult The previous search result. - * @return The next search result as an instance of {@link SearchResult}. + * @return The next search result as an instance of {@link Search.SearchResult}. * @throws IOException If an error occurred during the search. */ - public static SearchResult findNextArtifacts(SearchResult prevResult) throws IOException { - if (prevResult.start + prevResult.count >= prevResult.total) { - return null; + public Search.SearchResult findNextArtifacts(Search.SearchResult prevResult) + throws IOException { + if (offsetInPages) { + if ((prevResult.start + 1) * prevResult.count >= prevResult.total) { + return null; + } + } else { + if (prevResult.start + prevResult.count >= prevResult.total) { + return null; + } } - SearchResult result = - select(prevResult.query, prevResult.start + prevResult.count, prevResult.count); + int start = offsetInPages ? prevResult.start + 1 : prevResult.start + prevResult.count; + Search.SearchResult result = select(prevResult.query, start, prevResult.count); return result.artifacts.isEmpty() ? null : result; } - private static SearchResult select(String query, int start, int count) throws IOException { + private Search.SearchResult select(String query, int start, int count) throws IOException { String[] parts = query.split(":", -1); String finalQuery; if (parts.length >= 3) { @@ -63,15 +86,17 @@ private static SearchResult select(String query, int start, int count) throws IO } else if (parts.length == 2) { // Partial group/artifact match, we will filter the results // to remove those that match an inverted artifact/group - finalQuery = String.format("%s AND %s", parts[0], parts[1]); + finalQuery = String.format("%s %s", parts[0], parts[1]); } else { // Simple partial match finalQuery = query; } String searchUrl = String.format( - "https://search.maven.org/solrsearch/select?start=%d&rows=%d&q=%s", - start, count, URLEncoder.encode(finalQuery, "UTF-8")); + this.baseSearchUrl + "?start=%d&rows=%d&q=%s", + start, + count, + URLEncoder.encode(finalQuery, "UTF-8")); if (parts.length >= 3) { searchUrl += "&core=gav"; } @@ -84,38 +109,50 @@ private static SearchResult select(String query, int start, int count) throws IO try (CloseableHttpClient httpClient = HttpClients.custom().setUserAgent(agent).build()) { HttpGet request = new HttpGet(searchUrl); try (CloseableHttpResponse response = httpClient.execute(request)) { - if (response.getStatusLine().getStatusCode() != 200) { - throw new IOException( + int code = response.getStatusLine().getStatusCode(); + if (code != 200) { + String err = "Search failed: Maven Central Search API returned an error: " + response.getStatusLine().getStatusCode() + " " - + response.getStatusLine().getReasonPhrase()); + + response.getStatusLine().getReasonPhrase(); + if (code >= 500 && code < 600) { + err += + ". The service might be temporarily unavailable. You can try with a different search backend, run again using the -b option. "; + } + throw new IOException(err); } - DumperOptions dopts = new DumperOptions(); - Constructor cons = new Constructor(MvnSearchResult.class, new LoaderOptions()); - Representer representer = new Representer(dopts); - representer.getPropertyUtils().setSkipMissingProperties(true); - Yaml yaml = new Yaml(cons, representer, dopts); InputStream ins = response.getEntity().getContent(); - InputStreamReader rdr = new InputStreamReader(ins); - MvnSearchResult result = yaml.load(rdr); - if (result.responseHeader.status != 0) { - throw new IOException( - "Search failed: Maven Central Search API returned a response that could not be understood"); - } + MvnSearchResult result = parseSearchResult(ins); List artifacts = result.response.docs.stream() .filter(d -> acceptDoc(d, parts)) - .map(SearchUtils::toArtifact) + .map(SearchSolrRestImpl::toArtifact) .collect(Collectors.toList()); - return new SearchResult(artifacts, query, start, count, result.response.numFound); + return new Search.SearchResult( + artifacts, query, start, count, result.response.numFound); } } } + private static MvnSearchResult parseSearchResult(InputStream ins) throws IOException { + DumperOptions dopts = new DumperOptions(); + Constructor cons = new Constructor(MvnSearchResult.class, new LoaderOptions()); + Representer representer = new Representer(dopts); + representer.getPropertyUtils().setSkipMissingProperties(true); + Yaml yaml = new Yaml(cons, representer, dopts); + InputStreamReader rdr = new InputStreamReader(ins); + MvnSearchResult result = yaml.load(rdr); + if (result.responseHeader.status != 0) { + throw new IOException( + "Search failed: Maven Search API did not return a valid response"); + } + return result; + } + private static boolean acceptDoc(MsrDoc d, String[] parts) { return d.ec != null - && d.ec.contains(".jar") + && (d.ec.contains(".jar") || d.ec.contains("jar")) && (parts.length != 2 || d.g.contains(parts[0]) && d.a.contains(parts[1])); } diff --git a/src/main/java/org/codejive/jpm/util/SearchResult.java b/src/main/java/org/codejive/jpm/util/SearchResult.java deleted file mode 100644 index ab2d601..0000000 --- a/src/main/java/org/codejive/jpm/util/SearchResult.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.codejive.jpm.util; - -import java.util.Collections; -import java.util.List; -import org.eclipse.aether.artifact.Artifact; - -/** Hold the result of a search while also functioning as a kind of bookmark for paging purposes. */ -public class SearchResult { - /** The artifacts that matched the search query. */ - public final List artifacts; - - /** The search query that produced this result. */ - public final String query; - - /** The index of the first artifact in this result relative to the total result set. */ - public final int start; - - /** The maximum number of results to return */ - public final int count; - - /** The total number of artifacts that matched the search query. */ - public final int total; - - /** - * Create a new search result. - * - * @param artifacts The artifacts that matched the search query. - * @param query The search query that produced this result. - * @param start The index of the first artifact in this result relative to the total result set. - * @param count The maximum number of results to return. - * @param total The total number of artifacts that matched the search query. - */ - public SearchResult( - List artifacts, String query, int start, int count, int total) { - this.artifacts = Collections.unmodifiableList(artifacts); - this.query = query; - this.start = start; - this.count = count; - this.total = total; - } -} diff --git a/src/test/java/org/codejive/jpm/MainIntegrationTest.java b/src/test/java/org/codejive/jpm/MainTest.java similarity index 73% rename from src/test/java/org/codejive/jpm/MainIntegrationTest.java rename to src/test/java/org/codejive/jpm/MainTest.java index 85a11c4..740d546 100644 --- a/src/test/java/org/codejive/jpm/MainIntegrationTest.java +++ b/src/test/java/org/codejive/jpm/MainTest.java @@ -1,6 +1,6 @@ package org.codejive.jpm; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -14,7 +14,7 @@ import picocli.CommandLine; /** Integration tests for the Main class, focusing on the new 'do' command and aliases. */ -class MainIntegrationTest { +class MainTest { @TempDir Path tempDir; @@ -24,6 +24,7 @@ class MainIntegrationTest { void setUp() { originalDir = System.getProperty("user.dir"); System.setProperty("user.dir", tempDir.toString()); + System.setProperty("picocli.ansi", "false"); } @AfterEach @@ -235,14 +236,16 @@ void testDoWithOutput() throws IOException { @Test void testMainWithNoArgs() { - // Test the default behavior using CommandLine - CommandLine cmd = Main.getCommandLine(); - int exitCode = cmd.execute(); - - // Should show help when no args provided (CommandLine default behavior) - // The Main.main() method redirects to interactive search, but CommandLine.execute() - // with no args typically shows help - assertThat(exitCode >= 0).isTrue(); // Should not be negative (internal error) + try (TestOutputCapture capture = captureOutput()) { + // Test the default behavior using CommandLine + CommandLine cmd = Main.getCommandLine(); + int exitCode = cmd.execute(); + assertThat(exitCode >= 0).isTrue(); // Should not be negative (internal error) + assertThat(capture.getErr()).contains("Missing required subcommand"); + assertThat(capture.getErr()).contains("Usage: jpm [-hV] [COMMAND]"); + assertThat(capture.getErr()) + .contains("Simple command line tool for managing Maven artifacts"); + } } @Test @@ -259,97 +262,32 @@ void testDoAliasWithArgs() throws IOException { } } - @Test - void testCopyCommandWithRepositoryOptions() throws IOException { - // Test copy command with --repo options - CommandLine cmd = Main.getCommandLine(); - int exitCode = - cmd.execute( - "copy", - "--repo", - "central=https://repo1.maven.org/maven2", - "--repo", - "https://jcenter.bintray.com", - "com.google.guava:guava:31.1-jre"); - - // The command should execute successfully (even if dependency resolution might fail) - assertThat(exitCode >= 0).isTrue(); - } - - @Test - void testInstallCommandWithRepositoryOptions() throws IOException { - CommandLine cmd = Main.getCommandLine(); - int exitCode = - cmd.execute( - "install", - "--repo", - "central=https://repo1.maven.org/maven2", - "com.google.guava:guava:31.1-jre"); - - // The command should execute successfully (even if dependency resolution might fail) - assertThat(exitCode >= 0).isTrue(); - } - - @Test - void testPathCommandWithRepositoryOptionsAndAppYml() throws IOException { - // Create app.yml with repositories - createAppYmlWithRepositories(); - - try (TestOutputCapture capture = captureOutput()) { - CommandLine cmd = Main.getCommandLine(); - int exitCode = - cmd.execute( - "path", - "--repo", - "jcenter=https://jcenter.bintray.com", - "com.google.guava:guava:31.1-jre"); - - // The command should execute (even if dependency resolution might fail) - assertThat(exitCode >= 0).isTrue(); - } - } - private void createAppYml() throws IOException { String yamlContent = "dependencies:\n" - + " com.github.lalyos:jfiglet: \"0.0.9\"\n" + + " fake:dummy: \"1.2.3\"\n" + "\n" + "actions:\n" - + " build: \"echo building... .{/}libs{:}{{deps}}\"\n" - + " test: \"echo testing... .{/}libs{:}{{deps}}\"\n" - + " run: \"echo running... .{/}libs{:}{{deps}}\"\n" + + " build: \"echo building... .{/}libs{:}ext\"\n" + + " test: \"echo testing... .{/}libs{:}ext\"\n" + + " run: \"echo running... .{/}libs{:}ext\"\n" + " hello: \"echo Hello World\"\n"; Files.writeString(tempDir.resolve("app.yml"), yamlContent); } private void createAppYmlWithoutActions() throws IOException { - String yamlContent = "dependencies:\n" + " com.github.lalyos:jfiglet: \"0.0.9\"\n"; + String yamlContent = "dependencies:\n" + " fake:dummy: \"1.2.3\"\n"; Files.writeString(tempDir.resolve("app.yml"), yamlContent); } private void createAppYmlWithoutBuildAction() throws IOException { String yamlContent = "dependencies:\n" - + " com.github.lalyos:jfiglet: \"0.0.9\"\n" + + " fake:dummy: \"1.2.3\"\n" + "\n" + "actions:\n" + " test: \"java -cp {{deps}} TestRunner\"\n" + " hello: \"echo Hello World\"\n"; Files.writeString(tempDir.resolve("app.yml"), yamlContent); } - - private void createAppYmlWithRepositories() throws IOException { - String yamlContent = - "dependencies:\n" - + " com.github.lalyos:jfiglet: \"0.0.9\"\n" - + "\n" - + "repositories:\n" - + " central: \"https://repo1.maven.org/maven2\"\n" - + " custom: \"https://my.custom.repo/maven2\"\n" - + "\n" - + "actions:\n" - + " build: \"javac -cp {{deps}} *.java\"\n" - + " test: \"java -cp {{deps}} TestRunner\"\n"; - Files.writeString(tempDir.resolve("app.yml"), yamlContent); - } }