Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/main/java/io/github/randomcodespace/iq/CodeIqApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ public static void main(String[] args) {
System.setProperty("server.port", portStr);
}

// Disable web UI if --no-ui flag is present
boolean noUi = Arrays.asList(args).contains("--no-ui");
if (noUi) {
System.setProperty("codeiq.ui.enabled", "false");
// Also disable Spring Boot's static resource handler so no
// static files (index.html, JS, CSS bundles) are served.
System.setProperty("spring.web.resources.add-mappings", "false");
}

// Resolve codebase root so Neo4j points to the correct graph.db
String codebasePath = extractPositionalArg(args, "serve");
java.nio.file.Path root = java.nio.file.Path.of(
Expand Down Expand Up @@ -125,9 +134,18 @@ private static String extractFlag(String[] args, String flagName) {
return null;
}

/**
* Boolean (no-value) flags for the serve command.
* These must NOT consume the next token as their value.
*/
private static final java.util.Set<String> BOOLEAN_FLAGS = java.util.Set.of(
"--no-ui", "--help", "-h", "--version"
);

/**
* Extract the first positional argument after the command name.
* Skips flags (--name value pairs) to find positional args.
* Boolean flags (no value) are not allowed to consume the next token.
*/
private static String extractPositionalArg(String[] args, String command) {
boolean foundCommand = false;
Expand All @@ -142,17 +160,17 @@ private static String extractPositionalArg(String[] args, String command) {
continue;
}
if (foundCommand) {
// Skip --flag value pairs
if (arg.startsWith("--") && !arg.contains("=")) {
// Skip --flag value pairs, but not boolean flags that take no value
if (arg.startsWith("--") && !arg.contains("=") && !BOOLEAN_FLAGS.contains(arg)) {
skipNext = true;
continue;
}
if (arg.startsWith("-") && arg.length() == 2) {
if (arg.startsWith("-") && arg.length() == 2 && !BOOLEAN_FLAGS.contains(arg)) {
skipNext = true; // short flag like -p 8080
continue;
}
if (arg.startsWith("-")) {
continue; // --flag=value or -flag
continue; // --flag=value, boolean flag, or unknown short flag
}
return arg;
}
Expand Down
11 changes: 10 additions & 1 deletion src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public class ServeCommand implements Callable<Integer> {
@Option(names = {"--graph"}, description = "Path to shared graph directory (overrides default)")
private Path graphPath;

@Option(names = {"--no-ui"}, defaultValue = "false",
description = "Disable the web UI (React SPA). API and MCP endpoints remain active.")
private boolean noUi;

@Autowired
private CodeIqConfig config;

Expand Down Expand Up @@ -73,7 +77,11 @@ public Integer call() {

CliOutput.step("\uD83D\uDE80", "@|bold,green Server started|@");
System.out.println();
CliOutput.info(" URL: http://" + host + ":" + port);
if (noUi) {
CliOutput.info(" Web UI: disabled (API and MCP active at :" + port + ")");
} else {
CliOutput.info(" Web UI: http://" + host + ":" + port + " (React SPA)");
}
CliOutput.info(" REST API: http://" + host + ":" + port + "/api");
CliOutput.info(" MCP: http://" + host + ":" + port + "/mcp");
CliOutput.info(" Health: http://" + host + ":" + port + "/actuator/health");
Expand All @@ -94,4 +102,5 @@ public Integer call() {
public int getPort() { return port; }
public String getHost() { return host; }
public Path getGraphPath() { return graphPath; }
public boolean isNoUi() { return noUi; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public class CodeIqConfig {
/** Graph configuration sub-properties. */
private Graph graph = new Graph();

/** Whether to serve the React web UI. Set to false via --no-ui flag. */
private boolean uiEnabled = true;

public static class Graph {
private String path = ".osscodeiq/graph.db";

Expand Down Expand Up @@ -95,4 +98,12 @@ public Graph getGraph() {
public void setGraph(Graph graph) {
this.graph = graph;
}

public boolean isUiEnabled() {
return uiEnabled;
}

public void setUiEnabled(boolean uiEnabled) {
this.uiEnabled = uiEnabled;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.randomcodespace.iq.web;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -8,20 +9,21 @@
* Catch-all controller that forwards unmatched routes to index.html
* for React Router client-side routing (HTML5 pushState).
* <p>
* Only matches paths without a file extension (e.g. /topology, /explorer/class)
* Only matches paths without a file extension (e.g. /graph, /explorer/class)
* so static assets (.js, .css, .html, .svg) are served normally.
* <p>
* Disabled when {@code codeiq.ui.enabled=false} (i.e. {@code --no-ui} flag passed to serve).
*/
@Controller
@Profile("serving")
@ConditionalOnProperty(name = "codeiq.ui.enabled", havingValue = "true", matchIfMissing = true)
public class SpaController {

@GetMapping(value = {
"/topology",
"/topology/**",
"/graph",
"/graph/**",
"/explorer",
"/explorer/**",
"/flow",
"/flow/**",
"/console",
"/console/**",
"/api-docs",
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ codeiq:
max-depth: 10
max-radius: 10
batch-size: 500
ui:
enabled: true

spring.ai.mcp.server:
name: code-iq
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package io.github.randomcodespace.iq;

import org.junit.jupiter.api.Test;

import java.lang.reflect.Method;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

/**
* Unit tests for CodeIqApplication argument parsing helper methods.
* These are called in main() before the Spring context starts.
*/
class CodeIqApplicationArgParsingTest {

private static String extractPositionalArg(String[] args, String command) throws Exception {
Method m = CodeIqApplication.class.getDeclaredMethod("extractPositionalArg", String[].class, String.class);
m.setAccessible(true);
return (String) m.invoke(null, args, command);
}

@Test
void extractsPathAfterCommand() throws Exception {
String result = extractPositionalArg(new String[]{"serve", "/my/repo"}, "serve");
assertEquals("/my/repo", result);
}

@Test
void pathNotSwallowedByBooleanNoUiFlag() throws Exception {
// Regression: --no-ui is boolean; must not consume /repo as its value.
String result = extractPositionalArg(new String[]{"serve", "--no-ui", "/my/repo"}, "serve");
assertEquals("/my/repo", result);
}

@Test
void pathStillExtractedWhenPortFlagPrecedes() throws Exception {
String result = extractPositionalArg(new String[]{"serve", "--port", "9090", "/my/repo"}, "serve");
assertEquals("/my/repo", result);
}

@Test
void pathStillExtractedWithNoUiAndPort() throws Exception {
String result = extractPositionalArg(new String[]{"serve", "--no-ui", "--port", "9090", "/my/repo"}, "serve");
assertEquals("/my/repo", result);
}

@Test
void returnsNullWhenNoPath() throws Exception {
String result = extractPositionalArg(new String[]{"serve", "--no-ui"}, "serve");
assertNull(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,30 @@ void customPortIsParsed() {
cmdLine.parseArgs("--port", "9090");
assertEquals(9090, cmd.getPort());
}

@Test
void noUiDefaultsToFalse() {
var cmd = new ServeCommand();
var cmdLine = new CommandLine(cmd);
cmdLine.parseArgs();
assertEquals(false, cmd.isNoUi());
}

@Test
void noUiFlagIsRecognized() {
var cmd = new ServeCommand();
var cmdLine = new CommandLine(cmd);
cmdLine.parseArgs("--no-ui");
assertEquals(true, cmd.isNoUi());
}

@Test
void pathNotSwallowedWhenNoUiPrecedesPath() {
// Regression: --no-ui is boolean and must not consume the next positional arg.
var cmd = new ServeCommand();
var cmdLine = new CommandLine(cmd);
cmdLine.parseArgs("--no-ui", "/some/repo");
assertEquals(true, cmd.isNoUi());
assertEquals("/some/repo", cmd.getPath().toString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.github.randomcodespace.iq.web;

import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.GetMapping;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Verifies that SpaController is conditionally registered based on codeiq.ui.enabled,
* and that static resource serving is also disabled via spring.web.resources.add-mappings=false.
*/
class SpaControllerConditionalTest {

private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(SpaController.class)
.withSystemProperties("spring.profiles.active=serving");

@Test
void spaControllerHasConditionalOnPropertyAnnotation() {
var annotation = SpaController.class.getAnnotation(ConditionalOnProperty.class);
assertThat(annotation).isNotNull();
assertThat(annotation.name()).contains("codeiq.ui.enabled");
assertThat(annotation.havingValue()).isEqualTo("true");
assertThat(annotation.matchIfMissing()).isTrue();
}

@Test
void spaControllerRegisteredWhenUiEnabledTrue() {
contextRunner
.withPropertyValues("codeiq.ui.enabled=true")
.run(context -> assertThat(context).hasSingleBean(SpaController.class));
}

@Test
void spaControllerRegisteredWhenPropertyAbsent() {
contextRunner
.run(context -> assertThat(context).hasSingleBean(SpaController.class));
}

@Test
void spaControllerNotRegisteredWhenUiEnabledFalse() {
contextRunner
.withPropertyValues("codeiq.ui.enabled=false")
.run(context -> assertThat(context).doesNotHaveBean(SpaController.class));
}

@Test
void spaControllerHasProfileAnnotation() {
var annotation = SpaController.class.getAnnotation(Profile.class);
assertThat(annotation).isNotNull();
assertThat(annotation.value()).contains("serving");
}

@Test
void staticResourcesDisabledWhenUiDisabled() {
// When --no-ui is active, CodeIqApplication sets both properties.
// Verify that spring.web.resources.add-mappings=false combined with
// codeiq.ui.enabled=false leaves no SpaController in the context.
contextRunner
.withPropertyValues("codeiq.ui.enabled=false", "spring.web.resources.add-mappings=false")
.run(context -> assertThat(context).doesNotHaveBean(SpaController.class));
}

@Test
void spaControllerExplicitRoutesContainGraph() {
// Verify that /graph routes are present and /topology, /flow routes are removed.
Method forwardMethod = Arrays.stream(SpaController.class.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(GetMapping.class))
.filter(m -> m.getName().equals("forward"))
.findFirst()
.orElse(null);
assertThat(forwardMethod).isNotNull();
List<String> routes = Arrays.asList(forwardMethod.getAnnotation(GetMapping.class).value());
assertThat(routes).contains("/graph", "/graph/**");

Check warning on line 81 in src/test/java/io/github/randomcodespace/iq/web/SpaControllerConditionalTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Join these multiple assertions subject to one assertion chain.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_code-iq&issues=AZ1JYYu-0uoAvFUG_61J&open=AZ1JYYu-0uoAvFUG_61J&pullRequest=11
assertThat(routes).doesNotContain("/topology", "/topology/**", "/flow", "/flow/**");
}
}
Loading