feat(qqq): add --merge mode for selective regeneration
Implements Story #26 with include/exclude patterns, interactive conflict resolution, and file backups. Closes #26
This commit is contained in:
8 files changed
+1542
-11
No files matched your search
@@ -17,6 +17,7 @@ import java.io.IOException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -28,6 +29,7 @@ import io.qrun.qctl.qqq.registry.TemplateInfo;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistry;
|
||||
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
|
||||
import io.qrun.qctl.qqq.template.ConsoleUI;
|
||||
import io.qrun.qctl.qqq.template.MergeOptions;
|
||||
import io.qrun.qctl.qqq.template.PostGenHookRunner;
|
||||
import io.qrun.qctl.qqq.template.PromptRunner;
|
||||
import io.qrun.qctl.qqq.template.TemplateEngine;
|
||||
@@ -86,6 +88,23 @@ public class InitCommand implements Runnable
|
||||
@Option(names = {"-v", "--verbose"}, description = "Show detailed error information")
|
||||
boolean verbose;
|
||||
|
||||
@Option(names = "--merge", description = "Merge with existing directory (skip existing files)")
|
||||
boolean merge;
|
||||
|
||||
@Option(names = "--include", description = "Include patterns for merge (overwrite matching files)",
|
||||
split = ",")
|
||||
List<String> includePatterns = new ArrayList<>();
|
||||
|
||||
@Option(names = "--exclude", description = "Exclude patterns for merge (protect matching files)",
|
||||
split = ",")
|
||||
List<String> excludePatterns = new ArrayList<>();
|
||||
|
||||
@Option(names = "--interactive", description = "Prompt for each conflicting file in merge mode")
|
||||
boolean interactive;
|
||||
|
||||
@Option(names = "--no-backup", description = "Disable backup creation when overwriting files")
|
||||
boolean noBackup;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
@@ -118,10 +137,10 @@ public class InitCommand implements Runnable
|
||||
Path effectiveTargetDir = getTargetDirectory(ui);
|
||||
|
||||
// Check target directory
|
||||
if(Files.exists(effectiveTargetDir) && !force)
|
||||
if(Files.exists(effectiveTargetDir) && !force && !merge)
|
||||
{
|
||||
ui.error("target directory already exists: " + effectiveTargetDir);
|
||||
ui.println("Use --force to overwrite.");
|
||||
ui.println("Use --force to overwrite or --merge to merge with existing.");
|
||||
System.exit(ExitCodes.CONFLICT);
|
||||
}
|
||||
|
||||
@@ -162,11 +181,19 @@ public class InitCommand implements Runnable
|
||||
// Step 5: Evaluate computed variables
|
||||
TemplateEngine engine = new TemplateEngine();
|
||||
allVars = new LinkedHashMap<>(engine.evaluateComputed(manifest.computed(), allVars));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Build merge options //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
MergeOptions mergeOptions = merge
|
||||
? new MergeOptions(true, includePatterns, excludePatterns, interactive, !noBackup)
|
||||
: MergeOptions.disabled();
|
||||
|
||||
if(dryRun)
|
||||
{
|
||||
ui.println();
|
||||
ui.println("[DRY-RUN] Would create files:");
|
||||
engine.renderDryRun(templatePath, effectiveTargetDir, allVars, manifest);
|
||||
engine.renderDryRun(templatePath, effectiveTargetDir, allVars, manifest, mergeOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -174,8 +201,13 @@ public class InitCommand implements Runnable
|
||||
{
|
||||
ui.info("Overwriting existing directory...");
|
||||
}
|
||||
if(merge && Files.exists(effectiveTargetDir))
|
||||
{
|
||||
ui.info("Merging with existing directory...");
|
||||
}
|
||||
Files.createDirectories(effectiveTargetDir);
|
||||
int fileCount = engine.render(templatePath, effectiveTargetDir, allVars, manifest);
|
||||
int fileCount = engine.render(templatePath, effectiveTargetDir, allVars, manifest,
|
||||
mergeOptions, ui);
|
||||
ui.println();
|
||||
ui.success("Created " + fileCount + " files in " + effectiveTargetDir);
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Generates unified diff output for file comparison.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class FileDiffer
|
||||
{
|
||||
private static final String ANSI_RED = "\u001B[31m";
|
||||
private static final String ANSI_GREEN = "\u001B[32m";
|
||||
private static final String ANSI_CYAN = "\u001B[36m";
|
||||
private static final String ANSI_RESET = "\u001B[0m";
|
||||
|
||||
private static final int CONTEXT_LINES = 3;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Generate a unified diff between two strings.
|
||||
*
|
||||
* @param existing the existing file content
|
||||
* @param updated the new file content
|
||||
* @param filename the filename for the header
|
||||
* @return formatted diff output
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public String diff(String existing, String updated, String filename)
|
||||
{
|
||||
String[] oldLines = existing.split("\n", -1);
|
||||
String[] newLines = updated.split("\n", -1);
|
||||
|
||||
List<DiffLine> diffLines = computeDiff(oldLines, newLines);
|
||||
|
||||
return formatDiff(diffLines, filename, oldLines.length, newLines.length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Compute the diff between two arrays of lines.
|
||||
*
|
||||
* @param oldLines original lines
|
||||
* @param newLines new lines
|
||||
* @return list of diff lines
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private List<DiffLine> computeDiff(String[] oldLines, String[] newLines)
|
||||
{
|
||||
List<DiffLine> result = new ArrayList<>();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Simple line-by-line diff using longest common subsequence //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
int[][] lcs = computeLCS(oldLines, newLines);
|
||||
int i = oldLines.length;
|
||||
int j = newLines.length;
|
||||
|
||||
List<DiffLine> tempResult = new ArrayList<>();
|
||||
|
||||
while(i > 0 || j > 0)
|
||||
{
|
||||
if(i > 0 && j > 0 && oldLines[i - 1].equals(newLines[j - 1]))
|
||||
{
|
||||
tempResult.add(new DiffLine(DiffType.CONTEXT, oldLines[i - 1], i, j));
|
||||
i--;
|
||||
j--;
|
||||
}
|
||||
else if(j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]))
|
||||
{
|
||||
tempResult.add(new DiffLine(DiffType.ADD, newLines[j - 1], -1, j));
|
||||
j--;
|
||||
}
|
||||
else
|
||||
{
|
||||
tempResult.add(new DiffLine(DiffType.DELETE, oldLines[i - 1], i, -1));
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Reverse to get correct order //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
for(int k = tempResult.size() - 1; k >= 0; k--)
|
||||
{
|
||||
result.add(tempResult.get(k));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Compute the longest common subsequence matrix.
|
||||
*
|
||||
* @param oldLines original lines
|
||||
* @param newLines new lines
|
||||
* @return LCS matrix
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private int[][] computeLCS(String[] oldLines, String[] newLines)
|
||||
{
|
||||
int m = oldLines.length;
|
||||
int n = newLines.length;
|
||||
int[][] lcs = new int[m + 1][n + 1];
|
||||
|
||||
for(int i = 1; i <= m; i++)
|
||||
{
|
||||
for(int j = 1; j <= n; j++)
|
||||
{
|
||||
if(oldLines[i - 1].equals(newLines[j - 1]))
|
||||
{
|
||||
lcs[i][j] = lcs[i - 1][j - 1] + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lcs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Format the diff lines into unified diff output.
|
||||
*
|
||||
* @param diffLines the diff lines
|
||||
* @param filename the filename
|
||||
* @param oldCount original line count
|
||||
* @param newCount new line count
|
||||
* @return formatted diff string
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private String formatDiff(List<DiffLine> diffLines, String filename,
|
||||
int oldCount, int newCount)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Header //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
sb.append(ANSI_RED).append("--- existing ").append(filename)
|
||||
.append(ANSI_RESET).append("\n");
|
||||
sb.append(ANSI_GREEN).append("+++ template ").append(filename)
|
||||
.append(ANSI_RESET).append("\n");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Find hunks (groups of changes with context) //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
List<Hunk> hunks = findHunks(diffLines);
|
||||
|
||||
for(Hunk hunk : hunks)
|
||||
{
|
||||
sb.append(ANSI_CYAN)
|
||||
.append("@@ -").append(hunk.oldStart).append(",").append(hunk.oldCount)
|
||||
.append(" +").append(hunk.newStart).append(",").append(hunk.newCount)
|
||||
.append(" @@").append(ANSI_RESET).append("\n");
|
||||
|
||||
for(DiffLine line : hunk.lines)
|
||||
{
|
||||
switch(line.type)
|
||||
{
|
||||
case ADD:
|
||||
sb.append(ANSI_GREEN).append("+").append(line.content)
|
||||
.append(ANSI_RESET).append("\n");
|
||||
break;
|
||||
case DELETE:
|
||||
sb.append(ANSI_RED).append("-").append(line.content)
|
||||
.append(ANSI_RESET).append("\n");
|
||||
break;
|
||||
case CONTEXT:
|
||||
sb.append(" ").append(line.content).append("\n");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(hunks.isEmpty())
|
||||
{
|
||||
sb.append("(no differences)\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Find hunks (groups of changes with surrounding context).
|
||||
*
|
||||
* @param diffLines all diff lines
|
||||
* @return list of hunks
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private List<Hunk> findHunks(List<DiffLine> diffLines)
|
||||
{
|
||||
List<Hunk> hunks = new ArrayList<>();
|
||||
List<Integer> changeIndices = new ArrayList<>();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Find all change indices //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
for(int i = 0; i < diffLines.size(); i++)
|
||||
{
|
||||
if(diffLines.get(i).type != DiffType.CONTEXT)
|
||||
{
|
||||
changeIndices.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if(changeIndices.isEmpty())
|
||||
{
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Group changes into hunks //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
int hunkStart = Math.max(0, changeIndices.get(0) - CONTEXT_LINES);
|
||||
int hunkEnd = Math.min(diffLines.size() - 1, changeIndices.get(0) + CONTEXT_LINES);
|
||||
|
||||
for(int i = 1; i < changeIndices.size(); i++)
|
||||
{
|
||||
int changeIdx = changeIndices.get(i);
|
||||
int changeStart = changeIdx - CONTEXT_LINES;
|
||||
|
||||
if(changeStart <= hunkEnd + 1)
|
||||
{
|
||||
hunkEnd = Math.min(diffLines.size() - 1, changeIdx + CONTEXT_LINES);
|
||||
}
|
||||
else
|
||||
{
|
||||
hunks.add(createHunk(diffLines, hunkStart, hunkEnd));
|
||||
hunkStart = Math.max(0, changeIdx - CONTEXT_LINES);
|
||||
hunkEnd = Math.min(diffLines.size() - 1, changeIdx + CONTEXT_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
hunks.add(createHunk(diffLines, hunkStart, hunkEnd));
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a hunk from a range of diff lines.
|
||||
*
|
||||
* @param diffLines all diff lines
|
||||
* @param start start index
|
||||
* @param end end index
|
||||
* @return the hunk
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private Hunk createHunk(List<DiffLine> diffLines, int start, int end)
|
||||
{
|
||||
List<DiffLine> hunkLines = new ArrayList<>();
|
||||
int oldStart = 1;
|
||||
int oldCount = 0;
|
||||
int newStart = 1;
|
||||
int newCount = 0;
|
||||
|
||||
boolean foundOldStart = false;
|
||||
boolean foundNewStart = false;
|
||||
|
||||
for(int i = start; i <= end && i < diffLines.size(); i++)
|
||||
{
|
||||
DiffLine line = diffLines.get(i);
|
||||
hunkLines.add(line);
|
||||
|
||||
switch(line.type)
|
||||
{
|
||||
case CONTEXT:
|
||||
if(!foundOldStart && line.oldLine > 0)
|
||||
{
|
||||
oldStart = line.oldLine;
|
||||
foundOldStart = true;
|
||||
}
|
||||
if(!foundNewStart && line.newLine > 0)
|
||||
{
|
||||
newStart = line.newLine;
|
||||
foundNewStart = true;
|
||||
}
|
||||
oldCount++;
|
||||
newCount++;
|
||||
break;
|
||||
case DELETE:
|
||||
if(!foundOldStart && line.oldLine > 0)
|
||||
{
|
||||
oldStart = line.oldLine;
|
||||
foundOldStart = true;
|
||||
}
|
||||
oldCount++;
|
||||
break;
|
||||
case ADD:
|
||||
if(!foundNewStart && line.newLine > 0)
|
||||
{
|
||||
newStart = line.newLine;
|
||||
foundNewStart = true;
|
||||
}
|
||||
newCount++;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new Hunk(hunkLines, oldStart, oldCount, newStart, newCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Types of diff lines.
|
||||
***************************************************************************/
|
||||
private enum DiffType
|
||||
{
|
||||
CONTEXT,
|
||||
ADD,
|
||||
DELETE
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* A single line in the diff.
|
||||
***************************************************************************/
|
||||
private record DiffLine(DiffType type, String content, int oldLine, int newLine)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* A hunk (group of changes with context).
|
||||
***************************************************************************/
|
||||
private record Hunk(List<DiffLine> lines, int oldStart, int oldCount,
|
||||
int newStart, int newCount)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Configuration options for merge mode during template rendering.
|
||||
*
|
||||
* @param enabled whether merge mode is active
|
||||
* @param includePatterns glob patterns for files to include (overwrite if exist)
|
||||
* @param excludePatterns glob patterns for files to exclude (never overwrite)
|
||||
* @param interactive whether to prompt for each conflicting file
|
||||
* @param createBackups whether to create backups before overwriting
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public record MergeOptions(
|
||||
boolean enabled,
|
||||
List<String> includePatterns,
|
||||
List<String> excludePatterns,
|
||||
boolean interactive,
|
||||
boolean createBackups
|
||||
)
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
* Create disabled merge options (normal overwrite behavior).
|
||||
*
|
||||
* @return disabled merge options
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static MergeOptions disabled()
|
||||
{
|
||||
return new MergeOptions(false, List.of(), List.of(), false, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create default merge options (skip existing, create backups).
|
||||
*
|
||||
* @return default merge options
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public static MergeOptions defaults()
|
||||
{
|
||||
return new MergeOptions(true, List.of(), List.of(), false, true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file path matches the include patterns.
|
||||
*
|
||||
* @param relativePath the relative file path to check
|
||||
* @return true if the file matches include patterns (or no patterns defined)
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean matchesInclude(String relativePath)
|
||||
{
|
||||
if(includePatterns == null || includePatterns.isEmpty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return matchesAnyPattern(relativePath, includePatterns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file path matches the exclude patterns.
|
||||
*
|
||||
* @param relativePath the relative file path to check
|
||||
* @return true if the file matches exclude patterns
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public boolean matchesExclude(String relativePath)
|
||||
{
|
||||
if(excludePatterns == null || excludePatterns.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return matchesAnyPattern(relativePath, excludePatterns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Check if a file path matches any of the given glob patterns.
|
||||
*
|
||||
* @param relativePath the relative file path to check
|
||||
* @param patterns the glob patterns to match against
|
||||
* @return true if the file matches any pattern
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private boolean matchesAnyPattern(String relativePath, List<String> patterns)
|
||||
{
|
||||
for(String pattern : patterns)
|
||||
{
|
||||
if(matchesGlob(relativePath, pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Match a path against a glob pattern.
|
||||
*
|
||||
* @param path the path to check
|
||||
* @param pattern the glob pattern
|
||||
* @return true if the path matches
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private boolean matchesGlob(String path, String pattern)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Convert glob to regex: //
|
||||
// ** matches any path segments //
|
||||
// * matches any characters except path separator //
|
||||
// ? matches single character //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
String regex = pattern
|
||||
.replace(".", "\\.")
|
||||
.replace("**", "<<<DOUBLESTAR>>>")
|
||||
.replace("*", "[^/]*")
|
||||
.replace("<<<DOUBLESTAR>>>", ".*")
|
||||
.replace("?", ".");
|
||||
|
||||
return path.matches(regex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Handles file conflict resolution during merge operations.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
public class MergeStrategy
|
||||
{
|
||||
private static final DateTimeFormatter BACKUP_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");
|
||||
|
||||
private final MergeOptions options;
|
||||
private final ConsoleUI ui;
|
||||
private final FileDiffer differ;
|
||||
|
||||
private Boolean overwriteAll = null;
|
||||
private Boolean skipAll = null;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a merge strategy with the given options.
|
||||
*
|
||||
* @param options merge configuration
|
||||
* @param ui console UI for interactive prompts
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public MergeStrategy(MergeOptions options, ConsoleUI ui)
|
||||
{
|
||||
this.options = options;
|
||||
this.ui = ui;
|
||||
this.differ = new FileDiffer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Determine the action for a file during merge.
|
||||
*
|
||||
* @param relativePath relative path of the file
|
||||
* @param targetPath full target path
|
||||
* @param newContent the new content to be written
|
||||
* @return the merge action to take
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public MergeAction determineAction(String relativePath, Path targetPath, String newContent)
|
||||
throws IOException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// If merge mode is disabled, always write //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(!options.enabled())
|
||||
{
|
||||
return MergeAction.WRITE;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Check exclude patterns first //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(options.matchesExclude(relativePath))
|
||||
{
|
||||
return MergeAction.SKIP;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// If file doesn't exist, always write //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(!Files.exists(targetPath))
|
||||
{
|
||||
return MergeAction.WRITE;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// File exists - check include patterns //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
boolean hasIncludePatterns = options.includePatterns() != null
|
||||
&& !options.includePatterns().isEmpty();
|
||||
|
||||
if(hasIncludePatterns)
|
||||
{
|
||||
if(options.matchesInclude(relativePath))
|
||||
{
|
||||
return handleConflict(relativePath, targetPath, newContent);
|
||||
}
|
||||
else
|
||||
{
|
||||
return MergeAction.SKIP;
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// No include patterns - skip existing files by default //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
return MergeAction.SKIP;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Handle a file conflict (file exists and should be considered).
|
||||
*
|
||||
* @param relativePath relative path of the file
|
||||
* @param targetPath full target path
|
||||
* @param newContent the new content
|
||||
* @return the merge action
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private MergeAction handleConflict(String relativePath, Path targetPath, String newContent)
|
||||
throws IOException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Check if user chose "all" option previously //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(Boolean.TRUE.equals(overwriteAll))
|
||||
{
|
||||
return MergeAction.OVERWRITE;
|
||||
}
|
||||
if(Boolean.TRUE.equals(skipAll))
|
||||
{
|
||||
return MergeAction.SKIP;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Interactive mode - prompt user //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(options.interactive())
|
||||
{
|
||||
return promptForAction(relativePath, targetPath, newContent);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Non-interactive with include pattern - overwrite //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
return MergeAction.OVERWRITE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Prompt user for action on conflicting file.
|
||||
*
|
||||
* @param relativePath relative path of the file
|
||||
* @param targetPath full target path
|
||||
* @param newContent the new content
|
||||
* @return the merge action
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
private MergeAction promptForAction(String relativePath, Path targetPath, String newContent)
|
||||
throws IOException
|
||||
{
|
||||
String existingContent = Files.readString(targetPath);
|
||||
|
||||
while(true)
|
||||
{
|
||||
ui.println();
|
||||
ui.warning("File exists: " + relativePath);
|
||||
ui.println(" [o] Overwrite");
|
||||
ui.println(" [s] Skip");
|
||||
ui.println(" [d] Diff (show changes)");
|
||||
ui.println(" [a] Overwrite all remaining");
|
||||
ui.println(" [n] Skip all remaining");
|
||||
ui.println();
|
||||
|
||||
String choice = ui.promptText("Choice", "s").toLowerCase();
|
||||
|
||||
switch(choice)
|
||||
{
|
||||
case "o":
|
||||
return MergeAction.OVERWRITE;
|
||||
|
||||
case "s":
|
||||
return MergeAction.SKIP;
|
||||
|
||||
case "d":
|
||||
String diff = differ.diff(existingContent, newContent, relativePath);
|
||||
ui.println();
|
||||
ui.println(diff);
|
||||
break;
|
||||
|
||||
case "a":
|
||||
overwriteAll = true;
|
||||
return MergeAction.OVERWRITE;
|
||||
|
||||
case "n":
|
||||
skipAll = true;
|
||||
return MergeAction.SKIP;
|
||||
|
||||
default:
|
||||
ui.error("Invalid choice. Please enter o, s, d, a, or n.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Create a backup of the existing file before overwriting.
|
||||
*
|
||||
* @param targetPath the file to backup
|
||||
* @throws IOException if backup fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void createBackup(Path targetPath) throws IOException
|
||||
{
|
||||
if(!options.createBackups() || !Files.exists(targetPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
String timestamp = LocalDateTime.now().format(BACKUP_FORMATTER);
|
||||
String backupName = targetPath.getFileName().toString() + "." + timestamp + ".bak";
|
||||
Path backupPath = targetPath.resolveSibling(backupName);
|
||||
|
||||
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Actions that can be taken for a file during merge.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public enum MergeAction
|
||||
{
|
||||
/*************************************************************************
|
||||
* Write the file (new file, no conflict).
|
||||
*************************************************************************/
|
||||
WRITE,
|
||||
|
||||
/*************************************************************************
|
||||
* Overwrite existing file.
|
||||
*************************************************************************/
|
||||
OVERWRITE,
|
||||
|
||||
/*************************************************************************
|
||||
* Skip the file (keep existing).
|
||||
*************************************************************************/
|
||||
SKIP
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,29 @@ public class TemplateEngine
|
||||
***************************************************************************/
|
||||
public int render(Path templateDir, Path targetDir, Map<String, String> variables,
|
||||
TemplateManifest manifest) throws IOException, TemplateRenderException
|
||||
{
|
||||
return render(templateDir, targetDir, variables, manifest, MergeOptions.disabled(), null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Render templates with merge support.
|
||||
*
|
||||
* @param templateDir template source directory
|
||||
* @param targetDir target output directory
|
||||
* @param variables template variables
|
||||
* @param manifest template manifest
|
||||
* @param mergeOptions merge configuration
|
||||
* @param ui console UI for interactive prompts (may be null)
|
||||
* @return number of files created/modified
|
||||
* @throws IOException if file operations fail
|
||||
* @throws TemplateRenderException if template rendering fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public int render(Path templateDir, Path targetDir, Map<String, String> variables,
|
||||
TemplateManifest manifest, MergeOptions mergeOptions, ConsoleUI ui)
|
||||
throws IOException, TemplateRenderException
|
||||
{
|
||||
Path sourceDir = resolveTemplateSource(templateDir);
|
||||
Set<String> ignorePatterns = manifest.ignore() != null
|
||||
@@ -127,8 +150,11 @@ public class TemplateEngine
|
||||
ignorePatterns.add(".git");
|
||||
ignorePatterns.add("template.yaml");
|
||||
|
||||
AtomicInteger fileCount = new AtomicInteger();
|
||||
AtomicReference<Exception> renderError = new AtomicReference<>();
|
||||
ConsoleUI effectiveUi = ui != null ? ui : new ConsoleUI();
|
||||
MergeStrategy mergeStrategy = new MergeStrategy(mergeOptions, effectiveUi);
|
||||
|
||||
AtomicInteger fileCount = new AtomicInteger();
|
||||
AtomicReference<Exception> renderError = new AtomicReference<>();
|
||||
|
||||
Files.walkFileTree(sourceDir, new SimpleFileVisitor<>()
|
||||
{
|
||||
@@ -172,18 +198,56 @@ public class TemplateEngine
|
||||
String renderedPath = renderPath(relativePath.toString(), variables);
|
||||
Path targetPath = targetDir.resolve(renderedPath);
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Prepare content for merge decision //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
String rendered;
|
||||
if(isBinaryFile(file) || isVerbatimFile(file))
|
||||
{
|
||||
Files.copy(file, targetPath);
|
||||
rendered = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
String content = Files.readString(file);
|
||||
rendered = renderContent(content, variables);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Check merge strategy //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
MergeStrategy.MergeAction action = mergeStrategy.determineAction(
|
||||
renderedPath, targetPath, rendered);
|
||||
|
||||
switch(action)
|
||||
{
|
||||
case SKIP:
|
||||
effectiveUi.println(" [skip] " + renderedPath);
|
||||
return FileVisitResult.CONTINUE;
|
||||
|
||||
case OVERWRITE:
|
||||
mergeStrategy.createBackup(targetPath);
|
||||
effectiveUi.println(" [overwrite] " + renderedPath);
|
||||
break;
|
||||
|
||||
case WRITE:
|
||||
default:
|
||||
effectiveUi.println(" " + renderedPath);
|
||||
break;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Write the file //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
if(isBinaryFile(file) || isVerbatimFile(file))
|
||||
{
|
||||
Files.copy(file, targetPath,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
else
|
||||
{
|
||||
String content = Files.readString(file);
|
||||
String rendered = renderContent(content, variables);
|
||||
Files.writeString(targetPath, rendered);
|
||||
}
|
||||
fileCount.incrementAndGet();
|
||||
System.out.println(" " + renderedPath);
|
||||
}
|
||||
catch(TemplateRenderException e)
|
||||
{
|
||||
@@ -222,6 +286,27 @@ public class TemplateEngine
|
||||
***************************************************************************/
|
||||
public void renderDryRun(Path templateDir, Path targetDir, Map<String, String> variables,
|
||||
TemplateManifest manifest) throws IOException, TemplateRenderException
|
||||
{
|
||||
renderDryRun(templateDir, targetDir, variables, manifest, MergeOptions.disabled());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Preview template rendering with merge support.
|
||||
*
|
||||
* @param templateDir template source directory
|
||||
* @param targetDir target output directory
|
||||
* @param variables template variables
|
||||
* @param manifest template manifest
|
||||
* @param mergeOptions merge configuration
|
||||
* @throws IOException if file operations fail
|
||||
* @throws TemplateRenderException if path rendering fails
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
public void renderDryRun(Path templateDir, Path targetDir, Map<String, String> variables,
|
||||
TemplateManifest manifest, MergeOptions mergeOptions)
|
||||
throws IOException, TemplateRenderException
|
||||
{
|
||||
Path sourceDir = resolveTemplateSource(templateDir);
|
||||
Set<String> ignorePatterns = manifest.ignore() != null
|
||||
@@ -257,7 +342,34 @@ public class TemplateEngine
|
||||
try
|
||||
{
|
||||
String renderedPath = renderPath(relativePath.toString(), variables);
|
||||
System.out.println(" " + targetDir.resolve(renderedPath));
|
||||
Path targetPath = targetDir.resolve(renderedPath);
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// Check merge status for preview //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
String status = "";
|
||||
if(mergeOptions.enabled())
|
||||
{
|
||||
if(mergeOptions.matchesExclude(renderedPath))
|
||||
{
|
||||
status = "[skip] ";
|
||||
}
|
||||
else if(Files.exists(targetPath))
|
||||
{
|
||||
boolean hasIncludes = mergeOptions.includePatterns() != null
|
||||
&& !mergeOptions.includePatterns().isEmpty();
|
||||
if(hasIncludes && mergeOptions.matchesInclude(renderedPath))
|
||||
{
|
||||
status = "[overwrite] ";
|
||||
}
|
||||
else
|
||||
{
|
||||
status = "[skip] ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println(" " + status + targetPath);
|
||||
}
|
||||
catch(TemplateRenderException e)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Unit tests for FileDiffer.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
class FileDifferTest
|
||||
{
|
||||
private FileDiffer differ;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Set up test fixtures.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
differ = new FileDiffer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test diff of identical files shows no differences.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDiff_identicalContent_noDifferences()
|
||||
{
|
||||
String content = "line1\nline2\nline3";
|
||||
String result = differ.diff(content, content, "test.txt");
|
||||
|
||||
assertThat(result).contains("(no differences)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test diff shows added lines.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDiff_addedLines()
|
||||
{
|
||||
String existing = "line1\nline3";
|
||||
String updated = "line1\nline2\nline3";
|
||||
String result = differ.diff(existing, updated, "test.txt");
|
||||
|
||||
assertThat(result).contains("+line2");
|
||||
assertThat(result).contains("--- existing test.txt");
|
||||
assertThat(result).contains("+++ template test.txt");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test diff shows deleted lines.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDiff_deletedLines()
|
||||
{
|
||||
String existing = "line1\nline2\nline3";
|
||||
String updated = "line1\nline3";
|
||||
String result = differ.diff(existing, updated, "test.txt");
|
||||
|
||||
assertThat(result).contains("-line2");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test diff shows modified lines as delete and add.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDiff_modifiedLines()
|
||||
{
|
||||
String existing = "hello world";
|
||||
String updated = "hello there";
|
||||
String result = differ.diff(existing, updated, "test.txt");
|
||||
|
||||
assertThat(result).contains("-hello world");
|
||||
assertThat(result).contains("+hello there");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test diff includes hunk header with line numbers.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDiff_includesHunkHeader()
|
||||
{
|
||||
String existing = "line1\nold\nline3";
|
||||
String updated = "line1\nnew\nline3";
|
||||
String result = differ.diff(existing, updated, "test.txt");
|
||||
|
||||
assertThat(result).contains("@@");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test diff with empty existing file.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDiff_emptyExisting()
|
||||
{
|
||||
String existing = "";
|
||||
String updated = "new content";
|
||||
String result = differ.diff(existing, updated, "test.txt");
|
||||
|
||||
assertThat(result).contains("+new content");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test diff with empty updated file.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDiff_emptyUpdated()
|
||||
{
|
||||
String existing = "old content";
|
||||
String updated = "";
|
||||
String result = differ.diff(existing, updated, "test.txt");
|
||||
|
||||
assertThat(result).contains("-old content");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test diff with multi-line changes.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDiff_multipleChanges()
|
||||
{
|
||||
String existing = "line1\nline2\nline3\nline4\nline5";
|
||||
String updated = "line1\nmodified\nline3\ninserted\nline4\nline5";
|
||||
String result = differ.diff(existing, updated, "test.txt");
|
||||
|
||||
assertThat(result).contains("-line2");
|
||||
assertThat(result).contains("+modified");
|
||||
assertThat(result).contains("+inserted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Unit tests for MergeOptions.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
class MergeOptionsTest
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
* Test that disabled() creates options with merge disabled.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDisabled()
|
||||
{
|
||||
MergeOptions options = MergeOptions.disabled();
|
||||
|
||||
assertThat(options.enabled()).isFalse();
|
||||
assertThat(options.includePatterns()).isEmpty();
|
||||
assertThat(options.excludePatterns()).isEmpty();
|
||||
assertThat(options.interactive()).isFalse();
|
||||
assertThat(options.createBackups()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test that defaults() creates options with merge enabled and backups.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDefaults()
|
||||
{
|
||||
MergeOptions options = MergeOptions.defaults();
|
||||
|
||||
assertThat(options.enabled()).isTrue();
|
||||
assertThat(options.includePatterns()).isEmpty();
|
||||
assertThat(options.excludePatterns()).isEmpty();
|
||||
assertThat(options.interactive()).isFalse();
|
||||
assertThat(options.createBackups()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test matchesInclude with no patterns (matches all).
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testMatchesInclude_noPatterns_matchesAll()
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of(), List.of(), false, true);
|
||||
|
||||
assertThat(options.matchesInclude("any/file.txt")).isTrue();
|
||||
assertThat(options.matchesInclude("path/to/file.java")).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test matchesInclude with simple glob patterns.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testMatchesInclude_simpleGlob()
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of("*.java"), List.of(), false, true);
|
||||
|
||||
assertThat(options.matchesInclude("App.java")).isTrue();
|
||||
assertThat(options.matchesInclude("file.txt")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test matchesInclude with double-star glob patterns.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testMatchesInclude_doubleStarGlob()
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of("src/**/*.java"), List.of(), false, true);
|
||||
|
||||
assertThat(options.matchesInclude("src/main/App.java")).isTrue();
|
||||
assertThat(options.matchesInclude("src/test/pkg/Test.java")).isTrue();
|
||||
assertThat(options.matchesInclude("lib/App.java")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test matchesExclude with no patterns (excludes none).
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testMatchesExclude_noPatterns_excludesNone()
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of(), List.of(), false, true);
|
||||
|
||||
assertThat(options.matchesExclude("any/file.txt")).isFalse();
|
||||
assertThat(options.matchesExclude("path/to/file.java")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test matchesExclude with patterns.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testMatchesExclude_withPatterns()
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of(), List.of("*.bak", "**/.git/**"), false, true);
|
||||
|
||||
assertThat(options.matchesExclude("file.bak")).isTrue();
|
||||
assertThat(options.matchesExclude("dir/.git/config")).isTrue();
|
||||
assertThat(options.matchesExclude("file.txt")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test pattern matching with question mark wildcard.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testMatchesInclude_questionMarkWildcard()
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of("file?.txt"), List.of(), false, true);
|
||||
|
||||
assertThat(options.matchesInclude("file1.txt")).isTrue();
|
||||
assertThat(options.matchesInclude("fileA.txt")).isTrue();
|
||||
assertThat(options.matchesInclude("file.txt")).isFalse();
|
||||
assertThat(options.matchesInclude("file12.txt")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test multiple include patterns (any match succeeds).
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testMatchesInclude_multiplePatterns()
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of("*.java", "*.xml"), List.of(), false, true);
|
||||
|
||||
assertThat(options.matchesInclude("App.java")).isTrue();
|
||||
assertThat(options.matchesInclude("pom.xml")).isTrue();
|
||||
assertThat(options.matchesInclude("file.txt")).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* All Rights Reserved
|
||||
*
|
||||
* Copyright (c) 2025. QRunIO. Contact: contact@qrun.io
|
||||
*
|
||||
* THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL.
|
||||
* UNAUTHORIZED COPYING, TRANSFERRING, OR REPRODUCTION OF ANY PART OF THIS PROJECT, VIA ANY MEDIUM, IS STRICTLY PROHIBITED.
|
||||
*
|
||||
* The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them
|
||||
* for any purpose other than the purpose for which they were provided to you.
|
||||
*/
|
||||
|
||||
package io.qrun.qctl.qqq.template;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* Unit tests for MergeStrategy.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*******************************************************************************/
|
||||
class MergeStrategyTest
|
||||
{
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private ConsoleUI ui;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Set up test fixtures.
|
||||
*
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
ui = new ConsoleUI();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test determineAction with merge disabled always returns WRITE.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDetermineAction_mergeDisabled_alwaysWrites() throws IOException
|
||||
{
|
||||
MergeOptions options = MergeOptions.disabled();
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path existingFile = tempDir.resolve("existing.txt");
|
||||
Files.writeString(existingFile, "existing content");
|
||||
|
||||
MergeStrategy.MergeAction action = strategy.determineAction("existing.txt", existingFile, "new content");
|
||||
assertThat(action).isEqualTo(MergeStrategy.MergeAction.WRITE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test determineAction with new file returns WRITE.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDetermineAction_newFile_writes() throws IOException
|
||||
{
|
||||
MergeOptions options = MergeOptions.defaults();
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path newFile = tempDir.resolve("new.txt");
|
||||
|
||||
MergeStrategy.MergeAction action = strategy.determineAction("new.txt", newFile, "content");
|
||||
assertThat(action).isEqualTo(MergeStrategy.MergeAction.WRITE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test determineAction with existing file and exclude pattern returns SKIP.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDetermineAction_excludePattern_skips() throws IOException
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of(), List.of("*.bak"), false, true);
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path bakFile = tempDir.resolve("file.bak");
|
||||
Files.writeString(bakFile, "backup content");
|
||||
|
||||
MergeStrategy.MergeAction action = strategy.determineAction("file.bak", bakFile, "new");
|
||||
assertThat(action).isEqualTo(MergeStrategy.MergeAction.SKIP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test determineAction with existing file and no include patterns skips.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDetermineAction_existingFile_noInclude_skips() throws IOException
|
||||
{
|
||||
MergeOptions options = MergeOptions.defaults();
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path existingFile = tempDir.resolve("existing.txt");
|
||||
Files.writeString(existingFile, "existing content");
|
||||
|
||||
MergeStrategy.MergeAction action = strategy.determineAction("existing.txt", existingFile, "new");
|
||||
assertThat(action).isEqualTo(MergeStrategy.MergeAction.SKIP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test determineAction with include pattern and non-interactive overwrites.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDetermineAction_includePattern_overwrites() throws IOException
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of("*.java"), List.of(), false, true);
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path javaFile = tempDir.resolve("App.java");
|
||||
Files.writeString(javaFile, "old code");
|
||||
|
||||
MergeStrategy.MergeAction action = strategy.determineAction("App.java", javaFile, "new code");
|
||||
assertThat(action).isEqualTo(MergeStrategy.MergeAction.OVERWRITE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test determineAction skips file not matching include pattern.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testDetermineAction_includePattern_noMatch_skips() throws IOException
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of("*.java"), List.of(), false, true);
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path txtFile = tempDir.resolve("readme.txt");
|
||||
Files.writeString(txtFile, "readme content");
|
||||
|
||||
MergeStrategy.MergeAction action = strategy.determineAction("readme.txt", txtFile, "new readme");
|
||||
assertThat(action).isEqualTo(MergeStrategy.MergeAction.SKIP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test createBackup creates backup file with timestamp.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testCreateBackup_createsBackupFile() throws IOException
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of(), List.of(), false, true);
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path originalFile = tempDir.resolve("original.txt");
|
||||
Files.writeString(originalFile, "original content");
|
||||
|
||||
strategy.createBackup(originalFile);
|
||||
|
||||
long backupCount = Files.list(tempDir)
|
||||
.filter(p -> p.getFileName().toString().contains(".bak"))
|
||||
.count();
|
||||
assertThat(backupCount).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test createBackup with backups disabled does nothing.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testCreateBackup_backupsDisabled_noBackup() throws IOException
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of(), List.of(), false, false);
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path originalFile = tempDir.resolve("original.txt");
|
||||
Files.writeString(originalFile, "original content");
|
||||
|
||||
strategy.createBackup(originalFile);
|
||||
|
||||
long backupCount = Files.list(tempDir)
|
||||
.filter(p -> p.getFileName().toString().contains(".bak"))
|
||||
.count();
|
||||
assertThat(backupCount).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Test createBackup with non-existent file does nothing.
|
||||
*
|
||||
* @throws IOException if file operations fail
|
||||
* @since 0.2.0
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void testCreateBackup_nonExistentFile_noBackup() throws IOException
|
||||
{
|
||||
MergeOptions options = new MergeOptions(true, List.of(), List.of(), false, true);
|
||||
MergeStrategy strategy = new MergeStrategy(options, ui);
|
||||
|
||||
Path nonExistent = tempDir.resolve("nonexistent.txt");
|
||||
|
||||
strategy.createBackup(nonExistent);
|
||||
|
||||
long fileCount = Files.list(tempDir).count();
|
||||
assertThat(fileCount).isEqualTo(0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user