GoalReport.java
package network.ike.plugin.support;
import org.apache.maven.api.plugin.Log;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* Per-goal report writer for IKE plugins.
*
* <p>Each goal writes its own file directly in the Maven project root
* the goal was executed from (alongside the invoking {@code pom.xml}).
* Files are <b>overwritten</b> on each run (not appended), so the
* content always reflects the latest execution.
*
* <p>Filenames use {@code ꞉} (U+A789 MODIFIER LETTER COLON) to cluster
* visually as {@code <prefix>꞉goal-name.md} in IDE file browsers. For
* draft/publish goals, the filename includes the variant:
* {@code ike꞉release-draft.md}, {@code ike꞉release-publish.md},
* {@code idoc꞉asciidoc.md}.
*
* <p><strong>Self-healing gitignore:</strong> before writing, this
* class ensures {@code <prefix>꞉*.md} is listed in the
* {@code .gitignore} of the nearest {@code .git} ancestor. If the
* pattern is missing, it is appended. This keeps reports out of git
* without any manual setup — a fresh clone of a consumer repo becomes
* report-ready the first time an IKE goal runs. One entry is added
* per plugin prefix seen.
*
* <p>Parallels {@code network.ike.plugin.ws.WorkspaceReport} in the
* ws plugin; both writers target their respective roots (the workspace
* root for ws, the project root for ike/idoc) and both self-heal the
* nearest {@code .gitignore}.
*
* <p>Replaces the previous {@code network.ike.plugin.IkeReport} as part
* of the plugin split (ike-issues #215).
*/
public final class GoalReport {
/** U+A789 MODIFIER LETTER COLON — filesystem-safe visual colon. */
private static final char COLON = '\uA789';
private static final DateTimeFormatter TIMESTAMP_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private GoalReport() {}
/**
* Write a goal's report to its per-goal file at the project root,
* overwriting any previous content. Self-heals the nearest
* {@code .gitignore} if needed so the report does not land in git.
*
* @param projectRoot the Maven project root the goal executed from
* @param goal the goal whose output is being reported
* @param content markdown content to write
* @param log Maven logger (null-safe)
*/
public static void write(Path projectRoot, GoalRef goal,
String content, Log log) {
String filename = goal.pluginPrefix() + COLON + goal.goalName() + ".md";
Path reportFile = projectRoot.resolve(filename);
try {
ensureGitignored(projectRoot, gitignorePattern(goal), log);
String timestamp = LocalDateTime.now().format(TIMESTAMP_FMT);
String fullContent = "# " + goal.qualified() + "\n"
+ "_" + timestamp + "_\n\n"
+ content.stripTrailing() + "\n";
Files.writeString(reportFile, fullContent, StandardCharsets.UTF_8);
} catch (IOException e) {
if (log != null) {
log.debug("Could not write report " + filename + ": "
+ e.getMessage());
}
}
}
/**
* Resolve the report file path for a specific goal.
*
* @param projectRoot the Maven project root
* @param goal the goal whose report is being located
* @return path to the report file (may not exist yet)
*/
public static Path reportPath(Path projectRoot, GoalRef goal) {
String filename = goal.pluginPrefix() + COLON + goal.goalName() + ".md";
return projectRoot.resolve(filename);
}
/**
* The {@code .gitignore} glob used to cover every report file
* emitted by a given goal's plugin prefix, e.g. {@code ike꞉*.md},
* {@code idoc꞉*.md}.
*
* @param goal the goal whose plugin prefix seeds the glob
* @return the gitignore glob string
*/
static String gitignorePattern(GoalRef goal) {
return goal.pluginPrefix() + COLON + "*.md";
}
/**
* Walk up from {@code projectRoot} looking for a {@code .git}
* directory; ensure its sibling {@code .gitignore} lists
* {@code pattern}. If the file is missing, create it. If the
* pattern is missing, append it. No-op when no {@code .git}
* ancestor is found (e.g. the module is not yet in a git repo).
*
* @param projectRoot the Maven project root to search from
* @param pattern the gitignore glob to ensure is present
* @param log Maven logger (null-safe)
* @throws IOException if the gitignore file cannot be read or written
*/
static void ensureGitignored(Path projectRoot, String pattern, Log log)
throws IOException {
Path gitRoot = findGitRoot(projectRoot);
if (gitRoot == null) return;
Path gitignore = gitRoot.resolve(".gitignore");
if (Files.exists(gitignore)) {
List<String> lines = Files.readAllLines(gitignore,
StandardCharsets.UTF_8);
for (String line : lines) {
if (matchesPattern(line.trim(), pattern)) {
return;
}
}
String existing = Files.readString(gitignore,
StandardCharsets.UTF_8);
String appended = existing.endsWith("\n") ? existing
: existing + "\n";
Files.writeString(gitignore,
appended + "\n# " + prefixHeader(pattern)
+ " goal reports\n" + pattern + "\n",
StandardCharsets.UTF_8);
} else {
Files.writeString(gitignore,
"# " + prefixHeader(pattern) + " goal reports\n"
+ pattern + "\n",
StandardCharsets.UTF_8);
}
if (log != null) {
log.info("Added " + pattern
+ " to " + gitRoot.relativize(gitignore));
}
}
private static String prefixHeader(String pattern) {
int colonIx = pattern.indexOf(COLON);
return (colonIx > 0 ? pattern.substring(0, colonIx) : "ike") + ":*";
}
/**
* Walk up from {@code start} looking for a directory that contains
* a {@code .git} entry (directory or file — the latter for
* worktrees and submodules).
*
* @param start the starting directory for the search
* @return the git root directory, or {@code null} if none is found
*/
private static Path findGitRoot(Path start) {
Path current = start.toAbsolutePath();
while (current != null) {
if (Files.exists(current.resolve(".git"))) return current;
current = current.getParent();
}
return null;
}
/**
* Test whether a {@code .gitignore} line (with comments and leading
* slash handling) matches the given pattern. Treats {@code session},
* {@code session/}, and {@code /session/} as equivalent forms.
*
* @param line the {@code .gitignore} line, already trimmed
* @param pattern the normalized pattern to match against
* @return {@code true} if the line covers the pattern
*/
private static boolean matchesPattern(String line, String pattern) {
if (line.isEmpty() || line.startsWith("#")) return false;
String normalized = line.startsWith("/") ? line.substring(1) : line;
String stripped = pattern.endsWith("/")
? pattern.substring(0, pattern.length() - 1)
: pattern;
String lineStripped = normalized.endsWith("/")
? normalized.substring(0, normalized.length() - 1)
: normalized;
return lineStripped.equals(stripped);
}
}