25 Commits
Author SHA1 Message Date
james.maes e41534a47e Merge pull request 'ci: use shared ci-workflows Maven workflow' (#74) from ci/ci-workflows into develop
maven / build (push) Successful in 1m38s
build / maven (push) Successful in 1m38s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 8s
2026-11-06 18:02:24 +00:00
james.maesandClaude Opus 5.5 0a35966498 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 16s
maven / build (push) Successful in 1m23s
build / maven (push) Successful in 1m23s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m29s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Successful in 42m28s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:51:51 +00:00
james.maesandCodex 6a27f3d9b4 ci: align canonical shared orb workers (#69)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:33:03 -05:00
james.maesandCodex f80d1ac9f8 ci: delegate pipelines to shared QQQ orb
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:39:50 -05:00
James MaesandClaude Opus 4.8 e47cca4b81 docs: add CLAUDE.md pointer to QQQ knowledge-base dossier + hub
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 05:31:35 -05:00
james.maes c7cf55a99e chore: migrate license from AGPL-3.0 to Apache-2.0 2026-01-05 10:11:44 -06:00
james.maes dd275cb9fe chore: bump version to 0.2.1 for next development cycle 2026-01-04 19:34:13 -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
88 changed files with 12191 additions and 1270 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:*)"
]
}
}
+9
View File
@@ -0,0 +1,9 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
with:
publish: false
+17 -294
View File
@@ -1,302 +1,25 @@
# Generated by qqq-orb generate_qctl_workflow; edit the central worker, not local jobs.
# GitHub-native macOS/Windows runners are required for the release binaries.
name: Build
on:
push:
branches: [main, develop]
tags: ['v*']
pull_request:
branches: [main, develop]
workflow_dispatch:
inputs:
full_native:
description: Qualify all five native platforms without publishing
type: boolean
default: false
permissions:
contents: write
packages: write
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Run tests
run: mvn clean verify -q
build-native:
needs: test
if: github.event_name == 'push'
strategy:
matrix:
include:
- os: ubuntu-latest
artifact: qctl-linux-amd64
- os: ubuntu-24.04-arm
artifact: qctl-linux-arm64
- os: macos-15
artifact: qctl-macos-amd64
- os: macos-14
artifact: qctl-macos-arm64
- os: windows-latest
artifact: qctl-windows-amd64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: '21'
distribution: 'graalvm'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Build native image
run: mvn clean package -DskipTests -Pnative -q
- name: Rename artifact (Unix)
if: runner.os != 'Windows'
run: mv qctl-cli/target/qctl qctl-cli/target/${{ matrix.artifact }}
- name: Rename artifact (Windows)
if: runner.os == 'Windows'
run: mv qctl-cli/target/qctl.exe qctl-cli/target/${{ matrix.artifact }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: qctl-cli/target/${{ matrix.artifact }}
release:
needs: build-native
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
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: release/*
generate_release_notes: true
docker:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Download Linux AMD64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-amd64
path: .
- name: Download Linux ARM64 artifact
uses: actions/download-artifact@v4
with:
name: qctl-linux-arm64
path: .
- name: Prepare binaries
run: |
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
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: packaging/docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/qrun-io/qctl:${{ github.ref_name }}
ghcr.io/qrun-io/qctl:latest
update-homebrew:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- 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: |
VERSION="${VERSION#v}"
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_ARM64=$(shasum -a 256 artifacts/qctl-linux-arm64/qctl-linux-arm64 | cut -d' ' -f1)
SHA_LINUX_AMD64=$(shasum -a 256 artifacts/qctl-linux-amd64/qctl-linux-amd64 | cut -d' ' -f1)
FORMULA="homebrew-tap/Formula/qctl.rb"
# Update version
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
# 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 and push
run: |
cd homebrew-tap
git config user.name "github-actions[bot]"
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: 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
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: 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
native:
uses: QRun-IO/qqq-orb/.github/workflows/qctl-native.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
full_native: ${{ github.event_name == 'workflow_dispatch' && inputs.full_native }}
secrets: inherit
+112 -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,109 @@ 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.
## Knowledge base
Reviewed dossier for this repo (commit `c7cf55a99e51`, branch `develop`, 2026-07-04):
`R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qctl.md`
QQQ platform hub (cross-repo map, v4.0 impact tracking, licensing follow-ups):
`R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
+202 -680
View File
@@ -1,680 +1,202 @@
QQQ - Low-code Application Framework for Engineers
Copyright (C) 2021-2025 QRun-IO, LLC
https://www.qrun.io | https://github.com/QRun-IO
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
--------------------------------------------------------------------------------
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on
behalf of the copyright owner. For the purposes of this definition,
"submitted" means any form of electronic, verbal, or written
communication sent to the Licensor or its representatives, including
but not limited to communication on electronic mailing lists, source
code control systems, and issue tracking systems that are managed by,
or on behalf of, the Licensor for the purpose of discussing and
improving the Work, but excluding communication that is conspicuously
marked or otherwise designated in writing by the copyright owner as
"Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022-2025 QRunIO, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+15
View File
@@ -0,0 +1,15 @@
qctl - QRun CLI
Copyright 2022-2025 QRunIO, LLC
This product includes software developed at QRunIO, LLC (https://qrun.io/).
Licensed under the Apache License, Version 2.0.
================================================================================
Trademarks:
QQQ, qBits, qRun, qStudio, and the QRunIO logo are trademarks of QRunIO, LLC.
Use of these trademarks must comply with QRunIO's Trademark Guidelines.
================================================================================
+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"
}
}
}
+2 -2
View File
@@ -22,7 +22,7 @@
</modules>
<properties>
<revision>0.0.1</revision>
<revision>0.2.1</revision>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@@ -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>
@@ -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");
}
}