23 Commits
Author SHA1 Message Date
james.maes 25927df071 Merge develop into main for v0.2.0 release 2026-01-04 19:22:05 -06:00
james.maes 8ac4fdf2e1 feat(core): add doctor command and dependency pre-flight checks
- Add `qctl doctor` command to check Java, Maven, Git, Docker
- Add pre-flight dependency checks before running post-gen hooks
- Cross-platform support (Windows/Linux/macOS)
2026-01-04 19:13:29 -06:00
james.maes 386e6caecf fix: read version from properties file for native image compatibility 2026-01-04 18:09:38 -06:00
james.maes 04914eaf2d docs: update documentation for E2E testing infrastructure
- Add testing section to CLAUDE.md and README.md
- Update architecture.md with Testing Architecture section
- Create docs/testing.md with fixture and harness documentation
- Document __VARNAME__ path syntax for CI compatibility
2026-01-04 17:30:24 -06:00
james.maes 73c271c201 feat(qqq): add structured error handling with suggestions
Add TemplateRenderException constructors for undefined variable errors
with suggestions. Refactor InitCommand/ListCommand to return Integer
exit codes. Fix TransformExecutor and TemplateManifest for schema v2.
2026-01-04 12:12:08 -06:00
james.maes bc93243f6b fix(template): use __VAR__ syntax in paths for CI compatibility
Rename fixture directories from $packagePath to __packagePath__ to
prevent shell/Maven interpretation. TemplateEngine now converts
__VARNAME__ to $VARNAME before Velocity rendering.
2026-01-04 12:07:10 -06:00
james.maes a2ec447003 test(integration): add comprehensive E2E tests for init command
Add 35 tests covering variable substitution, computed variables,
directory transforms, merge mode, and edge cases. Includes test
harness and 8 fixture templates for offline testing.
2026-01-04 11:57:42 -06:00
james.maes abc118dbe5 feat(qqq): add --merge mode for selective regeneration
Implements Story #26 with include/exclude patterns, interactive
conflict resolution, and file backups.

