Skip to content

Commit 6ea345e

Browse files
committed
refactor(config): centralize CLI startup overrides in a config-package helper
Collapse the four production call sites that mutate the CodeIqConfig Spring singleton (ServeCommand, EnrichCommand, CliOutput, Analyzer) through a single package-adjacent helper. This pins the "mutation happens once at CLI startup" contract in one place and sets up a follow-up commit to tighten the bean's setter visibility to package-private. - New CliStartupConfigOverrides with applyServeOverrides / applyCacheDir / applyServiceName. Null/blank inputs are no-ops — never overwrite an in-code default with an absent value. - Analyzer.runSmartWithCache now routes service-name propagation through the helper (same semantics, same guard condition). - Six unit tests verify each helper mutates only the intended fields on a freshly-adapted CodeIqConfig and that null/blank inputs are no-ops.
1 parent 65aac67 commit 6ea345e

6 files changed

Lines changed: 187 additions & 9 deletions

File tree

src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import io.github.randomcodespace.iq.cache.AnalysisCache;
66
import io.github.randomcodespace.iq.cache.FileHasher;
77
import io.github.randomcodespace.iq.cli.VersionCommand;
8+
import io.github.randomcodespace.iq.config.CliStartupConfigOverrides;
89
import io.github.randomcodespace.iq.config.CodeIqConfig;
910
import io.github.randomcodespace.iq.config.unified.CodeIqUnifiedConfig;
1011
import io.github.randomcodespace.iq.detector.AbstractAntlrDetector;
@@ -814,7 +815,7 @@ private AnalysisResult runSmartWithCache(Path root, Integer parallelism, int bat
814815
report.accept("Service: " + infraRegistry.getServiceName());
815816
// Propagate to config if not already set
816817
if (config.getServiceName() == null || config.getServiceName().isBlank()) {
817-
config.setServiceName(infraRegistry.getServiceName());
818+
CliStartupConfigOverrides.applyServiceName(config, infraRegistry.getServiceName());
818819
}
819820
}
820821

src/main/java/io/github/randomcodespace/iq/cli/CliOutput.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,13 @@ static void configureFromOptions(io.github.randomcodespace.iq.config.CodeIqConfi
9595
java.nio.file.Path graphDir, String serviceName) {
9696
if (graphDir != null) {
9797
java.nio.file.Path sharedDir = graphDir.toAbsolutePath().normalize();
98-
config.setCacheDir(sharedDir.toString());
98+
io.github.randomcodespace.iq.config.CliStartupConfigOverrides.applyCacheDir(
99+
config, sharedDir.toString());
99100
info(" Graph dir: " + sharedDir + " (shared multi-repo)");
100101
}
101102
if (serviceName != null && !serviceName.isBlank()) {
102-
config.setServiceName(serviceName);
103+
io.github.randomcodespace.iq.config.CliStartupConfigOverrides.applyServiceName(
104+
config, serviceName);
103105
info(" Service name: " + serviceName);
104106
}
105107
}

