StarterSetPlan.java
/*
* Copyright © 2026 IKE Network (support@ike.network)
*
* 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.
*/
package network.ike.plugin;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* The genesis plan of a starter-set project: every name derived from the one human
* decision (the set name), the expansion tokens, and the template-to-target file
* mapping. Pure derivation — no I/O — so the plan is unit-testable and the draft and
* publish goals render exactly the same facts.
*
* <p>Derivations from {@code setName} (each overridable at the goal): slug
* ({@code cql-clause}), artifact root ({@code cql-clause-starter-set} — the family
* suffix is itself a parameter, {@code artifactSuffix}, default {@code starter-set}),
* compact class name ({@code CqlClause}), semantic tag (defaults to the class name —
* a headline item, never silently mechanical: it lives in every birth FQN forever),
* and base package ({@code <groupId>.cqlclause}).
*/
final class StarterSetPlan {
private final Map<String, String> tokens = new LinkedHashMap<>();
/**
* Builds the plan.
*
* @param setName the set's human name, e.g. {@code "CQL Clause"}
* @param groupId the Maven groupId placing the set in its family
* @param semanticTag the semantic tag, or null for the derived default
* @param artifactSuffix the artifact-root family suffix (e.g.
* {@code starter-set}), or null/blank for the
* default {@code starter-set}
* @param setUuid the set UUID (already minted or supplied)
* @param version the project version
* @param parentVersion the ike-parent version
* @param chronologyStoreVersion the engine version property value
* @param providerVersion the ike-knowledge-provider version
* @param toolingVersion the ike-maven-plugin version to pin
* @param konceptExtensionVersion the koncept-asciidoc-extension version
* @param baseArtifactSpec the kb module's base input line
* @param inceptionTime the inception stamp's literal ISO time
* @throws IllegalArgumentException if the set name or groupId is blank
*/
StarterSetPlan(String setName, String groupId, String semanticTag, String artifactSuffix,
String setUuid, String version, String parentVersion,
String chronologyStoreVersion, String providerVersion, String toolingVersion,
String konceptExtensionVersion, String baseArtifactSpec,
String inceptionTime) {
if (setName == null || setName.isBlank()) {
throw new IllegalArgumentException("A starter set requires -DsetName");
}
if (groupId == null || groupId.isBlank()) {
throw new IllegalArgumentException("A starter set requires -DgroupId"
+ " — the groupId places the set in its domain family");
}
String slug = slugOf(setName);
String className = classNameOf(setName);
String suffix = (artifactSuffix == null || artifactSuffix.isBlank())
? "starter-set" : artifactSuffix.strip();
tokens.put("setName", setName.strip());
tokens.put("groupId", groupId.strip());
tokens.put("slug", slug);
tokens.put("artifactRoot", slug + "-" + suffix);
tokens.put("className", className);
String familyLabel = titleCase(suffix);
tokens.put("familyLabel", familyLabel);
tokens.put("familyLabelLower", familyLabel.toLowerCase(Locale.ROOT));
tokens.put("tag", (semanticTag == null || semanticTag.isBlank())
? className : semanticTag.strip());
tokens.put("basePackage", groupId.strip() + "." + slug.replace("-", ""));
tokens.put("setUuid", Objects.requireNonNull(setUuid, "setUuid"));
tokens.put("version", version);
tokens.put("parentVersion", parentVersion);
tokens.put("chronologyStoreVersion", chronologyStoreVersion);
tokens.put("providerVersion", providerVersion);
tokens.put("toolingVersion", toolingVersion);
tokens.put("konceptExtensionVersion", konceptExtensionVersion);
tokens.put("baseArtifactSpec", baseArtifactSpec);
tokens.put("inceptionTime", inceptionTime);
}
/**
* Renders a kebab-case suffix as a human-readable, space-separated title-case
* label — {@code starter-set} becomes {@code Starter Set}. Used for free-text
* prose (POM {@code <name>}/{@code <description>}, READMEs, docs) that would
* otherwise hardcode the word "knowledge" regardless of the chosen
* {@code artifactSuffix}.
*
* @param suffix the kebab-case suffix
* @return the title-case label
*/
static String titleCase(String suffix) {
StringBuilder label = new StringBuilder();
for (String word : suffix.split("-")) {
if (!word.isEmpty()) {
if (label.length() > 0) {
label.append(' ');
}
label.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
}
return label.toString();
}
/**
* Derives the kebab-case slug of a set name.
*
* @param setName the human name
* @return the slug, e.g. {@code cql-clause}
*/
static String slugOf(String setName) {
String slug = setName.strip().toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (slug.isEmpty()) {
throw new IllegalArgumentException("Set name yields no usable slug: " + setName);
}
return slug;
}
/**
* Derives the compact class name of a set name.
*
* @param setName the human name
* @return the class name, e.g. {@code CqlClause}
*/
static String classNameOf(String setName) {
StringBuilder name = new StringBuilder();
for (String word : setName.strip().split("[^A-Za-z0-9]+")) {
if (!word.isEmpty()) {
name.append(Character.toUpperCase(word.charAt(0)));
if (word.length() > 1) {
name.append(word.substring(1));
}
}
}
if (name.isEmpty()) {
throw new IllegalArgumentException("Set name yields no usable class name: " + setName);
}
return name.toString();
}
/**
* One token's value.
*
* @param key the token name
* @return the value
*/
String token(String key) {
return tokens.get(key);
}
/**
* All tokens, in declaration order — the draft report's facts table.
*
* @return an unmodifiable view of the tokens
*/
Map<String, String> tokens() {
return java.util.Collections.unmodifiableMap(tokens);
}
/**
* Expands every {@code @token@} in template text.
*
* @param template the template text
* @return the expanded text
* @throws IllegalStateException if an unknown token remains after expansion
*/
String expand(String template) {
String expanded = template;
for (Map.Entry<String, String> token : tokens.entrySet()) {
expanded = expanded.replace("@" + token.getKey() + "@", token.getValue());
}
java.util.regex.Matcher leftover =
java.util.regex.Pattern.compile("@([a-zA-Z]+)@").matcher(expanded);
if (leftover.find()) {
throw new IllegalStateException("Unknown template token: @" + leftover.group(1) + "@");
}
return expanded;
}
/**
* Maps a template path (relative, within {@code starter-set/}) to its target path
* inside the generated project.
*
* @param templatePath the template-relative path
* @return the project-relative target path
*/
String targetPath(String templatePath) {
String target = templatePath
.replace("PACKAGE", tokens.get("basePackage").replace('.', '/'))
.replace("Handles.java", tokens.get("className") + ".java")
.replace("SetSource.java", tokens.get("className") + "Source.java")
.replace("guide.adoc", tokens.get("slug") + "-guide.adoc");
if (target.equals("gitignore")) {
return ".gitignore";
}
if (target.startsWith("mvn/")) {
return ".mvn/" + target.substring(4);
}
for (String module : new String[]{"terms", "bindings", "changeset", "doc", "kb"}) {
if (target.equals(module + "/pom.xml") || target.startsWith(module + "/src/")) {
return tokens.get("slug") + "-" + target;
}
}
return target;
}
}