Closes #26
2026-01-03 21:21:40 -06:00
james.maes 5005b8e5fa docs: update session state for story #25 completion 2026-01-03 21:11:42 -06:00
james.maes 30e124dde5 feat(qqq): add structured error handling with suggestions
- Add SuggestionEngine using Levenshtein distance for fuzzy matching
- Add TemplateError with categories mapped to exit codes
- Add ErrorFormatter for consistent ANSI-colored output
- Integrate did-you-mean suggestions in template commands
- Add --verbose flag for stack trace output
2026-01-03 21:10:32 -06:00
james.maes daea5e66b3 docs: update session state for Story #24 completion 2026-01-03 21:01:54 -06:00
james.maes a2e85f7f93 feat(qqq): add prompt validation with retry loop (#24)
- Add PromptValidation record for validation rules (pattern, length, range, enum)
- Add PromptValidator with support for required, pattern, length, range, and enum validation
- Update PromptRunner to validate and re-prompt on failure
- Validate --var CLI overrides, fallback to interactive prompt if invalid
- Add ConsoleUI methods for validation feedback (error, success, warning)
- Add 20 unit tests for PromptValidator

Closes #24
2026-01-03 21:00:41 -06:00
james.maes e14565a277 feat(qqq): add TemplateRegistry abstraction for Voyage mock API
Story #23 - prepares for Voyage API integration:
- TemplateRegistry interface for template listing/download
- GitHubTemplateRegistry wraps existing TemplatesHub
- MockTemplateRegistry with embedded template data
- ListCommand/InitCommand refactored to use registry
- Added --refresh flag to bypass cache
2026-01-03 20:52:43 -06:00
james.maes 5a967f072f feat(qqq): implement Velocity template engine with computed vars and transforms
Stories #18-21 complete:
- Apache Velocity replaces Handlebars for QQQ consistency
- StringTool with $str.camel(), pascal(), replace() helpers
- Computed variables ($str.replace($packageName, '.', '/'))
- Transform executor for rename/delete operations
- Manifest schema v2 with minimumQctlVersion check
2026-01-03 20:45:06 -06:00
james.maes a71af51f47 feat: add native-image reflection for Velocity and verbatim file handling 2026-01-03 20:42:36 -06:00
james.maes 1380b18652 docs: update CLAUDE.md and add session state for continuity 2025-12-31 08:28:31 -06:00
james.maes 334133af71 fix: remove accidentally committed binary artifacts 2025-12-31 08:24:37 -06:00
KofTwentyTwo a7abd429fe chore(aur): update PKGBUILD to v0.0.2 2025-12-31 08:12:59 -06:00
KofTwentyTwo 932f4d8e34 chore(scoop): update manifest to v0.0.2 2025-12-31 08:12:42 -06:00
james.maes 674adedcba fix(ci): use macos-15 for Intel builds 2025-12-31 07:41:00 -06:00
james.maes 18a3377ea7 fix(ci): repair broken release workflow
- Move native build to profile (fixes -Pnative flag)
- Fix Docker artifact download paths
- Split package updates: Homebrew to tap, Scoop/AUR via PRs
- Fix SHA replacement to work on subsequent releases
- Skip native builds on PRs for faster CI
2025-12-31 07:25:06 -06:00
james.maes 44ecc8793d docs: Standardize README 2025-12-24 14:21:42 -06:00
james.maes 67478867d9 ci: Add GitHub release configuration 2025-12-24 12:44:44 -06:00
87 changed files with 12160 additions and 371 deletions

No files matched your search

+22 -1
View File
@@ -28,7 +28,28 @@
"Bash(gh issue create:*)",
"Bash(gh issue list:*)",
"Bash(gh api:*)",
"Bash(gh issue edit:*)"
"Bash(gh issue edit:*)",
"Bash(gh repo create:*)",
"Bash(git branch:*)",
"Bash(git tag:*)",
"Bash(JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean compile:*)",
"Bash(gh release view:*)",
"Bash(gh release download:*)",
"Bash(shasum:*)",
"Bash(brew untap:*)",
"Bash(brew tap:*)",
"Bash(brew install:*)",
"Bash(qctl:*)",
"Bash(qctl --help:*)",
"Bash(git checkout:*)",
"Bash(cat:*)",
"Bash(gh secret list:*)",
"Bash(gh secret set:*)",
"Bash(gh run watch:*)",
"Bash(gh pr list:*)",
"Bash(gh pr merge:*)",
"Bash(git pull:*)",
"Bash(git stash:*)"
]
}
}
+31
View File
@@ -0,0 +1,31 @@
# GitHub Release Configuration
# Auto-generates release notes from PR labels
changelog:
exclude:
labels:
- ignore-for-release
- duplicate
- invalid
authors:
- dependabot
- dependabot[bot]
categories:
- title: Breaking Changes
labels:
- breaking-change
- breaking
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Dependencies
labels:
- dependencies
- title: Other Changes
labels:
- "*"
+150 -41
View File
@@ -32,24 +32,20 @@ jobs:
build-native:
needs: test
if: github.event_name == 'push'
strategy:
matrix:
include:
- os: ubuntu-latest
artifact: qctl-linux-amd64
arch: amd64
- os: ubuntu-24.04-arm
artifact: qctl-linux-arm64
arch: arm64
- os: macos-15
artifact: qctl-macos-amd64
arch: amd64
- os: macos-14
artifact: qctl-macos-arm64
arch: arm64
- os: windows-latest
artifact: qctl-windows-amd64.exe
arch: amd64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
@@ -97,10 +93,16 @@ jobs:
with:
path: artifacts
- name: Flatten artifacts
run: |
mkdir -p release
find artifacts -type f -exec mv {} release/ \;
ls -la release/
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: artifacts/**/*
files: release/*
generate_release_notes: true
docker:
@@ -113,19 +115,23 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download Linux artifacts
- name: Download Linux AMD64 artifact
uses: actions/download-artifact@v4
with:
pattern: qctl-linux-*
name: qctl-linux-amd64
path: .
- name: Download Linux ARM64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-arm64
path: .
merge-multiple: true
- name: Prepare binaries
run: |
mv qctl-linux-amd64/qctl-linux-amd64 qctl-linux-amd64.bin
mv qctl-linux-arm64/qctl-linux-arm64 qctl-linux-arm64.bin
mv qctl-linux-amd64.bin qctl-linux-amd64
mv qctl-linux-arm64.bin qctl-linux-arm64
chmod +x qctl-linux-amd64 qctl-linux-arm64
# Dockerfile expects qctl-linux-amd64 and qctl-linux-arm64
ls -la qctl-linux-*
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -148,23 +154,24 @@ jobs:
ghcr.io/qrun-io/qctl:${{ github.ref_name }}
ghcr.io/qrun-io/qctl:latest
update-packages:
update-homebrew:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- name: Download artifacts
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Update package manifests
- name: Checkout homebrew-qctl tap
uses: actions/checkout@v4
with:
repository: QRun-IO/homebrew-qctl
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: homebrew-tap
- name: Update Homebrew formula
env:
VERSION: ${{ github.ref_name }}
run: |
@@ -172,30 +179,132 @@ jobs:
SHA_MACOS_ARM64=$(shasum -a 256 artifacts/qctl-macos-arm64/qctl-macos-arm64 | cut -d' ' -f1)
SHA_MACOS_AMD64=$(shasum -a 256 artifacts/qctl-macos-amd64/qctl-macos-amd64 | cut -d' ' -f1)
SHA_LINUX_AMD64=$(shasum -a 256 artifacts/qctl-linux-amd64/qctl-linux-amd64 | cut -d' ' -f1)
SHA_LINUX_ARM64=$(shasum -a 256 artifacts/qctl-linux-arm64/qctl-linux-arm64 | cut -d' ' -f1)
SHA_WINDOWS=$(shasum -a 256 artifacts/qctl-windows-amd64.exe/qctl-windows-amd64.exe | cut -d' ' -f1)
SHA_LINUX_AMD64=$(shasum -a 256 artifacts/qctl-linux-amd64/qctl-linux-amd64 | cut -d' ' -f1)
# Update Homebrew
sed -i "s/version \".*\"/version \"${VERSION}\"/" packaging/homebrew/qctl.rb
sed -i "s/PLACEHOLDER_ARM64_SHA256/${SHA_MACOS_ARM64}/" packaging/homebrew/qctl.rb
sed -i "s/PLACEHOLDER_AMD64_SHA256/${SHA_MACOS_AMD64}/" packaging/homebrew/qctl.rb
sed -i "s/PLACEHOLDER_LINUX_AMD64_SHA256/${SHA_LINUX_AMD64}/" packaging/homebrew/qctl.rb
sed -i "s/PLACEHOLDER_LINUX_ARM64_SHA256/${SHA_LINUX_ARM64}/" packaging/homebrew/qctl.rb
FORMULA="homebrew-tap/Formula/qctl.rb"
# Update Scoop
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" packaging/scoop/qctl.json
sed -i "s|/v[0-9.]*\/|/v${VERSION}/|g" packaging/scoop/qctl.json
sed -i "s/PLACEHOLDER_WINDOWS_SHA256/${SHA_WINDOWS}/" packaging/scoop/qctl.json
# Update version
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
# Update AUR
sed -i "s/pkgver=.*/pkgver=${VERSION}/" packaging/aur/PKGBUILD
sed -i "s/PLACEHOLDER_LINUX_SHA256/${SHA_LINUX_AMD64}/" packaging/aur/PKGBUILD
# Update SHA256 hashes in order: macos-arm64, macos-amd64, linux-arm64, linux-amd64
awk -v v1="$SHA_MACOS_ARM64" -v v2="$SHA_MACOS_AMD64" -v v3="$SHA_LINUX_ARM64" -v v4="$SHA_LINUX_AMD64" '
BEGIN { n = 0 }
/sha256/ {
n++
if (n == 1) sub(/sha256 "[^"]*"/, "sha256 \"" v1 "\"")
else if (n == 2) sub(/sha256 "[^"]*"/, "sha256 \"" v2 "\"")
else if (n == 3) sub(/sha256 "[^"]*"/, "sha256 \"" v3 "\"")
else if (n == 4) sub(/sha256 "[^"]*"/, "sha256 \"" v4 "\"")
}
{ print }
' "$FORMULA" > "$FORMULA.tmp" && mv "$FORMULA.tmp" "$FORMULA"
- name: Commit package updates
- name: Commit and push
run: |
cd homebrew-tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packaging/homebrew/qctl.rb packaging/scoop/qctl.json packaging/aur/PKGBUILD
git commit -m "chore(packages): update manifests to ${GITHUB_REF_NAME}"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/qctl.rb
git diff --staged --quiet || git commit -m "Update qctl to ${GITHUB_REF_NAME}"
git push
update-scoop:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- name: Download Windows artifact
uses: actions/download-artifact@v4
with:
name: qctl-windows-amd64.exe
path: artifacts
- name: Update Scoop manifest
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#v}"
SHA_WINDOWS=$(shasum -a 256 artifacts/qctl-windows-amd64.exe | cut -d' ' -f1)
MANIFEST="packaging/scoop/qctl.json"
# Update version
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" "$MANIFEST"
# Update download URL version
sed -i "s|/download/v[^/]*/|/download/v${VERSION}/|g" "$MANIFEST"
# Update hash
sed -i "s/\"hash\": \"[^\"]*\"/\"hash\": \"${SHA_WINDOWS}\"/" "$MANIFEST"
- name: Clean up artifacts
run: rm -rf artifacts
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore(scoop): update manifest to ${{ github.ref_name }}"
title: "chore(scoop): update manifest to ${{ github.ref_name }}"
body: "Automated Scoop manifest update for release ${{ github.ref_name }}"
branch: chore/scoop-${{ github.ref_name }}
base: develop
add-paths: packaging/scoop/qctl.json
update-aur:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: develop
- name: Download Linux AMD64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-amd64
path: artifacts
- name: Update AUR PKGBUILD
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#v}"
SHA_LINUX=$(shasum -a 256 artifacts/qctl-linux-amd64 | cut -d' ' -f1)
PKGBUILD="packaging/aur/PKGBUILD"
# Update version
sed -i "s/pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
# Reset pkgrel for new version
sed -i "s/pkgrel=.*/pkgrel=1/" "$PKGBUILD"
# Update hash
sed -i "s/sha256sums_x86_64=('[^']*')/sha256sums_x86_64=('${SHA_LINUX}')/" "$PKGBUILD"
- name: Clean up artifacts
run: rm -rf artifacts
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore(aur): update PKGBUILD to ${{ github.ref_name }}"
title: "chore(aur): update PKGBUILD to ${{ github.ref_name }}"
body: "Automated AUR PKGBUILD update for release ${{ github.ref_name }}"
branch: chore/aur-${{ github.ref_name }}
base: develop
add-paths: packaging/aur/PKGBUILD
+104 -30
View File
@@ -22,10 +22,10 @@ mvn checkstyle:check
mvn spotless:check
mvn spotless:apply
# Native build (requires GraalVM 21)
JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean package -DskipTests
# Native build (requires GraalVM 21 and -Pnative profile)
JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn clean package -DskipTests -Pnative
# Output: qctl-cli/target/qctl (native profile is in qctl-cli pom.xml, not a -P flag)
# Output: qctl-cli/target/qctl
# Test version flag
./qctl-cli/target/qctl --version
@@ -35,16 +35,17 @@ JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-21.jdk/Contents/Home mvn cle
```
qctl/
├── qctl-shared # DTOs, ExitCodes, SPI interfaces, utilities
├── qctl-core # Config, HTTP client, auth, cache, logging, Main entrypoint
├── qctl-qqq # Template scaffolding (init, list) with Handlebars
├── qctl-qbit # Package management commands, lockfile handling
├── qctl-qrun # OCI packaging and deployment commands
├── qctl-qstudio # AI planning commands (offline V1)
├── qctl-cli # Aggregator for native image build (only module with native profile)
├── docs/ # Architecture and design documentation
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
└── codestyle/ # Checkstyle config and license headers
├── qctl-shared # DTOs, ExitCodes, SPI interfaces, utilities
├── qctl-core # Config, HTTP client, auth, cache, logging, Main entrypoint
├── qctl-qqq # Template scaffolding (init, list) with Velocity
├── qctl-qbit # Package management commands, lockfile handling
├── qctl-qrun # OCI packaging and deployment commands
├── qctl-qstudio # AI planning commands (offline V1)
├── qctl-cli # Aggregator for native image build (has native profile)
├── qctl-integration-tests # E2E and integration tests with fixture templates
├── docs/ # Architecture and design documentation
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
└── codestyle/ # Checkstyle config and license headers
```
## Architecture
@@ -53,13 +54,13 @@ qctl/
**CLI Framework**: Picocli with annotation-based commands. Main entrypoint in `qctl-core/Main.java`.
**Template System**: Templates fetched from [QRun-IO/templates-hub](https://github.com/QRun-IO/templates-hub). Handlebars for rendering with custom helpers (camelCase, pascalCase, etc.).
**Template System**: Templates fetched from [QRun-IO/templates-hub](https://github.com/QRun-IO/templates-hub). Apache Velocity for rendering with StringTool helpers (`$str.kebab()`, `$str.pascal()`, `$str.replace()`). Supports computed variables and transforms. Path variables use `__VARNAME__` or `$VARNAME` syntax.
**Interactive Commands**: All console I/O goes through `ConsoleUI` class (`qctl-qqq/template/ConsoleUI.java`). This separates UI from logic. Commands use `ConsoleUI` for prompts, styled output, and selections.
**Interactive Commands**: All console I/O goes through `ConsoleUI` class (`qctl-qqq/template/ConsoleUI.java`). This separates UI from logic.
**Version Info**: `VersionProvider` reads from `version.properties` (Maven-filtered). Shows version, build timestamp, and git commit hash.
**Version Info**: `VersionProvider` reads from `version.properties` (Maven-filtered). Shows version, build timestamp, and git commit hash. Uses `${revision}` property for CI-friendly versioning.
**Native Image**: GraalVM 21 with reflection config for Jackson records and Handlebars resources.
**Native Image**: GraalVM 21 with reflection config. Native build is in a Maven profile (`-Pnative`) in `qctl-cli/pom.xml`.
## Code Style
@@ -90,28 +91,101 @@ Flowerbox Javadoc:
- Linux: `$XDG_CONFIG_HOME/qctl/qctl.yaml`
- Windows: `%APPDATA%\qctl\qctl.yaml`
## CI/CD Pipeline
Workflow: `.github/workflows/build.yml`
**Trigger**: Push to `main`/`develop`, tags `v*`, PRs
**Jobs**:
1. `test` - Run `mvn clean verify`
2. `build-native` - Build native images for 5 platforms (only on push, not PRs)
3. `release` - Create GitHub release with artifacts (only on tags)
4. `docker` - Build/push multi-arch Docker image to GHCR
5. `update-homebrew` - Push to `QRun-IO/homebrew-qctl` tap
6. `update-scoop` - Create PR for Scoop manifest update
7. `update-aur` - Create PR for AUR PKGBUILD update
**Build Matrix**:
| Platform | Runner | Artifact |
|----------|--------|----------|
| Linux x64 | ubuntu-latest | qctl-linux-amd64 |
| Linux ARM64 | ubuntu-24.04-arm | qctl-linux-arm64 |
| macOS Intel | macos-15 | qctl-macos-amd64 |
| macOS ARM | macos-14 | qctl-macos-arm64 |
| Windows x64 | windows-latest | qctl-windows-amd64.exe |
**Required Secrets**:
- `HOMEBREW_TAP_TOKEN` - PAT with repo scope for homebrew tap
**Release Process**:
```bash
git tag -a v0.0.3 -m "Release v0.0.3"
git push origin v0.0.3
```
## Distribution
On tagged release (`git tag v1.0.0 && git push origin v1.0.0`):
| Channel | Location | Update Method |
|---------|----------|---------------|
| Homebrew | `QRun-IO/homebrew-qctl` | Direct push |
| Scoop | `packaging/scoop/qctl.json` | PR to develop |
| AUR | `packaging/aur/PKGBUILD` | PR to develop |
| Docker | `ghcr.io/qrun-io/qctl` | Direct push |
| GitHub | Releases | Auto-created |
| Channel | Location | Auto-Updated |
|---------|----------|--------------|
| Homebrew | `packaging/homebrew/qctl.rb` | Yes |
| Scoop | `packaging/scoop/qctl.json` | Yes |
| AUR | `packaging/aur/PKGBUILD` | Yes |
| Docker | `packaging/docker/Dockerfile` | Yes |
| GitHub | Releases | Yes |
**Install**:
```bash
# Homebrew
brew tap QRun-IO/qctl && brew install qctl
**Platforms**: Linux (x64, ARM64), macOS (Intel, Apple Silicon), Windows (x64)
# Docker
docker run ghcr.io/qrun-io/qctl --help
```
## Native Image Notes
- Only `qctl-cli` builds native image (other modules removed native profile)
- Handlebars requires `--initialize-at-run-time=com.github.jknack.handlebars.helper.DefaultHelperRegistry`
- Native profile is in `qctl-cli/pom.xml` (activated with `-Pnative`)
- Velocity requires runtime initialization for template parsing
- Jackson records need reflection config in `META-INF/native-image/reflect-config.json`
- Resources config in `META-INF/native-image/resource-config.json`
## Testing
- JUnit 5 + AssertJ + Mockito
- Run: `mvn test` or `mvn verify`
**Frameworks**: JUnit 5 + AssertJ + Mockito
**Run Tests**:
```bash
mvn test # Unit tests only
mvn verify # Unit + integration tests
mvn test -pl qctl-integration-tests # E2E tests only
```
**Test Modules**:
- Unit tests in each module's `src/test/java/`
- Integration tests in `qctl-integration-tests/`
**E2E Test Infrastructure** (`qctl-integration-tests/`):
- `CommandTestHarness` - In-process command execution with stdout/stderr capture
- `TestTemplateRegistry` - Fixture-based template registry for offline testing
- `TestableConsoleUI` - Simulated user input for interactive prompts
- `ExitCodeAssertions` - Fluent assertions for exit codes
**Test Fixtures** (`src/test/resources/fixtures/templates/`):
| Fixture | Purpose |
|---------|---------|
| `test-minimal` | Basic template with no prompts |
| `test-with-prompts` | Template with interactive prompts |
| `test-full-substitution` | Tests variable replacement in content |
| `test-computed-vars` | Tests computed variables (packagePath) |
| `test-transforms` | Tests delete transforms |
| `test-variable-typo` | Tests "Did you mean?" suggestions |
**Path Variable Syntax**:
- Use `__VARNAME__` in directory names (converted to `$VARNAME` at runtime)
- Avoids shell/Maven interpretation of `$` characters
- Example: `template/src/main/java/__packagePath__/App.java`
## Session Continuity
To continue from last session, see `docs/internal/session-state.md` for current progress and next steps.
+30 -11
View File
@@ -147,24 +147,31 @@ Templates are managed in [QRun-IO/templates-hub](https://github.com/QRun-IO/temp
```
qctl/
├── qctl-cli/ # Native image entry point (aggregates all modules)
├── qctl-core/ # Core commands (auth, cache)
├── qctl-shared/ # Shared utilities and constants
├── qctl-qqq/ # Scaffolding commands (init, list)
├── qctl-qbit/ # Package management commands
├── qctl-qrun/ # Deployment commands
├── qctl-qstudio/ # Planning commands
├── docs/ # Architecture and design documentation
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
└── codestyle/ # Checkstyle config and license headers
├── qctl-cli/ # Native image entry point (aggregates all modules)
├── qctl-core/ # Core commands (auth, cache)
├── qctl-shared/ # Shared utilities and constants
├── qctl-qqq/ # Scaffolding commands (init, list)
├── qctl-qbit/ # Package management commands
├── qctl-qrun/ # Deployment commands
├── qctl-qstudio/ # Planning commands
├── qctl-integration-tests/ # E2E tests with fixture templates
├── docs/ # Architecture and design documentation
├── packaging/ # Distribution manifests (Homebrew, Scoop, AUR, Docker)
└── codestyle/ # Checkstyle config and license headers
```
## Development
```bash
# Run tests
# Run all tests
mvn clean verify
# Run unit tests only
mvn test
# Run E2E tests only
mvn test -pl qctl-integration-tests
# Build native image (requires GraalVM)
JAVA_HOME=/path/to/graalvm mvn clean package -DskipTests -Pnative
@@ -173,6 +180,18 @@ mvn clean package -DskipTests
java -jar qctl-cli/target/qctl-cli-0.1.0-SNAPSHOT.jar --help
```
### Testing
The project includes comprehensive E2E tests in `qctl-integration-tests/`:
| Test Class | Coverage |
|------------|----------|
| `InitCommandE2ETest` | CLI flags and basic init behavior |
| `InitCommandFullE2ETest` | Variable substitution, computed vars, transforms |
| `NativeBinaryE2ETest` | Native binary smoke tests (requires build) |
Test fixtures in `src/test/resources/fixtures/templates/` provide offline, deterministic testing.
## Releasing
Create a tagged release to trigger the full CI/CD pipeline:
+363
View File
@@ -0,0 +1,363 @@
# qctl Roadmap
## Vision
qctl is the unified CLI for QRun platform developers. It enables the complete developer lifecycle: create projects, manage dependencies, build/deploy applications, and operate running services.
## Core Integration
**Voyage** - The QRun platform backend that qctl integrates with for:
- Template registry (qqq)
- Package registry (qbit)
- Artifact storage & deployment (qrun)
- Service orchestration & observability (qrun)
---
## User Journeys
### Journey 1: Create a New QQQ Application
**Goal:** Developer creates a new QQQ application from a template and has a running local dev environment.
**User Flow:**
```bash
# Step 1: See available templates
$ qctl qqq list
Available templates:
qqq-web-api Web API application with Javalin middleware
qqq-cli-tool Command-line tool with Picocli
qqq-batch Batch processing application
qqq-full-stack Full-stack with React frontend
# Step 2: Initialize a new project
$ qctl qqq init qqq-web-api my-app
? Java package [com.example.myapp]: com.acme.orders
? Maven groupId [com.acme]: com.acme
? Maven artifactId [my-app]: orders-service
? Application name [myApp]: ordersApp
? Application label [My App]: Orders Service
Creating project from template: qqq-web-api
Cloning template...
Rendering files with Velocity...
Moving src/main/java/com/example/orders → src/main/java/com/acme/orders
Moving src/test/java/com/example/orders → src/test/java/com/acme/orders
Running post-gen hook: build
> mvn clean verify -DskipTests
[BUILD SUCCESS]
Project created: ./my-app
Next steps:
cd my-app
mvn spring-boot:run
# Step 3: Run locally
$ cd my-app && mvn spring-boot:run
[INFO] Started OrdersApp in 2.3s
```
**Required Capabilities:**
| Capability | Description | Status |
|------------|-------------|--------|
| Template listing | Fetch templates from Voyage (or local cache) | POC - needs Voyage integration |
| Template selection | Interactive or CLI arg | POC - needs polish |
| Prompt collection | Interactive prompts from manifest | POC - needs validation |
| CLI var overrides | `--var key=value` for non-interactive | POC - needs testing |
| Velocity rendering | Render file contents with Velocity | **TODO** (currently Handlebars) |
| Path rendering | Render file/dir names with variables | POC - switch to Velocity |
| Computed variables | Derive `packagePath` from `packageName` | **TODO** |
| Directory transforms | Move directories per manifest rules | **TODO** |
| Post-gen hooks | Run shell commands after render | POC - needs phases |
| Dry-run mode | Show what would be created | POC - needs refinement |
| Force overwrite | `--force` to overwrite existing | POC - needs merge mode |
| Error handling | Clear errors with recovery suggestions | **TODO** |
| Template caching | Cache templates with TTL | **TODO** |
**Design Decision: Template Engine**
**Decision:** Use **Apache Velocity** instead of Handlebars for consistency with QQQ's existing template/report system.
- QQQ already uses Velocity for template rendering
- Template authors familiar with QQQ will know Velocity syntax (`$variable`, `#if...#end`)
- More powerful than Handlebars (full control flow, macros)
- Requires migration from current Handlebars implementation
**Design Decision: Template Transformation**
**Decision:** Hybrid approach with Velocity rendering + structural transformations.
1. **Velocity for content:** Templates use `$packageName`, `$groupId` in source files
2. **Manifest rules for structure:** Define path transformations and directory moves
3. **Computed variables:** Derive `packagePath` from `packageName` automatically
**Template Manifest Example:**
```yaml
name: qqq-web-api
prompts:
- name: packageName
message: "Java package"
default: "com.example.myapp"
- name: groupId
message: "Maven groupId"
default: "com.example"
- name: artifactId
message: "Maven artifactId"
default: "my-qqq-app"
- name: appName
message: "Application name"
default: "myApp"
computed:
packagePath: "${packageName.replace('.', '/')}"
transforms:
- type: move
from: "src/main/java/com/example/orders"
to: "src/main/java/${packagePath}"
- type: move
from: "src/test/java/com/example/orders"
to: "src/test/java/${packagePath}"
postGen:
- name: build
command: "mvn clean verify -DskipTests"
```
---
### Journey 2: Add QBit Dependencies
**Goal:** Developer finds and adds QQQ modules (qbits) to their project.
```bash
# Search for authentication modules
qctl qbit search auth
# Add the SSO qbit to project
qctl qbit add @qrun/auth-sso
# See what's installed
qctl qbit list
# Update to latest versions
qctl qbit update
```
**Required Capabilities:**
- [ ] Search Voyage registry by keyword/tag
- [ ] View qbit details (description, version, dependencies)
- [ ] Add qbit with version constraint (`@qrun/auth-sso@^2.0`)
- [ ] Resolve dependency tree (handle conflicts)
- [ ] Generate/update lockfile (`qbits.lock`)
- [ ] Vendor packages to `vendor/qbits/`
- [ ] Integrity verification (sha512)
---
### Journey 3: Build & Deploy Application
**Goal:** Developer packages their application and deploys it to an environment.
```bash
# Build OCI image
qctl qrun package
# Deploy to dev environment
qctl qrun publish --env dev
# Deploy to production
qctl qrun publish --env prod --channel stable
```
**Required Capabilities:**
- [ ] Build OCI image via Jib
- [ ] Push to Voyage artifact registry
- [ ] Create release record with metadata
- [ ] Deploy to target environment via Voyage
- [ ] Support release channels (dev, beta, stable)
- [ ] Rollback to previous version
---
### Journey 4: Operate Running Services
**Goal:** Developer monitors and manages their deployed applications.
```bash
# Check service status
qctl qrun status --app my-app --env prod
# View logs
qctl qrun logs --app my-app --env prod --follow
# Restart service
qctl qrun restart --app my-app --env prod
# Stop service (maintenance)
qctl qrun stop --app my-app --env prod
# Start service
qctl qrun start --app my-app --env prod
```
**Required Capabilities:**
- [ ] Query Voyage for service status (replicas, health, version)
- [ ] Stream logs from Voyage
- [ ] Stop/start/restart services via Voyage API
- [ ] View deployment history
- [ ] Scale replicas up/down
---
## Milestones
### v0.2.0 - Create & Manage
**Focus:** Complete "Create New Application" and "Add Dependencies" journeys.
**Epic: qqq - Template System**
| Feature | Description |
|---------|-------------|
| Velocity engine | Replace Handlebars with Apache Velocity |
| Computed variables | Auto-derive `packagePath` from `packageName` |
| Directory transforms | Move folders per manifest `transforms` rules |
| Template manifest v2 | Add `computed` and `transforms` sections |
| Migrate templates | Update templates-hub to Velocity syntax |
| Voyage mock integration | Template listing from mock API |
| Prompt validation | Validate required fields, types, constraints |
| Error messages | Clear errors with recovery suggestions |
| Merge mode | `--merge` for selective regeneration |
**Epic: qbit - Package Management**
| Feature | Description |
|---------|-------------|
| qbit search | Search against Voyage mock registry |
| qbit add | Add package with version constraints |
| qbit remove | Remove package, update lockfile |
| qbit update | Update packages to latest |
| qbit list | List installed packages |
| SemVer resolution | Constraint solving (^, ~, exact) |
| Conflict detection | Identify and report version conflicts |
| Vendor layout | Download to `vendor/qbits/` |
| Integrity verification | SHA-512 hash checks |
---
### v0.3.0 - Deploy & Operate
**Focus:** Complete "Deploy Application" and "Operate Services" journeys.
**Epic: qrun - Build & Publish**
| Feature | Description |
|---------|-------------|
| Jib integration | OCI image building |
| qrun package | Build container image |
| qrun publish | Push to Voyage registry |
| OCI labels | Inject standard and custom labels |
| Digest verification | Verify image after push |
| Release channels | stable/beta/alpha support |
| qrun rollback | Rollback to previous version |
**Epic: qrun - Service Operations**
| Feature | Description |
|---------|-------------|
| qrun status | Service status from Voyage |
| qrun logs | Stream logs with --follow |
| qrun stop | Stop service |
| qrun start | Start service |
| qrun restart | Restart service |
| Deployment history | View past deployments |
| qrun scale | Scale replicas up/down |
---
### v1.0.0 - Production Ready
**Focus:** AI planning, security hardening, enterprise features.
**Epic: qstudio - AI Planning**
| Feature | Description |
|---------|-------------|
| Code indexing | Local codebase analysis |
| qstudio plan | Generate implementation plan |
| Path policies | Allow/deny path lists |
| Ledger storage | Track plan history |
**Epic: Authentication & Security**
| Feature | Description |
|---------|-------------|
| OIDC device flow | Full authentication flow |
| Service accounts | CI/CD support |
| Signature enforcement | Require signed packages/images |
| OS keychain | Secure credential storage |
**Epic: Enterprise Features**
| Feature | Description |
|---------|-------------|
| SSO integration | SAML/OIDC enterprise SSO |
| RBAC | Team-based access control |
| Audit logging | Compliance audit trail |
| Org management | Organization/team management |
---
## Voyage Integration
**Voyage** is the QRun platform backend (currently in development). Until Voyage is ready, qctl will:
1. Use mock APIs for development and testing
2. Support local/offline modes where possible
3. Design against Voyage's planned API contracts (standard QQQ data model)
Voyage will provide a versioned API that drives templates, qbits, artifacts, and service orchestration.
## Voyage API Dependencies (Planned)
qctl requires these Voyage API endpoints:
### Templates (qqq)
- `GET /v1/templates` - List available templates
- `GET /v1/templates/{id}` - Get template details
- `GET /v1/templates/{id}/versions/{version}` - Download template
### Packages (qbit)
- `GET /v1/qbits?q={query}` - Search packages
- `GET /v1/qbits/{id}` - Get package details
- `GET /v1/qbits/{id}/versions/{version}` - Download package
### Artifacts (qrun)
- `POST /v1/artifacts` - Register artifact
- `GET /v1/artifacts/{id}` - Get artifact info
- `POST /v1/releases` - Create release
- `POST /v1/deployments` - Deploy to environment
### Services (qrun)
- `GET /v1/apps/{app}/status` - Service status
- `GET /v1/apps/{app}/logs` - Service logs
- `POST /v1/apps/{app}/restart` - Restart service
- `POST /v1/apps/{app}/stop` - Stop service
- `POST /v1/apps/{app}/start` - Start service
- `POST /v1/apps/{app}/scale` - Scale replicas
---
## GitHub Issues Structure
Each milestone will have:
- 1 Epic issue tracking the user journey
- Story issues for each capability (linked to epic)
- Labels: `epic`, `story`, `module:qqq|qbit|qrun|qstudio`
Example:
```
Epic: [v0.2.0] Create New Application Journey
├── Story: Template registry integration with Voyage
├── Story: Add web-api template
├── Story: Add cli-tool template
├── Story: Add batch-processor template
├── Story: Implement qqq upgrade command
└── Story: Enhanced post-init hooks
```
+66 -28
View File
@@ -72,26 +72,26 @@ qctl
└─────────────────────────────────────────────────────────────────┘
```
## Data Flow: `qctl qqq new`
## Data Flow: `qctl qqq init`
```
User: qctl qqq new voyage --template qqq-app
User: qctl qqq init qqq-app my-project
1. Load config (defaults → global → project → env → flags)
2. Resolve template:
- Check local cache for "qqq-app" template
- If missing/outdated, fetch from templates.qrun.io
- If missing/outdated, fetch from templates-hub
- Verify signature (warn-only in V1)
3. Prompt for variables (or use --non-interactive defaults)
4. Render template via Handlebars:
- Process template.yaml manifest
- Apply variable substitutions
- Copy/transform files to target directory
5. Post-generation hooks:
- Run `build` phase (mvn compile)
- Run `run` phase (start app)
- Run `healthcheck` phase (verify startup)
6. Output success message with next steps
3. Prompt for variables (or use --no-prompt defaults)
4. Evaluate computed variables (e.g., packagePath from packageName)
5. Render template via Apache Velocity:
- Process template.yaml manifest (schema v2)
- Substitute $variables in file content and paths
- Convert __VARNAME__ to $VARNAME in paths (CI-safe syntax)
- Apply transforms (delete patterns)
- Copy rendered files to target directory
6. Post-generation hooks (if defined)
7. Output success message with next steps
```
## Configuration Precedence
@@ -136,21 +136,39 @@ defaults ~/...qctl.yaml ./qctl.yaml QCTL_* --flag
- Streaming logs
- Promote/rollback workflows
## Template Structure (qqq-app)
## Template Structure (new-qqq-application)
```
templates/qqq-app/
├── template.yaml # Manifest with prompts and hooks
├── README.md.hbs # Handlebars template
├── pom.xml.hbs
├── src/
│ └── main/
│ └── java/
│ └── {{packagePath}}/
│ ├── Application.java.hbs
│ └── {{name}}QInstance.java.hbs
└── .qctl/
└── template-version # Tracks template version for upgrades
templates/new-qqq-application/
├── template.yaml # Manifest with prompts, computed vars, transforms
├── template/ # Template files directory
│ ├── README.md # Velocity template with $variables
│ ├── pom.xml # Maven POM with $groupId, $artifactId, etc.
│ └── src/
│ └── main/
│ └── java/
│ └── $packagePath/ # Path variable substitution
│ └── Application.java # Uses $packageName, $projectName
```
**template.yaml Schema v2**:
```yaml
schemaVersion: 2
id: new-qqq-application
name: New QQQ Application
version: 1.0.0
prompts:
- name: packageName
message: Java package name
type: text
defaultValue: com.example
required: true
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
transforms:
- type: delete
pattern: "**/.gitkeep"
```
## Key Files
@@ -162,10 +180,30 @@ templates/qqq-app/
## Technology Stack
- **Language**: Java 21 (Temurin)
- **Language**: Java 21 (Temurin/GraalVM)
- **CLI**: Picocli 4.7.5
- **Config**: Jackson YAML + JSON Schema validation
- **HTTP**: JDK HttpClient with retry/backoff
- **Templates**: Handlebars 4.4.0
- **Templates**: Apache Velocity 2.4.1
- **Build**: Maven 3.9+, GraalVM native-image
- **Test**: JUnit 5, AssertJ, Mockito, Testcontainers
## Testing Architecture
```
qctl-integration-tests/
├── src/test/java/io/qrun/qctl/e2e/
│ ├── InitCommandE2ETest.java # CLI flag tests
│ ├── InitCommandFullE2ETest.java # Full variable/transform tests
│ └── harness/
│ ├── CommandTestHarness.java # In-process command execution
│ ├── TestTemplateRegistry.java # Fixture-based registry
│ └── TestableConsoleUI.java # Simulated user input
└── src/test/resources/fixtures/templates/
├── test-minimal/ # Basic template
├── test-computed-vars/ # Computed variable tests
├── test-full-substitution/ # Variable replacement tests
└── test-transforms/ # Transform operation tests
```
**Path Variable Convention**: Use `__VARNAME__` in directory names for templates. The engine converts this to `$VARNAME` before Velocity processing, avoiding shell/Maven interpretation issues.
+242
View File
@@ -0,0 +1,242 @@
# Session State
Last updated: 2026-01-03
## Current Status
**Stories #18-25 complete.** All qqq template system stories finished except #26 (merge mode). Ready for Story #26 or to start Epic #11 (qbit package management).
## Recently Completed
### Story #25 - Improve Error Messages (2026-01-04)
Implemented structured error handling with recovery suggestions:
- Created `SuggestionEngine` with Levenshtein distance for fuzzy matching
- Created `TemplateError` with categories mapped to exit codes
- Created `ErrorFormatter` for consistent ANSI-colored output
- Added "Did you mean?" suggestions for mistyped template names
- Added `--verbose` flag to show stack traces
- Added 27 unit tests (13 SuggestionEngine, 6 TemplateError, 8 ErrorFormatter)
- Updated InitCommand and ListCommand to use structured errors
### Story #24 - Add Prompt Validation (2026-01-03)
Implemented input validation with retry loop for template prompts:
- Created `PromptValidation` record for validation rules (pattern, length, range, enum)
- Created `PromptValidator` class with support for required, pattern, length, range, and enum validation
- Updated `PromptRunner` to validate input and re-prompt on failure
- Added validation for `--var` CLI overrides with fallback to interactive prompt
- Added `ConsoleUI` methods for validation feedback (error checkmark, success checkmark, warning)
- Added 20 unit tests for PromptValidator
Manifest schema now supports:
```yaml
prompts:
- name: packageName
message: "Java package"
required: true
validation:
pattern: "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*$"
message: "Must be a valid Java package name"
```
### Story #23 - Voyage Mock API Integration (2026-01-03)
Implemented TemplateRegistry abstraction for template management:
- Created `TemplateRegistry` interface with list/get/download/refresh methods
- Created `TemplateInfo` record for template metadata
- Created `MockTemplateRegistry` with embedded template data
- Created `GitHubTemplateRegistry` wrapping existing TemplatesHub
- Created `TemplateRegistryFactory` for registry selection via env var
- Refactored `ListCommand` to use TemplateRegistry with `--refresh` flag
- Refactored `InitCommand` to use TemplateRegistry
- Added 13 unit tests for registry classes
- Added native-image reflection config for TemplateInfo
### Story #22 - Migrate templates-hub to Velocity (2026-01-03)
Completed migration of new-qqq-application-template:
- Restructured template repo with `template/` subdirectory
- Converted all Java files to Velocity syntax (`$variable`, `${variable}`)
- Fixed pom.xml with literal blocks (`#[[${maven.prop}]]#`) for Maven properties
- Added native-image reflection config for Velocity runtime classes
- Implemented verbatim file handling for shell scripts (mvnw, gradlew)
- Added Maven property detection to skip validation for `${prop.name}` patterns
- All changes pushed to both template repo and templates-hub
### Story #21 - Template Manifest Schema v2 (2026-01-03)
Updated manifest schema to v2 with backward compatibility:
- Added `schemaVersion` field (Integer) to TemplateManifest and TemplateEntry
- Added `minimumQctlVersion` field with version check in InitCommand
- Added `required` field (Boolean) to Prompt record
- Added `phase` field (String) to PostGenHook record
- Added `getEffectiveSchemaVersion()` helper method (defaults to 1)
- Added `isVersionSatisfied()` and `parseVersion()` helpers in InitCommand
- Updated TemplateEngineTest constructor calls (9 → 11 params)
Simplified scope (per user agreement):
- Deferred `prompts[].validation` to Story #24
- Deferred conditional fields to future story
### Story #20 - Directory Transforms (2026-01-03)
Implemented post-render file transforms for template processing:
- Created `Transform.java` record with type, pattern, replacement fields
- Created `TransformExecutor.java` for rename/delete operations using glob patterns
- Added `transforms` field to `TemplateManifest` and `TemplatesHub.TemplateEntry`
- Integrated transform execution in `InitCommand.java` after rendering
- Added 13 tests for TransformExecutor
Simplified scope (per user agreement):
- Only `rename` and `delete` transforms (dropped `move` as redundant with path variables)
- Glob pattern matching with `${varName}` substitution in replacement strings
Manifest schema now supports:
```yaml
transforms:
- type: rename
pattern: "**/*Template*.java"
replacement: "${appClassName}.java"
- type: delete
pattern: "**/.gitkeep"
```
### Story #19 - Computed Variables (2026-01-04)
Implemented computed variable support for template manifests:
- Added `replace()` method to `StringTool.java` for string substitution
- Created `ComputedVariable.java` record for manifest definitions
- Added `computed` field to `TemplateManifest` and `TemplatesHub.TemplateEntry`
- Added `evaluateComputed()` method to `TemplateEngine.java`
- Updated `InitCommand.java` to evaluate computed vars after prompts
- Added 16 new tests (9 ComputedVariable + 7 StringTool.replace)
Manifest schema now supports:
```yaml
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
- name: appClassName
expression: "$str.pascal($appName)"
```
### Story #18 - Velocity Engine (2026-01-04)
Implemented Apache Velocity template engine replacing Handlebars:
- Created `StringTool.java` with transformation methods ($str.camel(), etc.)
- Created `TemplateRenderException.java` for fail-fast error handling
- Refactored `TemplateEngine.java` to use VelocityEngine
- Added 51 unit tests (15 new + 36 existing)
- Updated native-image reflect-config.json for Velocity classes
### Epic #13-17 Story Details (2026-01-04)
Fleshed out all remaining stories for Epics #13-17:
- **Epic #13** (qrun Build & Publish): #36-42 - Jib integration, OCI packaging, publishing, labels, digest verification, release channels, rollback
- **Epic #14** (qrun Service Operations): #43-49 - Status, logs (SSE streaming), stop, start, restart, deployment history, scale
- **Epic #15** (qstudio AI Planning): #50-53 - Code indexing, plan generation (multi-LLM), path policies, plan ledger
- **Epic #16** (Authentication & Security): #54-57 - OIDC device flow, service accounts, signature verification (Sigstore/PGP), OS keychain integration
- **Epic #17** (Enterprise Features): #58-61 - SSO (SAML/OIDC), RBAC, audit logging, org/team management
### Epic #12 Story Details (2026-01-04)
Fleshed out all 9 stories for Epic #12 (qqq - Template System) with:
- User stories and design specs
- Architecture diagrams (current vs target state)
- Files to create/modify tables
- Implementation task checklists
- Acceptance criteria
Stories updated: #18-26 (Velocity, computed vars, transforms, manifest v2, templates migration, Voyage mock, validation, errors, merge mode)
### Epic #11 Story Details (2026-01-04)
Fleshed out all 9 stories for Epic #11 (qbit - Package Management) with:
- User stories and design specs
- Command interfaces with examples
- Files to create/modify tables
- Implementation task checklists
- Acceptance criteria
Stories updated: #27-35 (search, add, remove, update, list, SemVer, conflicts, vendor, integrity)
### Roadmap & Issue Creation (2026-01-03)
Created comprehensive roadmap and GitHub project structure:
1. **Roadmap document** - `docs/ROADMAP.md` with user journey focus
2. **Milestones** - v0.2.0, v0.3.0, v1.0.0
3. **Epics** - 7 epic issues (#11-#17) with proper labels
4. **Stories** - 44 story issues (#18-#61) linked as sub-issues
5. **Issue types** - Epic for #11-17, Feature for #18-61
6. **Sub-issues** - All stories linked to parent epics
### Epic Summary
| Epic | Milestone | Stories |
|------|-----------|---------|
| #11 qbit - Package Management | v0.2.0 | #27-35 (9) |
| #12 qqq - Template System | v0.2.0 | #18-26 (9) |
| #13 qrun - Build & Publish | v0.3.0 | #36-42 (7) |
| #14 qrun - Service Operations | v0.3.0 | #43-49 (7) |
| #15 qstudio - AI Planning | v1.0.0 | #50-53 (4) |
| #16 Authentication & Security | v1.0.0 | #54-57 (4) |
| #17 Enterprise Features | v1.0.0 | #58-61 (4) |
### Key Design Decisions
- **Template engine**: Apache Velocity (not Handlebars) for QQQ consistency
- **Voyage integration**: Mock APIs for now, real integration when ready
- **User journey focus**: Roadmap organized by concrete use cases
## Current Branch State
- **Branch**: `develop`
- **Clean**: Yes (no uncommitted changes)
## Next Steps / TODO
### v0.2.0 - Create & Manage (Priority)
**Epic #12 - qqq Template System** (in progress):
1. ~~#18 - Replace Handlebars with Apache Velocity~~ DONE
2. ~~#19 - Add computed variables support~~ DONE
3. ~~#20 - Implement directory transforms~~ DONE
4. ~~#21 - Update template manifest schema v2~~ DONE
5. ~~#22 - Migrate templates-hub to Velocity~~ DONE
6. ~~#23 - Integrate template listing with Voyage mock~~ DONE
7. ~~#24 - Add prompt validation~~ DONE
8. ~~#25 - Improve error messages~~ DONE
9. #26 - Implement --merge mode (next)
**Epic #11 - qbit Package Management** (after qqq):
1. #27 - qbit search command
2. #28 - qbit add with version constraints
3. #29 - qbit remove command
4. #30 - qbit update command
5. #31 - qbit list command
6. #32 - SemVer constraint resolution
7. #33 - Dependency conflict detection
8. #34 - Vendor directory layout
9. #35 - SHA-512 integrity verification
### Infrastructure
- GitHub project: https://github.com/orgs/QRun-IO/projects/12
## How to Continue
When starting a new session, say "continue from last session" and Claude will:
1. Read this file for context
2. Check git status for any pending work
3. Review GitHub project for current sprint
## Key Files
| File | Purpose |
|------|---------|
| `CLAUDE.md` | Project overview and build commands |
| `docs/ROADMAP.md` | User journey roadmap |
| `docs/internal/session-state.md` | This file - session continuity |
| `.github/workflows/build.yml` | CI/CD pipeline |
+55
View File
@@ -0,0 +1,55 @@
# Testing Guide
qctl uses JUnit 5 with comprehensive E2E tests that verify actual file content, variable substitution, and generated project validity.
## Running Tests
```bash
# All tests
mvn verify
# Integration tests only
mvn test -pl qctl-integration-tests
# Specific test class
mvn test -pl qctl-integration-tests -Dtest=InitCommandFullE2ETest
```
## Test Fixtures
Fixtures in `qctl-integration-tests/src/test/resources/fixtures/templates/`:
| Fixture | Purpose |
|---------|---------|
| `test-minimal` | Basic template with no variables |
| `test-computed-vars` | Computed variable evaluation (`packagePath` from `packageName`) |
| `test-full-substitution` | Variable replacement in content and paths |
| `test-transforms` | Delete and rename transforms |
| `test-with-prompts` | Interactive prompt handling |
| `test-variable-typo` | "Did you mean?" error suggestions |
| `test-transform-error` | Error message quality |
## Path Variable Convention
Use `__VARNAME__` syntax in directory names (e.g., `__packagePath__`). The template engine converts this to `$VARNAME` before Velocity processing, avoiding shell/Maven interpretation issues on CI.
```
template/src/main/java/__packagePath__/App.java
↓ converted to
template/src/main/java/$packagePath/App.java
↓ Velocity renders
src/main/java/com/example/app/App.java
```
## Test Harness
- **CommandTestHarness**: Executes commands in-process, captures stdout/stderr/exit code
- **TestTemplateRegistry**: Provides fixtures without network access
- **TestableConsoleUI**: Simulates user input for interactive prompts
## Adding a Fixture
1. Create directory under `fixtures/templates/<name>/`
2. Add `template.yaml` manifest (schema v2)
3. Add `template/` directory with files
4. Register in `TestTemplateRegistry.java`
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: QRun-IO <contact@qrun.io>
pkgname=qctl-bin
pkgver=0.1.0
pkgver=0.0.2
pkgrel=1
pkgdesc="CLI for the QRun ecosystem - scaffold, package, and deploy QQQ apps"
arch=('x86_64')
@@ -9,7 +9,7 @@ license=('AGPL-3.0')
provides=('qctl')
conflicts=('qctl')
source_x86_64=("https://github.com/QRun-IO/qctl/releases/download/v${pkgver}/qctl-linux-amd64")
sha256sums_x86_64=('PLACEHOLDER_LINUX_SHA256')
sha256sums_x86_64=('374f0f10a8aa781e1b5b8d72483ec257dfee9794b69419c7700f55ad7fbcf4b3')
package() {
install -Dm755 "${srcdir}/qctl-linux-amd64" "${pkgdir}/usr/bin/qctl"
+4 -4
View File
@@ -1,12 +1,12 @@
{
"version": "0.1.0",
"version": "0.0.2",
"description": "CLI for the QRun ecosystem - scaffold, package, and deploy QQQ apps",
"homepage": "https://github.com/QRun-IO/qctl",
"license": "AGPL-3.0",
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qctl/releases/download/v0.1.0/qctl-windows-amd64.exe",
"hash": "PLACEHOLDER_WINDOWS_SHA256"
"url": "https://github.com/QRun-IO/qctl/releases/download/v0.0.2/qctl-windows-amd64.exe",
"hash": "358aee9b7655afe8f86342ecdbebe150bdda9a1868d1848718876a18faab6f5e"
}
},
"bin": [["qctl-windows-amd64.exe", "qctl"]],
@@ -16,7 +16,7 @@
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qctl/releases/download/v$version/qctl-windows-amd64.exe"
"url": "https://github.com/QRun-IO/qctl/releases/download/v0.0.2/qctl-windows-amd64.exe"
}
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
<logback.version>1.5.15</logback.version>
<slf4j.version>2.0.16</slf4j.version>
<networknt.json.schema.version>1.5.4</networknt.json.schema.version>
<handlebars.version>4.4.0</handlebars.version>
<velocity.version>2.4.1</velocity.version>
<bouncycastle.version>1.79</bouncycastle.version>
<junit.jupiter.version>5.11.4</junit.jupiter.version>
<assertj.version>3.27.2</assertj.version>
+40 -35
View File
@@ -37,39 +37,44 @@
<version>${project.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native.maven.plugin.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<mainClass>io.qrun.qctl.core.Main</mainClass>
<imageName>qctl</imageName>
<useArgFile>false</useArgFile>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-url-protocols=http,https</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
<buildArg>-H:IncludeResources=.*\.properties$</buildArg>
<buildArg>-H:IncludeResources=.*\.xml$</buildArg>
<buildArg>-H:IncludeResources=.*\.json$</buildArg>
<buildArg>-H:IncludeResources=.*\.yaml$</buildArg>
<buildArg>-H:IncludeResources=.*\.yml$</buildArg>
<buildArg>-H:IncludeResources=META-INF/services/.*</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native.maven.plugin.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<mainClass>io.qrun.qctl.core.Main</mainClass>
<imageName>qctl</imageName>
<useArgFile>false</useArgFile>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-url-protocols=http,https</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
<buildArg>-H:IncludeResources=.*\.properties$</buildArg>
<buildArg>-H:IncludeResources=.*\.xml$</buildArg>
<buildArg>-H:IncludeResources=.*\.json$</buildArg>
<buildArg>-H:IncludeResources=.*\.yaml$</buildArg>
<buildArg>-H:IncludeResources=.*\.yml$</buildArg>
<buildArg>-H:IncludeResources=META-INF/services/.*</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
@@ -39,7 +39,8 @@ import picocli.CommandLine.Spec;
subcommands = {
HelpCommand.class,
io.qrun.qctl.core.cli.AuthCommand.class,
io.qrun.qctl.core.cli.CacheCommand.class
io.qrun.qctl.core.cli.CacheCommand.class,
io.qrun.qctl.core.cli.DoctorCommand.class
})
public class Main implements Runnable
{
@@ -0,0 +1,171 @@
/*
* 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.core.cli;
import java.util.List;
import io.qrun.qctl.core.doctor.DependencyChecker;
import io.qrun.qctl.core.doctor.DependencyStatus;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/*******************************************************************************
* Check system dependencies and report status.
*
* Verifies that required tools (Java, Maven, Git) are installed and meet
* version requirements. Reports optional tools (Docker) as well.
*
* @since 0.2.0
*******************************************************************************/
@Command(
name = "doctor",
description = "Check system dependencies and configuration"
)
public class DoctorCommand implements Runnable
{
private static final String ANSI_RESET = "\u001B[0m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_BOLD = "\u001B[1m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String CHECK_MARK = "✓";
private static final String CROSS_MARK = "✗";
private static final String WARN_MARK = "!";
@Option(names = {"-v", "--verbose"}, description = "Show detailed information")
private boolean verbose;
/***************************************************************************
* Run the doctor command.
*
* @since 0.2.0
***************************************************************************/
@Override
public void run()
{
System.out.println();
System.out.println(ANSI_BOLD + "qctl doctor" + ANSI_RESET);
System.out.println(ANSI_DIM + "Checking system dependencies..." + ANSI_RESET);
System.out.println();
List<DependencyStatus> results = DependencyChecker.checkAll();
int requiredMissing = 0;
int optionalMissing = 0;
for(DependencyStatus status : results)
{
printStatus(status);
if(!status.isOk() && !status.isOptional())
{
requiredMissing++;
}
else if(status.isOptional())
{
optionalMissing++;
}
}
System.out.println();
if(requiredMissing > 0)
{
System.out.println(ANSI_RED + requiredMissing + " required "
+ (requiredMissing == 1 ? "dependency" : "dependencies")
+ " missing." + ANSI_RESET);
System.out.println("Run " + ANSI_BOLD + "qctl doctor -v" + ANSI_RESET + " for installation hints.");
System.exit(ExitCodes.VALIDATION);
}
else if(optionalMissing > 0)
{
System.out.println(ANSI_YELLOW + optionalMissing + " optional "
+ (optionalMissing == 1 ? "dependency" : "dependencies")
+ " missing." + ANSI_RESET);
if(verbose)
{
System.out.println("Optional dependencies enable additional features but are not required.");
}
}
else
{
System.out.println(ANSI_GREEN + "All dependencies satisfied!" + ANSI_RESET);
}
}
/***************************************************************************
* Print a single dependency status line.
*
* @param status dependency status to print
* @since 0.2.0
***************************************************************************/
private void printStatus(DependencyStatus status)
{
String icon;
String color;
switch(status.status())
{
case OK:
icon = ANSI_GREEN + CHECK_MARK + ANSI_RESET;
color = ANSI_RESET;
break;
case NOT_FOUND:
case VERSION_MISMATCH:
case ERROR:
icon = ANSI_RED + CROSS_MARK + ANSI_RESET;
color = ANSI_RED;
break;
case OPTIONAL_MISSING:
icon = ANSI_YELLOW + WARN_MARK + ANSI_RESET;
color = ANSI_YELLOW;
break;
default:
icon = "?";
color = ANSI_RESET;
}
StringBuilder line = new StringBuilder();
line.append(" ").append(icon).append(" ");
if(status.isOk())
{
line.append(ANSI_BOLD).append(status.name()).append(ANSI_RESET);
line.append(" ").append(status.version());
if(status.path() != null && verbose)
{
line.append(ANSI_DIM).append(" ").append(status.path()).append(ANSI_RESET);
}
}
else
{
line.append(color).append(ANSI_BOLD).append(status.name()).append(ANSI_RESET);
line.append(color).append(" ").append(status.getMessage()).append(ANSI_RESET);
}
System.out.println(line);
if(verbose && status.hint() != null && !status.isOk())
{
System.out.println(ANSI_DIM + " " + status.hint() + ANSI_RESET);
}
}
}
@@ -0,0 +1,439 @@
/*
* 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.core.doctor;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*******************************************************************************
* Checks for required external dependencies (Java, Maven, Git, Docker).
*
* Provides both individual checks and a comprehensive report.
*
* @since 0.2.0
*******************************************************************************/
public class DependencyChecker
{
private static final int DEFAULT_TIMEOUT_MS = 5000;
private static final int THREAD_JOIN_TIMEOUT = 1000;
private static final int ERROR_EXIT_CODE = -1;
private static final int MIN_JAVA_VERSION = 21;
private static final String JAVA_VERSION_PATTERN = "version \"?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?";
private static final String MVN_VERSION_PATTERN = "Apache Maven (\\d+\\.\\d+\\.\\d+)";
private static final String GIT_VERSION_PATTERN = "git version (\\d+\\.\\d+\\.\\d+)";
/***************************************************************************
* Check if Java is installed and meets minimum version requirement.
*
* @return dependency status for Java
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkJava()
{
return checkJava(MIN_JAVA_VERSION);
}
/***************************************************************************
* Check if Java is installed and meets specified minimum version.
*
* @param minVersion minimum required Java version
* @return dependency status for Java
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkJava(int minVersion)
{
try
{
ProcessResult result = runCommand("java", "-version");
if(result.exitCode() != 0)
{
return DependencyStatus.notFound("Java", "java", "Install Java " + minVersion + "+ from https://adoptium.net/");
}
///////////////////////////////////////////////////////////////////
// Java prints version to stderr, so check both streams //
///////////////////////////////////////////////////////////////////
String output = result.stderr().isEmpty() ? result.stdout() : result.stderr();
Pattern pattern = Pattern.compile(JAVA_VERSION_PATTERN);
Matcher matcher = pattern.matcher(output);
if(matcher.find())
{
int majorVersion = Integer.parseInt(matcher.group(1));
String fullVersion = extractFullVersion(output, "java");
String path = findExecutablePath("java");
if(majorVersion >= minVersion)
{
return DependencyStatus.ok("Java", fullVersion, path);
}
else
{
return DependencyStatus.versionMismatch("Java", fullVersion, minVersion + "+", path,
"Upgrade to Java " + minVersion + "+ from https://adoptium.net/");
}
}
return DependencyStatus.notFound("Java", "java", "Install Java " + minVersion + "+ from https://adoptium.net/");
}
catch(Exception e)
{
return DependencyStatus.error("Java", e.getMessage());
}
}
/***************************************************************************
* Check if Maven is installed.
*
* @return dependency status for Maven
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkMaven()
{
try
{
ProcessResult result = runCommand("mvn", "-version");
if(result.exitCode() != 0)
{
return DependencyStatus.notFound("Maven", "mvn", "Install Maven from https://maven.apache.org/download.cgi");
}
Pattern pattern = Pattern.compile(MVN_VERSION_PATTERN);
Matcher matcher = pattern.matcher(result.stdout());
if(matcher.find())
{
String version = matcher.group(1);
String path = findExecutablePath("mvn");
return DependencyStatus.ok("Maven", version, path);
}
return DependencyStatus.notFound("Maven", "mvn", "Install Maven from https://maven.apache.org/download.cgi");
}
catch(Exception e)
{
return DependencyStatus.error("Maven", e.getMessage());
}
}
/***************************************************************************
* Check if Git is installed.
*
* @return dependency status for Git
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkGit()
{
try
{
ProcessResult result = runCommand("git", "--version");
if(result.exitCode() != 0)
{
return DependencyStatus.notFound("Git", "git", "Install Git from https://git-scm.com/downloads");
}
Pattern pattern = Pattern.compile(GIT_VERSION_PATTERN);
Matcher matcher = pattern.matcher(result.stdout());
if(matcher.find())
{
String version = matcher.group(1);
String path = findExecutablePath("git");
return DependencyStatus.ok("Git", version, path);
}
return DependencyStatus.notFound("Git", "git", "Install Git from https://git-scm.com/downloads");
}
catch(Exception e)
{
return DependencyStatus.error("Git", e.getMessage());
}
}
/***************************************************************************
* Check if Docker is installed (optional dependency).
*
* @return dependency status for Docker
* @since 0.2.0
***************************************************************************/
public static DependencyStatus checkDocker()
{
try
{
ProcessResult result = runCommand("docker", "--version");
if(result.exitCode() != 0)
{
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
}
///////////////////////////////////////////////////////////////////
// Docker version output: "Docker version 24.0.5, build ..." //
///////////////////////////////////////////////////////////////////
Pattern pattern = Pattern.compile("Docker version (\\d+\\.\\d+\\.\\d+)");
Matcher matcher = pattern.matcher(result.stdout());
if(matcher.find())
{
String version = matcher.group(1);
String path = findExecutablePath("docker");
return DependencyStatus.ok("Docker", version, path);
}
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
}
catch(Exception e)
{
return DependencyStatus.optional("Docker", "docker", "Install Docker from https://docs.docker.com/get-docker/");
}
}
/***************************************************************************
* Check all dependencies and return a comprehensive report.
*
* @return list of dependency statuses
* @since 0.2.0
***************************************************************************/
public static List<DependencyStatus> checkAll()
{
List<DependencyStatus> results = new ArrayList<>();
results.add(checkJava());
results.add(checkMaven());
results.add(checkGit());
results.add(checkDocker());
return results;
}
/***************************************************************************
* Require Java to be installed with minimum version. Throws if not met.
*
* @param minVersion minimum required Java version
* @throws DependencyException if Java is not installed or version too low
* @since 0.2.0
***************************************************************************/
public static void requireJava(int minVersion) throws DependencyException
{
DependencyStatus status = checkJava(minVersion);
if(!status.isOk())
{
throw new DependencyException(status);
}
}
/***************************************************************************
* Require Maven to be installed. Throws if not found.
*
* @throws DependencyException if Maven is not installed
* @since 0.2.0
***************************************************************************/
public static void requireMaven() throws DependencyException
{
DependencyStatus status = checkMaven();
if(!status.isOk())
{
throw new DependencyException(status);
}
}
/***************************************************************************
* Require Git to be installed. Throws if not found.
*
* @throws DependencyException if Git is not installed
* @since 0.2.0
***************************************************************************/
public static void requireGit() throws DependencyException
{
DependencyStatus status = checkGit();
if(!status.isOk())
{
throw new DependencyException(status);
}
}
/***************************************************************************
* Run a command and capture output.
*
* @param command command and arguments
* @return process result with exit code and output
* @since 0.2.0
***************************************************************************/
private static ProcessResult runCommand(String... command)
{
try
{
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(false);
Process process = pb.start();
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
Thread stdoutThread = new Thread(() -> readStream(process.getInputStream(), stdout));
Thread stderrThread = new Thread(() -> readStream(process.getErrorStream(), stderr));
stdoutThread.start();
stderrThread.start();
boolean completed = process.waitFor(DEFAULT_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
if(!completed)
{
process.destroyForcibly();
return new ProcessResult(ERROR_EXIT_CODE, "", "Command timed out");
}
stdoutThread.join(THREAD_JOIN_TIMEOUT);
stderrThread.join(THREAD_JOIN_TIMEOUT);
return new ProcessResult(process.exitValue(), stdout.toString(), stderr.toString());
}
catch(Exception e)
{
return new ProcessResult(ERROR_EXIT_CODE, "", e.getMessage());
}
}
/***************************************************************************
* Read an input stream into a StringBuilder.
*
* @param inputStream stream to read
* @param output builder to append to
* @since 0.2.0
***************************************************************************/
private static void readStream(java.io.InputStream inputStream, StringBuilder output)
{
try(BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)))
{
String line;
while((line = reader.readLine()) != null)
{
output.append(line).append("\n");
}
}
catch(Exception e)
{
// Ignore read errors
}
}
/***************************************************************************
* Find the path to an executable using 'which' (Unix) or 'where' (Windows).
*
* @param executable name of executable
* @return path or "unknown"
* @since 0.2.0
***************************************************************************/
private static String findExecutablePath(String executable)
{
try
{
String os = System.getProperty("os.name").toLowerCase();
String[] command = os.contains("win")
? new String[] { "where", executable }
: new String[] { "which", executable };
ProcessResult result = runCommand(command);
if(result.exitCode() == 0 && !result.stdout().isEmpty())
{
return result.stdout().trim().split("\n")[0];
}
}
catch(Exception e)
{
// Ignore
}
return "unknown";
}
/***************************************************************************
* Extract full version string from command output.
*
* @param output command output
* @param tool tool name for context
* @return version string or "unknown"
* @since 0.2.0
***************************************************************************/
private static String extractFullVersion(String output, String tool)
{
if("java".equals(tool))
{
////////////////////////////////////////////////////////////////
// Try to extract version like "21.0.5" or "21" //
////////////////////////////////////////////////////////////////
Pattern pattern = Pattern.compile("version \"?(\\d+(?:\\.\\d+)*(?:[._][\\w-]+)?)");
Matcher matcher = pattern.matcher(output);
if(matcher.find())
{
String version = matcher.group(1);
////////////////////////////////////////////////////////////
// Check for GraalVM or other JVM vendors //
////////////////////////////////////////////////////////////
if(output.contains("GraalVM"))
{
return version + " (GraalVM)";
}
else if(output.contains("Temurin"))
{
return version + " (Temurin)";
}
else if(output.contains("OpenJDK"))
{
return version + " (OpenJDK)";
}
return version;
}
}
return "unknown";
}
/***************************************************************************
* Result of running an external process.
*
* @param exitCode process exit code
* @param stdout standard output
* @param stderr standard error
* @since 0.2.0
***************************************************************************/
private record ProcessResult(int exitCode, String stdout, String stderr) {}
}
@@ -0,0 +1,70 @@
/*
* 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.core.doctor;
/*******************************************************************************
* Exception thrown when a required dependency is not satisfied.
*
* @since 0.2.0
*******************************************************************************/
public class DependencyException extends Exception
{
private final DependencyStatus status;
/***************************************************************************
* Create exception from dependency status.
*
* @param status the failing dependency status
* @since 0.2.0
***************************************************************************/
public DependencyException(DependencyStatus status)
{
super(status.getMessage());
this.status = status;
}
/***************************************************************************
* Get the dependency status that caused this exception.
*
* @return dependency status
* @since 0.2.0
***************************************************************************/
public DependencyStatus getStatus()
{
return status;
}
/***************************************************************************
* Get a user-friendly error message with installation hint.
*
* @return formatted error message
* @since 0.2.0
***************************************************************************/
public String getFormattedMessage()
{
StringBuilder sb = new StringBuilder();
sb.append(status.getMessage());
if(status.hint() != null && !status.hint().isEmpty())
{
sb.append("\n").append(status.hint());
}
return sb.toString();
}
}
@@ -0,0 +1,172 @@
/*
* 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.core.doctor;
/*******************************************************************************
* Status of a dependency check.
*
* @since 0.2.0
*******************************************************************************/
public record DependencyStatus(
String name,
Status status,
String version,
String path,
String requiredVersion,
String hint
)
{
/***************************************************************************
* Dependency check status values.
*
* @since 0.2.0
***************************************************************************/
public enum Status
{
OK,
NOT_FOUND,
VERSION_MISMATCH,
OPTIONAL_MISSING,
ERROR
}
/***************************************************************************
* Check if dependency is satisfied.
*
* @return true if OK
* @since 0.2.0
***************************************************************************/
public boolean isOk()
{
return status == Status.OK;
}
/***************************************************************************
* Check if this is an optional dependency.
*
* @return true if optional
* @since 0.2.0
***************************************************************************/
public boolean isOptional()
{
return status == Status.OPTIONAL_MISSING;
}
/***************************************************************************
* Create status for a found and valid dependency.
*
* @param name dependency name
* @param version found version
* @param path executable path
* @return OK status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus ok(String name, String version, String path)
{
return new DependencyStatus(name, Status.OK, version, path, null, null);
}
/***************************************************************************
* Create status for a missing required dependency.
*
* @param name dependency name
* @param executable executable name
* @param hint installation hint
* @return NOT_FOUND status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus notFound(String name, String executable, String hint)
{
return new DependencyStatus(name, Status.NOT_FOUND, null, null, null, hint);
}
/***************************************************************************
* Create status for a version mismatch.
*
* @param name dependency name
* @param foundVersion version that was found
* @param requiredVersion version that is required
* @param path executable path
* @param hint upgrade hint
* @return VERSION_MISMATCH status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus versionMismatch(String name, String foundVersion,
String requiredVersion, String path, String hint)
{
return new DependencyStatus(name, Status.VERSION_MISMATCH, foundVersion, path, requiredVersion, hint);
}
/***************************************************************************
* Create status for a missing optional dependency.
*
* @param name dependency name
* @param executable executable name
* @param hint installation hint
* @return OPTIONAL_MISSING status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus optional(String name, String executable, String hint)
{
return new DependencyStatus(name, Status.OPTIONAL_MISSING, null, null, null, hint);
}
/***************************************************************************
* Create status for an error during check.
*
* @param name dependency name
* @param errorMessage error message
* @return ERROR status
* @since 0.2.0
***************************************************************************/
public static DependencyStatus error(String name, String errorMessage)
{
return new DependencyStatus(name, Status.ERROR, null, null, null, errorMessage);
}
/***************************************************************************
* Get a user-friendly message for this status.
*
* @return status message
* @since 0.2.0
***************************************************************************/
public String getMessage()
{
return switch(status)
{
case OK -> name + " " + version;
case NOT_FOUND -> name + " not found";
case VERSION_MISMATCH -> name + " " + version + " found, but " + requiredVersion + " required";
case OPTIONAL_MISSING -> name + " not found (optional)";
case ERROR -> name + " check failed: " + hint;
};
}
}
+71
View File
@@ -10,7 +10,42 @@
</parent>
<artifactId>qctl-integration-tests</artifactId>
<name>qctl-integration-tests</name>
<description>Integration and E2E tests for qctl CLI</description>
<dependencies>
<!-- Command modules under test -->
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-core</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-qqq</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-qbit</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-qrun</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qrun.qctl</groupId>
<artifactId>qctl-qstudio</artifactId>
<version>${revision}</version>
<scope>test</scope>
</dependency>
<!-- Test frameworks -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -34,5 +69,41 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Picocli for command parsing -->
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>${picocli.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Run integration tests with failsafe -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<systemPropertyVariables>
<qctl.native.binary>${project.basedir}/../qctl-cli/target/qctl</qctl.native.binary>
</systemPropertyVariables>
<includes>
<include>**/*IT.java</include>
<include>**/*E2ETest.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,323 @@
/*
* 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.e2e;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import io.qrun.qctl.e2e.harness.CommandTestHarness;
import io.qrun.qctl.e2e.harness.TestTemplateRegistry;
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
import io.qrun.qctl.shared.ExitCodes;
import org.junit.jupiter.api.AfterEach;
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;
/*******************************************************************************
* Comprehensive E2E tests for the qqq init command using fixture templates.
*
* Tests command-line flags, project generation, and error handling scenarios.
* Uses TestTemplateRegistry to provide fully offline, deterministic tests.
*
* @since 0.2.0
*******************************************************************************/
class InitCommandE2ETest
{
private CommandTestHarness harness;
private TestTemplateRegistry registry;
private InputStream originalIn;
@TempDir
Path tempDir;
@BeforeEach
void setUp() throws IOException
{
harness = new CommandTestHarness();
registry = new TestTemplateRegistry();
registry.loadFromResources("test-minimal");
registry.loadFromResources("test-with-prompts");
TemplateRegistryFactory.setRegistry(registry);
//////////////////////////////////////////////////////////////////////////
// Provide empty input to prevent hanging on unexpected prompts //
//////////////////////////////////////////////////////////////////////////
originalIn = System.in;
System.setIn(new ByteArrayInputStream("\n\n\n".getBytes(StandardCharsets.UTF_8)));
}
@AfterEach
void tearDown()
{
TemplateRegistryFactory.reset();
System.setIn(originalIn);
}
/***************************************************************************
* Test happy path: init with minimal template creates project.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_minimalTemplate_createsProject()
{
Path outputDir = tempDir.resolve("my-project");
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// For debugging - print actual output if test fails //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Project initialized successfully");
assertThat(outputDir).exists();
assertThat(outputDir.resolve("README.md")).exists();
}
/***************************************************************************
* Test --dry-run flag shows what would be created without creating files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_dryRun_showsFilesWithoutCreating()
{
Path outputDir = tempDir.resolve("dry-run-test");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--dry-run",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("[DRY-RUN]");
assertThat(harness.getStdout()).contains("README.md");
assertThat(outputDir).doesNotExist();
}
/***************************************************************************
* Test directory conflict without --force shows error.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_existingDir_showsConflictError() throws IOException
{
Path outputDir = tempDir.resolve("existing-project");
Files.createDirectories(outputDir);
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.CONFLICT);
//////////////////////////////////////////////////////////////////////////
// Error message goes to stderr, suggestion goes to stdout //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getStderr()).contains("target directory already exists");
assertThat(harness.getStdout()).contains("--force");
}
/***************************************************************************
* Test --force flag overwrites existing directory.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_forceFlag_overwritesExistingDir() throws IOException
{
Path outputDir = tempDir.resolve("force-test");
Files.createDirectories(outputDir);
Files.writeString(outputDir.resolve("existing.txt"), "should be preserved");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--force",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Overwriting existing directory");
assertThat(outputDir.resolve("README.md")).exists();
}
/***************************************************************************
* Test --merge flag merges with existing directory.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeFlag_mergesWithExistingDir() throws IOException
{
Path outputDir = tempDir.resolve("merge-test");
Files.createDirectories(outputDir);
Files.writeString(outputDir.resolve("existing.txt"), "should be preserved");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Merging with existing directory");
assertThat(outputDir.resolve("existing.txt")).exists();
assertThat(outputDir.resolve("README.md")).exists();
}
/***************************************************************************
* Test --var flag sets template variables.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_varFlag_setsTemplateVariables()
{
Path outputDir = tempDir.resolve("var-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=custom-name",
"--var", "packageName=com.acme",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Project initialized successfully");
}
/***************************************************************************
* Test non-existent template shows error with suggestions.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_nonExistentTemplate_showsError()
{
Path outputDir = tempDir.resolve("error-test");
harness.execute("qqq", "init", "not-a-real-template", "--no-prompt",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Should show error - template not found //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getStdout() + harness.getStderr())
.containsIgnoringCase("not found")
.contains("not-a-real-template");
}
/***************************************************************************
* Test --verbose flag includes extra error details.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_verboseFlag_showsDetailedErrors()
{
Path outputDir = tempDir.resolve("verbose-test");
harness.execute("qqq", "init", "not-a-real-template", "--no-prompt", "-v",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Verbose output should include stack trace or extra context //
//////////////////////////////////////////////////////////////////////////
String output = harness.getStdout() + harness.getStderr();
assertThat(output).containsIgnoringCase("not found");
}
/***************************************************************************
* Test init with multiple --var flags.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_multipleVars_allApplied()
{
Path outputDir = tempDir.resolve("multi-var-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=my-app",
"--var", "packageName=org.example",
"--var", "includeTests=false",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
}
/***************************************************************************
* Test --skip-hooks flag prevents post-gen hooks from running.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_skipHooks_noHooksExecuted()
{
Path outputDir = tempDir.resolve("skip-hooks-test");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--skip-hooks",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("Project initialized successfully");
}
/***************************************************************************
* Test combined flags work together.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_combinedFlags_dryRunWithForce() throws IOException
{
Path outputDir = tempDir.resolve("combined-test");
Files.createDirectories(outputDir);
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
"--dry-run", "--force",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout()).contains("[DRY-RUN]");
//////////////////////////////////////////////////////////////////////////
// In dry-run mode, existing files should not be modified //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir.resolve("README.md")).doesNotExist();
}
}
@@ -0,0 +1,938 @@
/*
* 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.e2e;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import io.qrun.qctl.e2e.harness.CommandTestHarness;
import io.qrun.qctl.e2e.harness.TestTemplateRegistry;
import io.qrun.qctl.qqq.registry.TemplateRegistryFactory;
import io.qrun.qctl.shared.ExitCodes;
import org.junit.jupiter.api.AfterEach;
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;
/*******************************************************************************
* Full E2E tests that verify actual template rendering and output content.
*
* These tests go beyond smoke tests to verify:
* - Variable substitution in generated files
* - Computed variable evaluation
* - Error message quality (suggestions, line numbers)
* - Generated project validity
*
* @since 0.2.0
*******************************************************************************/
class InitCommandFullE2ETest
{
private CommandTestHarness harness;
private TestTemplateRegistry registry;
private InputStream originalIn;
@TempDir
Path tempDir;
@BeforeEach
void setUp() throws IOException
{
harness = new CommandTestHarness();
registry = new TestTemplateRegistry();
registry.loadFromResources("test-minimal");
registry.loadFromResources("test-with-prompts");
registry.loadFromResources("test-variable-typo");
registry.loadFromResources("test-full-substitution");
registry.loadFromResources("test-computed-vars");
registry.loadFromResources("test-transforms");
TemplateRegistryFactory.setRegistry(registry);
originalIn = System.in;
System.setIn(new ByteArrayInputStream("\n\n\n".getBytes(StandardCharsets.UTF_8)));
}
@AfterEach
void tearDown()
{
TemplateRegistryFactory.reset();
System.setIn(originalIn);
}
/***************************************************************************
* Test that template variables are actually substituted in file content.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_variableSubstitution_replacesVariablesInContent() throws IOException
{
Path outputDir = tempDir.resolve("var-substitution-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=MyAwesomeProject",
"--var", "packageName=com.example.awesome",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify file content has substituted values, not raw variables //
//////////////////////////////////////////////////////////////////////////
Path readmePath = outputDir.resolve("README.md");
assertThat(readmePath).exists();
String content = Files.readString(readmePath);
assertThat(content)
.as("README.md should contain substituted project name")
.contains("MyAwesomeProject")
.doesNotContain("$projectName");
assertThat(content)
.as("README.md should contain substituted package name")
.contains("com.example.awesome")
.doesNotContain("$packageName");
}
/***************************************************************************
* Test that computed variables are evaluated and available.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_computedVariables_evaluatesExpressions() throws IOException
{
Path outputDir = tempDir.resolve("computed-var-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=TestProject",
"--var", "packageName=org.example.test",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// The computed variable packagePath should be org/example/test //
// We'd need a template file that uses $packagePath to verify this //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getStdout()).contains("Project initialized successfully");
}
/***************************************************************************
* Test that undefined variable errors show "Did you mean?" suggestions.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_undefinedVariable_showsDidYouMeanSuggestion() throws IOException
{
Path outputDir = tempDir.resolve("typo-test");
//////////////////////////////////////////////////////////////////////////
// test-variable-typo has $projetcName (typo) instead of $projectName //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-variable-typo", "--no-prompt",
"--var", "projectName=MyProject",
"--var", "packageName=com.example",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Should fail with render error and show suggestion //
//////////////////////////////////////////////////////////////////////////
String output = harness.getStdout() + harness.getStderr();
assertThat(output)
.as("Error should mention the undefined variable")
.containsIgnoringCase("projetcName");
//////////////////////////////////////////////////////////////////////////
// The "Did you mean?" feature should suggest the correct variable //
//////////////////////////////////////////////////////////////////////////
assertThat(output)
.as("Error should suggest similar variable name")
.containsIgnoringCase("projectName");
}
/***************************************************************************
* Test that --dry-run shows accurate file list without creating.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_dryRun_showsAccurateFileList()
{
Path outputDir = tempDir.resolve("dry-run-list-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", "--dry-run",
"--var", "projectName=DryRunProject",
"--var", "packageName=com.dryrun",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout())
.contains("[DRY-RUN]")
.contains("README.md");
//////////////////////////////////////////////////////////////////////////
// Verify no files were actually created //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir).doesNotExist();
}
/***************************************************************************
* Test merge mode preserves existing files and adds new ones.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeMode_preservesExistingFiles() throws IOException
{
Path outputDir = tempDir.resolve("merge-content-test");
Files.createDirectories(outputDir);
//////////////////////////////////////////////////////////////////////////
// Create an existing file that should be preserved //
//////////////////////////////////////////////////////////////////////////
Path existingFile = outputDir.resolve("existing-config.txt");
Files.writeString(existingFile, "Original content that should not change");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify existing file content is unchanged //
//////////////////////////////////////////////////////////////////////////
assertThat(Files.readString(existingFile))
.isEqualTo("Original content that should not change");
//////////////////////////////////////////////////////////////////////////
// Verify new template files were added //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir.resolve("README.md")).exists();
}
/***************************************************************************
* Test that multiple --var flags all get applied.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_multipleVars_allSubstituted() throws IOException
{
Path outputDir = tempDir.resolve("multi-var-content-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=FirstVar",
"--var", "packageName=second.var.package",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
String content = Files.readString(outputDir.resolve("README.md"));
//////////////////////////////////////////////////////////////////////////
// Both variables should be substituted //
//////////////////////////////////////////////////////////////////////////
assertThat(content).contains("FirstVar");
assertThat(content).contains("second.var.package");
}
/***************************************************************************
* Test force mode actually overwrites existing file content.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_forceMode_overwritesFileContent() throws IOException
{
Path outputDir = tempDir.resolve("force-content-test");
Files.createDirectories(outputDir);
//////////////////////////////////////////////////////////////////////////
// Create a README.md with different content //
//////////////////////////////////////////////////////////////////////////
Path readmePath = outputDir.resolve("README.md");
Files.writeString(readmePath, "Old content that should be replaced");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt", "--force",
"--var", "projectName=NewProject",
"--var", "packageName=com.new.project",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify content was replaced with template output //
//////////////////////////////////////////////////////////////////////////
String newContent = Files.readString(readmePath);
assertThat(newContent)
.doesNotContain("Old content")
.contains("NewProject");
}
/***************************************************************************
* Test full substitution across multiple files with pom.xml variables.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_fullSubstitution_allVariablesInAllFiles() throws IOException
{
Path outputDir = tempDir.resolve("full-sub-test");
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
"--var", "projectName=AwesomeApp",
"--var", "packageName=io.awesome.app",
"--var", "groupId=io.awesome",
"--var", "artifactId=awesome-app",
"--var", "version=2.0.0",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify README.md has all variables substituted //
//////////////////////////////////////////////////////////////////////////
String readme = Files.readString(outputDir.resolve("README.md"));
assertThat(readme)
.contains("AwesomeApp")
.contains("io.awesome.app")
.contains("io.awesome")
.contains("awesome-app")
.contains("2.0.0")
.doesNotContain("$projectName")
.doesNotContain("$packageName")
.doesNotContain("$groupId")
.doesNotContain("$artifactId")
.doesNotContain("$version");
//////////////////////////////////////////////////////////////////////////
// Verify pom.xml has all variables substituted //
//////////////////////////////////////////////////////////////////////////
String pom = Files.readString(outputDir.resolve("pom.xml"));
assertThat(pom)
.contains("<groupId>io.awesome</groupId>")
.contains("<artifactId>awesome-app</artifactId>")
.contains("<version>2.0.0</version>")
.contains("<name>AwesomeApp</name>")
.doesNotContain("$groupId")
.doesNotContain("$artifactId")
.doesNotContain("$version");
}
/***************************************************************************
* Test computed variables are evaluated and substituted.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_computedVars_evaluatesAndSubstitutes() throws IOException
{
Path outputDir = tempDir.resolve("computed-test");
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
"--var", "packageName=com.test.example",
"--var", "projectName=my-cool-project",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify README.md has computed variables substituted //
//////////////////////////////////////////////////////////////////////////
String readme = Files.readString(outputDir.resolve("README.md"));
assertThat(readme)
.as("Package path should be computed from packageName")
.contains("com/test/example")
.doesNotContain("$packagePath");
assertThat(readme)
.as("Kebab case should be computed from projectName")
.contains("my-cool-project")
.doesNotContain("$projectNameKebab");
assertThat(readme)
.as("Pascal case should be computed from projectName (capitalizes first letter)")
.contains("My-cool-project")
.doesNotContain("$projectNamePascal");
}
/***************************************************************************
* Test that $packagePath in directory names gets substituted by Velocity.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_packagePathSubstitution_createsCorrectDirectories() throws IOException
{
Path outputDir = tempDir.resolve("transform-test");
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
"--var", "packageName=org.acme.test",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify $packagePath was substituted to org/acme/test by Velocity //
//////////////////////////////////////////////////////////////////////////
Path expectedJavaDir = outputDir.resolve("src/main/java/org/acme/test");
assertThat(expectedJavaDir)
.as("Directory should be created with $packagePath substituted to org/acme/test")
.exists()
.isDirectory();
Path appJava = expectedJavaDir.resolve("App.java");
assertThat(appJava)
.as("App.java should exist in transformed directory")
.exists();
//////////////////////////////////////////////////////////////////////////
// Verify literal $packagePath directory does not exist //
//////////////////////////////////////////////////////////////////////////
Path placeholderDir = outputDir.resolve("src/main/java/$packagePath");
assertThat(placeholderDir)
.as("Literal $packagePath directory should not exist after substitution")
.doesNotExist();
}
/***************************************************************************
* Test delete transform removes specified files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_deleteTransform_removesFiles() throws IOException
{
Path outputDir = tempDir.resolve("delete-transform-test");
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
"--var", "packageName=com.example",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify .gitkeep files were deleted by the delete transform //
//////////////////////////////////////////////////////////////////////////
Path gitkeepPath = outputDir.resolve("src/main/java/com/example/.gitkeep");
assertThat(gitkeepPath)
.as(".gitkeep should be deleted by transform")
.doesNotExist();
//////////////////////////////////////////////////////////////////////////
// Verify other files still exist //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir.resolve("src/main/java/com/example/App.java"))
.as("App.java should still exist")
.exists();
}
/***************************************************************************
* Test that provided values are used and other vars can be set together.
*
* Note: --no-prompt mode currently requires all vars to be provided via
* --var flags; defaults from template.yaml are not applied. This tests
* that explicitly provided values work correctly.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_providedValues_usedCorrectly() throws IOException
{
Path outputDir = tempDir.resolve("provided-values-test");
//////////////////////////////////////////////////////////////////////////
// Provide custom projectName, use defaults for others //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
"--var", "projectName=CustomProject",
"--var", "packageName=com.custom",
"--var", "groupId=com.custom",
"--var", "artifactId=custom-app",
"--var", "version=2.0.0",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify all provided values are used correctly //
//////////////////////////////////////////////////////////////////////////
String readme = Files.readString(outputDir.resolve("README.md"));
assertThat(readme)
.as("Custom project name should be used")
.contains("CustomProject");
assertThat(readme)
.as("Custom package name should be used")
.contains("com.custom");
String pom = Files.readString(outputDir.resolve("pom.xml"));
assertThat(pom)
.as("Custom groupId should be used")
.contains("<groupId>com.custom</groupId>");
assertThat(pom)
.as("Custom version should be used")
.contains("<version>2.0.0</version>");
}
/***************************************************************************
* Test App.java in transformed directory has correct package statement.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_javaFile_hasCorrectPackageStatement() throws IOException
{
Path outputDir = tempDir.resolve("java-package-test");
harness.execute("qqq", "init", "test-transforms", "--no-prompt",
"--var", "packageName=io.qrun.myapp",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify App.java has correct package statement //
//////////////////////////////////////////////////////////////////////////
Path appJava = outputDir.resolve("src/main/java/io/qrun/myapp/App.java");
assertThat(appJava).exists();
String content = Files.readString(appJava);
assertThat(content)
.as("App.java should have correct package statement")
.contains("package io.qrun.myapp;")
.doesNotContain("$packageName");
}
/***************************************************************************
* Test pom.xml is valid XML after substitution.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_generatedPom_isValidXml() throws Exception
{
Path outputDir = tempDir.resolve("valid-xml-test");
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
"--var", "projectName=XmlTest",
"--var", "packageName=com.xml.test",
"--var", "groupId=com.xml",
"--var", "artifactId=xml-test",
"--var", "version=1.0.0",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Parse pom.xml to verify it's valid XML //
//////////////////////////////////////////////////////////////////////////
Path pomPath = outputDir.resolve("pom.xml");
javax.xml.parsers.DocumentBuilderFactory factory =
javax.xml.parsers.DocumentBuilderFactory.newInstance();
javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder();
//////////////////////////////////////////////////////////////////////////
// This will throw an exception if XML is invalid //
//////////////////////////////////////////////////////////////////////////
org.w3c.dom.Document doc = builder.parse(pomPath.toFile());
assertThat(doc.getDocumentElement().getNodeName())
.as("Root element should be 'project'")
.isEqualTo("project");
}
/***************************************************************************
* Test behavior when requesting a template that doesn't exist.
*
* With TestTemplateRegistry, non-existent templates cause the system
* to fall back to the first available template in --no-prompt mode.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_unknownTemplate_fallsBackToFirst() throws IOException
{
Path outputDir = tempDir.resolve("unknown-template-test");
//////////////////////////////////////////////////////////////////////////
// With --no-prompt, requesting unknown template uses first available //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "non-existent-template", "--no-prompt",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Should complete successfully using first available template //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getExitCode())
.as("Should succeed with fallback template")
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Output directory should exist with files from fallback template //
//////////////////////////////////////////////////////////////////////////
assertThat(outputDir).exists().isDirectory();
}
/***************************************************************************
* Test merge mode skips files that already exist and are identical.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeMode_skipsIdenticalFiles() throws IOException
{
Path outputDir = tempDir.resolve("merge-identical-test");
//////////////////////////////////////////////////////////////////////////
// First run to create files //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-minimal", "--no-prompt",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Second run with merge should skip existing files //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
assertThat(harness.getStdout())
.as("Should show skip status for existing files")
.contains("[skip]");
}
/***************************************************************************
* Test merge with --include pattern only overwrites matched files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeWithInclude_onlyOverwritesMatched() throws IOException
{
Path outputDir = tempDir.resolve("merge-include-test");
Files.createDirectories(outputDir);
//////////////////////////////////////////////////////////////////////////
// Create existing README.md with custom content //
//////////////////////////////////////////////////////////////////////////
Path readme = outputDir.resolve("README.md");
Files.writeString(readme, "Original README content");
//////////////////////////////////////////////////////////////////////////
// Create existing other.txt that should not be touched //
//////////////////////////////////////////////////////////////////////////
Path other = outputDir.resolve("other.txt");
Files.writeString(other, "Other content");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"--include", "README.md",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// README.md should be overwritten (included in --include) //
//////////////////////////////////////////////////////////////////////////
assertThat(Files.readString(readme))
.as("README.md should be overwritten")
.doesNotContain("Original README content");
//////////////////////////////////////////////////////////////////////////
// other.txt should be untouched //
//////////////////////////////////////////////////////////////////////////
assertThat(Files.readString(other))
.as("other.txt should be preserved")
.isEqualTo("Other content");
}
/***************************************************************************
* Test merge with --exclude pattern skips matched files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_mergeWithExclude_skipsMatched() throws IOException
{
Path outputDir = tempDir.resolve("merge-exclude-test");
Files.createDirectories(outputDir);
//////////////////////////////////////////////////////////////////////////
// Create existing README.md that should be skipped //
//////////////////////////////////////////////////////////////////////////
Path readme = outputDir.resolve("README.md");
Files.writeString(readme, "Preserved README");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--merge",
"--exclude", "README.md",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// README.md should be preserved (excluded from merge) //
//////////////////////////////////////////////////////////////////////////
assertThat(Files.readString(readme))
.as("README.md should be preserved due to --exclude")
.isEqualTo("Preserved README");
}
/***************************************************************************
* Test that unicode characters in variable values are handled correctly.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_unicodeInVariables_handledCorrectly() throws IOException
{
Path outputDir = tempDir.resolve("unicode-test");
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=Projet-Franca\u0327ais",
"--var", "packageName=com.example.unicode",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
String readme = Files.readString(outputDir.resolve("README.md"));
assertThat(readme)
.as("Unicode characters should be preserved")
.contains("Projet-Franca\u0327ais");
}
/***************************************************************************
* Test that special characters in package names work correctly.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_specialCharsInPackage_escapedCorrectly() throws IOException
{
Path outputDir = tempDir.resolve("special-chars-test");
//////////////////////////////////////////////////////////////////////////
// Package names with underscores are valid Java package names //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
"--var", "projectName=test_project",
"--var", "packageName=com.example_company.test_app",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify directory structure is created correctly //
//////////////////////////////////////////////////////////////////////////
Path packageDir = outputDir.resolve("src/main/java/com/example_company/test_app");
assertThat(packageDir)
.as("Package directory with underscores should be created")
.exists()
.isDirectory();
}
/***************************************************************************
* Test deeply nested directories are created correctly.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_deeplyNestedPackage_createsAllDirectories() throws IOException
{
Path outputDir = tempDir.resolve("deep-nest-test");
harness.execute("qqq", "init", "test-computed-vars", "--no-prompt",
"--var", "projectName=deep-test",
"--var", "packageName=com.very.deeply.nested.package.structure",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verify deeply nested directory structure is created //
//////////////////////////////////////////////////////////////////////////
Path deepDir = outputDir.resolve(
"src/main/java/com/very/deeply/nested/package/structure");
assertThat(deepDir)
.as("Deep directory structure should be created")
.exists()
.isDirectory();
assertThat(deepDir.resolve("App.java"))
.as("App.java should exist in deep directory")
.exists();
}
/***************************************************************************
* Test that computed variable appears in directory path.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_computedVar_usedInDirectoryPath() throws IOException
{
Path outputDir = tempDir.resolve("computed-dir-test");
harness.execute("qqq", "init", "test-full-substitution", "--no-prompt",
"--var", "projectName=ComputedDirTest",
"--var", "packageName=org.test.computed",
"--var", "groupId=org.test",
"--var", "artifactId=computed-test",
"--var", "version=1.0.0",
"-o", outputDir.toString());
assertThat(harness.getExitCode())
.as("Stdout: %s\nStderr: %s", harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// packagePath computed from packageName should create org/test/computed//
//////////////////////////////////////////////////////////////////////////
Path javaDir = outputDir.resolve("src/main/java/org/test/computed");
assertThat(javaDir)
.as("Directory path should use computed packagePath")
.exists()
.isDirectory();
}
/***************************************************************************
* Test verbose mode shows additional information.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_verboseMode_showsExtraInfo()
{
Path outputDir = tempDir.resolve("verbose-test");
harness.execute("qqq", "init", "test-minimal", "--no-prompt", "--verbose",
"-o", outputDir.toString());
assertThat(harness.getExitCode()).isEqualTo(ExitCodes.SUCCESS);
//////////////////////////////////////////////////////////////////////////
// Verbose mode should complete successfully //
//////////////////////////////////////////////////////////////////////////
assertThat(harness.getStdout())
.contains("Project initialized successfully");
}
/***************************************************************************
* Test that empty project name is rejected.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_emptyVarValue_isHandled() throws IOException
{
Path outputDir = tempDir.resolve("empty-var-test");
//////////////////////////////////////////////////////////////////////////
// Empty string should be treated as a valid (empty) value //
//////////////////////////////////////////////////////////////////////////
harness.execute("qqq", "init", "test-with-prompts", "--no-prompt",
"--var", "projectName=",
"--var", "packageName=com.empty",
"-o", outputDir.toString());
//////////////////////////////////////////////////////////////////////////
// Command should complete (empty string is valid for non-required) //
// or fail validation if required //
//////////////////////////////////////////////////////////////////////////
String output = harness.getStdout() + harness.getStderr();
assertThat(output).isNotEmpty();
}
}
@@ -0,0 +1,132 @@
/*
* 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.e2e;
import io.qrun.qctl.e2e.harness.CommandTestHarness;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static io.qrun.qctl.e2e.harness.ExitCodeAssertions.assertSuccess;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Integration tests for the qqq init command.
*
* Tests command parsing, help text, and basic validation without
* spawning native processes or making network calls.
*
* @since 0.2.0
*******************************************************************************/
class InitCommandIT
{
private CommandTestHarness harness;
@BeforeEach
void setUp()
{
harness = new CommandTestHarness();
}
/***************************************************************************
* Test that --help displays init command usage.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_help_showsUsage()
{
harness.execute("qqq", "init", "--help");
assertSuccess(harness);
assertThat(harness.getStdout())
.contains("Initialize a new project from a template")
.contains("--output")
.contains("--force")
.contains("--dry-run")
.contains("--merge");
}
/***************************************************************************
* Test that qqq command group shows help.
*
* @since 0.2.0
***************************************************************************/
@Test
void testQqq_help_showsSubcommands()
{
harness.execute("qqq", "--help");
assertSuccess(harness);
assertThat(harness.getStdout())
.contains("init")
.contains("list");
}
/***************************************************************************
* Test that top-level --version works.
*
* @since 0.2.0
***************************************************************************/
@Test
void testVersion_showsVersionInfo()
{
harness.execute("--version");
assertSuccess(harness);
assertThat(harness.getStdout())
.containsPattern("qctl.*\\d+\\.\\d+\\.\\d+");
}
/***************************************************************************
* Test that unknown command shows error.
*
* @since 0.2.0
***************************************************************************/
@Test
void testUnknownCommand_showsError()
{
harness.execute("unknown-command");
assertThat(harness.getExitCode()).isNotZero();
assertThat(harness.getStderr())
.contains("unknown-command");
}
/***************************************************************************
* Test that qqq list runs and shows templates.
*
* @since 0.2.0
***************************************************************************/
@Test
void testList_showsTemplates()
{
harness.execute("qqq", "list");
assertSuccess(harness);
assertThat(harness.getStdout())
.containsIgnoringCase("Available Templates");
}
}
@@ -0,0 +1,375 @@
/*
* 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.e2e;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import io.qrun.qctl.shared.ExitCodes;
import org.junit.jupiter.api.BeforeAll;
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;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/*******************************************************************************
* End-to-end tests using the native qctl binary.
*
* These tests spawn the actual native binary and verify exit codes and output.
* Requires the native binary to be built (skipped if not present).
*
* @since 0.2.0
*******************************************************************************/
class NativeBinaryE2ETest
{
private static final String BINARY_PROPERTY = "qctl.native.binary";
private static final long TIMEOUT_SECONDS = 30;
private static Path binaryPath;
@TempDir
Path tempDir;
/***************************************************************************
* Check if native binary is available.
*
* @since 0.2.0
***************************************************************************/
@BeforeAll
static void checkBinaryExists()
{
String binaryPathStr = System.getProperty(BINARY_PROPERTY);
if(binaryPathStr != null)
{
binaryPath = Path.of(binaryPathStr);
}
else
{
//////////////////////////////////////////////////////////////////
// Try default location //
//////////////////////////////////////////////////////////////////
binaryPath = Path.of("../qctl-cli/target/qctl");
}
}
@BeforeEach
void assumeBinaryPresent()
{
assumeTrue(Files.exists(binaryPath) && Files.isExecutable(binaryPath),
"Native binary not found at " + binaryPath + ". Run 'mvn package -Pnative' to build.");
}
/***************************************************************************
* Test --version flag.
*
* @since 0.2.0
***************************************************************************/
@Test
void testVersion_showsVersionNumber() throws Exception
{
ProcessResult result = execute("--version");
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout).containsPattern("qctl.*\\d+\\.\\d+\\.\\d+");
}
/***************************************************************************
* Test --help flag.
*
* @since 0.2.0
***************************************************************************/
@Test
void testHelp_showsUsage() throws Exception
{
ProcessResult result = execute("--help");
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout)
.contains("qctl")
.contains("qqq")
.contains("qbit")
.contains("qrun");
}
/***************************************************************************
* Test qqq init --help.
*
* @since 0.2.0
***************************************************************************/
@Test
void testQqqInitHelp_showsUsage() throws Exception
{
ProcessResult result = execute("qqq", "init", "--help");
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout)
.contains("Initialize a new project")
.contains("--output")
.contains("--force")
.contains("--merge");
}
/***************************************************************************
* Test qqq list command.
*
* @since 0.2.0
***************************************************************************/
@Test
void testQqqList_showsTemplates() throws Exception
{
ProcessResult result = execute("qqq", "list");
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout)
.contains("Available Templates")
.contains("new-qqq-application");
}
/***************************************************************************
* Test unknown command shows error.
*
* @since 0.2.0
***************************************************************************/
@Test
void testUnknownCommand_showsError() throws Exception
{
ProcessResult result = execute("not-a-command");
assertThat(result.exitCode).isNotZero();
assertThat(result.stderr).contains("not-a-command");
}
/***************************************************************************
* Test init with non-existent template.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_nonExistentTemplate_showsError() throws Exception
{
ProcessResult result = execute("qqq", "init", "not-a-real-template", "--no-prompt",
"-o", tempDir.resolve("test-project").toString());
// Should show error with suggestions
assertThat(result.stdout + result.stderr)
.containsIgnoringCase("not found")
.contains("not-a-real-template");
}
/***************************************************************************
* Test init --dry-run shows preview without creating files.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_dryRun_showsPreview() throws Exception
{
Path outputDir = tempDir.resolve("dry-run-project");
ProcessResult result = execute("qqq", "init", "new-qqq-application",
"--no-prompt", "--dry-run",
"-o", outputDir.toString());
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout).contains("[DRY-RUN]");
assertThat(outputDir).doesNotExist();
}
/***************************************************************************
* Test init succeeds with new-qqq-application template.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_defaultTemplate_createsProject() throws Exception
{
Path outputDir = tempDir.resolve("default-template-project");
ProcessResult result = execute("qqq", "init", "new-qqq-application",
"--no-prompt",
"--var", "applicationName=test-app",
"--var", "groupId=com.test",
"--var", "artifactId=test-app",
"-o", outputDir.toString());
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout).contains("Project initialized successfully");
assertThat(outputDir).exists();
}
/***************************************************************************
* Test init with --force overwrites existing directory.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_force_overwritesExisting() throws Exception
{
Path outputDir = tempDir.resolve("force-project");
Files.createDirectories(outputDir);
Files.writeString(outputDir.resolve("existing.txt"), "test");
ProcessResult result = execute("qqq", "init", "new-qqq-application",
"--no-prompt", "--force",
"--var", "applicationName=test-app",
"--var", "groupId=com.test",
"--var", "artifactId=test-app",
"-o", outputDir.toString());
assertThat(result.exitCode).isEqualTo(ExitCodes.SUCCESS);
assertThat(result.stdout).contains("Overwriting");
}
/***************************************************************************
* Test init without --force fails on existing directory.
*
* @since 0.2.0
***************************************************************************/
@Test
void testInit_existingDir_showsConflict() throws Exception
{
Path outputDir = tempDir.resolve("existing-project");
Files.createDirectories(outputDir);
ProcessResult result = execute("qqq", "init", "new-qqq-application",
"--no-prompt",
"-o", outputDir.toString());
assertThat(result.exitCode).isEqualTo(ExitCodes.CONFLICT);
assertThat(result.stderr).contains("target directory already exists");
}
/***************************************************************************
* Execute the native binary with arguments.
*
* @param args command-line arguments
* @return process result with stdout, stderr, and exit code
* @throws IOException if execution fails
* @throws InterruptedException if wait is interrupted
* @since 0.2.0
***************************************************************************/
private ProcessResult execute(String... args) throws IOException, InterruptedException
{
List<String> command = new ArrayList<>();
command.add(binaryPath.toAbsolutePath().toString());
command.addAll(List.of(args));
ProcessBuilder pb = new ProcessBuilder(command);
pb.environment().put("QCTL_REGISTRY_TYPE", "mock");
pb.directory(tempDir.toFile());
Process process = pb.start();
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
try(BufferedReader outReader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
BufferedReader errReader = new BufferedReader(
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)))
{
Thread outThread = new Thread(() ->
{
try
{
String line;
while((line = outReader.readLine()) != null)
{
stdout.append(line).append("\n");
}
}
catch(IOException e)
{
// Ignore
}
});
Thread errThread = new Thread(() ->
{
try
{
String line;
while((line = errReader.readLine()) != null)
{
stderr.append(line).append("\n");
}
}
catch(IOException e)
{
// Ignore
}
});
outThread.start();
errThread.start();
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if(!finished)
{
process.destroyForcibly();
throw new IOException("Process timed out after " + TIMEOUT_SECONDS + " seconds");
}
outThread.join(1000);
errThread.join(1000);
}
return new ProcessResult(stdout.toString(), stderr.toString(), process.exitValue());
}
/***************************************************************************
* Result of a process execution.
*
* @since 0.2.0
***************************************************************************/
record ProcessResult(String stdout, String stderr, int exitCode)
{
}
}
@@ -0,0 +1,189 @@
/*
* 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.e2e.harness;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import io.qrun.qctl.core.Main;
import io.qrun.qctl.shared.spi.CommandPlugin;
import picocli.CommandLine;
/*******************************************************************************
* Test harness for executing qctl commands in-process.
*
* Captures stdout, stderr, and exit codes for assertion. This enables fast,
* deterministic E2E tests without spawning separate processes.
*
* @since 0.2.0
*******************************************************************************/
public class CommandTestHarness
{
private final ByteArrayOutputStream stdoutStream = new ByteArrayOutputStream();
private final ByteArrayOutputStream stderrStream = new ByteArrayOutputStream();
private int exitCode = -1;
/***************************************************************************
* Execute a qctl command with the given arguments.
*
* @param args command-line arguments (e.g., "qqq", "init", "my-template")
* @return this harness for fluent assertions
* @since 0.2.0
***************************************************************************/
public CommandTestHarness execute(String... args)
{
PrintStream origOut = System.out;
PrintStream origErr = System.err;
try
{
System.setOut(new PrintStream(stdoutStream, true, StandardCharsets.UTF_8));
System.setErr(new PrintStream(stderrStream, true, StandardCharsets.UTF_8));
CommandLine cmd = buildCommandLine();
exitCode = cmd.execute(args);
}
finally
{
System.setOut(origOut);
System.setErr(origErr);
}
return this;
}
/***************************************************************************
* Build CommandLine with all SPI-discovered plugins.
*
* @return configured CommandLine instance
* @since 0.2.0
***************************************************************************/
private CommandLine buildCommandLine()
{
CommandLine cmd = new CommandLine(new Main());
//////////////////////////////////////////////////////////////////////////
// Discover and register subcommands via SPI //
//////////////////////////////////////////////////////////////////////////
try
{
java.util.ServiceLoader<CommandPlugin> loader =
java.util.ServiceLoader.load(CommandPlugin.class);
for(CommandPlugin plugin : loader)
{
Object command = plugin.getCommand();
CommandLine sub = new CommandLine(command);
String name = sub.getCommandSpec().name();
cmd.addSubcommand(name, command);
}
}
catch(java.util.ServiceConfigurationError | NoClassDefFoundError e)
{
//////////////////////////////////////////////////////////////////
// Log but continue - some plugins may not be available //
//////////////////////////////////////////////////////////////////
System.err.println("warning: some command plugins could not be loaded: " + e.getMessage());
}
return cmd;
}
/***************************************************************************
* Get the captured stdout content.
*
* @return stdout as string
* @since 0.2.0
***************************************************************************/
public String getStdout()
{
return stdoutStream.toString(StandardCharsets.UTF_8);
}
/***************************************************************************
* Get the captured stderr content.
*
* @return stderr as string
* @since 0.2.0
***************************************************************************/
public String getStderr()
{
return stderrStream.toString(StandardCharsets.UTF_8);
}
/***************************************************************************
* Get the exit code from the last command execution.
*
* @return exit code (0 = success)
* @since 0.2.0
***************************************************************************/
public int getExitCode()
{
return exitCode;
}
/***************************************************************************
* Check if stdout contains a specific string.
*
* @param expected the string to search for
* @return true if stdout contains the string
* @since 0.2.0
***************************************************************************/
public boolean stdoutContains(String expected)
{
return getStdout().contains(expected);
}
/***************************************************************************
* Check if stderr contains a specific string.
*
* @param expected the string to search for
* @return true if stderr contains the string
* @since 0.2.0
***************************************************************************/
public boolean stderrContains(String expected)
{
return getStderr().contains(expected);
}
/***************************************************************************
* Reset the harness for reuse.
*
* @return this harness for fluent chaining
* @since 0.2.0
***************************************************************************/
public CommandTestHarness reset()
{
stdoutStream.reset();
stderrStream.reset();
exitCode = -1;
return this;
}
}
@@ -0,0 +1,131 @@
/*
* 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.e2e.harness;
import io.qrun.qctl.shared.ExitCodes;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* AssertJ-style assertions for CommandTestHarness exit codes.
*
* Provides readable assertions for common exit code scenarios.
*
* @since 0.2.0
*******************************************************************************/
public final class ExitCodeAssertions
{
/***************************************************************************
* Private constructor - utility class.
*
* @since 0.2.0
***************************************************************************/
private ExitCodeAssertions()
{
}
/***************************************************************************
* Assert the command executed successfully (exit code 0).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertSuccess(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected success (exit code 0), got %d.\nStdout: %s\nStderr: %s",
harness.getExitCode(), harness.getStdout(), harness.getStderr())
.isEqualTo(ExitCodes.SUCCESS);
}
/***************************************************************************
* Assert the command failed with a generic error (exit code 1).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertGenericError(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected generic error (exit code 1)")
.isEqualTo(ExitCodes.GENERIC);
}
/***************************************************************************
* Assert the command failed with a usage error (exit code 2).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertUsageError(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected usage error (exit code 2)")
.isEqualTo(ExitCodes.USAGE);
}
/***************************************************************************
* Assert the command failed with a validation error (exit code 6).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertValidationError(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected validation error (exit code 6)")
.isEqualTo(ExitCodes.VALIDATION);
}
/***************************************************************************
* Assert the command failed with a conflict error (exit code 8).
*
* @param harness the test harness
* @since 0.2.0
***************************************************************************/
public static void assertConflictError(CommandTestHarness harness)
{
assertThat(harness.getExitCode())
.as("Expected conflict error (exit code 8)")
.isEqualTo(ExitCodes.CONFLICT);
}
/***************************************************************************
* Assert the command returned a specific exit code.
*
* @param harness the test harness
* @param expectedCode expected exit code
* @since 0.2.0
***************************************************************************/
public static void assertExitCode(CommandTestHarness harness, int expectedCode)
{
assertThat(harness.getExitCode())
.as("Expected exit code %d", expectedCode)
.isEqualTo(expectedCode);
}
}
@@ -0,0 +1,222 @@
/*
* 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.e2e.harness;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import io.qrun.qctl.qqq.registry.TemplateInfo;
import io.qrun.qctl.qqq.registry.TemplateRegistry;
import io.qrun.qctl.qqq.template.TemplateManifest;
/*******************************************************************************
* Test template registry that loads fixtures from test resources.
*
* Provides fully offline, deterministic template loading for E2E tests.
* Templates are loaded from src/test/resources/fixtures/templates/.
*
* @since 0.2.0
*******************************************************************************/
public class TestTemplateRegistry implements TemplateRegistry
{
private static final String FIXTURES_DIR = "fixtures/templates";
private final Map<String, Path> templatePaths = new HashMap<>();
private final Map<String, TemplateInfo> templateInfos = new HashMap<>();
/***************************************************************************
* Register a fixture template by ID.
*
* @param id template ID
* @param fixtureDir path to fixture directory containing template.yaml
* @return this for fluent chaining
* @throws IOException if template cannot be loaded
* @since 0.2.0
***************************************************************************/
public TestTemplateRegistry addFixture(String id, Path fixtureDir) throws IOException
{
if(!Files.exists(fixtureDir))
{
throw new IOException("Fixture directory does not exist: " + fixtureDir);
}
templatePaths.put(id, fixtureDir);
Path manifestPath = fixtureDir.resolve("template.yaml");
if(Files.exists(manifestPath))
{
TemplateManifest manifest = TemplateManifest.load(fixtureDir);
templateInfos.put(id, createTemplateInfo(id, manifest, fixtureDir));
}
else
{
//////////////////////////////////////////////////////////////////
// Create minimal info for templates without manifests //
//////////////////////////////////////////////////////////////////
templateInfos.put(id, createMinimalInfo(id, fixtureDir));
}
return this;
}
/***************************************************************************
* Load a fixture from classpath resources.
*
* @param id template ID (also the directory name under fixtures/templates/)
* @return this for fluent chaining
* @throws IOException if fixture cannot be loaded
* @since 0.2.0
***************************************************************************/
public TestTemplateRegistry loadFromResources(String id) throws IOException
{
try
{
java.net.URL url = getClass().getClassLoader().getResource(FIXTURES_DIR + "/" + id);
if(url == null)
{
throw new IOException("Fixture not found in resources: " + id);
}
Path fixtureDir = Path.of(url.toURI());
return addFixture(id, fixtureDir);
}
catch(URISyntaxException e)
{
throw new IOException("Invalid fixture path: " + id, e);
}
}
/***************************************************************************
* Create TemplateInfo from manifest.
*
* @param id template ID
* @param manifest the loaded manifest
* @param fixtureDir the fixture directory path
* @return TemplateInfo record
* @since 0.2.0
***************************************************************************/
private TemplateInfo createTemplateInfo(String id, TemplateManifest manifest, Path fixtureDir)
{
return new TemplateInfo(
id,
manifest.name() != null ? manifest.name() : id,
manifest.version() != null ? manifest.version() : "1.0.0",
manifest.description() != null ? manifest.description() : "Test fixture template",
fixtureDir.toUri().toString(),
manifest.getEffectiveSchemaVersion(),
manifest.minimumQctlVersion(),
List.of("test", "fixture"),
"test",
manifest.prompts() != null ? manifest.prompts() : List.of(),
manifest.computed() != null ? manifest.computed() : List.of(),
manifest.transforms() != null ? manifest.transforms() : List.of(),
manifest.postGen() != null ? manifest.postGen() : List.of(),
manifest.ignore() != null ? manifest.ignore() : List.of()
);
}
/***************************************************************************
* Create minimal TemplateInfo for fixtures without manifest.
*
* @param id template ID
* @param fixtureDir the fixture directory path
* @return TemplateInfo record
* @since 0.2.0
***************************************************************************/
private TemplateInfo createMinimalInfo(String id, Path fixtureDir)
{
return new TemplateInfo(
id,
id,
"1.0.0",
"Test fixture template (no manifest)",
fixtureDir.toUri().toString(),
1,
null,
List.of("test"),
"test",
List.of(),
List.of(),
List.of(),
List.of(),
List.of()
);
}
@Override
public List<TemplateInfo> listTemplates()
{
return new ArrayList<>(templateInfos.values());
}
@Override
public Optional<TemplateInfo> getTemplate(String id)
{
return Optional.ofNullable(templateInfos.get(id));
}
@Override
public Path downloadTemplate(String id, String version) throws IOException
{
Path fixturePath = templatePaths.get(id);
if(fixturePath == null)
{
throw new IOException("Fixture template not found: " + id);
}
return fixturePath;
}
@Override
public void refresh()
{
// No-op for test registry
}
/***************************************************************************
* Clear all registered fixtures.
*
* @return this for fluent chaining
* @since 0.2.0
***************************************************************************/
public TestTemplateRegistry clear()
{
templatePaths.clear();
templateInfos.clear();
return this;
}
}
@@ -0,0 +1,292 @@
/*
* 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.e2e.harness;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.function.Function;
import io.qrun.qctl.qqq.template.ConsoleUI;
/*******************************************************************************
* Test-friendly ConsoleUI that queues inputs and captures outputs.
*
* Allows tests to pre-populate input responses and verify prompt sequences
* without actual user interaction.
*
* @since 0.2.0
*******************************************************************************/
public class TestableConsoleUI extends ConsoleUI
{
private final Queue<String> queuedInputs = new LinkedList<>();
private final List<String> promptHistory = new ArrayList<>();
private final List<String> outputHistory = new ArrayList<>();
/***************************************************************************
* Queue an input value to be returned by the next prompt.
*
* @param input the input value
* @return this for fluent chaining
* @since 0.2.0
***************************************************************************/
public TestableConsoleUI queueInput(String input)
{
queuedInputs.add(input);
return this;
}
/***************************************************************************
* Queue multiple input values in order.
*
* @param inputs the input values
* @return this for fluent chaining
* @since 0.2.0
***************************************************************************/
public TestableConsoleUI queueInputs(String... inputs)
{
for(String input : inputs)
{
queuedInputs.add(input);
}
return this;
}
/***************************************************************************
* Get the list of prompts that were displayed.
*
* @return list of prompt labels
* @since 0.2.0
***************************************************************************/
public List<String> getPromptHistory()
{
return new ArrayList<>(promptHistory);
}
/***************************************************************************
* Get the list of outputs (headers, info, success, etc).
*
* @return list of output messages
* @since 0.2.0
***************************************************************************/
public List<String> getOutputHistory()
{
return new ArrayList<>(outputHistory);
}
/***************************************************************************
* Reset all queued inputs and histories.
*
* @return this for fluent chaining
* @since 0.2.0
***************************************************************************/
public TestableConsoleUI reset()
{
queuedInputs.clear();
promptHistory.clear();
outputHistory.clear();
return this;
}
/***************************************************************************
* Check if any queued inputs remain unused.
*
* @return true if inputs remain
* @since 0.2.0
***************************************************************************/
public boolean hasUnusedInputs()
{
return !queuedInputs.isEmpty();
}
/***************************************************************************
* Get next queued input or empty string if none.
*
* @return queued input
* @since 0.2.0
***************************************************************************/
private String nextInput()
{
return queuedInputs.poll();
}
//////////////////////////////////////////////////////////////////////////
// Overridden methods to capture and simulate //
//////////////////////////////////////////////////////////////////////////
@Override
public void header(String title)
{
outputHistory.add("HEADER: " + title);
}
@Override
public void subtitle(String text)
{
outputHistory.add("SUBTITLE: " + text);
}
@Override
public void info(String text)
{
outputHistory.add("INFO: " + text);
}
@Override
public void success(String text)
{
outputHistory.add("SUCCESS: " + text);
}
@Override
public void successBold(String text)
{
outputHistory.add("SUCCESS_BOLD: " + text);
}
@Override
public void error(String text)
{
outputHistory.add("ERROR: " + text);
}
@Override
public void validationError(String text)
{
outputHistory.add("VALIDATION_ERROR: " + text);
}
@Override
public void validationSuccess()
{
outputHistory.add("VALIDATION_SUCCESS");
}
@Override
public void warning(String text)
{
outputHistory.add("WARNING: " + text);
}
@Override
public void println()
{
outputHistory.add("PRINTLN:");
}
@Override
public void println(String text)
{
outputHistory.add("PRINTLN: " + text);
}
@Override
public String promptText(String label, String defaultValue)
{
promptHistory.add(label);
String input = nextInput();
if(input == null || input.isEmpty())
{
return defaultValue != null ? defaultValue : "";
}
return input;
}
@Override
public void showValue(String label, String value)
{
outputHistory.add("SHOW_VALUE: " + label + "=" + value);
}
@Override
public void showValue(String label, String value, String annotation)
{
outputHistory.add("SHOW_VALUE: " + label + "=" + value + " (" + annotation + ")");
}
@Override
public <T> T promptSelect(String label,
List<T> items,
Function<T, String> displayFn,
Function<T, String> descriptionFn,
int defaultIndex)
{
promptHistory.add(label);
String input = nextInput();
int selectedIndex = defaultIndex;
if(input != null && !input.isEmpty())
{
try
{
int parsed = Integer.parseInt(input.trim()) - 1;
if(parsed >= 0 && parsed < items.size())
{
selectedIndex = parsed;
}
}
catch(NumberFormatException e)
{
// Use default
}
}
return items.get(selectedIndex);
}
}
@@ -0,0 +1,29 @@
schemaVersion: 2
id: test-computed-vars
name: Computed Variables Test Template
version: 1.0.0
description: Template for testing computed variable evaluation
prompts:
- name: packageName
message: Package name
type: text
defaultValue: com.example.app
required: true
- name: projectName
message: Project name
type: text
defaultValue: my-project
required: true
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
- name: projectNameKebab
expression: "$str.kebab($projectName)"
- name: projectNamePascal
expression: "$str.pascal($projectName)"
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,8 @@
# $projectName
## Computed Variables Test
- Package Name: $packageName
- Package Path: $packagePath
- Kebab Case: $projectNameKebab
- Pascal Case: $projectNamePascal
@@ -0,0 +1,15 @@
package $packageName;
/**
* Main application class.
* Project: $projectName
* Project (kebab): $projectNameKebab
* Project (pascal): $projectNamePascal
*/
public class App
{
public static void main(String[] args)
{
System.out.println("Hello from $projectName!");
}
}
@@ -0,0 +1,40 @@
schemaVersion: 2
id: test-full-substitution
name: Full Substitution Test Template
version: 1.0.0
description: Template for testing variable substitution in file content
prompts:
- name: projectName
message: Project name
type: text
defaultValue: my-project
required: true
- name: packageName
message: Package name
type: text
defaultValue: com.example
required: true
- name: groupId
message: Maven group ID
type: text
defaultValue: com.example
required: true
- name: artifactId
message: Maven artifact ID
type: text
defaultValue: my-app
required: true
- name: version
message: Project version
type: text
defaultValue: 1.0.0-SNAPSHOT
required: true
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,9 @@
# $projectName
A project using package `$packageName`.
## Maven Coordinates
- Group ID: $groupId
- Artifact ID: $artifactId
- Version: $version
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>$groupId</groupId>
<artifactId>$artifactId</artifactId>
<version>$version</version>
<packaging>jar</packaging>
<name>$projectName</name>
<description>Generated project for $packageName</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
@@ -0,0 +1,12 @@
package $packageName;
/**
* Main application class for $projectName.
*/
public class App
{
public static void main(String[] args)
{
System.out.println("Hello from $projectName!");
}
}
@@ -0,0 +1,10 @@
schemaVersion: 2
id: test-minimal
name: Minimal Test Template
version: 1.0.0
description: Minimal template for fast E2E tests
prompts: []
computed: []
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,3 @@
# Minimal Test Project
This is a minimal test template with no variables.
@@ -0,0 +1,15 @@
schemaVersion: 2
id: test-transform-error
name: Transform Error Test Template
version: 1.0.0
description: Template with invalid transform type
prompts: []
computed: []
transforms:
- type: bogus_transform_type
pattern: "**/*.txt"
postGen: []
ignore: []
@@ -0,0 +1 @@
Test file for transform error testing.
@@ -0,0 +1,23 @@
schemaVersion: 2
id: test-transforms
name: Transform Test Template
version: 1.0.0
description: Template for testing directory and file transforms
prompts:
- name: packageName
message: Package name
type: text
defaultValue: com.example
required: true
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
transforms:
- type: delete
pattern: "**/.gitkeep"
postGen: []
ignore: []
@@ -0,0 +1,3 @@
# Transform Test
Package: $packageName
@@ -0,0 +1 @@
# This file should be deleted by the delete transform
@@ -0,0 +1,9 @@
package $packageName;
public class App
{
public static void main(String[] args)
{
System.out.println("Hello!");
}
}
@@ -0,0 +1,22 @@
schemaVersion: 2
id: test-variable-typo
name: Variable Typo Test Template
version: 1.0.0
description: Template with intentional variable typo for error testing
prompts:
- name: projectName
message: Project name
type: text
defaultValue: my-project
required: true
- name: packageName
message: Package name
type: text
defaultValue: com.example
required: true
computed: []
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,6 @@
# $projetcName
This template has a typo in the variable name (projetcName instead of projectName).
It should trigger the "Did you mean?" suggestion.
Package: $pacakgeName
@@ -0,0 +1,30 @@
schemaVersion: 2
id: test-with-prompts
name: Test Template with Prompts
version: 1.0.0
description: Template for testing interactive prompts
prompts:
- name: projectName
message: Project name
type: text
defaultValue: my-project
required: true
- name: packageName
message: Package name
type: text
defaultValue: com.example
required: true
- name: includeTests
message: Include tests?
type: confirm
defaultValue: "true"
required: false
computed:
- name: packagePath
expression: "$str.replace($packageName, '.', '/')"
transforms: []
postGen: []
ignore: []
@@ -0,0 +1,3 @@
# $projectName
Package: $packageName
@@ -0,0 +1,11 @@
schemaVersion: 2
id: test-yaml-error
name: YAML Error Test Template
version: 1.0.0
description: Template with intentional YAML syntax error
prompts:
- name: projectName
message: Project name
- this_is_invalid # Intentional YAML error at line 9
type: text
+21 -9
View File
@@ -27,15 +27,9 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.github.jknack</groupId>
<artifactId>handlebars</artifactId>
<version>4.4.0</version>
<exclusions>
<exclusion>
<groupId>org.openjdk.nashorn</groupId>
<artifactId>nashorn-core</artifactId>
</exclusion>
</exclusions>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>${velocity.version}</version>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
@@ -43,6 +37,24 @@
<version>${picocli.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
@@ -14,19 +14,33 @@ package io.qrun.qctl.qqq;
import java.io.IOException;
import java.io.InputStream;
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;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.Callable;
import io.qrun.qctl.core.doctor.DependencyChecker;
import io.qrun.qctl.core.doctor.DependencyException;
import io.qrun.qctl.qqq.error.ErrorFormatter;
import io.qrun.qctl.qqq.error.SuggestionEngine;
import io.qrun.qctl.qqq.error.TemplateError;
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;
import io.qrun.qctl.qqq.template.TemplateManifest;
import io.qrun.qctl.qqq.template.TemplateResolver;
import io.qrun.qctl.qqq.template.TemplatesHub;
import io.qrun.qctl.qqq.template.TemplateRenderException;
import io.qrun.qctl.qqq.template.TransformExecutor;
import io.qrun.qctl.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
@@ -37,13 +51,13 @@ import picocli.CommandLine.Parameters;
* Initialize a new project from a template.
*
* Supports template IDs from hub, local paths, git URLs, or github shorthand.
* Renders Handlebars templates, prompts for variables, and runs post-gen hooks.
* Renders Velocity templates, prompts for variables, and runs post-gen hooks.
*
* @since 0.1.0
*******************************************************************************/
@Command(name = "init", description = "Initialize a new project from a template",
mixinStandardHelpOptions = true)
public class InitCommand implements Runnable
public class InitCommand implements Callable<Integer>
{
private static final String DEFAULT_TEMPLATE = "new-qqq-application";
private static final String DEFAULT_PROJECT_DIR = "my-qqq-app";
@@ -76,6 +90,26 @@ public class InitCommand implements Runnable
@Option(names = "--no-prompt", description = "Disable interactive prompts (use defaults)")
boolean noPrompt;
@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;
/***************************************************************************
@@ -90,7 +124,7 @@ public class InitCommand implements Runnable
@Override
public void run()
public Integer call()
{
ConsoleUI ui = new ConsoleUI();
@@ -100,39 +134,41 @@ public class InitCommand implements Runnable
ui.subtitle("Initialize a new QQQ project");
ui.println();
// Step 1: Template selection
TemplatesHub hub = new TemplatesHub();
TemplatesHub.TemplateEntry hubEntry = selectTemplate(hub, ui);
TemplatesHub.TemplatesIndex index = hub.fetchIndex();
String gitSource = hubEntry.getGitUrl(index.registry());
// Step 1: Template selection from registry
TemplateRegistry registry = TemplateRegistryFactory.getRegistry();
TemplateInfo templateInfo = selectTemplate(registry, ui);
// Step 2: Get target directory
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.");
System.exit(ExitCodes.CONFLICT);
ui.println("Use --force to overwrite or --merge to merge with existing.");
return ExitCodes.CONFLICT;
}
// Step 3: Resolve template
// Step 3: Download template from registry
ui.println();
ui.info("Resolving template...");
TemplateResolver resolver = new TemplateResolver();
Path templatePath = resolver.resolve(gitSource, version);
ui.info("Downloading template...");
Path templatePath = registry.downloadTemplate(templateInfo.id(), version);
// Get manifest from hub entry
TemplateManifest manifest = new TemplateManifest(
hubEntry.id(),
hubEntry.name(),
hubEntry.version(),
hubEntry.description(),
hubEntry.prompts(),
hubEntry.postGen(),
hubEntry.ignore()
);
// Get manifest from template info
TemplateManifest manifest = templateInfo.toManifest();
// Check minimum qctl version
if(manifest.minimumQctlVersion() != null)
{
String currentVersion = getVersionFromProperties();
if(!isVersionSatisfied(currentVersion, manifest.minimumQctlVersion()))
{
ui.error("Template requires qctl " + manifest.minimumQctlVersion() + " or later");
ui.println("Current version: " + currentVersion);
ui.println("Please upgrade qctl to use this template.");
return ExitCodes.VALIDATION;
}
}
// Step 4: Collect variables (prompts + CLI overrides)
Map<String, String> allVars = new LinkedHashMap<>();
@@ -143,13 +179,22 @@ public class InitCommand implements Runnable
}
allVars.putAll(variables);
// Step 5: Render templates
// 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
{
@@ -157,16 +202,46 @@ 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);
// Run transforms (rename/delete)
if(manifest.transforms() != null && !manifest.transforms().isEmpty())
{
ui.info("Applying transforms...");
TransformExecutor transformExecutor = new TransformExecutor();
transformExecutor.execute(effectiveTargetDir, manifest.transforms(), allVars);
}
// Run post-gen hooks
if(!skipHooks && manifest.postGen() != null)
{
PostGenHookRunner hookRunner = new PostGenHookRunner();
hookRunner.run(effectiveTargetDir, manifest.postGen());
//////////////////////////////////////////////////////////////////
// Pre-flight dependency checks before running hooks //
//////////////////////////////////////////////////////////////////
try
{
checkHookDependencies(manifest.postGen(), ui);
}
catch(DependencyException e)
{
ui.warning("Skipping post-gen hooks: " + e.getMessage());
ui.println("Run 'qctl doctor' for installation help.");
skipHooks = true;
}
if(!skipHooks)
{
PostGenHookRunner hookRunner = new PostGenHookRunner();
hookRunner.run(effectiveTargetDir, manifest.postGen());
}
}
}
@@ -176,16 +251,40 @@ public class InitCommand implements Runnable
ui.println("Next steps:");
ui.println(" cd " + effectiveTargetDir);
ui.println(" mvn clean verify");
return ExitCodes.SUCCESS;
}
catch(TemplateRenderException e)
{
ErrorFormatter formatter = new ErrorFormatter(verbose);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
e.getMessage(),
"Check template syntax and ensure all variables are defined"
);
System.err.print(formatter.format(error));
return error.getExitCode();
}
catch(IOException e)
{
ui.error(e.getMessage());
System.exit(ExitCodes.GENERIC);
ErrorFormatter formatter = new ErrorFormatter(verbose);
String hint = e instanceof UnknownHostException
? "Check your network connection and try again"
: null;
System.err.print(formatter.format(e, hint));
return ExitCodes.GENERIC;
}
catch(TemplateError e)
{
ErrorFormatter formatter = new ErrorFormatter(verbose);
System.err.print(formatter.format(e));
return e.getExitCode();
}
catch(Exception e)
{
ui.error(e.getMessage());
System.exit(ExitCodes.GENERIC);
ErrorFormatter formatter = new ErrorFormatter(verbose);
System.err.print(formatter.format(e, null));
return ExitCodes.GENERIC;
}
}
@@ -194,27 +293,42 @@ public class InitCommand implements Runnable
/***************************************************************************
* Select template - use provided name or prompt interactively.
*
* @param hub templates hub
* @param registry template registry
* @param ui console UI
* @return selected template entry
* @throws IOException if hub cannot be fetched
* @return selected template info
* @throws IOException if registry cannot be fetched
* @since 0.1.0
***************************************************************************/
private TemplatesHub.TemplateEntry selectTemplate(TemplatesHub hub, ConsoleUI ui) throws IOException
private TemplateInfo selectTemplate(TemplateRegistry registry, ConsoleUI ui) throws IOException
{
List<TemplatesHub.TemplateEntry> templates = hub.fetchIndex().templates();
List<TemplateInfo> templates = registry.listTemplates();
// If template name provided and found, use it
Optional<TemplatesHub.TemplateEntry> entry = hub.findById(templateName);
Optional<TemplateInfo> entry = registry.getTemplate(templateName);
if(entry.isPresent())
{
TemplatesHub.TemplateEntry selected = entry.get();
TemplateInfo selected = entry.get();
ui.showValue("Template", selected.name(), selected.id());
return selected;
}
// Template not found - prompt for selection
/////////////////////////////////////////////////////////////////////////
// Template not found - show error with suggestions //
/////////////////////////////////////////////////////////////////////////
List<String> templateIds = templates.stream().map(TemplateInfo::id).toList();
SuggestionEngine suggestionEngine = new SuggestionEngine();
List<String> suggestions = suggestionEngine.findSimilar(templateName, templateIds);
ui.error("Template '" + templateName + "' not found.");
if(!suggestions.isEmpty())
{
ui.println();
ui.println("Did you mean?");
for(String suggestion : suggestions)
{
ui.println(" " + suggestion);
}
}
ui.println();
// Find default index
@@ -231,8 +345,8 @@ public class InitCommand implements Runnable
return ui.promptSelect(
"Select a template:",
templates,
TemplatesHub.TemplateEntry::name,
TemplatesHub.TemplateEntry::description,
TemplateInfo::name,
TemplateInfo::description,
defaultIndex
);
}
@@ -263,4 +377,145 @@ public class InitCommand implements Runnable
String input = ui.promptText("Project directory", DEFAULT_PROJECT_DIR);
return Path.of(input);
}
/***************************************************************************
* Get version from version.properties resource file.
*
* @return version string from properties or "0.0.0-dev" if not found
* @since 0.2.0
***************************************************************************/
private String getVersionFromProperties()
{
Properties props = new Properties();
try(InputStream is = getClass().getResourceAsStream("/version.properties"))
{
if(is != null)
{
props.load(is);
}
}
catch(IOException e)
{
// Fall back to default
}
return props.getProperty("version", "0.0.0-dev");
}
/***************************************************************************
* Check if current version satisfies the minimum version requirement.
*
* @param current current version string (e.g., "0.2.0")
* @param minimum minimum required version (e.g., "0.1.0")
* @return true if current >= minimum
* @since 0.2.0
***************************************************************************/
private boolean isVersionSatisfied(String current, String minimum)
{
int[] currentParts = parseVersion(current);
int[] minimumParts = parseVersion(minimum);
for(int i = 0; i < 3; i++)
{
if(currentParts[i] > minimumParts[i])
{
return true;
}
if(currentParts[i] < minimumParts[i])
{
return false;
}
}
return true;
}
/***************************************************************************
* Parse a version string into major, minor, patch components.
*
* @param version version string (e.g., "1.2.3" or "1.2.3-dev")
* @return array of [major, minor, patch]
* @since 0.2.0
***************************************************************************/
private int[] parseVersion(String version)
{
int[] parts = {0, 0, 0};
if(version == null || version.isEmpty())
{
return parts;
}
/////////////////////////////////////////////////////////////////////////
// Strip any suffix after dash (e.g., "1.2.3-dev" -> "1.2.3") //
/////////////////////////////////////////////////////////////////////////
String cleanVersion = version.contains("-") ? version.split("-")[0] : version;
String[] segments = cleanVersion.split("\\.");
for(int i = 0; i < Math.min(segments.length, 3); i++)
{
try
{
parts[i] = Integer.parseInt(segments[i]);
}
catch(NumberFormatException e)
{
parts[i] = 0;
}
}
return parts;
}
/***************************************************************************
* Check dependencies required by post-gen hooks before running them.
*
* Examines hook commands and verifies required tools are installed:
* - git commands require Git
* - mvn commands require Java 21+ and Maven
*
* @param hooks list of post-gen hooks to check
* @param ui console UI for status messages
* @throws DependencyException if a required dependency is missing
* @since 0.2.0
***************************************************************************/
private void checkHookDependencies(List<TemplateManifest.PostGenHook> hooks, ConsoleUI ui)
throws DependencyException
{
boolean needsGit = false;
boolean needsMaven = false;
for(TemplateManifest.PostGenHook hook : hooks)
{
if(hook.command() == null)
{
continue;
}
String cmd = hook.command().trim().toLowerCase();
if(cmd.startsWith("git "))
{
needsGit = true;
}
if(cmd.startsWith("mvn ") || cmd.startsWith("./mvnw ") || cmd.contains("maven"))
{
needsMaven = true;
}
}
if(needsGit)
{
DependencyChecker.requireGit();
}
if(needsMaven)
{
DependencyChecker.requireJava(21);
DependencyChecker.requireMaven();
}
}
}
@@ -14,24 +14,38 @@ package io.qrun.qctl.qqq;
import java.io.IOException;
import io.qrun.qctl.qqq.template.TemplatesHub;
import java.net.UnknownHostException;
import java.util.List;
import java.util.concurrent.Callable;
import io.qrun.qctl.qqq.error.ErrorFormatter;
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.shared.ExitCodes;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/*******************************************************************************
* List available project templates from the hub.
* List available project templates from the registry.
*
* @since 0.1.0
*******************************************************************************/
@Command(name = "list", description = "List available project templates")
public class ListCommand implements Runnable
public class ListCommand implements Callable<Integer>
{
private static final String ANSI_BOLD = "\u001B[1m";
private static final String ANSI_CYAN = "\u001B[36m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_RESET = "\u001B[0m";
@Option(names = "--refresh", description = "Bypass cache and fetch fresh data")
boolean refresh;
@Option(names = {"-v", "--verbose"}, description = "Show detailed error information")
boolean verbose;
/***************************************************************************
@@ -46,25 +60,32 @@ public class ListCommand implements Runnable
@Override
public void run()
public Integer call()
{
try
{
System.out.println("Fetching templates from hub...\n");
TemplatesHub hub = new TemplatesHub();
var index = hub.fetchIndex();
System.out.println("Fetching templates from registry...\n");
TemplateRegistry registry = TemplateRegistryFactory.getRegistry();
if(index.templates() == null || index.templates().isEmpty())
if(refresh)
{
registry.refresh();
}
List<TemplateInfo> templates = registry.listTemplates();
if(templates.isEmpty())
{
System.out.println("No templates available.");
return;
return ExitCodes.SUCCESS;
}
System.out.println(ANSI_BOLD + "Available Templates:" + ANSI_RESET + "\n");
for(var template : index.templates())
for(TemplateInfo template : templates)
{
System.out.println(ANSI_CYAN + " " + template.id() + ANSI_RESET);
System.out.println(ANSI_CYAN + " " + template.id() + ANSI_RESET
+ " " + ANSI_GREEN + "v" + template.version() + ANSI_RESET);
System.out.println(" " + template.name());
System.out.println(" " + ANSI_DIM + template.description() + ANSI_RESET);
if(template.tags() != null && !template.tags().isEmpty())
@@ -74,12 +95,18 @@ public class ListCommand implements Runnable
System.out.println();
}
System.out.println("Use: qctl qqq init <template-id> <target-dir>");
System.out.println("Use: qctl qqq init <template-id> -o <target-dir>");
return ExitCodes.SUCCESS;
}
catch(IOException e)
{
System.err.println("error: Failed to fetch templates: " + e.getMessage());
System.exit(ExitCodes.NETWORK);
ErrorFormatter formatter = new ErrorFormatter(verbose);
String hint = e instanceof UnknownHostException
? "Check your network connection and try again"
: "Run with --refresh to bypass cache";
System.err.print(formatter.format(e, hint));
return ExitCodes.NETWORK;
}
}
}
@@ -0,0 +1,143 @@
/*
* 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.error;
import java.io.PrintWriter;
import java.io.StringWriter;
/*******************************************************************************
* Formats errors with consistent styling and recovery suggestions.
*
* @since 0.2.0
*******************************************************************************/
public class ErrorFormatter
{
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_YELLOW = "\u001B[33m";
private static final String ANSI_CYAN = "\u001B[36m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String ANSI_RESET = "\u001B[0m";
private final boolean verbose;
/***************************************************************************
* Create an error formatter.
*
* @param verbose whether to include stack traces
* @since 0.2.0
***************************************************************************/
public ErrorFormatter(boolean verbose)
{
this.verbose = verbose;
}
/***************************************************************************
* Format a template error for display.
*
* @param error the template error
* @return formatted error message
* @since 0.2.0
***************************************************************************/
public String format(TemplateError error)
{
StringBuilder sb = new StringBuilder();
// Error message
sb.append(ANSI_RED).append("error: ").append(ANSI_RESET);
sb.append(error.getMessage()).append("\n");
// Suggestions (did you mean?)
if(error.hasSuggestions())
{
sb.append("\n");
sb.append(ANSI_YELLOW).append("Did you mean?").append(ANSI_RESET).append("\n");
for(String suggestion : error.getSuggestions())
{
sb.append(" ").append(ANSI_CYAN).append(suggestion).append(ANSI_RESET).append("\n");
}
}
// Hint
if(error.getHint() != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append(error.getHint()).append(ANSI_RESET).append("\n");
}
// Stack trace (verbose mode)
if(verbose && error.getCause() != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append("Stack trace:").append(ANSI_RESET).append("\n");
sb.append(getStackTrace(error.getCause()));
}
return sb.toString();
}
/***************************************************************************
* Format a generic exception.
*
* @param error the exception
* @param hint optional hint for recovery
* @return formatted error message
* @since 0.2.0
***************************************************************************/
public String format(Exception error, String hint)
{
StringBuilder sb = new StringBuilder();
sb.append(ANSI_RED).append("error: ").append(ANSI_RESET);
sb.append(error.getMessage()).append("\n");
if(hint != null)
{
sb.append("\n");
sb.append(ANSI_DIM).append(hint).append(ANSI_RESET).append("\n");
}
if(verbose)
{
sb.append("\n");
sb.append(ANSI_DIM).append("Stack trace:").append(ANSI_RESET).append("\n");
sb.append(getStackTrace(error));
}
return sb.toString();
}
/***************************************************************************
* Get stack trace as string.
*
* @param throwable the exception
* @return stack trace string
* @since 0.2.0
***************************************************************************/
private String getStackTrace(Throwable throwable)
{
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
throwable.printStackTrace(pw);
return sw.toString();
}
}
@@ -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.error;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/*******************************************************************************
* Provides fuzzy matching suggestions for typos and similar names.
*
* Uses Levenshtein distance and prefix matching to suggest alternatives.
*
* @since 0.2.0
*******************************************************************************/
public class SuggestionEngine
{
private static final int MAX_SUGGESTIONS = 3;
private static final int MAX_DISTANCE = 3;
/***************************************************************************
* Find similar strings from a list of candidates.
*
* @param input the user's input
* @param candidates list of valid options
* @return list of similar options, sorted by relevance
* @since 0.2.0
***************************************************************************/
public List<String> findSimilar(String input, List<String> candidates)
{
if(input == null || input.isEmpty() || candidates == null || candidates.isEmpty())
{
return List.of();
}
String lowerInput = input.toLowerCase();
List<ScoredCandidate> scored = new ArrayList<>();
for(String candidate : candidates)
{
int score = calculateScore(lowerInput, candidate.toLowerCase());
if(score <= MAX_DISTANCE)
{
scored.add(new ScoredCandidate(candidate, score));
}
}
scored.sort(Comparator.comparingInt(ScoredCandidate::score));
return scored.stream()
.limit(MAX_SUGGESTIONS)
.map(ScoredCandidate::value)
.toList();
}
/***************************************************************************
* Calculate similarity score (lower is better).
*
* Combines Levenshtein distance with prefix bonus.
*
* @param input user input (lowercase)
* @param candidate candidate value (lowercase)
* @return similarity score
* @since 0.2.0
***************************************************************************/
private int calculateScore(String input, String candidate)
{
int distance = levenshteinDistance(input, candidate);
// Bonus for prefix match (reduce distance)
if(candidate.startsWith(input) || input.startsWith(candidate))
{
distance = Math.max(0, distance - 1);
}
// Bonus for containing the input
if(candidate.contains(input))
{
distance = Math.max(0, distance - 1);
}
return distance;
}
/***************************************************************************
* Calculate Levenshtein distance between two strings.
*
* @param s1 first string
* @param s2 second string
* @return edit distance
* @since 0.2.0
***************************************************************************/
private int levenshteinDistance(String s1, String s2)
{
int[][] dp = new int[s1.length() + 1][s2.length() + 1];
for(int i = 0; i <= s1.length(); i++)
{
dp[i][0] = i;
}
for(int j = 0; j <= s2.length(); j++)
{
dp[0][j] = j;
}
for(int i = 1; i <= s1.length(); i++)
{
for(int j = 1; j <= s2.length(); j++)
{
int cost = s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1;
dp[i][j] = Math.min(
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
dp[i - 1][j - 1] + cost
);
}
}
return dp[s1.length()][s2.length()];
}
/***************************************************************************
* Scored candidate for sorting.
***************************************************************************/
private record ScoredCandidate(String value, int score)
{
}
}
@@ -0,0 +1,209 @@
/*
* 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.error;
import java.util.List;
import io.qrun.qctl.shared.ExitCodes;
/*******************************************************************************
* Structured error with category, message, and recovery suggestions.
*
* @since 0.2.0
*******************************************************************************/
public class TemplateError extends RuntimeException
{
private final ErrorCategory category;
private final List<String> suggestions;
private final String hint;
/***************************************************************************
* Error categories mapping to exit codes.
*
* @since 0.2.0
***************************************************************************/
public enum ErrorCategory
{
TEMPLATE_NOT_FOUND(ExitCodes.NOT_FOUND),
VARIABLE_NOT_FOUND(ExitCodes.VALIDATION),
MANIFEST_INVALID(ExitCodes.VALIDATION),
RENDER_ERROR(ExitCodes.VALIDATION),
TRANSFORM_ERROR(ExitCodes.VALIDATION),
NETWORK_ERROR(ExitCodes.NETWORK),
VERSION_MISMATCH(ExitCodes.VALIDATION),
DIRECTORY_EXISTS(ExitCodes.CONFLICT),
IO_ERROR(ExitCodes.GENERIC);
private final int exitCode;
/*************************************************************************
* Create an error category with exit code.
*
* @param exitCode the exit code for this category
* @since 0.2.0
*************************************************************************/
ErrorCategory(int exitCode)
{
this.exitCode = exitCode;
}
/*************************************************************************
* Get the exit code for this category.
*
* @return exit code
* @since 0.2.0
*************************************************************************/
public int getExitCode()
{
return exitCode;
}
}
/***************************************************************************
* Create a template error.
*
* @param category error category
* @param message error message
* @param suggestions list of suggestions (e.g., similar names)
* @param hint actionable hint for recovery
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, List<String> suggestions, String hint)
{
super(message);
this.category = category;
this.suggestions = suggestions != null ? suggestions : List.of();
this.hint = hint;
}
/***************************************************************************
* Create a template error without suggestions.
*
* @param category error category
* @param message error message
* @param hint actionable hint
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, String hint)
{
this(category, message, null, hint);
}
/***************************************************************************
* Create a template error with only message.
*
* @param category error category
* @param message error message
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message)
{
this(category, message, null, null);
}
/***************************************************************************
* Create a template error wrapping a cause.
*
* @param category error category
* @param message error message
* @param cause the underlying cause
* @since 0.2.0
***************************************************************************/
public TemplateError(ErrorCategory category, String message, Throwable cause)
{
super(message, cause);
this.category = category;
this.suggestions = List.of();
this.hint = null;
}
/***************************************************************************
* Get the error category.
*
* @return error category
* @since 0.2.0
***************************************************************************/
public ErrorCategory getCategory()
{
return category;
}
/***************************************************************************
* Get the exit code for this error.
*
* @return exit code
* @since 0.2.0
***************************************************************************/
public int getExitCode()
{
return category.getExitCode();
}
/***************************************************************************
* Get suggestions for recovery.
*
* @return list of suggestions
* @since 0.2.0
***************************************************************************/
public List<String> getSuggestions()
{
return suggestions;
}
/***************************************************************************
* Get actionable hint.
*
* @return hint or null
* @since 0.2.0
***************************************************************************/
public String getHint()
{
return hint;
}
/***************************************************************************
* Check if error has suggestions.
*
* @return true if suggestions available
* @since 0.2.0
***************************************************************************/
public boolean hasSuggestions()
{
return !suggestions.isEmpty();
}
}
@@ -0,0 +1,160 @@
/*
* 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.registry;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import io.qrun.qctl.qqq.template.TemplateResolver;
import io.qrun.qctl.qqq.template.TemplatesHub;
/*******************************************************************************
* Template registry backed by GitHub templates-hub repository.
*
* Fetches template index from raw.githubusercontent.com and clones templates
* from GitHub repositories. Includes caching with configurable TTL.
*
* @since 0.2.0
*******************************************************************************/
public class GitHubTemplateRegistry implements TemplateRegistry
{
private static final Duration DEFAULT_CACHE_TTL = Duration.ofMinutes(15);
private final TemplatesHub hub;
private final TemplateResolver resolver;
private final Duration cacheTtl;
private List<TemplateInfo> cachedTemplates;
private Instant cacheExpiry;
/***************************************************************************
* Construct with default cache TTL.
*
* @since 0.2.0
***************************************************************************/
public GitHubTemplateRegistry()
{
this(DEFAULT_CACHE_TTL);
}
/***************************************************************************
* Construct with custom cache TTL.
*
* @param cacheTtl cache duration
* @since 0.2.0
***************************************************************************/
public GitHubTemplateRegistry(Duration cacheTtl)
{
this.hub = new TemplatesHub();
this.resolver = new TemplateResolver();
this.cacheTtl = cacheTtl;
}
@Override
public List<TemplateInfo> listTemplates() throws IOException
{
if(cachedTemplates != null && cacheExpiry != null && Instant.now().isBefore(cacheExpiry))
{
return cachedTemplates;
}
TemplatesHub.TemplatesIndex index = hub.fetchIndex();
if(index.templates() == null)
{
cachedTemplates = List.of();
}
else
{
cachedTemplates = index.templates().stream()
.map(entry -> convertToTemplateInfo(entry, index.registry()))
.toList();
}
cacheExpiry = Instant.now().plus(cacheTtl);
return cachedTemplates;
}
@Override
public Optional<TemplateInfo> getTemplate(String id) throws IOException
{
return listTemplates().stream()
.filter(t -> t.id().equals(id))
.findFirst();
}
@Override
public Path downloadTemplate(String id, String version) throws IOException
{
Optional<TemplateInfo> template = getTemplate(id);
if(template.isEmpty())
{
throw new IOException("Template not found: " + id);
}
return resolver.resolve(template.get().downloadUrl(), version);
}
@Override
public void refresh()
{
cachedTemplates = null;
cacheExpiry = null;
}
/***************************************************************************
* Convert hub entry to TemplateInfo.
*
* @param entry hub template entry
* @param registry base registry URL
* @return template info
* @since 0.2.0
***************************************************************************/
private TemplateInfo convertToTemplateInfo(TemplatesHub.TemplateEntry entry, String registry)
{
return new TemplateInfo(
entry.id(),
entry.name(),
entry.version(),
entry.description(),
entry.getGitUrl(registry),
entry.schemaVersion(),
entry.minimumQctlVersion(),
entry.tags(),
entry.maintainer(),
entry.prompts(),
entry.computed(),
entry.transforms(),
entry.postGen(),
entry.ignore()
);
}
}
@@ -0,0 +1,164 @@
/*
* 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.registry;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import io.qrun.qctl.qqq.template.ComputedVariable;
import io.qrun.qctl.qqq.template.TemplateManifest;
import io.qrun.qctl.qqq.template.TemplateResolver;
import io.qrun.qctl.qqq.template.Transform;
/*******************************************************************************
* Mock template registry with embedded template data.
*
* Provides stable template listing without network dependencies.
* Used for development, testing, and as a fallback when Voyage is unavailable.
*
* @since 0.2.0
*******************************************************************************/
public class MockTemplateRegistry implements TemplateRegistry
{
private static final String GITHUB_REGISTRY = "https://github.com/QRun-IO";
private static final List<TemplateInfo> MOCK_TEMPLATES = List.of(
new TemplateInfo(
"new-qqq-application",
"QQQ Application Starter",
"1.0.0",
"Complete QQQ application with entities, database, and dashboard",
GITHUB_REGISTRY + "/new-qqq-application-template.git",
2,
"0.2.0",
List.of("qqq", "java", "starter"),
"QRun-IO",
List.of(
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-qqq-app", null, null, true),
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-qqq-app", null, null, true),
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.myapp", null, null, true)
),
List.of(
new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")
),
List.of(
new Transform("delete", "**/.gitkeep", null)
),
List.of(
new TemplateManifest.PostGenHook("Initialize git repository", "git init", null, true, "init"),
new TemplateManifest.PostGenHook("Build project", "mvn clean compile -DskipTests", null, true, "build")
),
List.of(".git", ".github", ".idea", "target", "*.iml", "LICENSE", "README.md")
),
new TemplateInfo(
"qqq-web-api",
"QQQ Web API",
"1.0.0",
"REST API application with Javalin middleware and OpenAPI docs",
GITHUB_REGISTRY + "/qqq-web-api-template.git",
2,
"0.2.0",
List.of("qqq", "java", "api", "rest"),
"QRun-IO",
List.of(
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-api", null, null, true),
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-api", null, null, true),
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.api", null, null, true)
),
List.of(new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")),
List.of(new Transform("delete", "**/.gitkeep", null)),
List.of(),
List.of(".git", ".github", "target")
),
new TemplateInfo(
"qqq-cli-tool",
"QQQ CLI Tool",
"1.0.0",
"Command-line tool with Picocli and native image support",
GITHUB_REGISTRY + "/qqq-cli-tool-template.git",
2,
"0.2.0",
List.of("qqq", "java", "cli", "native"),
"QRun-IO",
List.of(
new TemplateManifest.Prompt("projectName", "Project name", "text", "my-cli", null, null, true),
new TemplateManifest.Prompt("groupId", "Maven group ID", "text", "com.example", null, null, true),
new TemplateManifest.Prompt("artifactId", "Maven artifact ID", "text", "my-cli", null, null, true),
new TemplateManifest.Prompt("packageName", "Java package name", "text", "com.example.cli", null, null, true),
new TemplateManifest.Prompt("commandName", "Main command name", "text", "mycli", null, null, true)
),
List.of(new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")),
List.of(new Transform("delete", "**/.gitkeep", null)),
List.of(),
List.of(".git", ".github", "target")
)
);
private final TemplateResolver resolver;
/***************************************************************************
* Default constructor.
*
* @since 0.2.0
***************************************************************************/
public MockTemplateRegistry()
{
this.resolver = new TemplateResolver();
}
@Override
public List<TemplateInfo> listTemplates()
{
return MOCK_TEMPLATES;
}
@Override
public Optional<TemplateInfo> getTemplate(String id)
{
return MOCK_TEMPLATES.stream()
.filter(t -> t.id().equals(id))
.findFirst();
}
@Override
public Path downloadTemplate(String id, String version) throws IOException
{
Optional<TemplateInfo> template = getTemplate(id);
if(template.isEmpty())
{
throw new IOException("Template not found: " + id);
}
return resolver.resolve(template.get().downloadUrl(), version);
}
@Override
public void refresh()
{
// No-op for mock registry - data is embedded
}
}
@@ -0,0 +1,82 @@
/*
* 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.registry;
import java.util.List;
import io.qrun.qctl.qqq.template.ComputedVariable;
import io.qrun.qctl.qqq.template.TemplateManifest;
import io.qrun.qctl.qqq.template.Transform;
/*******************************************************************************
* Template metadata from the registry.
*
* Contains all information needed to display and initialize a template.
*
* @param id unique template identifier
* @param name display name
* @param version template version (semver)
* @param description brief description
* @param downloadUrl URL to fetch template archive or git repo
* @param schemaVersion manifest schema version
* @param minimumQctlVersion minimum qctl version required
* @param tags categorization tags
* @param maintainer template maintainer
* @param prompts interactive prompts for template variables
* @param computed computed variables derived from prompts
* @param transforms file transforms (rename/delete)
* @param postGen post-generation hooks
* @param ignore patterns to ignore during rendering
* @since 0.2.0
*******************************************************************************/
public record TemplateInfo(
String id,
String name,
String version,
String description,
String downloadUrl,
Integer schemaVersion,
String minimumQctlVersion,
List<String> tags,
String maintainer,
List<TemplateManifest.Prompt> prompts,
List<ComputedVariable> computed,
List<Transform> transforms,
List<TemplateManifest.PostGenHook> postGen,
List<String> ignore
)
{
/***************************************************************************
* Convert to TemplateManifest for rendering.
*
* @return template manifest
* @since 0.2.0
***************************************************************************/
public TemplateManifest toManifest()
{
return new TemplateManifest(
schemaVersion,
id,
name,
version,
description,
minimumQctlVersion,
prompts,
computed,
transforms,
postGen,
ignore
);
}
}
@@ -0,0 +1,76 @@
/*
* 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.registry;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
/*******************************************************************************
* Registry for fetching and managing project templates.
*
* Provides abstraction over template sources (GitHub, Voyage API, mock).
* Implementations handle caching, authentication, and download logic.
*
* @since 0.2.0
*******************************************************************************/
public interface TemplateRegistry
{
/***************************************************************************
* List all available templates.
*
* @return list of template metadata
* @throws IOException if registry cannot be reached
* @since 0.2.0
***************************************************************************/
List<TemplateInfo> listTemplates() throws IOException;
/***************************************************************************
* Get template by ID.
*
* @param id template identifier
* @return template metadata if found
* @throws IOException if registry cannot be reached
* @since 0.2.0
***************************************************************************/
Optional<TemplateInfo> getTemplate(String id) throws IOException;
/***************************************************************************
* Download template to local path.
*
* @param id template identifier
* @param version specific version or null for latest
* @return path to downloaded template directory
* @throws IOException if download fails
* @since 0.2.0
***************************************************************************/
Path downloadTemplate(String id, String version) throws IOException;
/***************************************************************************
* Refresh the template cache.
*
* Forces a fresh fetch from the registry on next call.
*
* @since 0.2.0
***************************************************************************/
void refresh();
}
@@ -0,0 +1,118 @@
/*
* 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.registry;
/*******************************************************************************
* Factory for creating template registry instances.
*
* Selects the appropriate registry implementation based on configuration.
* Currently supports: GitHub (default), Mock (for testing/offline).
* Future: Voyage API registry.
*
* @since 0.2.0
*******************************************************************************/
public final class TemplateRegistryFactory
{
private static final String REGISTRY_TYPE_ENV = "QCTL_REGISTRY_TYPE";
private static final String REGISTRY_TYPE_GITHUB = "github";
private static final String REGISTRY_TYPE_MOCK = "mock";
private static TemplateRegistry instance;
/***************************************************************************
* Private constructor - utility class.
*
* @since 0.2.0
***************************************************************************/
private TemplateRegistryFactory()
{
}
/***************************************************************************
* Get the configured template registry.
*
* Registry type is determined by QCTL_REGISTRY_TYPE environment variable:
* - "github" (default): GitHub templates-hub repository
* - "mock": Embedded mock templates for testing/offline
* - "voyage": Voyage API (future)
*
* @return template registry instance
* @since 0.2.0
***************************************************************************/
public static TemplateRegistry getRegistry()
{
if(instance == null)
{
instance = createRegistry();
}
return instance;
}
/***************************************************************************
* Create a new registry instance based on configuration.
*
* @return new template registry
* @since 0.2.0
***************************************************************************/
private static TemplateRegistry createRegistry()
{
String registryType = System.getenv(REGISTRY_TYPE_ENV);
if(registryType == null || registryType.isBlank())
{
registryType = REGISTRY_TYPE_GITHUB;
}
return switch(registryType.toLowerCase())
{
case REGISTRY_TYPE_MOCK -> new MockTemplateRegistry();
case REGISTRY_TYPE_GITHUB -> new GitHubTemplateRegistry();
default -> new GitHubTemplateRegistry();
};
}
/***************************************************************************
* Reset the cached registry instance.
*
* Used for testing to allow switching registry types.
*
* @since 0.2.0
***************************************************************************/
public static void reset()
{
instance = null;
}
/***************************************************************************
* Set a specific registry instance.
*
* Used for testing to inject mock registries.
*
* @param registry registry to use
* @since 0.2.0
***************************************************************************/
public static void setRegistry(TemplateRegistry registry)
{
instance = registry;
}
}
@@ -0,0 +1,35 @@
/*
* 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 com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/*******************************************************************************
* A computed variable definition for template manifests.
*
* Computed variables are derived from other variables using Velocity expressions.
* They are evaluated in order after prompts are collected.
*
* @param name variable name to create
* @param expression Velocity expression to evaluate
* @since 0.2.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ComputedVariable(
String name,
String expression
)
{
}
@@ -34,6 +34,8 @@ public class ConsoleUI
private static final String ANSI_RESET = "\u001B[0m";
private static final String ANSI_DIM = "\u001B[2m";
private static final String ANSI_GREEN = "\u001B[32m";
private static final String ANSI_RED = "\u001B[31m";
private static final String ANSI_YELLOW = "\u001B[33m";
private final Console console;
private final Scanner scanner;
@@ -132,6 +134,44 @@ public class ConsoleUI
/***************************************************************************
* Print a validation error message inline.
*
* @param text the error message
* @since 0.2.0
***************************************************************************/
public void validationError(String text)
{
System.out.println(" " + ANSI_RED + "✗ " + text + ANSI_RESET);
}
/***************************************************************************
* Print a validation success indicator.
*
* @since 0.2.0
***************************************************************************/
public void validationSuccess()
{
System.out.println(" " + ANSI_GREEN + "✓" + ANSI_RESET);
}
/***************************************************************************
* Print a warning message.
*
* @param text the warning message
* @since 0.2.0
***************************************************************************/
public void warning(String text)
{
System.out.println(ANSI_YELLOW + "warning: " + text + ANSI_RESET);
}
/***************************************************************************
* Print a blank line.
*
@@ -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
}
}
@@ -23,12 +23,14 @@ import java.util.Map;
* Runs interactive prompts to collect template variables.
*
* Uses ConsoleUI for all interactive input. Supports text input and selections.
* Validates input against prompt rules and re-prompts on failure.
*
* @since 0.1.0
*******************************************************************************/
public class PromptRunner
{
private final ConsoleUI ui;
private final PromptValidator validator;
@@ -40,6 +42,7 @@ public class PromptRunner
public PromptRunner()
{
this.ui = new ConsoleUI();
this.validator = new PromptValidator();
}
@@ -53,6 +56,7 @@ public class PromptRunner
public PromptRunner(ConsoleUI ui)
{
this.ui = ui;
this.validator = new PromptValidator();
}
@@ -77,12 +81,26 @@ public class PromptRunner
for(TemplateManifest.Prompt prompt : prompts)
{
// Skip if already provided via CLI
// Check if provided via CLI
if(overrides.containsKey(prompt.name()))
{
String value = overrides.get(prompt.name());
ui.showValue(prompt.message() != null ? prompt.message() : prompt.name(), value);
results.put(prompt.name(), value);
String label = prompt.message() != null ? prompt.message() : prompt.name();
PromptValidator.ValidationResult result = validator.validate(prompt, value);
if(result.valid())
{
ui.showValue(label, value);
results.put(prompt.name(), value);
}
else
{
ui.showValue(label, value);
ui.validationError(result.errorMessage());
ui.warning("Invalid --var value, prompting interactively");
String corrected = promptForValue(prompt);
results.put(prompt.name(), corrected);
}
continue;
}
@@ -96,10 +114,12 @@ public class PromptRunner
/***************************************************************************
* Prompt for a single value.
* Prompt for a single value with validation.
*
* Re-prompts on validation failure until valid input is provided.
*
* @param prompt prompt definition
* @return user input value
* @return validated user input value
* @since 0.1.0
***************************************************************************/
private String promptForValue(TemplateManifest.Prompt prompt)
@@ -111,9 +131,24 @@ public class PromptRunner
{
return promptSelect(message, prompt.choices(), prompt.defaultValue());
}
else
boolean hasValidation = prompt.isRequired() || prompt.validation() != null;
while(true)
{
return ui.promptText(message, prompt.defaultValue());
String value = ui.promptText(message, prompt.defaultValue());
PromptValidator.ValidationResult result = validator.validate(prompt, value);
if(result.valid())
{
if(hasValidation)
{
ui.validationSuccess();
}
return value;
}
ui.validationError(result.errorMessage());
}
}
@@ -0,0 +1,45 @@
/*
* 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 com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/*******************************************************************************
* Validation rules for template prompts.
*
* Supports string validation (pattern, length) and integer validation (range).
*
* @param pattern regex pattern for string validation
* @param minLength minimum string length
* @param maxLength maximum string length
* @param min minimum value for integers
* @param max maximum value for integers
* @param values allowed values (enum validation)
* @param message custom error message
* @since 0.2.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PromptValidation(
String pattern,
Integer minLength,
Integer maxLength,
Integer min,
Integer max,
List<String> values,
String message
)
{
}
@@ -0,0 +1,235 @@
/*
* 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.Optional;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
/*******************************************************************************
* Validates prompt values against validation rules.
*
* Supports pattern matching, length constraints, numeric ranges, and enum values.
*
* @since 0.2.0
*******************************************************************************/
public class PromptValidator
{
/***************************************************************************
* Validation result containing success status and error message.
*
* @param valid true if validation passed
* @param errorMessage error message if validation failed
* @since 0.2.0
***************************************************************************/
public record ValidationResult(boolean valid, String errorMessage)
{
/*************************************************************************
* Create a successful validation result.
*
* @return success result
* @since 0.2.0
*************************************************************************/
public static ValidationResult success()
{
return new ValidationResult(true, null);
}
/*************************************************************************
* Create a failed validation result.
*
* @param message the error message
* @return failure result
* @since 0.2.0
*************************************************************************/
public static ValidationResult failure(String message)
{
return new ValidationResult(false, message);
}
}
/***************************************************************************
* Validate a value against prompt rules.
*
* @param prompt the prompt definition with validation rules
* @param value the value to validate
* @return validation result
* @since 0.2.0
***************************************************************************/
public ValidationResult validate(TemplateManifest.Prompt prompt, String value)
{
// Check required
if(prompt.isRequired() && (value == null || value.isBlank()))
{
return ValidationResult.failure("This field is required");
}
// If empty and not required, skip further validation
if(value == null || value.isBlank())
{
return ValidationResult.success();
}
// Get validation rules
PromptValidation validation = prompt.validation();
if(validation == null)
{
return ValidationResult.success();
}
// Determine type
String type = prompt.type() != null ? prompt.type().toLowerCase() : "text";
// Type-specific validation
return switch(type)
{
case "integer", "number" -> validateInteger(validation, value);
default -> validateString(validation, value);
};
}
/***************************************************************************
* Validate a string value.
*
* @param validation validation rules
* @param value the value to validate
* @return validation result
* @since 0.2.0
***************************************************************************/
private ValidationResult validateString(PromptValidation validation, String value)
{
// Pattern validation
if(validation.pattern() != null)
{
Optional<ValidationResult> patternResult = validatePattern(validation.pattern(), value, validation.message());
if(patternResult.isPresent())
{
return patternResult.get();
}
}
// Length validation
if(validation.minLength() != null && value.length() < validation.minLength())
{
String message = validation.message() != null
? validation.message()
: "Must be at least " + validation.minLength() + " characters";
return ValidationResult.failure(message);
}
if(validation.maxLength() != null && value.length() > validation.maxLength())
{
String message = validation.message() != null
? validation.message()
: "Must be at most " + validation.maxLength() + " characters";
return ValidationResult.failure(message);
}
// Enum validation
if(validation.values() != null && !validation.values().isEmpty())
{
if(!validation.values().contains(value))
{
String message = validation.message() != null
? validation.message()
: "Must be one of: " + String.join(", ", validation.values());
return ValidationResult.failure(message);
}
}
return ValidationResult.success();
}
/***************************************************************************
* Validate an integer value.
*
* @param validation validation rules
* @param value the value to validate
* @return validation result
* @since 0.2.0
***************************************************************************/
private ValidationResult validateInteger(PromptValidation validation, String value)
{
// Parse as integer
int intValue;
try
{
intValue = Integer.parseInt(value.trim());
}
catch(NumberFormatException e)
{
return ValidationResult.failure("Must be a valid integer");
}
// Min validation
if(validation.min() != null && intValue < validation.min())
{
String message = validation.message() != null
? validation.message()
: "Must be at least " + validation.min();
return ValidationResult.failure(message);
}
// Max validation
if(validation.max() != null && intValue > validation.max())
{
String message = validation.message() != null
? validation.message()
: "Must be at most " + validation.max();
return ValidationResult.failure(message);
}
return ValidationResult.success();
}
/***************************************************************************
* Validate against a regex pattern.
*
* @param pattern the regex pattern
* @param value the value to validate
* @param customMessage custom error message
* @return validation result if failed, empty if passed
* @since 0.2.0
***************************************************************************/
private Optional<ValidationResult> validatePattern(String pattern, String value, String customMessage)
{
try
{
Pattern regex = Pattern.compile(pattern);
if(!regex.matcher(value).matches())
{
String message = customMessage != null
? customMessage
: "Must match pattern: " + pattern;
return Optional.of(ValidationResult.failure(message));
}
}
catch(PatternSyntaxException e)
{
return Optional.of(ValidationResult.failure("Invalid validation pattern: " + pattern));
}
return Optional.empty();
}
}
@@ -0,0 +1,157 @@
/*
* 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;
/*******************************************************************************
* Velocity tool for string transformations.
*
* Provides methods for common case conversions used in templates.
* Exposed in Velocity context as $str.
*
* @since 0.2.0
*******************************************************************************/
public class StringTool
{
/***************************************************************************
* Convert string to lowercase.
*
* @param value input string
* @return lowercase string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String lower(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return value.toLowerCase();
}
/***************************************************************************
* Convert string to uppercase.
*
* @param value input string
* @return uppercase string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String upper(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return value.toUpperCase();
}
/***************************************************************************
* Convert string to camelCase (first letter lowercase).
*
* @param value input string
* @return camelCase string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String camel(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return Character.toLowerCase(value.charAt(0)) + value.substring(1);
}
/***************************************************************************
* Convert string to PascalCase (first letter uppercase).
*
* @param value input string
* @return PascalCase string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String pascal(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return Character.toUpperCase(value.charAt(0)) + value.substring(1);
}
/***************************************************************************
* Convert camelCase to kebab-case.
*
* @param value input string in camelCase or PascalCase
* @return kebab-case string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String kebab(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return value.replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase();
}
/***************************************************************************
* Convert camelCase to snake_case.
*
* @param value input string in camelCase or PascalCase
* @return snake_case string, or empty string if null/empty
* @since 0.2.0
***************************************************************************/
public String snake(String value)
{
if(value == null || value.isEmpty())
{
return "";
}
return value.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
}
/***************************************************************************
* Replace all occurrences of target with replacement.
*
* @param value input string
* @param target string to find
* @param replacement string to replace with
* @return string with replacements, or empty if input is null/empty
* @since 0.2.0
***************************************************************************/
public String replace(String value, String target, String replacement)
{
if(value == null || value.isEmpty())
{
return "";
}
if(target == null || replacement == null)
{
return value;
}
return value.replace(target, replacement);
}
}
@@ -14,23 +14,36 @@ package io.qrun.qctl.qqq.template;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import com.github.jknack.handlebars.Handlebars;
import com.github.jknack.handlebars.Template;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.qrun.qctl.qqq.error.SuggestionEngine;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.exception.MethodInvocationException;
import org.apache.velocity.exception.ParseErrorException;
import org.apache.velocity.exception.ResourceNotFoundException;
import org.apache.velocity.runtime.RuntimeConstants;
/*******************************************************************************
* Renders templates using Handlebars.
* Renders templates using Apache Velocity.
*
* Supports path substitution ({{projectName}}/src) and content templating.
* Supports path substitution ($packagePath/src) and content templating with
* full Velocity syntax including control flow and macros.
*
* @since 0.1.0
*******************************************************************************/
@@ -42,7 +55,22 @@ public class TemplateEngine
".zip", ".tar", ".gz", ".war", ".ear", ".pdf"
);
private final Handlebars handlebars;
/////////////////////////////////////////////////////////////////////////////
// Files that should be copied verbatim without template processing //
/////////////////////////////////////////////////////////////////////////////
private static final Set<String> VERBATIM_FILENAMES = Set.of(
"mvnw", "mvnw.cmd", "gradlew", "gradlew.bat"
);
/////////////////////////////////////////////////////////////////////////////
// Pattern to detect undefined Velocity references ($var or ${var}) //
/////////////////////////////////////////////////////////////////////////////
private static final Pattern UNDEFINED_REF_PATTERN =
Pattern.compile("\\$(!)?\\{?([a-zA-Z][a-zA-Z0-9_]*)\\}?");
private final VelocityEngine velocityEngine;
private final StringTool stringTool;
private final SuggestionEngine suggestionEngine;
@@ -53,80 +81,30 @@ public class TemplateEngine
***************************************************************************/
public TemplateEngine()
{
this.handlebars = new Handlebars();
registerHelpers();
this.velocityEngine = createVelocityEngine();
this.stringTool = new StringTool();
this.suggestionEngine = new SuggestionEngine();
}
/***************************************************************************
* Register custom Handlebars helpers.
* Create and configure the Velocity engine.
*
* @since 0.1.0
* @return configured VelocityEngine
* @since 0.2.0
***************************************************************************/
private void registerHelpers()
private VelocityEngine createVelocityEngine()
{
// lowercase helper
handlebars.registerHelper("lowercase", (context, options) ->
context != null ? context.toString().toLowerCase() : "");
Properties props = new Properties();
props.setProperty(RuntimeConstants.INPUT_ENCODING, "UTF-8");
props.setProperty(RuntimeConstants.RESOURCE_LOADERS, "string");
props.setProperty("resource.loader.string.class",
"org.apache.velocity.runtime.resource.loader.StringResourceLoader");
// uppercase helper
handlebars.registerHelper("uppercase", (context, options) ->
context != null ? context.toString().toUpperCase() : "");
// camelCase helper
handlebars.registerHelper("camelCase", (context, options) ->
{
if(context == null)
{
return "";
}
String s = context.toString();
if(s.isEmpty())
{
return "";
}
return Character.toLowerCase(s.charAt(0)) + s.substring(1);
});
// PascalCase helper
handlebars.registerHelper("pascalCase", (context, options) ->
{
if(context == null)
{
return "";
}
String s = context.toString();
if(s.isEmpty())
{
return "";
}
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
});
// kebab-case helper
handlebars.registerHelper("kebabCase", (context, options) ->
{
if(context == null)
{
return "";
}
return context.toString()
.replaceAll("([a-z])([A-Z])", "$1-$2")
.toLowerCase();
});
// snake_case helper
handlebars.registerHelper("snakeCase", (context, options) ->
{
if(context == null)
{
return "";
}
return context.toString()
.replaceAll("([a-z])([A-Z])", "$1_$2")
.toLowerCase();
});
VelocityEngine engine = new VelocityEngine();
engine.init(props);
return engine;
}
@@ -139,20 +117,48 @@ public class TemplateEngine
* @param variables template variables
* @param manifest template manifest
* @return number of files created
* @throws IOException if rendering fails
* @throws IOException if file operations fail
* @throws TemplateRenderException if template rendering fails
* @since 0.1.0
***************************************************************************/
public int render(Path templateDir, Path targetDir, Map<String, String> variables,
TemplateManifest manifest) throws IOException
TemplateManifest manifest) throws IOException, TemplateRenderException
{
Path sourceDir = resolveTemplateSource(templateDir);
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
? new HashSet<>(manifest.ignore())
: new HashSet<>();
ignorePatterns.add(".git");
ignorePatterns.add("template.yaml");
AtomicInteger fileCount = new AtomicInteger();
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<>()
{
@@ -165,10 +171,18 @@ public class TemplateEngine
return FileVisitResult.SKIP_SUBTREE;
}
Path relativePath = sourceDir.relativize(dir);
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
Files.createDirectories(targetPath);
try
{
Path relativePath = sourceDir.relativize(dir);
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
Files.createDirectories(targetPath);
}
catch(TemplateRenderException e)
{
renderError.set(e);
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}
@@ -183,25 +197,81 @@ public class TemplateEngine
return FileVisitResult.CONTINUE;
}
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
try
{
String renderedPath = renderPath(relativePath.toString(), variables);
Path targetPath = targetDir.resolve(renderedPath);
if(isBinaryFile(file))
{
Files.copy(file, targetPath);
//////////////////////////////////////////////////////////////////
// Prepare content for merge decision //
//////////////////////////////////////////////////////////////////
String rendered;
if(isBinaryFile(file) || isVerbatimFile(file))
{
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
{
Files.writeString(targetPath, rendered);
}
fileCount.incrementAndGet();
}
else
catch(TemplateRenderException e)
{
String content = Files.readString(file);
String rendered = renderContent(content, variables);
Files.writeString(targetPath, rendered);
renderError.set(e);
return FileVisitResult.TERMINATE;
}
fileCount.incrementAndGet();
System.out.println(" " + renderedPath);
return FileVisitResult.CONTINUE;
}
});
if(renderError.get() != null)
{
Exception e = renderError.get();
if(e instanceof TemplateRenderException tre)
{
throw tre;
}
throw new TemplateRenderException("Template rendering failed", e);
}
return fileCount.get();
}
@@ -214,19 +284,43 @@ public class TemplateEngine
* @param targetDir target output directory
* @param variables template variables
* @param manifest template manifest
* @throws IOException if rendering fails
* @throws IOException if file operations fail
* @throws TemplateRenderException if path rendering fails
* @since 0.1.0
***************************************************************************/
public void renderDryRun(Path templateDir, Path targetDir, Map<String, String> variables,
TemplateManifest manifest) throws IOException
TemplateManifest manifest) throws IOException, TemplateRenderException
{
Path sourceDir = resolveTemplateSource(templateDir);
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
? new HashSet<>(manifest.ignore())
: new HashSet<>();
ignorePatterns.add(".git");
ignorePatterns.add("template.yaml");
AtomicReference<TemplateRenderException> renderError = new AtomicReference<>();
Files.walkFileTree(sourceDir, new SimpleFileVisitor<>()
{
@Override
@@ -249,11 +343,308 @@ public class TemplateEngine
return FileVisitResult.CONTINUE;
}
String renderedPath = renderPath(relativePath.toString(), variables);
System.out.println(" " + targetDir.resolve(renderedPath));
try
{
String renderedPath = renderPath(relativePath.toString(), variables);
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)
{
renderError.set(e);
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}
});
if(renderError.get() != null)
{
throw renderError.get();
}
}
/***************************************************************************
* Render a path string with Velocity variable substitution.
*
* @param path path string with $var placeholders
* @param variables template variables
* @return rendered path
* @throws TemplateRenderException if rendering fails
* @since 0.1.0
***************************************************************************/
public String renderPath(String path, Map<String, String> variables)
throws TemplateRenderException
{
/////////////////////////////////////////////////////////////////////////
// Convert __VARNAME__ placeholders to $VARNAME for Velocity. //
// This allows templates to use __VARNAME__ in paths, which is safer //
// for shells and build tools that might interpret $ as a variable. //
/////////////////////////////////////////////////////////////////////////
String normalizedPath = path.replaceAll("__([a-zA-Z][a-zA-Z0-9]*)__", "\\$$1");
return renderContent(normalizedPath, variables);
}
/***************************************************************************
* Render content using Velocity with string variables.
*
* @param content template content
* @param variables template variables (String values)
* @return rendered content
* @throws TemplateRenderException if rendering fails
* @since 0.1.0
***************************************************************************/
public String renderContent(String content, Map<String, String> variables)
throws TemplateRenderException
{
VelocityContext context = createContext(variables);
return evaluateTemplate(content, context);
}
/***************************************************************************
* Render content using Velocity with object variables.
*
* Supports complex objects like lists for #foreach directives.
*
* @param content template content
* @param variables template variables (Object values)
* @return rendered content
* @throws TemplateRenderException if rendering fails
* @since 0.2.0
***************************************************************************/
public String renderContentWithObjects(String content, Map<String, Object> variables)
throws TemplateRenderException
{
VelocityContext context = new VelocityContext();
context.put("str", stringTool);
for(Map.Entry<String, Object> entry : variables.entrySet())
{
context.put(entry.getKey(), entry.getValue());
}
return evaluateTemplate(content, context);
}
/***************************************************************************
* Evaluate computed variables and merge with input variables.
*
* Computed variables are evaluated in order. Each computed variable
* can reference previously defined variables (prompts or earlier computed).
* If a computed variable references an undefined variable, rendering fails.
*
* @param computed list of computed variable definitions (may be null)
* @param variables input variables from prompts
* @return merged map containing original and computed variables
* @throws TemplateRenderException if evaluation fails
* @since 0.2.0
***************************************************************************/
public Map<String, String> evaluateComputed(List<ComputedVariable> computed,
Map<String, String> variables)
throws TemplateRenderException
{
if(computed == null || computed.isEmpty())
{
return variables;
}
Map<String, String> result = new HashMap<>(variables);
for(ComputedVariable cv : computed)
{
String value = renderContent(cv.expression(), result);
result.put(cv.name(), value);
}
return result;
}
/***************************************************************************
* Create a Velocity context with string variables and tools.
*
* @param variables template variables
* @return configured VelocityContext
* @since 0.2.0
***************************************************************************/
private VelocityContext createContext(Map<String, String> variables)
{
VelocityContext context = new VelocityContext();
context.put("str", stringTool);
for(Map.Entry<String, String> entry : variables.entrySet())
{
context.put(entry.getKey(), entry.getValue());
}
return context;
}
/***************************************************************************
* Evaluate a template string with the given context.
*
* @param template template string
* @param context Velocity context
* @return rendered string
* @throws TemplateRenderException if rendering fails
* @since 0.2.0
***************************************************************************/
private String evaluateTemplate(String template, VelocityContext context)
throws TemplateRenderException
{
try
{
StringWriter writer = new StringWriter();
boolean result = velocityEngine.evaluate(context, writer, "template", template);
if(!result)
{
throw new TemplateRenderException("Template evaluation failed");
}
String output = writer.toString();
/////////////////////////////////////////////////////////////////////
// Check for undefined references that weren't resolved //
/////////////////////////////////////////////////////////////////////
checkForUndefinedReferences(template, output, context);
return output;
}
catch(ParseErrorException e)
{
throw new TemplateRenderException("Template syntax error: " + e.getMessage(), e);
}
catch(MethodInvocationException e)
{
throw new TemplateRenderException("Template method error: " + e.getMessage(), e);
}
catch(ResourceNotFoundException e)
{
throw new TemplateRenderException("Template resource not found: " + e.getMessage(), e);
}
}
/***************************************************************************
* Check for undefined variable references in the output.
*
* If a variable reference like $name appears in both input and output
* unchanged, it means the variable was not defined.
*
* @param template original template
* @param output rendered output
* @param context Velocity context
* @throws TemplateRenderException if undefined references found
* @since 0.2.0
***************************************************************************/
private void checkForUndefinedReferences(String template, String output,
VelocityContext context)
throws TemplateRenderException
{
Matcher matcher = UNDEFINED_REF_PATTERN.matcher(output);
while(matcher.find())
{
String varName = matcher.group(2);
/////////////////////////////////////////////////////////////////////
// Skip if it's a tool reference (like $str) //
/////////////////////////////////////////////////////////////////////
if("str".equals(varName))
{
continue;
}
/////////////////////////////////////////////////////////////////////
// Skip if it looks like a Maven property (${name.something}) //
// These are intentionally left as literals in templates //
/////////////////////////////////////////////////////////////////////
int endPos = matcher.end();
if(endPos < output.length() && output.charAt(endPos) == '.')
{
continue;
}
/////////////////////////////////////////////////////////////////////
// If the variable is not in context, it's undefined //
/////////////////////////////////////////////////////////////////////
if(context.get(varName) == null)
{
/////////////////////////////////////////////////////////////////
// Check if this exact reference was in the original template //
/////////////////////////////////////////////////////////////////
String fullRef = matcher.group(0);
if(template.contains(fullRef))
{
//////////////////////////////////////////////////////////////
// Collect available variables and find similar ones //
//////////////////////////////////////////////////////////////
List<String> available = collectAvailableVariables(context);
List<String> suggestions = suggestionEngine.findSimilar(varName, available);
throw new TemplateRenderException(varName, suggestions, available);
}
}
}
}
/***************************************************************************
* Collect all available variable names from the context.
*
* @param context Velocity context
* @return list of variable names
* @since 0.2.0
***************************************************************************/
private List<String> collectAvailableVariables(VelocityContext context)
{
List<String> variables = new ArrayList<>();
for(Object key : context.getKeys())
{
String name = key.toString();
/////////////////////////////////////////////////////////////////////
// Skip internal tools like $str //
/////////////////////////////////////////////////////////////////////
if(!"str".equals(name))
{
variables.add(name);
}
}
return variables;
}
@@ -277,50 +668,6 @@ public class TemplateEngine
/***************************************************************************
* Render a path string with variable substitution.
*
* @param path path string with {{var}} placeholders
* @param variables template variables
* @return rendered path
* @since 0.1.0
***************************************************************************/
private String renderPath(String path, Map<String, String> variables)
{
String result = path;
for(Map.Entry<String, String> entry : variables.entrySet())
{
result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
return result;
}
/***************************************************************************
* Render content using Handlebars.
*
* @param content template content
* @param variables template variables
* @return rendered content
* @since 0.1.0
***************************************************************************/
private String renderContent(String content, Map<String, String> variables)
{
try
{
Template template = handlebars.compileInline(content);
return template.apply(variables);
}
catch(IOException e)
{
// If template compilation fails, return content as-is
return content;
}
}
/***************************************************************************
* Check if a file should be ignored.
*
@@ -370,4 +717,58 @@ public class TemplateEngine
}
return false;
}
/***************************************************************************
* Check if a file should be copied verbatim without template processing.
*
* @param file file path
* @return true if file should be copied verbatim
* @since 0.2.0
***************************************************************************/
private boolean isVerbatimFile(Path file)
{
String name = file.getFileName().toString();
return VERBATIM_FILENAMES.contains(name);
}
/***************************************************************************
* Inner class to hold exception reference for use in visitor.
*
* @param <T> exception type
* @since 0.2.0
***************************************************************************/
private static class AtomicReference<T>
{
private T value;
/*************************************************************************
* Set the value.
*
* @param value value to set
* @since 0.2.0
*************************************************************************/
public void set(T value)
{
this.value = value;
}
/*************************************************************************
* Get the value.
*
* @return stored value
* @since 0.2.0
*************************************************************************/
public T get()
{
return value;
}
}
}
@@ -18,6 +18,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.JsonLocation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
@@ -25,21 +27,42 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
/*******************************************************************************
* Represents a template manifest (template.yaml).
*
* Schema v2 adds: schemaVersion, minimumQctlVersion, prompts[].required,
* postGen[].phase. Backward compatible with v1 manifests.
*
* @since 0.1.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TemplateManifest(
Integer schemaVersion,
String id,
String name,
String version,
String description,
String minimumQctlVersion,
List<Prompt> prompts,
List<ComputedVariable> computed,
List<Transform> transforms,
List<PostGenHook> postGen,
List<String> ignore
)
{
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
private static final String MANIFEST_FILE = "template.yaml";
private static final int DEFAULT_SCHEMA_VERSION = 1;
/***************************************************************************
* Get effective schema version (defaults to 1 for backward compatibility).
*
* @return schema version
* @since 0.2.0
***************************************************************************/
public int getEffectiveSchemaVersion()
{
return schemaVersion != null ? schemaVersion : DEFAULT_SCHEMA_VERSION;
}
@@ -58,7 +81,55 @@ public record TemplateManifest(
{
throw new IOException("template.yaml not found in " + templateDir);
}
return YAML.readValue(manifestPath.toFile(), TemplateManifest.class);
try
{
return YAML.readValue(manifestPath.toFile(), TemplateManifest.class);
}
catch(JsonProcessingException e)
{
throw new IOException(formatYamlError(e, manifestPath), e);
}
}
/***************************************************************************
* Format a YAML parsing error with line and column information.
*
* @param e the JSON processing exception
* @param manifestPath path to the manifest file
* @return formatted error message
* @since 0.2.0
***************************************************************************/
private static String formatYamlError(JsonProcessingException e, Path manifestPath)
{
JsonLocation location = e.getLocation();
StringBuilder sb = new StringBuilder();
sb.append("YAML parse error in ").append(manifestPath.getFileName());
if(location != null)
{
int line = location.getLineNr();
int col = location.getColumnNr();
sb.append(" (line ").append(line);
if(col >= 0)
{
sb.append(", column ").append(col);
}
sb.append(")");
}
//////////////////////////////////////////////////////////////////////////
// Extract the original error message without the location suffix //
//////////////////////////////////////////////////////////////////////////
String originalMessage = e.getOriginalMessage();
if(originalMessage != null && !originalMessage.isEmpty())
{
sb.append(": ").append(originalMessage);
}
return sb.toString();
}
@@ -75,9 +146,20 @@ public record TemplateManifest(
String type,
String defaultValue,
List<String> choices,
String validation
PromptValidation validation,
Boolean required
)
{
/*************************************************************************
* Check if this prompt is required.
*
* @return true if required (defaults to true if not specified)
* @since 0.2.0
*************************************************************************/
public boolean isRequired()
{
return required == null || required;
}
}
@@ -92,7 +174,8 @@ public record TemplateManifest(
String name,
String command,
String workDir,
Boolean optional
Boolean optional,
String phase
)
{
}
@@ -0,0 +1,260 @@
/*
* 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;
/*******************************************************************************
* Exception thrown when template rendering fails.
*
* Provides context about the template error including location information
* when available from the underlying template engine.
*
* @since 0.2.0
*******************************************************************************/
public class TemplateRenderException extends Exception
{
private final String undefinedVariable;
private final List<String> suggestions;
private final List<String> availableVariables;
private final String filePath;
private final Integer lineNumber;
/***************************************************************************
* Create a new exception with a message.
*
* @param message error message
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String message)
{
super(message);
this.undefinedVariable = null;
this.suggestions = List.of();
this.availableVariables = List.of();
this.filePath = null;
this.lineNumber = null;
}
/***************************************************************************
* Create a new exception with a message and cause.
*
* @param message error message
* @param cause underlying cause
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String message, Throwable cause)
{
super(message, cause);
this.undefinedVariable = null;
this.suggestions = List.of();
this.availableVariables = List.of();
this.filePath = null;
this.lineNumber = null;
}
/***************************************************************************
* Create exception for undefined variable with suggestions.
*
* @param undefinedVariable the undefined variable name
* @param suggestions similar variable names
* @param availableVariables all available variable names
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String undefinedVariable, List<String> suggestions,
List<String> availableVariables)
{
super(buildMessage(undefinedVariable, suggestions, availableVariables));
this.undefinedVariable = undefinedVariable;
this.suggestions = suggestions != null ? suggestions : List.of();
this.availableVariables = availableVariables != null ? availableVariables : List.of();
this.filePath = null;
this.lineNumber = null;
}
/***************************************************************************
* Create exception with file path context.
*
* @param message error message
* @param filePath path to the file being processed
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String message, String filePath)
{
super(message + " (in " + filePath + ")");
this.undefinedVariable = null;
this.suggestions = List.of();
this.availableVariables = List.of();
this.filePath = filePath;
this.lineNumber = null;
}
/***************************************************************************
* Create exception with file path and line number.
*
* @param message error message
* @param filePath path to the file
* @param lineNumber line number where error occurred
* @since 0.2.0
***************************************************************************/
public TemplateRenderException(String message, String filePath, Integer lineNumber)
{
super(buildMessageWithLocation(message, filePath, lineNumber));
this.undefinedVariable = null;
this.suggestions = List.of();
this.availableVariables = List.of();
this.filePath = filePath;
this.lineNumber = lineNumber;
}
/***************************************************************************
* Build error message for undefined variable.
*
* @param varName undefined variable name
* @param suggestions similar variable names
* @param available all available variables
* @return formatted error message
* @since 0.2.0
***************************************************************************/
private static String buildMessage(String varName, List<String> suggestions,
List<String> available)
{
StringBuilder sb = new StringBuilder();
sb.append("Undefined variable: ").append(varName);
if(suggestions != null && !suggestions.isEmpty())
{
sb.append("\n\nDid you mean?");
for(String s : suggestions)
{
sb.append("\n - ").append(s);
}
}
if(available != null && !available.isEmpty())
{
sb.append("\n\nAvailable variables:");
for(String v : available)
{
sb.append("\n - ").append(v);
}
}
sb.append("\n\nTip: Variable names are case-sensitive");
return sb.toString();
}
/***************************************************************************
* Build error message with file location.
*
* @param message base message
* @param filePath file path
* @param lineNumber line number (may be null)
* @return formatted message with location
* @since 0.2.0
***************************************************************************/
private static String buildMessageWithLocation(String message, String filePath,
Integer lineNumber)
{
if(filePath == null)
{
return message;
}
if(lineNumber != null)
{
return message + " (" + filePath + ":" + lineNumber + ")";
}
return message + " (in " + filePath + ")";
}
/***************************************************************************
* Get the undefined variable name if this is an undefined variable error.
*
* @return variable name or null
* @since 0.2.0
***************************************************************************/
public String getUndefinedVariable()
{
return undefinedVariable;
}
/***************************************************************************
* Get suggestions for similar variable names.
*
* @return list of suggestions (may be empty)
* @since 0.2.0
***************************************************************************/
public List<String> getSuggestions()
{
return suggestions;
}
/***************************************************************************
* Get all available variable names.
*
* @return list of available variables (may be empty)
* @since 0.2.0
***************************************************************************/
public List<String> getAvailableVariables()
{
return availableVariables;
}
/***************************************************************************
* Get the file path where error occurred.
*
* @return file path or null
* @since 0.2.0
***************************************************************************/
public String getFilePath()
{
return filePath;
}
/***************************************************************************
* Get the line number where error occurred.
*
* @return line number or null
* @since 0.2.0
***************************************************************************/
public Integer getLineNumber()
{
return lineNumber;
}
}
@@ -132,14 +132,18 @@ public class TemplatesHub
***************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TemplateEntry(
Integer schemaVersion,
String id,
String name,
String description,
String repo,
String version,
String minimumQctlVersion,
List<String> tags,
String maintainer,
List<TemplateManifest.Prompt> prompts,
List<ComputedVariable> computed,
List<Transform> transforms,
List<TemplateManifest.PostGenHook> postGen,
List<String> ignore
)
@@ -0,0 +1,74 @@
/*
* 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 com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/*******************************************************************************
* Transform definition for post-render file operations.
*
* Supports two transform types:
* - rename: Rename files matching a glob pattern
* - delete: Delete files matching a glob pattern
*
* @param type transform type (rename or delete)
* @param pattern glob pattern to match files (e.g., "**\/*.java")
* @param replacement replacement pattern for rename (uses $varName substitution)
* @since 0.2.0
*******************************************************************************/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Transform(
String type,
String pattern,
String replacement
)
{
/***************************************************************************
* Transform type constants.
***************************************************************************/
public static final String TYPE_RENAME = "rename";
public static final String TYPE_DELETE = "delete";
/***************************************************************************
* Validate this transform definition.
*
* @throws IllegalArgumentException if transform is invalid
* @since 0.2.0
***************************************************************************/
public void validate()
{
if(type == null || type.isBlank())
{
throw new IllegalArgumentException("Transform type is required");
}
if(!TYPE_RENAME.equals(type) && !TYPE_DELETE.equals(type))
{
throw new IllegalArgumentException("Invalid transform type: " + type + ". Must be 'rename' or 'delete'");
}
if(pattern == null || pattern.isBlank())
{
throw new IllegalArgumentException("Transform pattern is required");
}
if(TYPE_RENAME.equals(type) && (replacement == null || replacement.isBlank()))
{
throw new IllegalArgumentException("Replacement is required for rename transforms");
}
}
}
@@ -0,0 +1,219 @@
/*
* 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.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*******************************************************************************
* Executes post-render transforms on generated files.
*
* Supports rename and delete operations using glob patterns.
*
* @since 0.2.0
*******************************************************************************/
public class TransformExecutor
{
/////////////////////////////////////////////////////////////////////////////
// Pattern for variable substitution in replacement strings: ${varName} //
/////////////////////////////////////////////////////////////////////////////
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{([a-zA-Z][a-zA-Z0-9_]*)}");
/***************************************************************************
* Execute a list of transforms on the target directory.
*
* @param targetDir target directory containing rendered files
* @param transforms list of transforms to execute (may be null)
* @param variables template variables for replacement substitution
* @throws IOException if file operations fail
* @throws TemplateRenderException if transform is invalid
* @since 0.2.0
***************************************************************************/
public void execute(Path targetDir, List<Transform> transforms, Map<String, String> variables)
throws IOException, TemplateRenderException
{
if(transforms == null || transforms.isEmpty())
{
return;
}
for(Transform transform : transforms)
{
executeTransform(targetDir, transform, variables);
}
}
/***************************************************************************
* Execute a single transform.
*
* @param targetDir target directory
* @param transform transform to execute
* @param variables template variables
* @throws IOException if file operations fail
* @throws TemplateRenderException if transform is invalid
* @since 0.2.0
***************************************************************************/
private void executeTransform(Path targetDir, Transform transform, Map<String, String> variables)
throws IOException, TemplateRenderException
{
String type = transform.type();
try
{
if(Transform.TYPE_DELETE.equals(type))
{
executeDelete(targetDir, transform.pattern());
}
else if(Transform.TYPE_RENAME.equals(type))
{
executeRename(targetDir, transform.pattern(), transform.replacement(), variables);
}
else
{
throw new TemplateRenderException("Invalid transform type: " + type,
"manifest.yaml (transforms section)");
}
}
catch(IOException e)
{
throw new IOException("Transform failed for pattern '" + transform.pattern()
+ "' in " + targetDir + ": " + e.getMessage(), e);
}
}
/***************************************************************************
* Execute a delete transform.
*
* @param targetDir target directory
* @param pattern glob pattern to match files
* @throws IOException if file operations fail
* @since 0.2.0
***************************************************************************/
private void executeDelete(Path targetDir, String pattern) throws IOException
{
List<Path> matchingFiles = findMatchingFiles(targetDir, pattern);
for(Path file : matchingFiles)
{
Files.deleteIfExists(file);
}
}
/***************************************************************************
* Execute a rename transform.
*
* @param targetDir target directory
* @param pattern glob pattern to match files
* @param replacement replacement filename (may contain ${var} placeholders)
* @param variables template variables for substitution
* @throws IOException if file operations fail
* @since 0.2.0
***************************************************************************/
private void executeRename(Path targetDir, String pattern, String replacement,
Map<String, String> variables) throws IOException
{
List<Path> matchingFiles = findMatchingFiles(targetDir, pattern);
String resolvedReplacement = substituteVariables(replacement, variables);
for(Path file : matchingFiles)
{
Path newPath = file.getParent().resolve(resolvedReplacement);
Files.move(file, newPath);
}
}
/***************************************************************************
* Find all files matching a glob pattern.
*
* @param targetDir target directory to search
* @param pattern glob pattern
* @return list of matching file paths
* @throws IOException if traversal fails
* @since 0.2.0
***************************************************************************/
private List<Path> findMatchingFiles(Path targetDir, String pattern) throws IOException
{
List<Path> matches = new ArrayList<>();
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
Files.walkFileTree(targetDir, new SimpleFileVisitor<>()
{
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
{
Path relativePath = targetDir.relativize(file);
if(matcher.matches(relativePath))
{
matches.add(file);
}
return FileVisitResult.CONTINUE;
}
});
return matches;
}
/***************************************************************************
* Substitute ${varName} placeholders in a string.
*
* @param template string with placeholders
* @param variables variable values
* @return string with placeholders replaced
* @since 0.2.0
***************************************************************************/
private String substituteVariables(String template, Map<String, String> variables)
{
if(template == null || variables == null || variables.isEmpty())
{
return template;
}
Matcher matcher = VARIABLE_PATTERN.matcher(template);
StringBuffer result = new StringBuffer();
while(matcher.find())
{
String varName = matcher.group(1);
String value = variables.getOrDefault(varName, matcher.group(0));
matcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
matcher.appendTail(result);
return result.toString();
}
}
@@ -1,4 +1,13 @@
[
{
"name": "io.qrun.qctl.qqq.registry.TemplateInfo",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.TemplatesHub$TemplatesIndex",
"allDeclaredConstructors": true,
@@ -43,5 +52,151 @@
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.ComputedVariable",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.Transform",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "io.qrun.qctl.qqq.template.StringTool",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
},
{
"name": "org.apache.velocity.runtime.resource.ResourceManagerImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.resource.ResourceCacheImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.resource.util.StringResourceRepositoryImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.ParserPoolImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.util.introspection.UberspectImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.util.introspection.TypeConversionHandlerImpl",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.log.Slf4jLogChute",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.parser.StandardParser",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.resource.loader.StringResourceLoader",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Foreach",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Include",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Parse",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Macro",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Define",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Evaluate",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Stop",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.apache.velocity.runtime.directive.Break",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
}
]
@@ -0,0 +1,201 @@
/*
* 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.error;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Tests for ErrorFormatter output formatting.
*
* @since 0.2.0
*******************************************************************************/
class ErrorFormatterTest
{
/***************************************************************************
* Test basic error formatting.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_basicError()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
"Syntax error in template"
);
String output = formatter.format(error);
assertThat(output).contains("error:");
assertThat(output).contains("Syntax error in template");
}
/***************************************************************************
* Test error with suggestions.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_withSuggestions()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND,
"Template 'foo' not found",
List.of("foobar", "foo-api"),
null
);
String output = formatter.format(error);
assertThat(output).contains("Did you mean?");
assertThat(output).contains("foobar");
assertThat(output).contains("foo-api");
}
/***************************************************************************
* Test error with hint.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_withHint()
{
ErrorFormatter formatter = new ErrorFormatter(false);
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.NETWORK_ERROR,
"Connection refused",
"Check your network connection"
);
String output = formatter.format(error);
assertThat(output).contains("Check your network connection");
}
/***************************************************************************
* Test verbose mode includes stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_verboseWithCause()
{
ErrorFormatter formatter = new ErrorFormatter(true);
Exception cause = new RuntimeException("Root cause");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.IO_ERROR,
"Failed to read file",
cause
);
String output = formatter.format(error);
assertThat(output).contains("Stack trace:");
assertThat(output).contains("RuntimeException");
assertThat(output).contains("Root cause");
}
/***************************************************************************
* Test non-verbose mode hides stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_nonVerboseHidesStackTrace()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception cause = new RuntimeException("Root cause");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.IO_ERROR,
"Failed to read file",
cause
);
String output = formatter.format(error);
assertThat(output).doesNotContain("Stack trace:");
}
/***************************************************************************
* Test formatting generic exception.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_genericException()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception e = new RuntimeException("Something went wrong");
String output = formatter.format(e, "Try again later");
assertThat(output).contains("error:");
assertThat(output).contains("Something went wrong");
assertThat(output).contains("Try again later");
}
/***************************************************************************
* Test formatting exception without hint.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_exceptionNoHint()
{
ErrorFormatter formatter = new ErrorFormatter(false);
Exception e = new RuntimeException("Error message");
String output = formatter.format(e, null);
assertThat(output).contains("error:");
assertThat(output).contains("Error message");
}
/***************************************************************************
* Test verbose exception shows stack trace.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFormat_verboseException()
{
ErrorFormatter formatter = new ErrorFormatter(true);
Exception e = new RuntimeException("Error");
String output = formatter.format(e, null);
assertThat(output).contains("Stack trace:");
}
}
@@ -0,0 +1,234 @@
/*
* 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.error;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Tests for SuggestionEngine fuzzy matching.
*
* @since 0.2.0
*******************************************************************************/
class SuggestionEngineTest
{
private SuggestionEngine engine;
private List<String> candidates;
@BeforeEach
void setUp()
{
engine = new SuggestionEngine();
candidates = List.of(
"new-qqq-application",
"new-qqq-etl",
"new-qqq-api",
"spring-boot-starter",
"react-dashboard"
);
}
/***************************************************************************
* Test exact match returns first in results.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_exactMatch_returnsFirst()
{
List<String> results = engine.findSimilar("new-qqq-application", candidates);
assertThat(results).isNotEmpty();
assertThat(results.get(0)).isEqualTo("new-qqq-application");
}
/***************************************************************************
* Test typo with one character difference.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_typoOneChar_suggestsCorrect()
{
List<String> results = engine.findSimilar("new-qqq-aplication", candidates);
assertThat(results).contains("new-qqq-application");
}
/***************************************************************************
* Test prefix match gives bonus.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_prefixMatch_suggestsMatches()
{
List<String> results = engine.findSimilar("new-qqq-ap", candidates);
assertThat(results).contains("new-qqq-api");
}
/***************************************************************************
* Test missing hyphen typo.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_missingHyphen_suggests()
{
List<String> results = engine.findSimilar("newqqq-application", candidates);
assertThat(results).contains("new-qqq-application");
}
/***************************************************************************
* Test wrong word order returns nothing for distant strings.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_veryDifferent_returnsEmpty()
{
List<String> results = engine.findSimilar("completely-different-name", candidates);
assertThat(results).isEmpty();
}
/***************************************************************************
* Test null input returns empty.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_nullInput_returnsEmpty()
{
List<String> results = engine.findSimilar(null, candidates);
assertThat(results).isEmpty();
}
/***************************************************************************
* Test empty input returns empty.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_emptyInput_returnsEmpty()
{
List<String> results = engine.findSimilar("", candidates);
assertThat(results).isEmpty();
}
/***************************************************************************
* Test null candidates returns empty.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_nullCandidates_returnsEmpty()
{
List<String> results = engine.findSimilar("test", null);
assertThat(results).isEmpty();
}
/***************************************************************************
* Test empty candidates returns empty.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_emptyCandidates_returnsEmpty()
{
List<String> results = engine.findSimilar("test", List.of());
assertThat(results).isEmpty();
}
/***************************************************************************
* Test case insensitive matching.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_caseInsensitive_suggestsMatch()
{
List<String> results = engine.findSimilar("NEW-QQQ-APPLICATION", candidates);
assertThat(results).contains("new-qqq-application");
}
/***************************************************************************
* Test limits suggestions to 3.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_limitsSuggestions()
{
List<String> manyCandidates = List.of(
"test1", "test2", "test3", "test4", "test5"
);
List<String> results = engine.findSimilar("test", manyCandidates);
assertThat(results).hasSizeLessThanOrEqualTo(3);
}
/***************************************************************************
* Test transposed characters.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_transposedChars_suggests()
{
List<String> results = engine.findSimilar("new-qqq-applictaion", candidates);
assertThat(results).contains("new-qqq-application");
}
/***************************************************************************
* Test short strings with similar candidates.
*
* @since 0.2.0
***************************************************************************/
@Test
void testFindSimilar_shortCandidates_suggests()
{
List<String> shortCandidates = List.of("api", "etl", "web", "app");
List<String> results = engine.findSimilar("aip", shortCandidates);
assertThat(results).contains("api");
}
}
@@ -0,0 +1,169 @@
/*
* 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.error;
import java.util.List;
import io.qrun.qctl.shared.ExitCodes;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Tests for TemplateError structured error handling.
*
* @since 0.2.0
*******************************************************************************/
class TemplateErrorTest
{
/***************************************************************************
* Test error with all fields.
*
* @since 0.2.0
***************************************************************************/
@Test
void testConstructor_allFields()
{
List<String> suggestions = List.of("foo", "bar");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND,
"Template not found",
suggestions,
"Check the template name"
);
assertThat(error.getCategory()).isEqualTo(TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND);
assertThat(error.getMessage()).isEqualTo("Template not found");
assertThat(error.getSuggestions()).containsExactly("foo", "bar");
assertThat(error.getHint()).isEqualTo("Check the template name");
assertThat(error.hasSuggestions()).isTrue();
}
/***************************************************************************
* Test error with only message and hint.
*
* @since 0.2.0
***************************************************************************/
@Test
void testConstructor_messageAndHint()
{
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
"Failed to render",
"Check your syntax"
);
assertThat(error.getMessage()).isEqualTo("Failed to render");
assertThat(error.getHint()).isEqualTo("Check your syntax");
assertThat(error.getSuggestions()).isEmpty();
assertThat(error.hasSuggestions()).isFalse();
}
/***************************************************************************
* Test error with only message.
*
* @since 0.2.0
***************************************************************************/
@Test
void testConstructor_messageOnly()
{
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.IO_ERROR,
"File not readable"
);
assertThat(error.getMessage()).isEqualTo("File not readable");
assertThat(error.getHint()).isNull();
assertThat(error.getSuggestions()).isEmpty();
}
/***************************************************************************
* Test error wrapping a cause.
*
* @since 0.2.0
***************************************************************************/
@Test
void testConstructor_withCause()
{
Exception cause = new RuntimeException("Root cause");
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.NETWORK_ERROR,
"Connection failed",
cause
);
assertThat(error.getMessage()).isEqualTo("Connection failed");
assertThat(error.getCause()).isEqualTo(cause);
}
/***************************************************************************
* Test exit code mapping for each category.
*
* @since 0.2.0
***************************************************************************/
@Test
void testExitCodes()
{
assertThat(new TemplateError(TemplateError.ErrorCategory.TEMPLATE_NOT_FOUND, "x").getExitCode())
.isEqualTo(ExitCodes.NOT_FOUND);
assertThat(new TemplateError(TemplateError.ErrorCategory.VARIABLE_NOT_FOUND, "x").getExitCode())
.isEqualTo(ExitCodes.VALIDATION);
assertThat(new TemplateError(TemplateError.ErrorCategory.MANIFEST_INVALID, "x").getExitCode())
.isEqualTo(ExitCodes.VALIDATION);
assertThat(new TemplateError(TemplateError.ErrorCategory.RENDER_ERROR, "x").getExitCode())
.isEqualTo(ExitCodes.VALIDATION);
assertThat(new TemplateError(TemplateError.ErrorCategory.NETWORK_ERROR, "x").getExitCode())
.isEqualTo(ExitCodes.NETWORK);
assertThat(new TemplateError(TemplateError.ErrorCategory.DIRECTORY_EXISTS, "x").getExitCode())
.isEqualTo(ExitCodes.CONFLICT);
assertThat(new TemplateError(TemplateError.ErrorCategory.IO_ERROR, "x").getExitCode())
.isEqualTo(ExitCodes.GENERIC);
}
/***************************************************************************
* Test null suggestions becomes empty list.
*
* @since 0.2.0
***************************************************************************/
@Test
void testNullSuggestions_becomesEmptyList()
{
TemplateError error = new TemplateError(
TemplateError.ErrorCategory.RENDER_ERROR,
"Error",
null,
"hint"
);
assertThat(error.getSuggestions()).isEmpty();
assertThat(error.hasSuggestions()).isFalse();
}
}
@@ -0,0 +1,173 @@
/*
* 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.registry;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Unit tests for MockTemplateRegistry.
*
* @since 0.2.0
*******************************************************************************/
class MockTemplateRegistryTest
{
private MockTemplateRegistry registry;
@BeforeEach
void setUp()
{
registry = new MockTemplateRegistry();
}
@Test
void testListTemplates_returnsEmbeddedTemplates()
{
List<TemplateInfo> templates = registry.listTemplates();
assertThat(templates).isNotEmpty();
assertThat(templates).hasSizeGreaterThanOrEqualTo(3);
}
@Test
void testListTemplates_containsExpectedTemplates()
{
List<TemplateInfo> templates = registry.listTemplates();
List<String> ids = templates.stream()
.map(TemplateInfo::id)
.toList();
assertThat(ids).contains("new-qqq-application", "qqq-web-api", "qqq-cli-tool");
}
@Test
void testGetTemplate_existingTemplate_returnsTemplate()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
assertThat(result.get().id()).isEqualTo("new-qqq-application");
assertThat(result.get().name()).isEqualTo("QQQ Application Starter");
assertThat(result.get().version()).isEqualTo("1.0.0");
}
@Test
void testGetTemplate_nonExistingTemplate_returnsEmpty()
{
Optional<TemplateInfo> result = registry.getTemplate("non-existent");
assertThat(result).isEmpty();
}
@Test
void testTemplateInfo_hasExpectedMetadata()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
TemplateInfo info = result.get();
assertThat(info.schemaVersion()).isEqualTo(2);
assertThat(info.minimumQctlVersion()).isEqualTo("0.2.0");
assertThat(info.tags()).contains("qqq", "java", "starter");
assertThat(info.maintainer()).isEqualTo("QRun-IO");
}
@Test
void testTemplateInfo_hasPrompts()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
TemplateInfo info = result.get();
assertThat(info.prompts()).isNotEmpty();
assertThat(info.prompts()).hasSize(4);
List<String> promptNames = info.prompts().stream()
.map(p -> p.name())
.toList();
assertThat(promptNames).containsExactly("projectName", "groupId", "artifactId", "packageName");
}
@Test
void testTemplateInfo_hasComputedVariables()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
TemplateInfo info = result.get();
assertThat(info.computed()).isNotEmpty();
assertThat(info.computed().get(0).name()).isEqualTo("packagePath");
}
@Test
void testTemplateInfo_hasTransforms()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
TemplateInfo info = result.get();
assertThat(info.transforms()).isNotEmpty();
assertThat(info.transforms().get(0).type()).isEqualTo("delete");
}
@Test
void testTemplateInfo_toManifest_convertsCorrectly()
{
Optional<TemplateInfo> result = registry.getTemplate("new-qqq-application");
assertThat(result).isPresent();
var manifest = result.get().toManifest();
assertThat(manifest.id()).isEqualTo("new-qqq-application");
assertThat(manifest.name()).isEqualTo("QQQ Application Starter");
assertThat(manifest.schemaVersion()).isEqualTo(2);
assertThat(manifest.prompts()).hasSize(4);
}
@Test
void testRefresh_doesNothing()
{
// Refresh is a no-op for mock registry - just ensure it doesn't throw
registry.refresh();
// Templates should still be available
assertThat(registry.listTemplates()).isNotEmpty();
}
}
@@ -0,0 +1,78 @@
/*
* 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.registry;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Unit tests for TemplateRegistryFactory.
*
* @since 0.2.0
*******************************************************************************/
class TemplateRegistryFactoryTest
{
@BeforeEach
void setUp()
{
TemplateRegistryFactory.reset();
}
@AfterEach
void tearDown()
{
TemplateRegistryFactory.reset();
}
@Test
void testGetRegistry_returnsSameInstance()
{
TemplateRegistry first = TemplateRegistryFactory.getRegistry();
TemplateRegistry second = TemplateRegistryFactory.getRegistry();
assertThat(first).isSameAs(second);
}
@Test
void testReset_clearsCache()
{
TemplateRegistry first = TemplateRegistryFactory.getRegistry();
TemplateRegistryFactory.reset();
TemplateRegistry second = TemplateRegistryFactory.getRegistry();
assertThat(first).isNotSameAs(second);
}
@Test
void testSetRegistry_overridesDefault()
{
MockTemplateRegistry mock = new MockTemplateRegistry();
TemplateRegistryFactory.setRegistry(mock);
TemplateRegistry result = TemplateRegistryFactory.getRegistry();
assertThat(result).isSameAs(mock);
}
}
@@ -0,0 +1,228 @@
/*
* 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.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
* Unit tests for computed variable evaluation.
*
* @since 0.2.0
*******************************************************************************/
class ComputedVariableTest
{
private TemplateEngine engine;
@BeforeEach
void setUp()
{
engine = new TemplateEngine();
}
/***************************************************************************
* Test basic computed variable using StringTool.replace().
***************************************************************************/
@Test
void testComputedPackagePath() throws TemplateRenderException
{
Map<String, String> variables = new HashMap<>();
variables.put("packageName", "com.example.myapp");
List<ComputedVariable> computed = List.of(
new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')")
);
Map<String, String> result = engine.evaluateComputed(computed, variables);
assertThat(result).containsEntry("packageName", "com.example.myapp");
assertThat(result).containsEntry("packagePath", "com/example/myapp");
}
/***************************************************************************
* Test computed variable using StringTool.pascal().
***************************************************************************/
@Test
void testComputedPascalCase() throws TemplateRenderException
{
Map<String, String> variables = new HashMap<>();
variables.put("appName", "myApplication");
List<ComputedVariable> computed = List.of(
new ComputedVariable("appClassName", "$str.pascal($appName)")
);
Map<String, String> result = engine.evaluateComputed(computed, variables);
assertThat(result).containsEntry("appClassName", "MyApplication");
}
/***************************************************************************
* Test chained computed variables (one computed uses another).
***************************************************************************/
@Test
void testChainedComputedVariables() throws TemplateRenderException
{
Map<String, String> variables = new HashMap<>();
variables.put("packageName", "com.example.app");
List<ComputedVariable> computed = List.of(
new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')"),
new ComputedVariable("srcPath", "src/main/java/$packagePath")
);
Map<String, String> result = engine.evaluateComputed(computed, variables);
assertThat(result).containsEntry("packagePath", "com/example/app");
assertThat(result).containsEntry("srcPath", "src/main/java/com/example/app");
}
/***************************************************************************
* Test computed variable referencing undefined variable throws exception.
***************************************************************************/
@Test
void testUndefinedVariableThrows()
{
Map<String, String> variables = new HashMap<>();
// packageName is not defined
/////////////////////////////////////////////////////////////////////////
// Use simple reference, not method call (method calls may return "") //
/////////////////////////////////////////////////////////////////////////
List<ComputedVariable> computed = List.of(
new ComputedVariable("packagePath", "path: $packageName")
);
assertThatThrownBy(() -> engine.evaluateComputed(computed, variables))
.isInstanceOf(TemplateRenderException.class)
.hasMessageContaining("packageName");
}
/***************************************************************************
* Test circular dependency detected (implicit via order).
***************************************************************************/
@Test
void testCircularDependencyDetected()
{
Map<String, String> variables = new HashMap<>();
/////////////////////////////////////////////////////////////////////////
// varA references varB, but varB is defined after varA //
/////////////////////////////////////////////////////////////////////////
List<ComputedVariable> computed = List.of(
new ComputedVariable("varA", "$varB"),
new ComputedVariable("varB", "value")
);
assertThatThrownBy(() -> engine.evaluateComputed(computed, variables))
.isInstanceOf(TemplateRenderException.class)
.hasMessageContaining("varB");
}
/***************************************************************************
* Test empty computed list returns original variables.
***************************************************************************/
@Test
void testEmptyComputedReturnsOriginalVariables() throws TemplateRenderException
{
Map<String, String> variables = Map.of("name", "test");
Map<String, String> result = engine.evaluateComputed(List.of(), variables);
assertThat(result).isEqualTo(variables);
}
/***************************************************************************
* Test null computed list returns original variables.
***************************************************************************/
@Test
void testNullComputedReturnsOriginalVariables() throws TemplateRenderException
{
Map<String, String> variables = Map.of("name", "test");
Map<String, String> result = engine.evaluateComputed(null, variables);
assertThat(result).isEqualTo(variables);
}
/***************************************************************************
* Test multiple StringTool transformations in one expression.
***************************************************************************/
@Test
void testChainedTransformations() throws TemplateRenderException
{
Map<String, String> variables = new HashMap<>();
variables.put("appName", "myApp");
List<ComputedVariable> computed = List.of(
new ComputedVariable("kebabName", "$str.kebab($appName)"),
new ComputedVariable("upperKebab", "$str.upper($kebabName)")
);
Map<String, String> result = engine.evaluateComputed(computed, variables);
assertThat(result).containsEntry("kebabName", "my-app");
assertThat(result).containsEntry("upperKebab", "MY-APP");
}
/***************************************************************************
* Test computed variables work with full template rendering.
***************************************************************************/
@Test
void testComputedVariablesInTemplateRendering() throws TemplateRenderException
{
Map<String, String> variables = new HashMap<>();
variables.put("packageName", "com.example.myapp");
variables.put("appName", "myApp");
List<ComputedVariable> computed = List.of(
new ComputedVariable("packagePath", "$str.replace($packageName, '.', '/')"),
new ComputedVariable("appClassName", "$str.pascal($appName)")
);
Map<String, String> allVariables = engine.evaluateComputed(computed, variables);
String template = "package $packageName;\n\npublic class $appClassName {}";
String result = engine.renderContent(template, allVariables);
assertThat(result).isEqualTo("package com.example.myapp;\n\npublic class MyApp {}");
}
}
@@ -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);
}
}
@@ -0,0 +1,415 @@
/*
* 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.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Unit tests for PromptValidator.
*
* @since 0.2.0
*******************************************************************************/
class PromptValidatorTest
{
private PromptValidator validator;
@BeforeEach
void setUp()
{
validator = new PromptValidator();
}
/***************************************************************************
* Test required field with blank value fails.
***************************************************************************/
@Test
void testRequiredFieldBlankFails()
{
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, null, true);
PromptValidator.ValidationResult result = validator.validate(prompt, "");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).isEqualTo("This field is required");
}
/***************************************************************************
* Test required field with null value fails.
***************************************************************************/
@Test
void testRequiredFieldNullFails()
{
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, null, true);
PromptValidator.ValidationResult result = validator.validate(prompt, null);
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).isEqualTo("This field is required");
}
/***************************************************************************
* Test required field with valid value passes.
***************************************************************************/
@Test
void testRequiredFieldWithValuePasses()
{
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, null, true);
PromptValidator.ValidationResult result = validator.validate(prompt, "John");
assertThat(result.valid()).isTrue();
}
/***************************************************************************
* Test optional field with blank value passes.
***************************************************************************/
@Test
void testOptionalFieldBlankPasses()
{
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, null, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "");
assertThat(result.valid()).isTrue();
}
/***************************************************************************
* Test pattern validation passes for valid input.
***************************************************************************/
@Test
void testPatternValidationPasses()
{
PromptValidation validation = new PromptValidation(
"^[a-z][a-z0-9]*$", null, null, null, null, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "myapp123");
assertThat(result.valid()).isTrue();
}
/***************************************************************************
* Test pattern validation fails for invalid input.
***************************************************************************/
@Test
void testPatternValidationFails()
{
PromptValidation validation = new PromptValidation(
"^[a-z][a-z0-9]*$", null, null, null, null, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "123invalid");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).contains("pattern");
}
/***************************************************************************
* Test custom error message is used on validation failure.
***************************************************************************/
@Test
void testCustomErrorMessage()
{
PromptValidation validation = new PromptValidation(
"^[a-z]+$", null, null, null, null, null, "Must contain only lowercase letters");
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "ABC123");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).isEqualTo("Must contain only lowercase letters");
}
/***************************************************************************
* Test minLength validation passes.
***************************************************************************/
@Test
void testMinLengthPasses()
{
PromptValidation validation = new PromptValidation(
null, 3, null, null, null, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "abc");
assertThat(result.valid()).isTrue();
}
/***************************************************************************
* Test minLength validation fails.
***************************************************************************/
@Test
void testMinLengthFails()
{
PromptValidation validation = new PromptValidation(
null, 5, null, null, null, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "ab");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).contains("at least 5");
}
/***************************************************************************
* Test maxLength validation passes.
***************************************************************************/
@Test
void testMaxLengthPasses()
{
PromptValidation validation = new PromptValidation(
null, null, 10, null, null, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "short");
assertThat(result.valid()).isTrue();
}
/***************************************************************************
* Test maxLength validation fails.
***************************************************************************/
@Test
void testMaxLengthFails()
{
PromptValidation validation = new PromptValidation(
null, null, 5, null, null, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "toolongvalue");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).contains("at most 5");
}
/***************************************************************************
* Test enum values validation passes.
***************************************************************************/
@Test
void testEnumValuesPasses()
{
PromptValidation validation = new PromptValidation(
null, null, null, null, null, List.of("dev", "staging", "prod"), null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"env", "Environment", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "dev");
assertThat(result.valid()).isTrue();
}
/***************************************************************************
* Test enum values validation fails.
***************************************************************************/
@Test
void testEnumValuesFails()
{
PromptValidation validation = new PromptValidation(
null, null, null, null, null, List.of("dev", "staging", "prod"), null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"env", "Environment", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "invalid");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).contains("one of");
}
/***************************************************************************
* Test integer validation passes.
***************************************************************************/
@Test
void testIntegerValidationPasses()
{
PromptValidation validation = new PromptValidation(
null, null, null, 1, 100, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"count", "Count", "integer", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "50");
assertThat(result.valid()).isTrue();
}
/***************************************************************************
* Test integer min validation fails.
***************************************************************************/
@Test
void testIntegerMinFails()
{
PromptValidation validation = new PromptValidation(
null, null, null, 10, null, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"count", "Count", "integer", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "5");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).contains("at least 10");
}
/***************************************************************************
* Test integer max validation fails.
***************************************************************************/
@Test
void testIntegerMaxFails()
{
PromptValidation validation = new PromptValidation(
null, null, null, null, 100, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"count", "Count", "integer", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "200");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).contains("at most 100");
}
/***************************************************************************
* Test non-integer input fails for integer type.
***************************************************************************/
@Test
void testNonIntegerFails()
{
PromptValidation validation = new PromptValidation(
null, null, null, 1, 100, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"count", "Count", "integer", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "abc");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).contains("valid integer");
}
/***************************************************************************
* Test invalid regex pattern in validation.
***************************************************************************/
@Test
void testInvalidPatternReportsError()
{
PromptValidation validation = new PromptValidation(
"[invalid", null, null, null, null, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "test");
assertThat(result.valid()).isFalse();
assertThat(result.errorMessage()).contains("Invalid validation pattern");
}
/***************************************************************************
* Test no validation rules passes.
***************************************************************************/
@Test
void testNoValidationRulesPasses()
{
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"name", "Name", "text", null, null, null, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "anything");
assertThat(result.valid()).isTrue();
}
/***************************************************************************
* Test number type works same as integer type.
***************************************************************************/
@Test
void testNumberTypeValidation()
{
PromptValidation validation = new PromptValidation(
null, null, null, 1, 10, null, null);
TemplateManifest.Prompt prompt = new TemplateManifest.Prompt(
"count", "Count", "number", null, null, validation, false);
PromptValidator.ValidationResult result = validator.validate(prompt, "5");
assertThat(result.valid()).isTrue();
}
}
@@ -0,0 +1,271 @@
/*
* 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 org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
* Unit tests for StringTool.
*
* @since 0.2.0
*******************************************************************************/
class StringToolTest
{
private StringTool stringTool;
@BeforeEach
void setUp()
{
stringTool = new StringTool();
}
/***************************************************************************
* Test lower() with various inputs.
***************************************************************************/
@ParameterizedTest
@CsvSource({
"HelloWorld, helloworld",
"UPPERCASE, uppercase",
"already-lower, already-lower",
"MixedCase123, mixedcase123"
})
void testLower(String input, String expected)
{
assertThat(stringTool.lower(input)).isEqualTo(expected);
}
/***************************************************************************
* Test lower() with null and empty values.
***************************************************************************/
@ParameterizedTest
@NullAndEmptySource
void testLowerNullAndEmpty(String input)
{
assertThat(stringTool.lower(input)).isEqualTo("");
}
/***************************************************************************
* Test upper() with various inputs.
***************************************************************************/
@ParameterizedTest
@CsvSource({
"HelloWorld, HELLOWORLD",
"lowercase, LOWERCASE",
"already-UPPER, ALREADY-UPPER",
"MixedCase123, MIXEDCASE123"
})
void testUpper(String input, String expected)
{
assertThat(stringTool.upper(input)).isEqualTo(expected);
}
/***************************************************************************
* Test upper() with null and empty values.
***************************************************************************/
@ParameterizedTest
@NullAndEmptySource
void testUpperNullAndEmpty(String input)
{
assertThat(stringTool.upper(input)).isEqualTo("");
}
/***************************************************************************
* Test camel() converts to camelCase.
***************************************************************************/
@ParameterizedTest
@CsvSource({
"HelloWorld, helloWorld",
"PascalCase, pascalCase",
"already-camel, already-camel",
"X, x"
})
void testCamel(String input, String expected)
{
assertThat(stringTool.camel(input)).isEqualTo(expected);
}
/***************************************************************************
* Test camel() with null and empty values.
***************************************************************************/
@ParameterizedTest
@NullAndEmptySource
void testCamelNullAndEmpty(String input)
{
assertThat(stringTool.camel(input)).isEqualTo("");
}
/***************************************************************************
* Test pascal() converts to PascalCase.
***************************************************************************/
@ParameterizedTest
@CsvSource({
"helloWorld, HelloWorld",
"camelCase, CamelCase",
"x, X"
})
void testPascal(String input, String expected)
{
assertThat(stringTool.pascal(input)).isEqualTo(expected);
}
/***************************************************************************
* Test pascal() with null and empty values.
***************************************************************************/
@ParameterizedTest
@NullAndEmptySource
void testPascalNullAndEmpty(String input)
{
assertThat(stringTool.pascal(input)).isEqualTo("");
}
/***************************************************************************
* Test kebab() converts camelCase to kebab-case.
***************************************************************************/
@ParameterizedTest
@CsvSource({
"helloWorld, hello-world",
"HelloWorld, hello-world",
"myApiName, my-api-name",
"simple, simple"
})
void testKebab(String input, String expected)
{
assertThat(stringTool.kebab(input)).isEqualTo(expected);
}
/***************************************************************************
* Test kebab() with null and empty values.
***************************************************************************/
@ParameterizedTest
@NullAndEmptySource
void testKebabNullAndEmpty(String input)
{
assertThat(stringTool.kebab(input)).isEqualTo("");
}
/***************************************************************************
* Test snake() converts camelCase to snake_case.
***************************************************************************/
@ParameterizedTest
@CsvSource({
"helloWorld, hello_world",
"HelloWorld, hello_world",
"myApiName, my_api_name",
"simple, simple"
})
void testSnake(String input, String expected)
{
assertThat(stringTool.snake(input)).isEqualTo(expected);
}
/***************************************************************************
* Test snake() with null and empty values.
***************************************************************************/
@ParameterizedTest
@NullAndEmptySource
void testSnakeNullAndEmpty(String input)
{
assertThat(stringTool.snake(input)).isEqualTo("");
}
/***************************************************************************
* Test that StringTool can be used in a Velocity-like context.
***************************************************************************/
@Test
void testChainedTransformations()
{
// Simulating $str.upper($str.kebab($name))
String name = "myProjectName";
String kebab = stringTool.kebab(name);
String upper = stringTool.upper(kebab);
assertThat(upper).isEqualTo("MY-PROJECT-NAME");
}
/***************************************************************************
* Test replace() for package path derivation.
***************************************************************************/
@ParameterizedTest
@CsvSource({
"com.example.app, ., /, com/example/app",
"hello-world, -, _, hello_world",
"no_match, x, y, no_match",
"aaa, a, b, bbb"
})
void testReplace(String input, String target, String replacement, String expected)
{
assertThat(stringTool.replace(input, target, replacement)).isEqualTo(expected);
}
/***************************************************************************
* Test replace() with null and empty values.
***************************************************************************/
@ParameterizedTest
@NullAndEmptySource
void testReplaceNullAndEmpty(String input)
{
assertThat(stringTool.replace(input, ".", "/")).isEqualTo("");
}
/***************************************************************************
* Test replace() with null target or replacement.
***************************************************************************/
@Test
void testReplaceNullTargetOrReplacement()
{
assertThat(stringTool.replace("test", null, "/")).isEqualTo("test");
assertThat(stringTool.replace("test", ".", null)).isEqualTo("test");
}
}
@@ -0,0 +1,396 @@
/*
* 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.HashMap;
import java.util.List;
import java.util.Map;
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;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
* Unit tests for TemplateEngine with Velocity.
*
* @since 0.2.0
*******************************************************************************/
class TemplateEngineTest
{
@TempDir
Path tempDir;
private TemplateEngine engine;
@BeforeEach
void setUp()
{
engine = new TemplateEngine();
}
/***************************************************************************
* Test simple variable substitution in content.
***************************************************************************/
@Test
void testRenderContentSimpleVariable() throws TemplateRenderException
{
String template = "Hello, $name!";
Map<String, String> variables = Map.of("name", "World");
String result = engine.renderContent(template, variables);
assertThat(result).isEqualTo("Hello, World!");
}
/***************************************************************************
* Test variable substitution with braces syntax.
***************************************************************************/
@Test
void testRenderContentBracesSyntax() throws TemplateRenderException
{
String template = "Package: ${packageName}";
Map<String, String> variables = Map.of("packageName", "com.example.app");
String result = engine.renderContent(template, variables);
assertThat(result).isEqualTo("Package: com.example.app");
}
/***************************************************************************
* Test StringTool usage in templates.
***************************************************************************/
@Test
void testRenderContentWithStringTool() throws TemplateRenderException
{
String template = "class $str.pascal($appName) {}";
Map<String, String> variables = Map.of("appName", "myApplication");
String result = engine.renderContent(template, variables);
assertThat(result).isEqualTo("class MyApplication {}");
}
/***************************************************************************
* Test all StringTool transformations in content.
***************************************************************************/
@Test
void testRenderContentAllStringTools() throws TemplateRenderException
{
String template = """
lower: $str.lower($name)
upper: $str.upper($name)
camel: $str.camel($name)
pascal: $str.pascal($name)
kebab: $str.kebab($name)
snake: $str.snake($name)""";
Map<String, String> variables = Map.of("name", "MyProject");
String result = engine.renderContent(template, variables);
assertThat(result).isEqualTo("""
lower: myproject
upper: MYPROJECT
camel: myProject
pascal: MyProject
kebab: my-project
snake: my_project""");
}
/***************************************************************************
* Test Velocity control flow - #if.
***************************************************************************/
@Test
void testRenderContentIfStatement() throws TemplateRenderException
{
String template = "#if($includeTests)tests included#end";
Map<String, String> variables = Map.of("includeTests", "true");
String result = engine.renderContent(template, variables);
assertThat(result).isEqualTo("tests included");
}
/***************************************************************************
* Test Velocity control flow - #foreach.
***************************************************************************/
@Test
void testRenderContentForeach() throws TemplateRenderException
{
String template = "#foreach($item in $items)$item #end";
Map<String, Object> variables = new HashMap<>();
variables.put("items", List.of("a", "b", "c"));
String result = engine.renderContentWithObjects(template, variables);
assertThat(result).isEqualTo("a b c ");
}
/***************************************************************************
* Test path rendering with Velocity variables.
***************************************************************************/
@Test
void testRenderPath() throws TemplateRenderException
{
String path = "src/main/java/$packagePath/App.java";
Map<String, String> variables = Map.of("packagePath", "com/example/app");
String result = engine.renderPath(path, variables);
assertThat(result).isEqualTo("src/main/java/com/example/app/App.java");
}
/***************************************************************************
* Test path rendering with StringTool transformations.
***************************************************************************/
@Test
void testRenderPathWithStringTool() throws TemplateRenderException
{
String path = "src/$str.kebab($projectName)/main.java";
Map<String, String> variables = Map.of("projectName", "MyProject");
String result = engine.renderPath(path, variables);
assertThat(result).isEqualTo("src/my-project/main.java");
}
/***************************************************************************
* Test that missing required variable throws exception.
***************************************************************************/
@Test
void testRenderContentMissingVariableThrows()
{
String template = "Hello, $name!";
Map<String, String> variables = Map.of(); // no variables
assertThatThrownBy(() -> engine.renderContent(template, variables))
.isInstanceOf(TemplateRenderException.class)
.hasMessageContaining("name");
}
/***************************************************************************
* Test that syntax error throws exception.
***************************************************************************/
@Test
void testRenderContentSyntaxErrorThrows()
{
String template = "#if($x) unclosed if";
Map<String, String> variables = Map.of("x", "true");
assertThatThrownBy(() -> engine.renderContent(template, variables))
.isInstanceOf(TemplateRenderException.class);
}
/***************************************************************************
* Test rendering full template directory.
***************************************************************************/
@Test
void testRenderDirectory() throws IOException, TemplateRenderException
{
// Set up source template
Path templateDir = tempDir.resolve("template-source");
Path templateSubdir = templateDir.resolve("template");
Files.createDirectories(templateSubdir.resolve("src/main/java"));
Files.writeString(templateSubdir.resolve("pom.xml"),
"<artifactId>$artifactId</artifactId>");
Files.writeString(templateSubdir.resolve("src/main/java/App.java"),
"package $packageName;\npublic class $str.pascal($appName) {}");
// Create manifest
TemplateManifest manifest = new TemplateManifest(
null, "test-template", "Test Template", null, null, null, null, null, null, null, null);
// Render to target
Path targetDir = tempDir.resolve("output");
Files.createDirectories(targetDir);
Map<String, String> variables = Map.of(
"artifactId", "my-app",
"packageName", "com.example",
"appName", "myApp"
);
int fileCount = engine.render(templateDir, targetDir, variables, manifest);
// Verify
assertThat(fileCount).isEqualTo(2);
assertThat(targetDir.resolve("pom.xml")).content()
.isEqualTo("<artifactId>my-app</artifactId>");
assertThat(targetDir.resolve("src/main/java/App.java")).content()
.isEqualTo("package com.example;\npublic class MyApp {}");
}
/***************************************************************************
* Test rendering with path variable substitution.
***************************************************************************/
@Test
void testRenderDirectoryWithPathVariables() throws IOException, TemplateRenderException
{
// Set up source template with variable in path
Path templateDir = tempDir.resolve("template-source");
Path templateSubdir = templateDir.resolve("template");
Path srcPath = templateSubdir.resolve("src/main/java/$packagePath");
Files.createDirectories(srcPath);
Files.writeString(srcPath.resolve("App.java"),
"package $packageName;\npublic class App {}");
// Create manifest
TemplateManifest manifest = new TemplateManifest(
null, "test-template", "Test Template", null, null, null, null, null, null, null, null);
// Render to target
Path targetDir = tempDir.resolve("output");
Files.createDirectories(targetDir);
Map<String, String> variables = Map.of(
"packageName", "com.acme.orders",
"packagePath", "com/acme/orders"
);
int fileCount = engine.render(templateDir, targetDir, variables, manifest);
// Verify path was rendered correctly
assertThat(fileCount).isEqualTo(1);
assertThat(targetDir.resolve("src/main/java/com/acme/orders/App.java"))
.exists()
.content().contains("package com.acme.orders;");
}
/***************************************************************************
* Test binary files are copied without modification.
***************************************************************************/
@Test
void testRenderBinaryFileCopiedUnmodified() throws IOException, TemplateRenderException
{
// Set up source template with binary file
Path templateDir = tempDir.resolve("template-source");
Path templateSubdir = templateDir.resolve("template");
Files.createDirectories(templateSubdir);
byte[] binaryContent = {0x00, 0x01, 0x02, (byte) 0xFF};
Files.write(templateSubdir.resolve("image.png"), binaryContent);
// Create manifest
TemplateManifest manifest = new TemplateManifest(
null, "test-template", "Test Template", null, null, null, null, null, null, null, null);
// Render to target
Path targetDir = tempDir.resolve("output");
Files.createDirectories(targetDir);
int fileCount = engine.render(templateDir, targetDir, Map.of(), manifest);
// Verify binary file copied as-is
assertThat(fileCount).isEqualTo(1);
assertThat(Files.readAllBytes(targetDir.resolve("image.png")))
.isEqualTo(binaryContent);
}
/***************************************************************************
* Test ignored files are skipped.
***************************************************************************/
@Test
void testRenderIgnoresFiles() throws IOException, TemplateRenderException
{
// Set up source template
Path templateDir = tempDir.resolve("template-source");
Path templateSubdir = templateDir.resolve("template");
Files.createDirectories(templateSubdir);
Files.writeString(templateSubdir.resolve("keep.txt"), "keep me");
Files.writeString(templateSubdir.resolve("ignore.bak"), "ignore me");
// Create manifest with ignore pattern
TemplateManifest manifest = new TemplateManifest(
null, "test-template", "Test Template", null, null, null, null, null, null, null, List.of("*.bak"));
// Render to target
Path targetDir = tempDir.resolve("output");
Files.createDirectories(targetDir);
int fileCount = engine.render(templateDir, targetDir, Map.of(), manifest);
// Verify ignored file was skipped
assertThat(fileCount).isEqualTo(1);
assertThat(targetDir.resolve("keep.txt")).exists();
assertThat(targetDir.resolve("ignore.bak")).doesNotExist();
}
/***************************************************************************
* Test dry run mode shows files without creating them.
***************************************************************************/
@Test
void testRenderDryRun() throws IOException, TemplateRenderException
{
// Set up source template
Path templateDir = tempDir.resolve("template-source");
Path templateSubdir = templateDir.resolve("template");
Files.createDirectories(templateSubdir);
Files.writeString(templateSubdir.resolve("file.txt"), "content");
// Create manifest
TemplateManifest manifest = new TemplateManifest(
null, "test-template", "Test Template", null, null, null, null, null, null, null, null);
// Render dry run to target
Path targetDir = tempDir.resolve("output");
// Note: don't create targetDir - dry run should work without it
engine.renderDryRun(templateDir, targetDir, Map.of(), manifest);
// Verify no files were created
assertThat(targetDir).doesNotExist();
}
}
@@ -0,0 +1,348 @@
/*
* 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 java.util.Map;
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;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
* Unit tests for TransformExecutor.
*
* @since 0.2.0
*******************************************************************************/
class TransformExecutorTest
{
@TempDir
Path tempDir;
private TransformExecutor executor;
@BeforeEach
void setUp()
{
executor = new TransformExecutor();
}
/***************************************************************************
* Test delete transform removes matching files.
***************************************************************************/
@Test
void testDeleteTransform() throws IOException, TemplateRenderException
{
// Arrange
Path gitkeep1 = tempDir.resolve("src/.gitkeep");
Path gitkeep2 = tempDir.resolve("test/.gitkeep");
Path keepFile = tempDir.resolve("src/Main.java");
Files.createDirectories(gitkeep1.getParent());
Files.createDirectories(gitkeep2.getParent());
Files.writeString(gitkeep1, "");
Files.writeString(gitkeep2, "");
Files.writeString(keepFile, "class Main {}");
Transform deleteTransform = new Transform(
Transform.TYPE_DELETE,
"**/.gitkeep",
null
);
// Act
executor.execute(tempDir, List.of(deleteTransform), Map.of());
// Assert
assertThat(gitkeep1).doesNotExist();
assertThat(gitkeep2).doesNotExist();
assertThat(keepFile).exists();
}
/***************************************************************************
* Test rename transform renames matching files.
***************************************************************************/
@Test
void testRenameTransform() throws IOException, TemplateRenderException
{
// Arrange
Path templateFile = tempDir.resolve("src/TemplateApp.java");
Files.createDirectories(templateFile.getParent());
Files.writeString(templateFile, "class TemplateApp {}");
Transform renameTransform = new Transform(
Transform.TYPE_RENAME,
"**/TemplateApp.java",
"MyApp.java"
);
// Act
executor.execute(tempDir, List.of(renameTransform), Map.of());
// Assert
assertThat(templateFile).doesNotExist();
assertThat(tempDir.resolve("src/MyApp.java")).exists();
}
/***************************************************************************
* Test rename transform with variable substitution.
***************************************************************************/
@Test
void testRenameTransformWithVariables() throws IOException, TemplateRenderException
{
// Arrange
Path templateFile = tempDir.resolve("src/TemplateApplication.java");
Files.createDirectories(templateFile.getParent());
Files.writeString(templateFile, "class TemplateApplication {}");
Transform renameTransform = new Transform(
Transform.TYPE_RENAME,
"**/TemplateApplication.java",
"${appClassName}Application.java"
);
Map<String, String> variables = Map.of("appClassName", "MyApp");
// Act
executor.execute(tempDir, List.of(renameTransform), variables);
// Assert
assertThat(templateFile).doesNotExist();
assertThat(tempDir.resolve("src/MyAppApplication.java")).exists();
}
/***************************************************************************
* Test multiple transforms execute in order.
***************************************************************************/
@Test
void testMultipleTransforms() throws IOException, TemplateRenderException
{
// Arrange
Path srcDir = tempDir.resolve("src");
Files.createDirectories(srcDir);
Path gitkeep = srcDir.resolve(".gitkeep");
Path templateFile = srcDir.resolve("Template.java");
Files.writeString(gitkeep, "");
Files.writeString(templateFile, "class Template {}");
List<Transform> transforms = List.of(
new Transform(Transform.TYPE_DELETE, "**/.gitkeep", null),
new Transform(Transform.TYPE_RENAME, "**/Template.java", "App.java")
);
// Act
executor.execute(tempDir, transforms, Map.of());
// Assert
assertThat(gitkeep).doesNotExist();
assertThat(templateFile).doesNotExist();
assertThat(srcDir.resolve("App.java")).exists();
}
/***************************************************************************
* Test empty transforms list does nothing.
***************************************************************************/
@Test
void testEmptyTransformsList() throws IOException, TemplateRenderException
{
// Arrange
Path file = tempDir.resolve("test.txt");
Files.writeString(file, "content");
// Act
executor.execute(tempDir, List.of(), Map.of());
// Assert
assertThat(file).exists();
}
/***************************************************************************
* Test null transforms list does nothing.
***************************************************************************/
@Test
void testNullTransformsList() throws IOException, TemplateRenderException
{
// Arrange
Path file = tempDir.resolve("test.txt");
Files.writeString(file, "content");
// Act
executor.execute(tempDir, null, Map.of());
// Assert
assertThat(file).exists();
}
/***************************************************************************
* Test rename preserves file content.
***************************************************************************/
@Test
void testRenamePreservesContent() throws IOException, TemplateRenderException
{
// Arrange
String content = "package com.example;\n\nclass MyClass { }";
Path srcDir = tempDir.resolve("src");
Files.createDirectories(srcDir);
Path originalFile = srcDir.resolve("Original.java");
Files.writeString(originalFile, content);
Transform renameTransform = new Transform(
Transform.TYPE_RENAME,
"**/Original.java",
"Renamed.java"
);
// Act
executor.execute(tempDir, List.of(renameTransform), Map.of());
// Assert
Path renamedFile = srcDir.resolve("Renamed.java");
assertThat(renamedFile).exists();
assertThat(Files.readString(renamedFile)).isEqualTo(content);
}
/***************************************************************************
* Test rename in nested directory.
***************************************************************************/
@Test
void testRenameInNestedDirectory() throws IOException, TemplateRenderException
{
// Arrange
Path nestedFile = tempDir.resolve("src/main/java/Template.java");
Files.createDirectories(nestedFile.getParent());
Files.writeString(nestedFile, "class Template {}");
Transform renameTransform = new Transform(
Transform.TYPE_RENAME,
"**/Template.java",
"App.java"
);
// Act
executor.execute(tempDir, List.of(renameTransform), Map.of());
// Assert
assertThat(nestedFile).doesNotExist();
assertThat(tempDir.resolve("src/main/java/App.java")).exists();
}
/***************************************************************************
* Test pattern that matches nothing does not fail.
***************************************************************************/
@Test
void testNoMatchingFiles() throws IOException, TemplateRenderException
{
// Arrange
Path file = tempDir.resolve("test.txt");
Files.writeString(file, "content");
Transform deleteTransform = new Transform(
Transform.TYPE_DELETE,
"**/*.nonexistent",
null
);
// Act - should not throw
executor.execute(tempDir, List.of(deleteTransform), Map.of());
// Assert
assertThat(file).exists();
}
/***************************************************************************
* Test invalid transform type throws exception.
***************************************************************************/
@Test
void testInvalidTransformType()
{
Transform invalidTransform = new Transform("invalid", "**/*", null);
assertThatThrownBy(() -> executor.execute(tempDir, List.of(invalidTransform), Map.of()))
.isInstanceOf(TemplateRenderException.class)
.hasMessageContaining("Invalid transform type");
}
/***************************************************************************
* Test Transform validation - missing type.
***************************************************************************/
@Test
void testTransformValidationMissingType()
{
Transform transform = new Transform(null, "**/*", null);
assertThatThrownBy(transform::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("type is required");
}
/***************************************************************************
* Test Transform validation - missing pattern.
***************************************************************************/
@Test
void testTransformValidationMissingPattern()
{
Transform transform = new Transform(Transform.TYPE_DELETE, null, null);
assertThatThrownBy(transform::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("pattern is required");
}
/***************************************************************************
* Test Transform validation - rename without replacement.
***************************************************************************/
@Test
void testTransformValidationRenameWithoutReplacement()
{
Transform transform = new Transform(Transform.TYPE_RENAME, "**/*", null);
assertThatThrownBy(transform::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Replacement is required");
}
}