src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io.github.randomcodespace.iq.analyzer.LayerClassifier;
55
import io.github.randomcodespace.iq.analyzer.linker.Linker;
66
import io.github.randomcodespace.iq.cache.AnalysisCache;
7+
import io.github.randomcodespace.iq.config.CliStartupConfigOverrides;
78
import io.github.randomcodespace.iq.config.CodeIqConfig;
89
import io.github.randomcodespace.iq.intelligence.RepositoryIdentity;
910
import io.github.randomcodespace.iq.intelligence.extractor.LanguageEnricher;
@@ -91,8 +92,9 @@ public Integer call() {
9192

9293
// If --graph is set, override cache directory to shared location
9394
if (graphDir != null) {
94-
config.setCacheDir(graphDir.toAbsolutePath().normalize().toString());
95-
CliOutput.info(" Graph dir: " + graphDir.toAbsolutePath().normalize() + " (shared multi-repo)");
95+
Path sharedDir = graphDir.toAbsolutePath().normalize();
96+
CliStartupConfigOverrides.applyCacheDir(config, sharedDir.toString());
97+
CliOutput.info(" Graph dir: " + sharedDir + " (shared multi-repo)");
9698
}
9799

98100
// 1. Open H2 file

src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.randomcodespace.iq.cli;
22

3+
import io.github.randomcodespace.iq.config.CliStartupConfigOverrides;
34
import io.github.randomcodespace.iq.config.CodeIqConfig;
45
import io.github.randomcodespace.iq.config.GraphBootstrapper;
56
import io.github.randomcodespace.iq.graph.GraphStore;
@@ -71,10 +72,7 @@ public class ServeCommand implements Callable<Integer> {
7172
@Override
7273
public Integer call() {
7374
Path root = path.toAbsolutePath().normalize();
74-
config.setRootPath(root.toString());
75-
if (readOnly) {
76-
config.setReadOnly(true);
77-
}
75+
CliStartupConfigOverrides.applyServeOverrides(config, root, readOnly);
7876
NumberFormat nf = NumberFormat.getIntegerInstance(Locale.US);
7977

8078
// Bootstrap Neo4j from the H2 analysis cache if Neo4j is empty. This is
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package io.github.randomcodespace.iq.config;
2+
3+
import java.nio.file.Path;
4+
5+
/**
6+
* Centralised, CLI-startup-only mutation of the {@link CodeIqConfig} Spring
7+
* singleton.
8+
*
9+
* <p><b>Call contract:</b> these helpers are invoked exactly once per JVM
10+
* invocation, from a Picocli command's {@code call()} entry point, <em>before</em>
11+
* any downstream consumer reads config state. Treat the config as frozen
12+
* afterwards.
13+
*
14+
* <p>Do <b>not</b> invoke from request handlers, background workers, controllers,
15+
* MCP tools, or any serving-layer code path. The {@link CodeIqConfig} bean is a
16+
* Spring singleton shared across every consumer — mutating it at runtime is a
17+
* correctness hazard and was the motivation for collapsing all existing call
18+
* sites into this one package-private surface.
19+
*
20+
* <p>Visibility is package-private by design: only other classes inside
21+
* {@code io.github.randomcodespace.iq.config} can reach {@link CodeIqConfig}'s
22+
* package-private setters via this helper. CLI callers in
23+
* {@code io.github.randomcodespace.iq.cli} and analyzer callers in
24+
* {@code io.github.randomcodespace.iq.analyzer} route through the public
25+
* {@code apply*} methods below.
26+
*/
27+
public final class CliStartupConfigOverrides {
28+
29+
private CliStartupConfigOverrides() {}
30+
31+
/**
32+
* Apply the {@code serve} command's startup overrides to the config bean:
33+
* absolute root path, and read-only mode when the {@code --read-only} flag
34+
* was set.
35+
*
36+
* @param config the Spring-managed {@link CodeIqConfig} singleton
37+
* @param root absolute, normalised root path (must not be {@code null})
38+
* @param readOnly whether the {@code --read-only} CLI flag was set
39+
*/
40+
public static void applyServeOverrides(CodeIqConfig config, Path root, boolean readOnly) {
41+
if (config == null || root == null) {
42+
return;
43+
}
44+
config.setRootPath(root.toString());
45+
if (readOnly) {
46+
config.setReadOnly(true);
47+
}
48+
}
49+
50+
/**
51+
* Override the cache directory. No-op if {@code cacheDir} is {@code null}
52+
* or blank — we never overwrite the in-code default with an absent value.
53+
*/
54+
public static void applyCacheDir(CodeIqConfig config, String cacheDir) {
55+
if (config == null || cacheDir == null || cacheDir.isBlank()) {
56+
return;
57+
}
58+
config.setCacheDir(cacheDir);
59+
}
60+
61+
/**
62+
* Override the service-name tag used in multi-repo graph mode. No-op if
63+
* {@code name} is {@code null} or blank.
64+
*/
65+
public static void applyServiceName(CodeIqConfig config, String name) {
66+
if (config == null || name == null || name.isBlank()) {
67+
return;
68+
}
69+
config.setServiceName(name);
70+
}
71+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package io.github.randomcodespace.iq.config;
2+
3+
import io.github.randomcodespace.iq.config.unified.ConfigDefaults;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.nio.file.Path;
7+
import java.nio.file.Paths;
8+
9+
import static org.junit.jupiter.api.Assertions.assertEquals;
10+
import static org.junit.jupiter.api.Assertions.assertFalse;
11+
import static org.junit.jupiter.api.Assertions.assertTrue;
12+
13+
/**
14+
* Verifies {@link CliStartupConfigOverrides} mutates only the intended fields
15+
* on a freshly-adapted {@link CodeIqConfig} and leaves every other field at
16+
* the built-in default. Protects the CLI startup contract: one helper per
17+
* override group, no collateral writes, null/blank inputs are no-ops.
18+
*/
19+
class CliStartupConfigOverridesTest {
20+
21+
private CodeIqConfig freshConfig() {
22+
return UnifiedConfigAdapter.toCodeIqConfig(ConfigDefaults.builtIn());
23+
}
24+
25+
@Test
26+
void applyServeOverrides_sets_rootPath_and_readOnly_only() {
27+
CodeIqConfig cfg = freshConfig();
28+
String originalCacheDir = cfg.getCacheDir();
29+
int originalMaxDepth = cfg.getMaxDepth();
30+
int originalBatchSize = cfg.getBatchSize();
31+
String originalGraphPath = cfg.getGraph().getPath();
32+
33+
Path root = Paths.get("/tmp/some-repo").toAbsolutePath().normalize();
34+
CliStartupConfigOverrides.applyServeOverrides(cfg, root, true);
35+
36+
assertEquals(root.toString(), cfg.getRootPath());
37+
assertTrue(cfg.isReadOnly());
38+
// no collateral mutation
39+
assertEquals(originalCacheDir, cfg.getCacheDir());
40+
assertEquals(originalMaxDepth, cfg.getMaxDepth());
41+
assertEquals(originalBatchSize, cfg.getBatchSize());
42+
assertEquals(originalGraphPath, cfg.getGraph().getPath());
43+
}
44+
45+
@Test
46+
void applyServeOverrides_readOnly_false_leaves_flag_at_default() {
47+
CodeIqConfig cfg = freshConfig();
48+
Path root = Paths.get("/tmp/other-repo").toAbsolutePath().normalize();
49+
CliStartupConfigOverrides.applyServeOverrides(cfg, root, false);
50+
assertEquals(root.toString(), cfg.getRootPath());
51+
assertFalse(cfg.isReadOnly());
52+
}
53+
54+
@Test
55+
void applyCacheDir_sets_cacheDir_only() {
56+
CodeIqConfig cfg = freshConfig();
57+
String originalRoot = cfg.getRootPath();
58+
String originalServiceName = cfg.getServiceName();
59+
boolean originalReadOnly = cfg.isReadOnly();
60+
61+
CliStartupConfigOverrides.applyCacheDir(cfg, "/shared/graph");
62+
63+
assertEquals("/shared/graph", cfg.getCacheDir());
64+
assertEquals(originalRoot, cfg.getRootPath());
65+
assertEquals(originalServiceName, cfg.getServiceName());
66+
assertEquals(originalReadOnly, cfg.isReadOnly());
67+
}
68+
69+
@Test
70+
void applyCacheDir_null_or_blank_is_noop() {
71+
CodeIqConfig cfg = freshConfig();
72+
String before = cfg.getCacheDir();
73+
CliStartupConfigOverrides.applyCacheDir(cfg, null);
74+
assertEquals(before, cfg.getCacheDir());
75+
CliStartupConfigOverrides.applyCacheDir(cfg, " ");
76+
assertEquals(before, cfg.getCacheDir());
77+
}
78+
79+
@Test
80+
void applyServiceName_sets_serviceName_only() {
81+
CodeIqConfig cfg = freshConfig();
82+
String originalRoot = cfg.getRootPath();
83+
String originalCacheDir = cfg.getCacheDir();
84+
int originalMaxDepth = cfg.getMaxDepth();
85+
86+
CliStartupConfigOverrides.applyServiceName(cfg, "payments-api");
87+
88+
assertEquals("payments-api", cfg.getServiceName());
89+
assertEquals(originalRoot, cfg.getRootPath());
90+
assertEquals(originalCacheDir, cfg.getCacheDir());
91+
assertEquals(originalMaxDepth, cfg.getMaxDepth());
92+
}
93+
94+
@Test
95+
void applyServiceName_null_or_blank_is_noop() {
96+
CodeIqConfig cfg = freshConfig();
97+
CliStartupConfigOverrides.applyServiceName(cfg, null);
98+
assertEquals(null, cfg.getServiceName()); // default is null
99+
CliStartupConfigOverrides.applyServiceName(cfg, "");
100+
assertEquals(null, cfg.getServiceName());
101+
CliStartupConfigOverrides.applyServiceName(cfg, " ");
102+
assertEquals(null, cfg.getServiceName());
103+
}
104+
}

0 commit comments

Comments
 (0)