diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7cfad50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: ['1.22', '1.23', '1.24', '1.25'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Build + run: go build -v ./... + + - name: Run unit tests + run: go test -v -race ./internal/... + + - name: Run acceptance tests + run: go test -v -race ./test/acceptance/... + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=5m + + build: + name: Build Artifacts + runs-on: ubuntu-latest + needs: [test, lint] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Build for multiple platforms + run: | + mkdir -p dist + + # Linux amd64 + GOOS=linux GOARCH=amd64 go build -o dist/render-linux-amd64 ./cmd/render + + # Linux arm64 + GOOS=linux GOARCH=arm64 go build -o dist/render-linux-arm64 ./cmd/render + + # macOS amd64 + GOOS=darwin GOARCH=amd64 go build -o dist/render-darwin-amd64 ./cmd/render + + # macOS arm64 + GOOS=darwin GOARCH=arm64 go build -o dist/render-darwin-arm64 ./cmd/render + + # Windows amd64 + GOOS=windows GOARCH=amd64 go build -o dist/render-windows-amd64.exe ./cmd/render + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: binaries + path: dist/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..10ca381 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Run tests + run: | + go test -v -race ./internal/... + go test -v -race ./test/acceptance/... + + - name: Build release binaries + run: | + VERSION=${GITHUB_REF#refs/tags/} + mkdir -p dist + + # Linux amd64 + GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" \ + -o dist/render-linux-amd64 ./cmd/render + + # Linux arm64 + GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION}" \ + -o dist/render-linux-arm64 ./cmd/render + + # macOS amd64 + GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" \ + -o dist/render-darwin-amd64 ./cmd/render + + # macOS arm64 + GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION}" \ + -o dist/render-darwin-arm64 ./cmd/render + + # Windows amd64 + GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" \ + -o dist/render-windows-amd64.exe ./cmd/render + + - name: Create archives + run: | + cd dist + + # Create tar.gz for Linux and macOS + for f in render-linux-* render-darwin-*; do + if [ -f "$f" ]; then + tar -czvf "${f}.tar.gz" "$f" + rm "$f" + fi + done + + # Create zip for Windows + for f in render-windows-*.exe; do + if [ -f "$f" ]; then + zip "${f%.exe}.zip" "$f" + rm "$f" + fi + done + + - name: Generate checksums + run: | + cd dist + sha256sum * > checksums.txt + + - name: Create Release + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=${GITHUB_REF#refs/tags/} + + # Determine if prerelease + PRERELEASE_FLAG="" + if [[ "$VERSION" == *"-rc"* ]] || [[ "$VERSION" == *"-beta"* ]] || [[ "$VERSION" == *"-alpha"* ]]; then + PRERELEASE_FLAG="--prerelease" + fi + + # Create release with auto-generated notes + gh release create "$VERSION" \ + --generate-notes \ + $PRERELEASE_FLAG \ + dist/*.tar.gz \ + dist/*.zip \ + dist/checksums.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4d7a2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,725 @@ +/render +.idea/ +.vscode/ +__pycache__/ +.scratchpad/ + +### Maven template +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# https://github.com/takari/maven-wrapper#usage-without-binary-jar +.mvn/wrapper/maven-wrapper.jar + +# Eclipse m2e generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### Images template +# JPEG +*.jpg +*.jpeg +*.jpe +*.jif +*.jfif +*.jfi + +# JPEG 2000 +*.jp2 +*.j2k +*.jpf +*.jpx +*.jpm +*.mj2 + +# JPEG XR +*.jxr +*.hdp +*.wdp + +# Graphics Interchange Format +*.gif + +# RAW +*.raw + +# Web P +*.webp + +# Portable Network Graphics +*.png + +# Animated Portable Network Graphics +*.apng + +# Multiple-image Network Graphics +*.mng + +# Tagged Image File Format +*.tiff +*.tif + +# Scalable Vector Graphics +*.svg +*.svgz + +# Portable Document Format +*.pdf + +# X BitMap +*.xbm + +# BMP +*.bmp +*.dib + +# ICO +*.ico + +# 3D Images +*.3dm +*.max + +### Go template +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +### Text template +*.doc +*.docx +*.log +*.msg +*.pages +*.rtf +*.txt +*.wpd +*.wps + +### Windows template +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Linux template +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### VisualStudio template +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +### Archives template +# It's better to unpack these files and commit the raw source because +# git has its own built in compression methods. +*.7z +*.jar +*.rar +*.zip +*.gz +*.gzip +*.tgz +*.bzip +*.bzip2 +*.bz2 +*.xz +*.lzma +*.cab +*.xar +*.zst +*.tzst + +# Packing-only formats +*.iso +*.tar + +# Package management formats +*.dmg +*.xpi +*.gem +*.egg +*.deb +*.rpm +*.msi +*.msm +*.msp +*.txz + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..027dc97 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,3 @@ +version: "2" +run: + timeout: 5m diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0872564 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +# Exclude generated output directories from all hooks +exclude: '(^|/)output/' + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-added-large-files + - id: mixed-line-ending + args: ['--fix=lf'] + + - repo: local + hooks: + - id: golangci-lint + name: golangci-lint + entry: golangci-lint run --timeout=5m + language: system + types: [go] + pass_filenames: false + + - repo: local + hooks: + - id: go-build + name: go build + entry: bash -c 'go build $(go list ./... | grep -v -E "(output|test/acceptance)")' + language: system + types: [go] + pass_filenames: false + + - id: go-test + name: go test + entry: bash -c 'go test $(go list ./... | grep -v /output/)' + language: system + types: [go] + pass_filenames: false diff --git a/.whitesource b/.whitesource new file mode 100644 index 0000000..9c7ae90 --- /dev/null +++ b/.whitesource @@ -0,0 +1,14 @@ +{ + "scanSettings": { + "baseBranches": [] + }, + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure", + "displayMode": "diff", + "useMendCheckNames": true + }, + "issueSettings": { + "minSeverityLevel": "LOW", + "issueType": "DEPENDENCY" + } +} \ No newline at end of file diff --git a/README.md b/README.md index bbb580b..e53aae0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,281 @@ # render -A jig that renders content using a template and data. Useful for an AI assistant that generates the same code + +A CLI tool that uses Go text templates to generate output files from JSON or YAML data sources. Useful for AI agents and humans generating repetitive code, configuration files, and documentation. + +## Installation + +```bash +go install github.com/wernerstrydom/render/cmd/render@latest +``` + +Or build from source: + +```bash +git clone https://github.com/wernerstrydom/render.git +cd render +go build -o bin/render ./cmd/render +``` + +## Usage + +render supports three modes of operation: + +### File Mode + +Render a single template file: + +```bash +render file -t template.txt -d data.json -o output.txt +render file --template config.yaml.tmpl --data values.json --output config.yaml +``` + +### Directory Mode + +Render a directory of templates. Files with `.tmpl` extension are rendered (extension stripped), other files are copied verbatim: + +```bash +render dir -t ./templates -d data.json -o ./output +render dir --template ./src --data config.yaml --output ./dist +``` + +### Each Mode + +Render templates for each element in an array selected via jq query. The output path is itself a Go template: + +```bash +render each -t user.tmpl -d users.json -q '.users[]' -o '{{.username}}.txt' +render each -t ./user-templates -d data.yaml -q '.items[]' -o './output/{{.id}}' +``` + +## Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--template` | `-t` | Path to template file or directory | +| `--data` | `-d` | Path to JSON or YAML data file | +| `--output` | `-o` | Output file or directory path | +| `--query` | `-q` | jq query to select elements (each mode only) | +| `--force` | `-f` | Overwrite existing output files | +| `--dry-run` | | Show what would be written without writing (dir mode only) | + +## Configuration File + +When using `dir` or `each` mode with a template directory, you can create a `.render.yaml` file (or `.render.yml`, `render.json`) to control how output paths are transformed. This is useful for renaming files or directories based on template data. + +### Path Mappings + +The `paths` key maps source paths to output path templates: + +```yaml +# .render.yaml +paths: + # Rename a specific file using template data + "model.go.tmpl": "{{ .name | snakeCase }}.go" + + # Rename a directory prefix + "src": "{{ .package }}" +``` + +Path templates have access to: +- All data from your JSON/YAML data file +- All template functions (snakeCase, pascalCase, etc.) + +### Examples + +**Rename a file based on data:** + +```yaml +# Template: user.go.tmpl +# Data: {"name": "UserProfile"} +# .render.yaml +paths: + "user.go.tmpl": "{{ .name | snakeCase }}.go" +# Output: user_profile.go +``` + +**Rename a directory:** + +```yaml +# Template directory contains: src/main.go.tmpl +# Data: {"package": "myapp"} +# .render.yaml +paths: + "src": "{{ .package }}" +# Output: myapp/main.go +``` + +**Multiple mappings:** + +```yaml +paths: + "model.go.tmpl": "{{ .name | snakeCase }}.go" + "templates": "{{ .outputDir }}" +``` + +The config file itself is never copied to the output directory. + +## Template Functions + +In addition to Go's standard template functions, render provides: + +### Casing Functions + +| Function | Description | Example | +|----------|-------------|---------| +| `lower` | Lowercase | `{{ lower "Hello" }}` → `hello` | +| `upper` | Uppercase | `{{ upper "Hello" }}` → `HELLO` | +| `title` | Title Case | `{{ title "hello world" }}` → `Hello World` | +| `camelCase` | camelCase | `{{ camelCase "hello world" }}` → `helloWorld` | +| `pascalCase` | PascalCase | `{{ pascalCase "hello world" }}` → `HelloWorld` | +| `snakeCase` | snake_case | `{{ snakeCase "HelloWorld" }}` → `hello_world` | +| `kebabCase` | kebab-case | `{{ kebabCase "HelloWorld" }}` → `hello-world` | +| `upperSnakeCase` | UPPER_SNAKE | `{{ upperSnakeCase "hello" }}` → `HELLO` | +| `upperKebabCase` | UPPER-KEBAB | `{{ upperKebabCase "hello" }}` → `HELLO` | + +### String Functions + +| Function | Description | +|----------|-------------| +| `trim` | Remove leading/trailing whitespace | +| `trimPrefix` | Remove prefix | +| `trimSuffix` | Remove suffix | +| `replace` | Replace all occurrences | +| `contains` | Check if string contains substring | +| `hasPrefix` | Check prefix | +| `hasSuffix` | Check suffix | +| `repeat` | Repeat string N times | +| `reverse` | Reverse string | +| `substr` | Substring (start, end) | +| `truncate` | Truncate to length | +| `padLeft` | Pad left to length | +| `padRight` | Pad right to length | +| `indent` | Indent with spaces | +| `nindent` | Newline + indent | + +### Splitting and Joining + +| Function | Description | +|----------|-------------| +| `split` | Split string by separator | +| `join` | Join array with separator | +| `lines` | Split into lines | +| `first` | First element | +| `last` | Last element | +| `rest` | All but first | +| `initial` | All but last | +| `nth` | Nth element | + +### Conversion Functions + +| Function | Description | +|----------|-------------| +| `toString` | Convert to string | +| `toInt` | Convert to int | +| `toFloat` | Convert to float | +| `toBool` | Convert to bool | +| `toJson` | Convert to JSON string | +| `toPrettyJson` | Convert to pretty JSON | +| `fromJson` | Parse JSON string | + +### Unicode Functions + +| Function | Description | +|----------|-------------| +| `nfc` | NFC normalization | +| `nfd` | NFD normalization | +| `nfkc` | NFKC normalization | +| `nfkd` | NFKD normalization | +| `ascii` | Convert to ASCII | +| `slug` | URL-safe slug | + +### Logic and Comparison + +| Function | Description | +|----------|-------------| +| `eq`, `ne` | Equal, not equal | +| `lt`, `le` | Less than, less or equal | +| `gt`, `ge` | Greater than, greater or equal | +| `and`, `or`, `not` | Boolean operations | +| `default` | Default value if empty | +| `empty` | Check if empty | +| `coalesce` | First non-empty value | +| `ternary` | Conditional value | + +### Collection Functions + +| Function | Description | +|----------|-------------| +| `list` | Create a list | +| `dict` | Create a dictionary | +| `keys` | Get map keys | +| `values` | Get map values | +| `hasKey` | Check if key exists | +| `get` | Get value by key | +| `merge` | Merge maps | +| `append` | Append to list | +| `uniq` | Remove duplicates | +| `sortAlpha` | Sort alphabetically | +| `len` | Length of collection | + +### Math Functions + +| Function | Description | +|----------|-------------| +| `add`, `sub` | Addition, subtraction | +| `mul`, `div` | Multiplication, division | +| `mod` | Modulo | +| `max`, `min` | Maximum, minimum | +| `floor`, `ceil`, `round` | Rounding | + +### Regex Functions + +| Function | Description | +|----------|-------------| +| `regexMatch` | Check if matches | +| `regexFind` | Find first match | +| `regexFindAll` | Find all matches | +| `regexReplace` | Replace matches | +| `regexSplit` | Split by pattern | + +## Examples + +### Generate Configuration Files + +```bash +# values.yaml +database: + host: localhost + port: 5432 + name: myapp + +# config.yaml.tmpl +database: + connection_string: "postgresql://{{ .database.host }}:{{ .database.port }}/{{ .database.name }}" + +# Generate +render file -t config.yaml.tmpl -d values.yaml -o config.yaml +``` + +### Generate Multiple Files from Array + +```bash +# users.json +{ + "users": [ + {"name": "Alice", "role": "admin"}, + {"name": "Bob", "role": "user"} + ] +} + +# user.tmpl +Name: {{ .name }} +Role: {{ .role }} +Username: {{ snakeCase .name }} + +# Generate one file per user +render each -t user.tmpl -d users.json -q '.users[]' -o '{{ snakeCase .name }}.txt' +``` + +## License + +MIT License - see LICENSE file for details. diff --git a/_examples/.gitignore b/_examples/.gitignore new file mode 100644 index 0000000..ea1472e --- /dev/null +++ b/_examples/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/_examples/README.md b/_examples/README.md new file mode 100644 index 0000000..72ed351 --- /dev/null +++ b/_examples/README.md @@ -0,0 +1,136 @@ +# Render Examples + +This directory contains examples demonstrating how to use the `render` CLI tool +for various code generation scenarios. Each example shows a practical use case +where render provides significant advantages over manual file creation. + +## Why Use Render? + +When an AI assistant needs to generate repetitive code structures, using `render` +offers several advantages over writing files individually: + +1. **Speed**: Generate dozens of files in a single command +2. **Consistency**: Templates enforce uniform patterns across files +3. **Maintainability**: Update the template once, regenerate everything +4. **Reduced Errors**: Less opportunity for copy-paste mistakes +5. **Context Efficiency**: Smaller token usage than writing each file + +## Examples + +| Example | Description | Key Features | +|---------|-------------|--------------| +| [go-workspace-grpc](./go-workspace-grpc/) | Multi-module Go workspace with gRPC | Each mode, path transformation | +| [maven-multimodule-grpc](./maven-multimodule-grpc/) | Maven multi-module Java project | Directory mode, package paths | +| [go-driver](./go-driver/) | Driver interface pattern | Two-pass, interface + implementations | +| [go-workspace-makefile](./go-workspace-makefile/) | Makefile for Go workspaces | File mode, loops, conditionals | +| [go-cli-scaffold](./go-cli-scaffold/) | CLI with Cobra/Viper | Command structure, flags | +| [python-fastapi-service](./python-fastapi-service/) | Python FastAPI Service | Two-pass rendering, Models/Routers | +| [docker-compose](./docker-compose/) | Docker Compose stacks | Complex config from simple data | +| [kubernetes-manifests](./kubernetes-manifests/) | K8s manifests per environment | Nested loops, environment config | +| [github-actions](./github-actions/) | CI/CD workflow generation | Conditionals, matrix builds | +| [terraform-modules](./terraform-modules/) | Terraform infrastructure | Modules, environments | + +## Quick Start + +```bash +# Navigate to any example directory +cd examples/go-cli-scaffold + +# Preview what would be generated +render templates cli.yaml -o output --dry-run + +# Generate the files +render templates cli.yaml -o output + +# Explore the generated structure +tree output/ +``` + +## Template Syntax + +Render uses Go's `text/template` syntax with additional functions: + +```go +// Variable interpolation +{{ .name }} + +// Nested access +{{ .config.database.host }} + +// Pipes and functions +{{ .name | pascalCase }} +{{ .items | join ", " }} + +// Conditionals +{{ if .enabled }}...{{ end }} +{{ if eq .type "service" }}...{{ end }} + +// Loops +{{ range .services }} + {{ .name }} +{{ end }} + +// With context +{{ with .database }} + Host: {{ .host }} +{{ end }} +``` + +## Available Functions + +Render includes 100+ template functions: + +- **Casing**: `camelCase`, `pascalCase`, `snakeCase`, `kebabCase` +- **String**: `lower`, `upper`, `title`, `trim`, `replace` +- **Collections**: `first`, `last`, `join`, `split`, `len` +- **Math**: `add`, `sub`, `mul`, `div` +- **Logic**: `eq`, `ne`, `lt`, `gt`, `and`, `or`, `not` +- **JSON**: `toJson`, `toPrettyJson`, `fromJson` + +See the [main documentation](../README.md) for the complete function reference. + +## Modes of Operation + +### File Mode +Single template to single output file. +```bash +render template.tmpl data.yaml -o output.txt +``` + +### Directory Mode +Template directory to output directory, preserving structure. +```bash +render templates/ data.yaml -o output/ +``` + +### Each Mode +Template rendered for each array element with dynamic paths. +```bash +render template.tmpl items.yaml -o "{{.name}}/file.txt" +``` + +## Path Transformation + +Use `.render.yaml` in your template directory to transform output paths: + +```yaml +paths: + # Rename files dynamically + "model.go.tmpl": "{{ .name | snakeCase }}.go" + + # Transform directory prefixes + "src/main/java": "src/main/java/{{ .package | replace \".\" \"/\" }}" +``` + +## When to Use Render vs. Write Tool + +**Use Render when:** +- Generating multiple similar files +- Creating environment-specific configurations +- Scaffolding project structures +- Maintaining templates for reuse + +**Use Write Tool when:** +- Creating a single unique file +- Making targeted edits to existing files +- Content doesn't follow a repeatable pattern diff --git a/_examples/docker-compose/README.md b/_examples/docker-compose/README.md new file mode 100644 index 0000000..a5d09ed --- /dev/null +++ b/_examples/docker-compose/README.md @@ -0,0 +1,20 @@ +# Docker Compose Example + +This example demonstrates how to generate a `docker-compose.yaml` file from a simplified stack definition. + +## Why Use Render? + +Hand-writing `docker-compose.yaml` for large stacks is tedious and error-prone, especially when managing environment variables and volumes across multiple services. `render` allows you to: +- **Simplify Configuration**: Define your stack in a clean YAML format. +- **Enforce Best Practices**: Your template can include standard logging, restart policies, or networks that apply to all services. + +## Usage + +```bash +./render.sh +``` + +## Structure + +- `stack.yaml`: The high-level definition of your services, networks, and volumes. +- `templates/docker-compose.yaml.tmpl`: The template that transforms the stack definition into a valid Docker Compose file. diff --git a/_examples/docker-compose/render.sh b/_examples/docker-compose/render.sh new file mode 100755 index 0000000..85cf5f0 --- /dev/null +++ b/_examples/docker-compose/render.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Ensure we are in the example directory +cd "$(dirname "$0")" + +# Path to the render binary +RENDER="../../bin/render" + +# Build render if it doesn't exist +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd ../.. && go build -o bin/render ./cmd/render/main.go) +fi + +echo "Generating docker-compose.yaml..." +$RENDER templates stack.yaml -o output --force + +echo "Generation complete! Check the 'output' directory." diff --git a/_examples/docker-compose/stack.yaml b/_examples/docker-compose/stack.yaml new file mode 100644 index 0000000..2f5ddc2 --- /dev/null +++ b/_examples/docker-compose/stack.yaml @@ -0,0 +1,25 @@ +stack_name: "my-microservices" +services: + - name: "web" + image: "nginx:latest" + ports: + - "80:80" + env: + NODE_ENV: "production" + depends_on: + - "redis" + - name: "worker" + image: "python:3.9-slim" + command: "python worker.py" + env: + REDIS_HOST: "redis" + - name: "redis" + image: "redis:alpine" + volumes: + - "redis-data:/data" + +networks: + - "app-network" + +volumes: + - "redis-data" diff --git a/_examples/docker-compose/templates/docker-compose.yaml.tmpl b/_examples/docker-compose/templates/docker-compose.yaml.tmpl new file mode 100644 index 0000000..718a6f2 --- /dev/null +++ b/_examples/docker-compose/templates/docker-compose.yaml.tmpl @@ -0,0 +1,45 @@ +version: "3.8" + +services: +{{- range .services }} + {{ .name }}: + image: {{ .image }} + {{- if .ports }} + ports: + {{- range .ports }} + - "{{ . }}" + {{- end }} + {{- end }} + {{- if .command }} + command: {{ .command }} + {{- end }} + {{- if .env }} + environment: + {{- range $key, $value := .env }} + {{ $key }}: "{{ $value }}" + {{- end }} + {{- end }} + {{- if .volumes }} + volumes: + {{- range .volumes }} + - {{ . }} + {{- end }} + {{- end }} + {{- if .depends_on }} + depends_on: + {{- range .depends_on }} + - {{ . }} + {{- end }} + {{- end }} +{{- end }} + +networks: +{{- range .networks }} + {{ . }}: + driver: bridge +{{- end }} + +volumes: +{{- range .volumes }} + {{ . }}: +{{- end }} diff --git a/_examples/github-actions/README.md b/_examples/github-actions/README.md new file mode 100644 index 0000000..f205901 --- /dev/null +++ b/_examples/github-actions/README.md @@ -0,0 +1,53 @@ +# GitHub Actions Workflow Generator + +This example demonstrates generating GitHub Actions workflows for different +project types. Define your project structure and generate appropriate CI/CD +workflows with best practices built in. + +## Why Use Render? + +Setting up GitHub Actions involves: +- Understanding best practices for each language/framework +- Configuring caching, artifacts, and deployments correctly +- Maintaining consistency across multiple repositories + +With `render`, generate proven workflow patterns instantly. + +## Workflow Types + +- **go**: Go project with testing, linting, and releases +- **node**: Node.js project with npm/yarn support +- **python**: Python project with pip, pytest, and typing +- **docker**: Multi-platform Docker builds with caching + +## Usage + +```bash +# Generate workflows for a Go project +render templates project.yaml -o .github/workflows + +# Generate for multiple project types +render templates/go.yaml.tmpl config.yaml -o .github/workflows/go.yaml +render templates/docker.yaml.tmpl config.yaml -o .github/workflows/docker.yaml +``` + +## Template Features Demonstrated + +- **File Mode**: Generate specific workflows +- **Conditionals**: Include/exclude steps based on config +- **Environment Variables**: Secure secrets handling +- **Matrix Builds**: Test across versions + +## Workflow Features + +- **Security**: Minimal permissions, dependency scanning +- **Caching**: Smart caching for faster builds +- **Releases**: Automatic versioning and changelog +- **Docker**: Multi-platform with layer caching +- **Notifications**: Slack/email on failure + +## Real-World Use Case + +An AI assistant asked to "set up CI/CD for our new Go service with Docker +deployment" can generate production-ready workflows with proper caching, +security scanning, and multi-platform Docker builds. diff --git a/_examples/github-actions/project.yaml b/_examples/github-actions/project.yaml new file mode 100644 index 0000000..ae6efe3 --- /dev/null +++ b/_examples/github-actions/project.yaml @@ -0,0 +1,49 @@ +name: myservice +language: go +goVersion: "1.22" +nodeVersion: "20" + +# Repository settings +defaultBranch: main +protectedBranches: + - main + - release/* + +# Build settings +buildCommand: make build +testCommand: make test +lintCommand: make lint + +# Docker settings +docker: + enabled: true + registry: ghcr.io + imageName: myorg/myservice + platforms: + - linux/amd64 + - linux/arm64 + dockerfile: Dockerfile + +# Feature flags +features: + caching: true + dependabot: true + codeql: true + releaseAutomation: true + +# Deployment +deploy: + enabled: true + environments: + - name: staging + url: https://staging.example.com + requiresApproval: false + - name: production + url: https://example.com + requiresApproval: true + +# Notifications +notifications: + slack: + enabled: true + channel: "#deployments" diff --git a/_examples/github-actions/render.cmd b/_examples/github-actions/render.cmd new file mode 100644 index 0000000..b1eb1d4 --- /dev/null +++ b/_examples/github-actions/render.cmd @@ -0,0 +1,27 @@ +@echo off +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set RENDER=%SCRIPT_DIR%..\..\render.exe +set OUTPUT_DIR=%SCRIPT_DIR%output + +:: Build render if needed +if not exist "%RENDER%" ( + echo Building render... + pushd %SCRIPT_DIR%..\.. + go build -o render.exe ./cmd/render + popd +) + +:: Clean and create output directory +if exist "%OUTPUT_DIR%" rmdir /s /q "%OUTPUT_DIR%" +mkdir "%OUTPUT_DIR%" + +echo Generating GitHub Actions workflows... +"%RENDER%" "%SCRIPT_DIR%templates" "%SCRIPT_DIR%project.yaml" ^ + -o "%OUTPUT_DIR%\.github\workflows" + +echo. +echo Output generated in: %OUTPUT_DIR% +echo. +dir /s /b "%OUTPUT_DIR%" 2>nul diff --git a/_examples/github-actions/render.sh b/_examples/github-actions/render.sh new file mode 100755 index 0000000..5f13cbc --- /dev/null +++ b/_examples/github-actions/render.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RENDER="${SCRIPT_DIR}/../../render" +OUTPUT_DIR="${SCRIPT_DIR}/output" + +# Build render if needed +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd "${SCRIPT_DIR}/../.." && go build -o render ./cmd/render) +fi + +# Clean and create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "Generating GitHub Actions workflows..." +"$RENDER" "${SCRIPT_DIR}/templates" "${SCRIPT_DIR}/project.yaml" \ + -o "${OUTPUT_DIR}/.github/workflows" + +echo "" +echo "Output generated in: ${OUTPUT_DIR}" +echo "" +find "$OUTPUT_DIR" -type f diff --git a/_examples/github-actions/templates/ci.yaml.tmpl b/_examples/github-actions/templates/ci.yaml.tmpl new file mode 100644 index 0000000..1e42c93 --- /dev/null +++ b/_examples/github-actions/templates/ci.yaml.tmpl @@ -0,0 +1,156 @@ +name: CI + +on: + push: + branches: [{{ .defaultBranch }}] + pull_request: + branches: [{{ .defaultBranch }}] + +permissions: + contents: read + pull-requests: write +{{ if .features.codeql }} security-events: write{{ end }} + +concurrency: + group: ${{ "{{" }} github.workflow {{ "}}" }}-${{ "{{" }} github.ref {{ "}}" }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + +{{ if eq .language "go" }} + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "{{ .goVersion }}" +{{ if .features.caching }} cache: true{{ end }} + + - name: golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest +{{ else if eq .language "node" }} + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "{{ .nodeVersion }}" +{{ if .features.caching }} cache: npm{{ end }} + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint +{{ end }} + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + +{{ if eq .language "go" }} + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "{{ .goVersion }}" +{{ if .features.caching }} cache: true{{ end }} + + - name: Run tests + run: {{ .testCommand }} + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: coverage.out +{{ else if eq .language "node" }} + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "{{ .nodeVersion }}" +{{ if .features.caching }} cache: npm{{ end }} + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test -- --coverage +{{ end }} + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - uses: actions/checkout@v4 + +{{ if eq .language "go" }} + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "{{ .goVersion }}" +{{ if .features.caching }} cache: true{{ end }} + + - name: Build + run: {{ .buildCommand }} +{{ end }} +{{ if .docker.enabled }} + docker: + name: Build Docker Image + runs-on: ubuntu-latest + needs: [lint, test] + if: github.event_name == 'push' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: {{ .docker.registry }} + username: ${{ "{{" }} github.actor {{ "}}" }} + password: ${{ "{{" }} secrets.GITHUB_TOKEN {{ "}}" }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: {{ .docker.dockerfile }} + platforms: {{ .docker.platforms | join "," }} + push: true + tags: | + {{ .docker.registry }}/{{ .docker.imageName }}:${{ "{{" }} github.sha {{ "}}" }} + {{ .docker.registry }}/{{ .docker.imageName }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max +{{ end }} +{{ if .features.codeql }} + security: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: {{ .language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 +{{ end }} diff --git a/_examples/github-actions/templates/release.yaml.tmpl b/_examples/github-actions/templates/release.yaml.tmpl new file mode 100644 index 0000000..f166b39 --- /dev/null +++ b/_examples/github-actions/templates/release.yaml.tmpl @@ -0,0 +1,77 @@ +{{ if .features.releaseAutomation -}} +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + +{{ if eq .language "go" }} + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "{{ .goVersion }}" + cache: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ "{{" }} secrets.GITHUB_TOKEN {{ "}}" }} +{{ end }} +{{ if .docker.enabled }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: {{ .docker.registry }} + username: ${{ "{{" }} github.actor {{ "}}" }} + password: ${{ "{{" }} secrets.GITHUB_TOKEN {{ "}}" }} + + - name: Extract version + id: version + run: echo "version=${{ "{{" }} github.ref_name {{ "}}" }}" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: {{ .docker.dockerfile }} + platforms: {{ .docker.platforms | join "," }} + push: true + tags: | + {{ .docker.registry }}/{{ .docker.imageName }}:${{ "{{" }} steps.version.outputs.version {{ "}}" }} + {{ .docker.registry }}/{{ .docker.imageName }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max +{{ end }} +{{ if .notifications.slack.enabled }} + - name: Notify Slack + uses: slackapi/slack-github-action@v1 + with: + channel-id: "{{ .notifications.slack.channel }}" + slack-message: "🚀 Released {{ .name }} ${{ "{{" }} github.ref_name {{ "}}" }}" + env: + SLACK_BOT_TOKEN: ${{ "{{" }} secrets.SLACK_BOT_TOKEN {{ "}}" }} +{{ end }} +{{- end }} diff --git a/_examples/go-cli-scaffold/README.md b/_examples/go-cli-scaffold/README.md new file mode 100644 index 0000000..5dba93a --- /dev/null +++ b/_examples/go-cli-scaffold/README.md @@ -0,0 +1,67 @@ +# Go CLI Application Scaffold + +This example demonstrates generating a complete CLI application scaffold using +Cobra and Viper. Define your commands, flags, and structure in YAML and generate +a production-ready CLI. + +## Why Use Render? + +Building a CLI manually involves: +- Creating the root command with proper initialization +- Adding subcommands with consistent patterns +- Setting up configuration with environment variable support +- Wiring flags to configuration values + +With `render`, define the CLI structure declaratively and generate it all at once. + +## Structure Generated + +``` +{app}/ +├── main.go +├── cmd/ +│ ├── root.go # Root command with config setup +│ ├── version.go # Version command +│ └── {command}/ +│ └── {command}.go # Subcommand implementation +├── internal/ +│ └── config/ +│ └── config.go # Configuration struct +└── .{app}.yaml # Example config file +``` + +## Usage + +This example demonstrates a **two-pass** approach: + +```bash +# Pass 1: Generate base structure (directory mode) +render templates cli.yaml -o output + +# Pass 2: Generate one file per command (each mode) +render command-template/command.go.tmpl commands.yaml -o "output/cmd/{{.name}}.go" + +# Or run both with the script +./render.sh +``` + +## Template Features Demonstrated + +- **Two-Pass Rendering**: Base structure + per-item files +- **Directory Mode**: Generates main.go, go.mod, cmd/root.go, cmd/version.go +- **Each Mode**: Generates cmd/get.go, cmd/create.go, cmd/delete.go, cmd/describe.go +- **Separate Data Files**: cli.yaml for base, commands.yaml for commands array + +## CLI Features Generated + +- Cobra command structure with help and completion +- Viper configuration with file, env, and flag binding +- Persistent and local flags per command +- Version command with build info +- Config file support with sensible defaults + +## Real-World Use Case + +An AI assistant asked to "create a CLI for managing Kubernetes resources with +get, create, delete, and describe commands" can generate a complete, consistent +CLI scaffold in seconds. diff --git a/_examples/go-cli-scaffold/cli.yaml b/_examples/go-cli-scaffold/cli.yaml new file mode 100644 index 0000000..5d30a47 --- /dev/null +++ b/_examples/go-cli-scaffold/cli.yaml @@ -0,0 +1,86 @@ +name: myctl +module: github.com/example/myctl +description: A powerful CLI for managing resources +version: 0.1.0 + +# Global configuration options +config: + - name: verbose + type: bool + default: false + env: MYCTL_VERBOSE + description: Enable verbose output + + - name: config + type: string + default: "" + env: MYCTL_CONFIG + description: Path to config file + + - name: output + type: string + default: text + env: MYCTL_OUTPUT + description: Output format (text, json, yaml) + +# Commands +commands: + - name: get + short: Get resources + long: Retrieve and display one or more resources from the server + flags: + - name: all + short: a + type: bool + default: false + description: Show all resources including hidden ones + - name: namespace + short: n + type: string + default: default + description: Namespace to query + subcommands: + - name: pods + short: List all pods + - name: services + short: List all services + - name: deployments + short: List all deployments + + - name: create + short: Create a resource + long: Create a new resource from a file or inline specification + flags: + - name: file + short: f + type: string + required: true + description: Path to resource definition file + - name: dry-run + type: bool + default: false + description: Preview without creating + + - name: delete + short: Delete resources + long: Delete one or more resources by name or selector + flags: + - name: force + type: bool + default: false + description: Force deletion without confirmation + - name: selector + short: l + type: string + description: Label selector to filter resources + + - name: describe + short: Describe a resource + long: Show detailed information about a specific resource + args: + - name: resource + required: true + description: Resource type (pod, service, deployment) + - name: name + required: true + description: Resource name diff --git a/_examples/go-cli-scaffold/command-template/command.go.tmpl b/_examples/go-cli-scaffold/command-template/command.go.tmpl new file mode 100644 index 0000000..61410fa --- /dev/null +++ b/_examples/go-cli-scaffold/command-template/command.go.tmpl @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var {{ .name }}Cmd = &cobra.Command{ + Use: "{{ .name }}{{ range .args }} <{{ .name }}>{{ end }}", + Short: "{{ .short }}", + Long: `{{ .long }}`, +{{- if .args }} + Args: cobra.ExactArgs({{ len .args }}), +{{- end }} + RunE: func(cmd *cobra.Command, args []string) error { +{{- range $i, $arg := .args }} + {{ $arg.name }} := args[{{ $i }}] + _ = {{ $arg.name }} // TODO: use {{ $arg.name }} +{{- end }} + + // TODO: Implement {{ .name }} command + fmt.Println("{{ .name }} called") + return nil + }, +} + +func init() { +{{- range .flags }} +{{- if .short }} + {{ $.name }}Cmd.Flags().{{ .type | title }}P("{{ .name }}", "{{ .short }}", {{ if eq .type "bool" }}{{ .default }}{{ else if eq .type "string" }}"{{ .default }}"{{ else }}{{ .default }}{{ end }}, "{{ .description }}") +{{- else }} + {{ $.name }}Cmd.Flags().{{ .type | title }}("{{ .name }}", {{ if eq .type "bool" }}{{ .default }}{{ else if eq .type "string" }}"{{ .default }}"{{ else }}{{ .default }}{{ end }}, "{{ .description }}") +{{- end }} +{{- if .required }} + {{ $.name }}Cmd.MarkFlagRequired("{{ .name }}") +{{- end }} +{{- end }} +{{ range .subcommands }} + {{ $.name }}Cmd.AddCommand(&cobra.Command{ + Use: "{{ .name }}", + Short: "{{ .short }}", + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: Implement {{ $.name }} {{ .name }} + fmt.Println("{{ $.name }} {{ .name }} called") + return nil + }, + }) +{{- end }} +} diff --git a/_examples/go-cli-scaffold/commands.yaml b/_examples/go-cli-scaffold/commands.yaml new file mode 100644 index 0000000..0e45eec --- /dev/null +++ b/_examples/go-cli-scaffold/commands.yaml @@ -0,0 +1,59 @@ +- name: get + short: Get resources + long: Retrieve and display one or more resources from the server + flags: + - name: all + short: a + type: bool + default: false + description: Show all resources including hidden ones + - name: namespace + short: n + type: string + default: default + description: Namespace to query + subcommands: + - name: pods + short: List all pods + - name: services + short: List all services + - name: deployments + short: List all deployments + +- name: create + short: Create a resource + long: Create a new resource from a file or inline specification + flags: + - name: file + short: f + type: string + required: true + description: Path to resource definition file + - name: dry-run + type: bool + default: false + description: Preview without creating + +- name: delete + short: Delete resources + long: Delete one or more resources by name or selector + flags: + - name: force + type: bool + default: false + description: Force deletion without confirmation + - name: selector + short: l + type: string + description: Label selector to filter resources + +- name: describe + short: Describe a resource + long: Show detailed information about a specific resource + args: + - name: resource + required: true + description: Resource type (pod, service, deployment) + - name: name + required: true + description: Resource name diff --git a/_examples/go-cli-scaffold/render.cmd b/_examples/go-cli-scaffold/render.cmd new file mode 100644 index 0000000..54e8a43 --- /dev/null +++ b/_examples/go-cli-scaffold/render.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set RENDER=%SCRIPT_DIR%..\..\render.exe +set OUTPUT_DIR=%SCRIPT_DIR%output + +:: Build render if needed +if not exist "%RENDER%" ( + echo Building render... + pushd %SCRIPT_DIR%..\.. + go build -o render.exe ./cmd/render + popd +) + +:: Clean and create output directory +if exist "%OUTPUT_DIR%" rmdir /s /q "%OUTPUT_DIR%" +mkdir "%OUTPUT_DIR%" + +echo === Pass 1: Generating base CLI structure === +"%RENDER%" "%SCRIPT_DIR%templates" "%SCRIPT_DIR%cli.yaml" ^ + -o "%OUTPUT_DIR%" + +echo. +echo === Pass 2: Generating command files === +"%RENDER%" "%SCRIPT_DIR%command-template\command.go.tmpl" "%SCRIPT_DIR%commands.yaml" ^ + -o "%OUTPUT_DIR%\cmd\{{.name}}.go" + +echo. +echo Output generated in: %OUTPUT_DIR% +echo. +dir /s /b "%OUTPUT_DIR%" 2>nul diff --git a/_examples/go-cli-scaffold/render.sh b/_examples/go-cli-scaffold/render.sh new file mode 100755 index 0000000..52676a4 --- /dev/null +++ b/_examples/go-cli-scaffold/render.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RENDER="${SCRIPT_DIR}/../../render" +OUTPUT_DIR="${SCRIPT_DIR}/output" + +# Build render if needed +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd "${SCRIPT_DIR}/../.." && go build -o render ./cmd/render) +fi + +# Clean and create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "=== Pass 1: Generating base CLI structure ===" +"$RENDER" "${SCRIPT_DIR}/templates" "${SCRIPT_DIR}/cli.yaml" \ + -o "${OUTPUT_DIR}" + +echo "" +echo "=== Pass 2: Generating command files ===" +# Extract commands array and render each command +# The commands array needs to be at the root for each mode +"$RENDER" "${SCRIPT_DIR}/command-template/command.go.tmpl" "${SCRIPT_DIR}/commands.yaml" \ + -o "${OUTPUT_DIR}/cmd/{{.name}}.go" + +echo "" +echo "Output generated in: ${OUTPUT_DIR}" +echo "" +find "$OUTPUT_DIR" -type f | sort diff --git a/_examples/go-cli-scaffold/templates/cmd/root.go.tmpl b/_examples/go-cli-scaffold/templates/cmd/root.go.tmpl new file mode 100644 index 0000000..c323ced --- /dev/null +++ b/_examples/go-cli-scaffold/templates/cmd/root.go.tmpl @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "{{ .name }}", + Short: "{{ .description }}", + Long: `{{ .description }} + +Complete documentation is available at https://github.com/example/{{ .name }}`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return initConfig() + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +func Execute() error { + return rootCmd.Execute() +} + +func init() { + // Global flags + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.{{ .name }}.yaml)") +{{- range .config }} +{{- if ne .name "config" }} + rootCmd.PersistentFlags().{{ .type | title }}("{{ .name }}", {{ if eq .type "bool" }}{{ .default }}{{ else if eq .type "string" }}"{{ .default }}"{{ else }}{{ .default }}{{ end }}, "{{ .description }}") + viper.BindPFlag("{{ .name }}", rootCmd.PersistentFlags().Lookup("{{ .name }}")) +{{- end }} +{{- end }} + + // Add subcommands +{{- range .commands }} + rootCmd.AddCommand({{ .name }}Cmd) +{{- end }} +} + +func initConfig() error { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + viper.AddConfigPath(home) + viper.AddConfigPath(".") + viper.SetConfigType("yaml") + viper.SetConfigName(".{{ .name }}") + } + + // Environment variables + viper.SetEnvPrefix("{{ .name | upper }}") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + viper.AutomaticEnv() + + // Read config file + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("error reading config: %w", err) + } + } + + return nil +} diff --git a/_examples/go-cli-scaffold/templates/cmd/version.go.tmpl b/_examples/go-cli-scaffold/templates/cmd/version.go.tmpl new file mode 100644 index 0000000..8efb6fd --- /dev/null +++ b/_examples/go-cli-scaffold/templates/cmd/version.go.tmpl @@ -0,0 +1,62 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "runtime" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + version = "dev" + commit = "unknown" + buildTime = "unknown" +) + +// SetVersionInfo sets the version information from build flags +func SetVersionInfo(v, c, bt string) { + version = v + commit = c + buildTime = bt +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Long: `Print version, commit hash, and build time information.`, + Run: func(cmd *cobra.Command, args []string) { + info := struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildTime string `json:"buildTime"` + GoVersion string `json:"goVersion"` + OS string `json:"os"` + Arch string `json:"arch"` + }{ + Version: version, + Commit: commit, + BuildTime: buildTime, + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + + switch viper.GetString("output") { + case "json": + data, _ := json.MarshalIndent(info, "", " ") + fmt.Println(string(data)) + default: + fmt.Printf("{{ .name }} version %s\n", info.Version) + fmt.Printf(" commit: %s\n", info.Commit) + fmt.Printf(" built: %s\n", info.BuildTime) + fmt.Printf(" go version: %s\n", info.GoVersion) + fmt.Printf(" os/arch: %s/%s\n", info.OS, info.Arch) + } + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/_examples/go-cli-scaffold/templates/config.yaml.tmpl b/_examples/go-cli-scaffold/templates/config.yaml.tmpl new file mode 100644 index 0000000..b337f33 --- /dev/null +++ b/_examples/go-cli-scaffold/templates/config.yaml.tmpl @@ -0,0 +1,7 @@ +# {{ .name }} configuration file +# Place this file at ~/.{{ .name }}.yaml or specify with --config flag +{{ range .config }} +# {{ .description }} +{{ if .env }}# Environment variable: {{ .env }}{{ end }} +{{ .name }}: {{ if eq .type "string" }}"{{ .default }}"{{ else }}{{ .default }}{{ end }} +{{ end }} diff --git a/_examples/go-cli-scaffold/templates/go.mod.tmpl b/_examples/go-cli-scaffold/templates/go.mod.tmpl new file mode 100644 index 0000000..10c30ab --- /dev/null +++ b/_examples/go-cli-scaffold/templates/go.mod.tmpl @@ -0,0 +1,8 @@ +module {{ .module }} + +go 1.22 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 +) diff --git a/_examples/go-cli-scaffold/templates/main.go.tmpl b/_examples/go-cli-scaffold/templates/main.go.tmpl new file mode 100644 index 0000000..b937117 --- /dev/null +++ b/_examples/go-cli-scaffold/templates/main.go.tmpl @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "{{ .module }}/cmd" +) + +// Build information set by ldflags +var ( + version = "dev" + commit = "unknown" + buildTime = "unknown" +) + +func main() { + cmd.SetVersionInfo(version, commit, buildTime) + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/_examples/go-driver/README.md b/_examples/go-driver/README.md new file mode 100644 index 0000000..eb416a3 --- /dev/null +++ b/_examples/go-driver/README.md @@ -0,0 +1,71 @@ +# Go Driver Interface Pattern + +This example demonstrates how to generate a driver-based architecture in Go, +following the pattern used by `database/sql`. This pattern enables pluggable +implementations while maintaining a consistent API. + +## Why Use Render? + +The driver pattern requires multiple coordinated files: +- The public facade (user-facing API) +- The driver interface (implementor contract) +- A registration mechanism +- Multiple driver implementations + +With `render`, generate a complete driver architecture using two passes. + +## Structure Generated + +``` +pkg/storage/ +├── storage.go # Public facade API +├── driver.go # Driver interface definition +├── register.go # Driver registration mechanism +└── drivers/ + ├── s3/ + │ └── s3.go # S3 driver implementation + ├── gcs/ + │ └── gcs.go # GCS driver implementation + ├── azure/ + │ └── azure.go # Azure driver implementation + ├── filesystem/ + │ └── filesystem.go # Local filesystem driver + └── memory/ + └── memory.go # In-memory driver for testing +``` + +## Usage + +This example demonstrates a **two-pass** approach: + +```bash +# Pass 1: Generate core package (directory mode) +render templates drivers.yaml -o pkg/storage + +# Pass 2: Generate driver implementations (each mode) +render driver-template/driver_impl.go.tmpl drivers-list.yaml \ + -o "pkg/storage/drivers/{{.name}}/{{.name}}.go" + +# Or run both with the script +./render.sh +``` + +## Template Features Demonstrated + +- **Two-Pass Rendering**: Core package + per-driver implementations +- **Directory Mode**: Generates facade, interface, and registration +- **Each Mode**: Generates one driver per entry in drivers-list.yaml +- **Separate Data Files**: drivers.yaml for core, drivers-list.yaml for each mode + +## Pattern Benefits + +1. **Decoupling**: Users depend on interface, not implementation +2. **Extensibility**: Add new drivers without changing core code +3. **Testability**: Mock drivers for unit testing +4. **Init-time Registration**: Drivers register via `init()` functions + +## Real-World Use Case + +An AI assistant asked to "create a storage abstraction supporting S3, GCS, and +local filesystem" can generate the complete driver architecture in seconds, +ensuring all implementations follow the same contract. diff --git a/_examples/go-driver/driver-template/driver_impl.go.tmpl b/_examples/go-driver/driver-template/driver_impl.go.tmpl new file mode 100644 index 0000000..7f6d4c4 --- /dev/null +++ b/_examples/go-driver/driver-template/driver_impl.go.tmpl @@ -0,0 +1,75 @@ +// Package {{ .name }} provides an {{ .description | lower }} for the storage package. +package {{ .name }} + +import ( + "context" + "fmt" + "io" + + "storage" +) + +func init() { + storage.Register("{{ .name }}", &connector{}) +} + +// Config holds the configuration for the {{ .name }} driver. +type Config struct { +{{- range .configFields }} + {{ .name }} {{ .type }} +{{- end }} +} + +type connector struct{} + +func (c *connector) Open(config any) (storage.Driver, error) { + cfg, ok := config.(*Config) + if !ok { + return nil, fmt.Errorf("{{ .name }}: invalid config type, expected *Config") + } + return newDriver(cfg) +} + +type driver struct { + config *Config +} + +func newDriver(cfg *Config) (*driver, error) { + // TODO: Validate configuration and establish connection + return &driver{config: cfg}, nil +} + +func (d *driver) Close() error { + // TODO: Clean up resources + return nil +} + +// Get retrieves an object by key. +func (d *driver) Get(ctx context.Context, key string) (io.ReadCloser, error) { + // TODO: Implement Get + return nil, fmt.Errorf("{{ .name }}: Get not implemented") +} + +// Put stores an object with the given key. +func (d *driver) Put(ctx context.Context, key string, data io.Reader) error { + // TODO: Implement Put + return fmt.Errorf("{{ .name }}: Put not implemented") +} + +// Delete deletes an object by key. +func (d *driver) Delete(ctx context.Context, key string) error { + // TODO: Implement Delete + return fmt.Errorf("{{ .name }}: Delete not implemented") +} + +// List lists objects matching the prefix. +func (d *driver) List(ctx context.Context, prefix string) ([]string, error) { + // TODO: Implement List + return nil, fmt.Errorf("{{ .name }}: List not implemented") +} + +// Exists checks if an object exists. +func (d *driver) Exists(ctx context.Context, key string) (bool, error) { + // TODO: Implement Exists + return false, fmt.Errorf("{{ .name }}: Exists not implemented") +} diff --git a/_examples/go-driver/drivers-list.yaml b/_examples/go-driver/drivers-list.yaml new file mode 100644 index 0000000..0428532 --- /dev/null +++ b/_examples/go-driver/drivers-list.yaml @@ -0,0 +1,45 @@ +- name: s3 + description: Amazon S3 driver + configFields: + - name: Region + type: string + - name: Bucket + type: string + - name: AccessKeyID + type: string + - name: SecretAccessKey + type: string + +- name: gcs + description: Google Cloud Storage driver + configFields: + - name: Project + type: string + - name: Bucket + type: string + - name: CredentialsFile + type: string + +- name: azure + description: Azure Blob Storage driver + configFields: + - name: AccountName + type: string + - name: AccountKey + type: string + - name: Container + type: string + +- name: filesystem + description: Local filesystem driver + configFields: + - name: BasePath + type: string + - name: CreateDirs + type: bool + +- name: memory + description: In-memory driver for testing + configFields: + - name: MaxSize + type: int64 diff --git a/_examples/go-driver/drivers.yaml b/_examples/go-driver/drivers.yaml new file mode 100644 index 0000000..d8737c6 --- /dev/null +++ b/_examples/go-driver/drivers.yaml @@ -0,0 +1,107 @@ +package: storage +description: Cloud storage abstraction with pluggable drivers + +# The facade API methods +methods: + - name: Get + args: + - name: ctx + type: context.Context + - name: key + type: string + returns: + - type: io.ReadCloser + - type: error + description: Retrieves an object by key + + - name: Put + args: + - name: ctx + type: context.Context + - name: key + type: string + - name: data + type: io.Reader + returns: + - type: error + description: Stores an object with the given key + + - name: Delete + args: + - name: ctx + type: context.Context + - name: key + type: string + returns: + - type: error + description: Deletes an object by key + + - name: List + args: + - name: ctx + type: context.Context + - name: prefix + type: string + returns: + - type: "[]string" + - type: error + description: Lists objects matching the prefix + + - name: Exists + args: + - name: ctx + type: context.Context + - name: key + type: string + returns: + - type: bool + - type: error + description: Checks if an object exists + +# Driver implementations +drivers: + - name: s3 + description: Amazon S3 driver + configFields: + - name: Region + type: string + - name: Bucket + type: string + - name: AccessKeyID + type: string + - name: SecretAccessKey + type: string + + - name: gcs + description: Google Cloud Storage driver + configFields: + - name: Project + type: string + - name: Bucket + type: string + - name: CredentialsFile + type: string + + - name: azure + description: Azure Blob Storage driver + configFields: + - name: AccountName + type: string + - name: AccountKey + type: string + - name: Container + type: string + + - name: filesystem + description: Local filesystem driver + configFields: + - name: BasePath + type: string + - name: CreateDirs + type: bool + + - name: memory + description: In-memory driver for testing + configFields: + - name: MaxSize + type: int64 diff --git a/_examples/go-driver/render.cmd b/_examples/go-driver/render.cmd new file mode 100644 index 0000000..7494b3f --- /dev/null +++ b/_examples/go-driver/render.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set RENDER=%SCRIPT_DIR%..\..\render.exe +set OUTPUT_DIR=%SCRIPT_DIR%output + +:: Build render if needed +if not exist "%RENDER%" ( + echo Building render... + pushd %SCRIPT_DIR%..\.. + go build -o render.exe ./cmd/render + popd +) + +:: Clean and create output directory +if exist "%OUTPUT_DIR%" rmdir /s /q "%OUTPUT_DIR%" +mkdir "%OUTPUT_DIR%" + +echo === Pass 1: Generating core package (facade, interface, registration) === +"%RENDER%" "%SCRIPT_DIR%templates" "%SCRIPT_DIR%drivers.yaml" ^ + -o "%OUTPUT_DIR%\pkg\storage" + +echo. +echo === Pass 2: Generating driver implementations === +"%RENDER%" "%SCRIPT_DIR%driver-template\driver_impl.go.tmpl" "%SCRIPT_DIR%drivers-list.yaml" ^ + -o "%OUTPUT_DIR%\pkg\storage\drivers\{{.name}}\{{.name}}.go" + +echo. +echo Output generated in: %OUTPUT_DIR% +echo. +dir /s /b "%OUTPUT_DIR%" 2>nul diff --git a/_examples/go-driver/render.sh b/_examples/go-driver/render.sh new file mode 100755 index 0000000..ed4d0ff --- /dev/null +++ b/_examples/go-driver/render.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RENDER="${SCRIPT_DIR}/../../render" +OUTPUT_DIR="${SCRIPT_DIR}/output" + +# Build render if needed +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd "${SCRIPT_DIR}/../.." && go build -o render ./cmd/render) +fi + +# Clean and create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "=== Pass 1: Generating core package (facade, interface, registration) ===" +"$RENDER" "${SCRIPT_DIR}/templates" "${SCRIPT_DIR}/drivers.yaml" \ + -o "${OUTPUT_DIR}/pkg/storage" + +echo "" +echo "=== Pass 2: Generating driver implementations ===" +"$RENDER" "${SCRIPT_DIR}/driver-template/driver_impl.go.tmpl" "${SCRIPT_DIR}/drivers-list.yaml" \ + -o "${OUTPUT_DIR}/pkg/storage/drivers/{{.name}}/{{.name}}.go" + +echo "" +echo "Output generated in: ${OUTPUT_DIR}" +echo "" +find "$OUTPUT_DIR" -type f | sort diff --git a/_examples/go-driver/templates/driver.go.tmpl b/_examples/go-driver/templates/driver.go.tmpl new file mode 100644 index 0000000..f71f429 --- /dev/null +++ b/_examples/go-driver/templates/driver.go.tmpl @@ -0,0 +1,21 @@ +package {{ .package }} + +import ( + "context" + "io" +) + +// Driver is the interface that must be implemented by storage drivers. +type Driver interface { +{{- range .methods }} + // {{ .name }} {{ .description | lower }}. + {{ .name }}({{ range $i, $arg := .args }}{{ if $i }}, {{ end }}{{ $arg.name }} {{ $arg.type }}{{ end }}) ({{ range $i, $ret := .returns }}{{ if $i }}, {{ end }}{{ $ret.type }}{{ end }}) +{{- end }} +} + +// DriverConnector is implemented by drivers that can open connections. +type DriverConnector interface { + // Open opens a connection using the provided configuration. + // The config type depends on the driver implementation. + Open(config any) (Driver, error) +} diff --git a/_examples/go-driver/templates/register.go.tmpl b/_examples/go-driver/templates/register.go.tmpl new file mode 100644 index 0000000..a03ac7a --- /dev/null +++ b/_examples/go-driver/templates/register.go.tmpl @@ -0,0 +1,48 @@ +package {{ .package }} + +import ( + "fmt" + "sort" + "sync" +) + +var ( + driversMu sync.RWMutex + drivers = make(map[string]DriverConnector) +) + +// Register makes a storage driver available by the provided name. +// If Register is called twice with the same name or if driver is nil, +// it panics. +func Register(name string, driver DriverConnector) { + driversMu.Lock() + defer driversMu.Unlock() + + if driver == nil { + panic("{{ .package }}: Register driver is nil") + } + if _, dup := drivers[name]; dup { + panic("{{ .package }}: Register called twice for driver " + name) + } + drivers[name] = driver +} + +// UnregisterAllDrivers is used in tests to reset the driver registry. +func UnregisterAllDrivers() { + driversMu.Lock() + defer driversMu.Unlock() + drivers = make(map[string]DriverConnector) +} + +// RegisteredDrivers returns a sorted list of registered driver names. +func RegisteredDrivers() []string { + driversMu.RLock() + defer driversMu.RUnlock() + + names := make([]string, 0, len(drivers)) + for name := range drivers { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/_examples/go-driver/templates/storage.go.tmpl b/_examples/go-driver/templates/storage.go.tmpl new file mode 100644 index 0000000..e5bbc0f --- /dev/null +++ b/_examples/go-driver/templates/storage.go.tmpl @@ -0,0 +1,77 @@ +// Package {{ .package }} provides a unified interface for cloud storage operations. +// {{ .description }} +// +// Usage: +// +// import ( +// "{{ .package }}" +// _ "{{ .package }}/drivers/s3" // Register S3 driver +// ) +// +// func main() { +// client, err := {{ .package }}.Open("s3", config) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// data, err := client.Get(ctx, "my-key") +// // ... +// } +package {{ .package }} + +import ( + "context" + "fmt" + "io" + "sync" +) + +// Client provides a unified interface for storage operations. +type Client struct { + driver Driver +} + +// Open creates a new Client using the named driver. +// The driver must have been registered via Register. +func Open(driverName string, config any) (*Client, error) { + driversMu.RLock() + driver, ok := drivers[driverName] + driversMu.RUnlock() + + if !ok { + return nil, fmt.Errorf("{{ .package }}: unknown driver %q (forgotten import?)", driverName) + } + + d, err := driver.Open(config) + if err != nil { + return nil, fmt.Errorf("{{ .package }}: failed to open driver %q: %w", driverName, err) + } + + return &Client{driver: d}, nil +} + +// Close closes the client and releases any resources. +func (c *Client) Close() error { + if closer, ok := c.driver.(io.Closer); ok { + return closer.Close() + } + return nil +} +{{ range .methods }} +// {{ .name }} {{ .description | lower }}. +func (c *Client) {{ .name }}({{ range $i, $arg := .args }}{{ if $i }}, {{ end }}{{ $arg.name }} {{ $arg.type }}{{ end }}) ({{ range $i, $ret := .returns }}{{ if $i }}, {{ end }}{{ $ret.type }}{{ end }}) { + return c.driver.{{ .name }}({{ range $i, $arg := .args }}{{ if $i }}, {{ end }}{{ $arg.name }}{{ end }}) +} +{{ end }} +// Drivers returns a list of registered driver names. +func Drivers() []string { + driversMu.RLock() + defer driversMu.RUnlock() + + names := make([]string, 0, len(drivers)) + for name := range drivers { + names = append(names, name) + } + return names +} diff --git a/_examples/go-workspace-grpc/README.md b/_examples/go-workspace-grpc/README.md new file mode 100644 index 0000000..78da189 --- /dev/null +++ b/_examples/go-workspace-grpc/README.md @@ -0,0 +1,70 @@ +# Go Workspace with gRPC Services + +This example demonstrates how to quickly scaffold a Go workspace containing multiple +modules, each with a complete gRPC service architecture. + +## Why Use Render? + +When an AI assistant needs to create a multi-module Go workspace, writing each file +individually is slow and error-prone. With `render`: + +1. **Speed**: Generate dozens of files in a single command +2. **Consistency**: All services follow the same structure +3. **Maintainability**: Update the template once, regenerate all services +4. **Customization**: Each service gets its own name, port, and configuration + +## Structure Generated + +For each service defined in `services.yaml`, render creates: + +``` +services/ +└── {service-name}/ + ├── go.mod + ├── proto/ + │ └── {service}.proto + ├── cmd/ + │ ├── server/ + │ │ └── main.go + │ └── cli/ + │ └── main.go + ├── internal/ + │ ├── server/ + │ │ └── server.go + │ └── client/ + │ └── client.go + ├── api/ + │ └── bff/ + │ └── main.go + └── web/ + ├── admin/ + │ └── index.html + └── frontend/ + └── index.html +``` + +## Usage + +```bash +# Generate all services +render templates services.yaml -o "services/{{.name | kebabCase}}" + +# Preview what would be generated +render templates services.yaml -o "services/{{.name | kebabCase}}" --dry-run +``` + +## Template Features Demonstrated + +- **Each Mode**: Generates a complete directory structure per service +- **Path Transformation**: Uses `.render.yaml` for dynamic file naming +- **Casing Functions**: `kebabCase`, `pascalCase`, `snakeCase` for idiomatic naming +- **Nested Data**: Services with ports, descriptions, and custom settings + +## Real-World Use Case + +An AI assistant asked to "create a microservices architecture with user, order, and +payment services" can: + +1. Define the services in a YAML file +2. Run a single render command +3. Have a complete, consistent workspace ready for implementation diff --git a/_examples/go-workspace-grpc/render.cmd b/_examples/go-workspace-grpc/render.cmd new file mode 100644 index 0000000..eeca23f --- /dev/null +++ b/_examples/go-workspace-grpc/render.cmd @@ -0,0 +1,27 @@ +@echo off +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set RENDER=%SCRIPT_DIR%..\..\render.exe +set OUTPUT_DIR=%SCRIPT_DIR%output + +:: Build render if needed +if not exist "%RENDER%" ( + echo Building render... + pushd %SCRIPT_DIR%..\.. + go build -o render.exe ./cmd/render + popd +) + +:: Clean and create output directory +if exist "%OUTPUT_DIR%" rmdir /s /q "%OUTPUT_DIR%" +mkdir "%OUTPUT_DIR%" + +echo Generating Go workspace with gRPC services... +"%RENDER%" "%SCRIPT_DIR%templates" "%SCRIPT_DIR%services.yaml" ^ + -o "%OUTPUT_DIR%\services\{{.name | kebabCase}}" + +echo. +echo Output generated in: %OUTPUT_DIR% +echo. +dir /s /b "%OUTPUT_DIR%" 2>nul | findstr /n "^" | findstr "^[1-9]:" | findstr /v "^[2-9][0-9]:" diff --git a/_examples/go-workspace-grpc/render.sh b/_examples/go-workspace-grpc/render.sh new file mode 100755 index 0000000..e8c0662 --- /dev/null +++ b/_examples/go-workspace-grpc/render.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RENDER="${SCRIPT_DIR}/../../render" +OUTPUT_DIR="${SCRIPT_DIR}/output" + +# Build render if needed +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd "${SCRIPT_DIR}/../.." && go build -o render ./cmd/render) +fi + +# Clean and create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "Generating Go workspace with gRPC services..." +"$RENDER" "${SCRIPT_DIR}/templates" "${SCRIPT_DIR}/services.yaml" \ + -o "${OUTPUT_DIR}/services/{{.name | kebabCase}}" + +echo "" +echo "Output generated in: ${OUTPUT_DIR}" +echo "" +find "$OUTPUT_DIR" -type f | head -20 diff --git a/_examples/go-workspace-grpc/services.yaml b/_examples/go-workspace-grpc/services.yaml new file mode 100644 index 0000000..3e5264b --- /dev/null +++ b/_examples/go-workspace-grpc/services.yaml @@ -0,0 +1,45 @@ +- name: user + port: 50051 + httpPort: 8081 + description: User management and authentication service + entities: + - name: User + fields: + - name: id + type: string + - name: email + type: string + - name: name + type: string + +- name: order + port: 50052 + httpPort: 8082 + description: Order processing and management service + entities: + - name: Order + fields: + - name: id + type: string + - name: userId + type: string + - name: total + type: double + - name: status + type: string + +- name: payment + port: 50053 + httpPort: 8083 + description: Payment processing service + entities: + - name: Payment + fields: + - name: id + type: string + - name: orderId + type: string + - name: amount + type: double + - name: method + type: string diff --git a/_examples/go-workspace-grpc/templates/.render.yaml b/_examples/go-workspace-grpc/templates/.render.yaml new file mode 100644 index 0000000..9c3f3ea --- /dev/null +++ b/_examples/go-workspace-grpc/templates/.render.yaml @@ -0,0 +1,2 @@ +paths: + "proto/service.proto.tmpl": "proto/{{ .name }}.proto" diff --git a/_examples/go-workspace-grpc/templates/api/bff/main.go.tmpl b/_examples/go-workspace-grpc/templates/api/bff/main.go.tmpl new file mode 100644 index 0000000..740f9a4 --- /dev/null +++ b/_examples/go-workspace-grpc/templates/api/bff/main.go.tmpl @@ -0,0 +1,67 @@ +package main + +import ( + "encoding/json" + "flag" + "log" + "net/http" +) + +var ( + port = flag.Int("port", {{ .httpPort }}, "The HTTP server port") +) + +func main() { + flag.Parse() + + mux := http.NewServeMux() + + // Health check endpoint + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "healthy"}) + }) +{{ range .entities }} + // {{ .name }} endpoints + mux.HandleFunc("GET /api/{{ $.name }}/{{ .name | lower }}s", list{{ .name }}sHandler) + mux.HandleFunc("GET /api/{{ $.name }}/{{ .name | lower }}s/{id}", get{{ .name }}Handler) + mux.HandleFunc("POST /api/{{ $.name }}/{{ .name | lower }}s", create{{ .name }}Handler) + mux.HandleFunc("PUT /api/{{ $.name }}/{{ .name | lower }}s/{id}", update{{ .name }}Handler) + mux.HandleFunc("DELETE /api/{{ $.name }}/{{ .name | lower }}s/{id}", delete{{ .name }}Handler) +{{ end }} + log.Printf("{{ .name | pascalCase }} BFF listening on :%d", *port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), mux)) +} +{{ range .entities }} +func list{{ .name }}sHandler(w http.ResponseWriter, r *http.Request) { + // TODO: Call gRPC service and return JSON + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]interface{}{}) +} + +func get{{ .name }}Handler(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + // TODO: Call gRPC service and return JSON + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": id}) +} + +func create{{ .name }}Handler(w http.ResponseWriter, r *http.Request) { + // TODO: Parse body, call gRPC service, return JSON + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"status": "created"}) +} + +func update{{ .name }}Handler(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + // TODO: Parse body, call gRPC service, return JSON + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": id, "status": "updated"}) +} + +func delete{{ .name }}Handler(w http.ResponseWriter, r *http.Request) { + // TODO: Call gRPC service + w.WriteHeader(http.StatusNoContent) +} +{{ end }} diff --git a/_examples/go-workspace-grpc/templates/cmd/cli/main.go.tmpl b/_examples/go-workspace-grpc/templates/cmd/cli/main.go.tmpl new file mode 100644 index 0000000..7115419 --- /dev/null +++ b/_examples/go-workspace-grpc/templates/cmd/cli/main.go.tmpl @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "flag" + "log" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +var ( + addr = flag.String("addr", "localhost:{{ .port }}", "the address to connect to") +) + +func main() { + flag.Parse() + + conn, err := grpc.NewClient(*addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Fatalf("did not connect: %v", err) + } + defer conn.Close() + + // TODO: Create {{ .name | pascalCase }}Service client + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + _ = ctx // Use ctx for RPC calls + log.Printf("Connected to {{ .name }} service at %s", *addr) +} diff --git a/_examples/go-workspace-grpc/templates/cmd/server/main.go.tmpl b/_examples/go-workspace-grpc/templates/cmd/server/main.go.tmpl new file mode 100644 index 0000000..6d29079 --- /dev/null +++ b/_examples/go-workspace-grpc/templates/cmd/server/main.go.tmpl @@ -0,0 +1,31 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net" + + "google.golang.org/grpc" +) + +var ( + port = flag.Int("port", {{ .port }}, "The server port") +) + +func main() { + flag.Parse() + + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port)) + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + + s := grpc.NewServer() + // TODO: Register {{ .name | pascalCase }}Service server + + log.Printf("{{ .name | pascalCase }} service listening at %v", lis.Addr()) + if err := s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } +} diff --git a/_examples/go-workspace-grpc/templates/go.mod.tmpl b/_examples/go-workspace-grpc/templates/go.mod.tmpl new file mode 100644 index 0000000..8dd34f6 --- /dev/null +++ b/_examples/go-workspace-grpc/templates/go.mod.tmpl @@ -0,0 +1,8 @@ +module github.com/example/{{ .name }}-service + +go 1.22 + +require ( + google.golang.org/grpc v1.62.0 + google.golang.org/protobuf v1.32.0 +) diff --git a/_examples/go-workspace-grpc/templates/internal/client/client.go.tmpl b/_examples/go-workspace-grpc/templates/internal/client/client.go.tmpl new file mode 100644 index 0000000..367532c --- /dev/null +++ b/_examples/go-workspace-grpc/templates/internal/client/client.go.tmpl @@ -0,0 +1,41 @@ +package client + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// {{ .name | pascalCase }}Client provides a client for the {{ .name | pascalCase }}Service. +type {{ .name | pascalCase }}Client struct { + conn *grpc.ClientConn +} + +// New{{ .name | pascalCase }}Client creates a new client connected to the specified address. +func New{{ .name | pascalCase }}Client(addr string) (*{{ .name | pascalCase }}Client, error) { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, err + } + return &{{ .name | pascalCase }}Client{conn: conn}, nil +} + +// Close closes the client connection. +func (c *{{ .name | pascalCase }}Client) Close() error { + return c.conn.Close() +} +{{ range .entities }} +// Get{{ .name }} retrieves a {{ .name }} by ID. +func (c *{{ $.name | pascalCase }}Client) Get{{ .name }}(ctx context.Context, id string) (*{{ .name }}, error) { + // TODO: Implement gRPC call + return nil, nil +} + +// {{ .name }} represents a {{ .name }} entity. +type {{ .name }} struct { +{{- range .fields }} + {{ .name | pascalCase }} {{ if eq .type "double" }}float64{{ else }}{{ .type }}{{ end }} +{{- end }} +} +{{ end }} diff --git a/_examples/go-workspace-grpc/templates/internal/server/server.go.tmpl b/_examples/go-workspace-grpc/templates/internal/server/server.go.tmpl new file mode 100644 index 0000000..43a9686 --- /dev/null +++ b/_examples/go-workspace-grpc/templates/internal/server/server.go.tmpl @@ -0,0 +1,53 @@ +package server + +import ( + "context" +) + +// {{ .name | pascalCase }}Server implements the {{ .name | pascalCase }}Service. +type {{ .name | pascalCase }}Server struct { + // TODO: Add dependencies (database, cache, etc.) +} + +// New{{ .name | pascalCase }}Server creates a new {{ .name | pascalCase }}Server. +func New{{ .name | pascalCase }}Server() *{{ .name | pascalCase }}Server { + return &{{ .name | pascalCase }}Server{} +} +{{ range .entities }} +// Get{{ .name }} retrieves a {{ .name }} by ID. +func (s *{{ $.name | pascalCase }}Server) Get{{ .name }}(ctx context.Context, id string) (*{{ .name }}, error) { + // TODO: Implement Get{{ .name }} + return nil, nil +} + +// List{{ .name }}s returns a paginated list of {{ .name }}s. +func (s *{{ $.name | pascalCase }}Server) List{{ .name }}s(ctx context.Context, pageSize int32, pageToken string) ([]*{{ .name }}, string, error) { + // TODO: Implement List{{ .name }}s + return nil, "", nil +} + +// Create{{ .name }} creates a new {{ .name }}. +func (s *{{ $.name | pascalCase }}Server) Create{{ .name }}(ctx context.Context, entity *{{ .name }}) (*{{ .name }}, error) { + // TODO: Implement Create{{ .name }} + return nil, nil +} + +// Update{{ .name }} updates an existing {{ .name }}. +func (s *{{ $.name | pascalCase }}Server) Update{{ .name }}(ctx context.Context, entity *{{ .name }}) (*{{ .name }}, error) { + // TODO: Implement Update{{ .name }} + return nil, nil +} + +// Delete{{ .name }} deletes a {{ .name }} by ID. +func (s *{{ $.name | pascalCase }}Server) Delete{{ .name }}(ctx context.Context, id string) error { + // TODO: Implement Delete{{ .name }} + return nil +} + +// {{ .name }} represents a {{ .name }} entity. +type {{ .name }} struct { +{{- range .fields }} + {{ .name | pascalCase }} {{ if eq .type "double" }}float64{{ else }}{{ .type }}{{ end }} +{{- end }} +} +{{ end }} diff --git a/_examples/go-workspace-grpc/templates/proto/service.proto.tmpl b/_examples/go-workspace-grpc/templates/proto/service.proto.tmpl new file mode 100644 index 0000000..bbda6c1 --- /dev/null +++ b/_examples/go-workspace-grpc/templates/proto/service.proto.tmpl @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package {{ .name }}; + +option go_package = "github.com/example/{{ .name }}-service/gen/{{ .name }}pb"; + +// {{ .description }} +service {{ .name | pascalCase }}Service { +{{- range .entities }} + rpc Get{{ .name }}(Get{{ .name }}Request) returns ({{ .name }}) {} + rpc List{{ .name }}s(List{{ .name }}sRequest) returns (List{{ .name }}sResponse) {} + rpc Create{{ .name }}(Create{{ .name }}Request) returns ({{ .name }}) {} + rpc Update{{ .name }}(Update{{ .name }}Request) returns ({{ .name }}) {} + rpc Delete{{ .name }}(Delete{{ .name }}Request) returns (Delete{{ .name }}Response) {} +{{- end }} +} +{{ range .entities }} +message {{ .name }} { +{{- range $i, $f := .fields }} + {{ $f.type }} {{ $f.name }} = {{ add $i 1 }}; +{{- end }} +} + +message Get{{ .name }}Request { + string id = 1; +} + +message List{{ .name }}sRequest { + int32 page_size = 1; + string page_token = 2; +} + +message List{{ .name }}sResponse { + repeated {{ .name }} {{ .name | lower }}s = 1; + string next_page_token = 2; +} + +message Create{{ .name }}Request { + {{ .name }} {{ .name | lower }} = 1; +} + +message Update{{ .name }}Request { + {{ .name }} {{ .name | lower }} = 1; +} + +message Delete{{ .name }}Request { + string id = 1; +} + +message Delete{{ .name }}Response {} +{{ end }} diff --git a/_examples/go-workspace-grpc/templates/web/admin/index.html.tmpl b/_examples/go-workspace-grpc/templates/web/admin/index.html.tmpl new file mode 100644 index 0000000..dd11bac --- /dev/null +++ b/_examples/go-workspace-grpc/templates/web/admin/index.html.tmpl @@ -0,0 +1,64 @@ + + + + + + {{ .name | pascalCase }} Service - Admin + + + +
+

{{ .name | pascalCase }} Service Administration

+
+
+

{{ .description }}

+ {{ range .entities }} +
+

{{ .name }} Management

+ + + + {{- range .fields }} + + {{- end }} + + + + + + +
{{ .name | title }}Actions
+ +
+ {{ end }} +
+ + + diff --git a/_examples/go-workspace-grpc/templates/web/frontend/index.html.tmpl b/_examples/go-workspace-grpc/templates/web/frontend/index.html.tmpl new file mode 100644 index 0000000..6c827a1 --- /dev/null +++ b/_examples/go-workspace-grpc/templates/web/frontend/index.html.tmpl @@ -0,0 +1,42 @@ + + + + + + {{ .name | pascalCase }} Service + + + +
+

{{ .name | pascalCase }} Service

+

{{ .description }}

+
+
+

Available Resources

+ {{ range .entities }} +
+

{{ .name }}

+

Manage {{ .name | lower }} resources with full CRUD operations.

+ +
+ {{ end }} +
+ + + diff --git a/_examples/go-workspace-makefile/README.md b/_examples/go-workspace-makefile/README.md new file mode 100644 index 0000000..fcd0bd2 --- /dev/null +++ b/_examples/go-workspace-makefile/README.md @@ -0,0 +1,61 @@ +# Go Workspace Makefile Generator + +This example demonstrates generating a comprehensive Makefile for a Go workspace +with multiple modules. The Makefile includes targets for building, testing, linting, +and managing all modules consistently. + +## Why Use Render? + +Manually maintaining a Makefile for a multi-module Go workspace is tedious: +- Each module needs similar targets +- Dependencies between modules must be tracked +- Adding a new module requires updating multiple places + +With `render`, regenerate the Makefile whenever modules change. + +## Generated Targets + +For a workspace with modules `api`, `cli`, and `worker`: + +```makefile +# Global targets +make all # Build all modules +make test # Test all modules +make lint # Lint all modules +make clean # Clean all modules +make tidy # Tidy all modules + +# Per-module targets +make build-api # Build api module +make test-api # Test api module +make lint-api # Lint api module + +make build-cli # Build cli module +make build-worker # Build worker module +# ... etc +``` + +## Usage + +```bash +# Generate Makefile +render templates/Makefile.tmpl workspace.yaml -o Makefile + +# Preview +render templates/Makefile.tmpl workspace.yaml -o Makefile --dry-run +``` + +## Template Features Demonstrated + +- **File Mode**: Single template to single output file +- **Loops**: Generate targets per module +- **Conditionals**: Different build flags per module type +- **String Functions**: Path manipulation, naming conventions + +## Real-World Use Case + +An AI assistant asked to "add a new service module to the workspace" can: + +1. Add the module to `workspace.yaml` +2. Regenerate the Makefile +3. Have all standard targets immediately available diff --git a/_examples/go-workspace-makefile/render.cmd b/_examples/go-workspace-makefile/render.cmd new file mode 100644 index 0000000..bc16255 --- /dev/null +++ b/_examples/go-workspace-makefile/render.cmd @@ -0,0 +1,27 @@ +@echo off +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set RENDER=%SCRIPT_DIR%..\..\render.exe +set OUTPUT_DIR=%SCRIPT_DIR%output + +:: Build render if needed +if not exist "%RENDER%" ( + echo Building render... + pushd %SCRIPT_DIR%..\.. + go build -o render.exe ./cmd/render + popd +) + +:: Clean and create output directory +if exist "%OUTPUT_DIR%" rmdir /s /q "%OUTPUT_DIR%" +mkdir "%OUTPUT_DIR%" + +echo Generating Makefile for Go workspace... +"%RENDER%" "%SCRIPT_DIR%templates\Makefile.tmpl" "%SCRIPT_DIR%workspace.yaml" ^ + -o "%OUTPUT_DIR%\Makefile" + +echo. +echo Output generated in: %OUTPUT_DIR% +echo. +dir "%OUTPUT_DIR%" diff --git a/_examples/go-workspace-makefile/render.sh b/_examples/go-workspace-makefile/render.sh new file mode 100755 index 0000000..8b64ee2 --- /dev/null +++ b/_examples/go-workspace-makefile/render.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RENDER="${SCRIPT_DIR}/../../render" +OUTPUT_DIR="${SCRIPT_DIR}/output" + +# Build render if needed +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd "${SCRIPT_DIR}/../.." && go build -o render ./cmd/render) +fi + +# Clean and create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "Generating Makefile for Go workspace..." +"$RENDER" "${SCRIPT_DIR}/templates/Makefile.tmpl" "${SCRIPT_DIR}/workspace.yaml" \ + -o "${OUTPUT_DIR}/Makefile" + +echo "" +echo "Output generated in: ${OUTPUT_DIR}" +echo "" +ls -la "$OUTPUT_DIR" diff --git a/_examples/go-workspace-makefile/templates/Makefile.tmpl b/_examples/go-workspace-makefile/templates/Makefile.tmpl new file mode 100644 index 0000000..16a288f --- /dev/null +++ b/_examples/go-workspace-makefile/templates/Makefile.tmpl @@ -0,0 +1,153 @@ +# Makefile for {{ .name }} Go workspace +# Generated by render - do not edit manually + +.PHONY: all build test lint clean tidy fmt vet help +.PHONY:{{ range .modules }} build-{{ .name }} test-{{ .name }} lint-{{ .name }} clean-{{ .name }}{{ end }} +{{ if .dockerRegistry }}.PHONY:{{ range .modules }}{{ if .hasDocker }} docker-{{ .name }}{{ end }}{{ end }}{{ end }} + +# Go settings +GO := go +GOFLAGS := -v +LDFLAGS := {{ .ldflags }} +{{ if .buildTags }}BUILD_TAGS := -tags {{ .buildTags }}{{ end }} + +# Version info +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +LDFLAGS += -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) + +# Docker settings +{{ if .dockerRegistry -}} +DOCKER_REGISTRY := {{ .dockerRegistry }} +DOCKER_TAG ?= $(VERSION) +{{- end }} + +# Directories +BIN_DIR := bin +DIST_DIR := dist + +#============================================================================== +# Default target +#============================================================================== + +all: build + +#============================================================================== +# Global targets +#============================================================================== + +build:{{ range .modules }} build-{{ .name }}{{ end }} + +test:{{ range .modules }} test-{{ .name }}{{ end }} + +lint:{{ range .modules }} lint-{{ .name }}{{ end }} + @echo "All modules linted successfully" + +clean:{{ range .modules }} clean-{{ .name }}{{ end }} + rm -rf $(BIN_DIR) $(DIST_DIR) + +tidy: + @for dir in {{ range .modules }}{{ .path }} {{ end }}; do \ + echo "Tidying $$dir..."; \ + (cd $$dir && $(GO) mod tidy); \ + done + +fmt: + @gofmt -s -w . + +vet: + @$(GO) vet ./... + +#============================================================================== +# Per-module targets +#============================================================================== +{{ range .modules }} +# {{ .name }} ({{ .type }}) +build-{{ .name }}: +{{- if eq .type "library" }} + @echo "Checking {{ .name }}..." + @(cd {{ .path }} && $(GO) build ./...) +{{- else if eq .type "proto" }} + @echo "Generating {{ .name }}..." + @(cd {{ .path }} && buf generate) +{{- else }} + @echo "Building {{ .name }}..." + @mkdir -p $(BIN_DIR) + @(cd {{ .path }} && $(GO) build $(GOFLAGS) $(BUILD_TAGS) -ldflags "$(LDFLAGS)" -o ../../$(BIN_DIR)/{{ .binary }} .) +{{- end }} + +test-{{ .name }}: + @echo "Testing {{ .name }}..." + @(cd {{ .path }} && $(GO) test -race -cover ./...) + +lint-{{ .name }}: + @echo "Linting {{ .name }}..." + @(cd {{ .path }} && golangci-lint run) + +clean-{{ .name }}: +{{- if .binary }} + @rm -f $(BIN_DIR)/{{ .binary }} +{{- end }} + @(cd {{ .path }} && $(GO) clean) +{{ if .hasDocker }} +docker-{{ .name }}: + @echo "Building Docker image for {{ .name }}..." + @docker build -t $(DOCKER_REGISTRY)/{{ .name }}:$(DOCKER_TAG) -f {{ .path }}/Dockerfile . +{{ end }} +{{- end }} + +#============================================================================== +# Development targets +#============================================================================== +{{ range .modules }}{{ if .port }} +run-{{ .name }}: build-{{ .name }} + @echo "Running {{ .name }} on port {{ .port }}..." + @$(BIN_DIR)/{{ .binary }} +{{ end }}{{ end }} + +#============================================================================== +# CI/CD targets +#============================================================================== + +ci: lint test build + +install-tools: + @echo "Installing development tools..." + @go install github.com/golangci/golangci-lint/cmd/golangci-lint@{{ .golangciVersion }} +{{ if .dockerRegistry }} +docker-push:{{ range .modules }}{{ if .hasDocker }} docker-push-{{ .name }}{{ end }}{{ end }} + +{{ range .modules }}{{ if .hasDocker -}} +docker-push-{{ .name }}: docker-{{ .name }} + @docker push $(DOCKER_REGISTRY)/{{ .name }}:$(DOCKER_TAG) +{{ end }}{{ end }} +{{- end }} + +#============================================================================== +# Help +#============================================================================== + +help: + @echo "{{ .name }} Makefile" + @echo "" + @echo "Global targets:" + @echo " all Build all modules (default)" + @echo " build Build all modules" + @echo " test Test all modules" + @echo " lint Lint all modules" + @echo " clean Clean all modules and build artifacts" + @echo " tidy Run go mod tidy on all modules" + @echo " fmt Format all Go files" + @echo " vet Run go vet on all packages" + @echo "" + @echo "Module targets:" +{{- range .modules }} + @echo " build-{{ .name }} Build {{ .name }} module" + @echo " test-{{ .name }} Test {{ .name }} module" + @echo " lint-{{ .name }} Lint {{ .name }} module" +{{- end }} + @echo "" + @echo "Development:" + @echo " install-tools Install development tools" + @echo " ci Run full CI pipeline" diff --git a/_examples/go-workspace-makefile/workspace.yaml b/_examples/go-workspace-makefile/workspace.yaml new file mode 100644 index 0000000..5e49644 --- /dev/null +++ b/_examples/go-workspace-makefile/workspace.yaml @@ -0,0 +1,41 @@ +name: myproject +goVersion: "1.22" +defaultBranch: main + +modules: + - name: api + path: services/api + type: service + binary: api-server + port: 8080 + hasDocker: true + + - name: worker + path: services/worker + type: service + binary: worker + hasDocker: true + + - name: cli + path: cmd/cli + type: cli + binary: myctl + + - name: shared + path: pkg/shared + type: library + + - name: proto + path: pkg/proto + type: proto + +# Build settings +ldflags: "-s -w" +buildTags: "" + +# Docker settings +dockerRegistry: ghcr.io/myorg +dockerBaseImage: gcr.io/distroless/static:nonroot + +# Linting +golangciVersion: v1.56.0 diff --git a/_examples/kubernetes-manifests/README.md b/_examples/kubernetes-manifests/README.md new file mode 100644 index 0000000..a9209ea --- /dev/null +++ b/_examples/kubernetes-manifests/README.md @@ -0,0 +1,64 @@ +# Kubernetes Manifests Generator + +This example demonstrates generating Kubernetes manifests for multiple services +across multiple environments. Define your services once and generate consistent +deployments, services, and configmaps for each environment. + +## Why Use Render? + +Managing Kubernetes manifests for multiple environments involves: +- Copy-pasting YAML with subtle differences +- Risk of configuration drift between environments +- Difficulty keeping all environments in sync + +With `render`, maintain a single template and generate environment-specific manifests. + +## Structure Generated + +``` +k8s/ +├── base/ +│ └── namespace.yaml +└── overlays/ + ├── dev/ + │ └── {service}/ + │ ├── deployment.yaml + │ ├── service.yaml + │ └── configmap.yaml + ├── staging/ + │ └── {service}/ + │ └── ... + └── prod/ + └── {service}/ + └── ... +``` + +## Usage + +```bash +# Generate manifests for all environments +render templates cluster.yaml -o k8s + +# Generate for a specific environment (filter data first) +yq '.environments[] | select(.name == "dev")' cluster.yaml | \ + render templates/env - -o k8s/overlays/dev +``` + +## Template Features Demonstrated + +- **Directory Mode**: Complete Kubernetes structure +- **Nested Loops**: Services × Environments +- **Path Transformation**: Environment and service in paths +- **Conditionals**: Different resource limits per environment + +## Environment-Specific Configuration + +- **Dev**: Lower resources, single replica, debug logging +- **Staging**: Moderate resources, 2 replicas, standard config +- **Prod**: High resources, 3+ replicas, production settings + +## Real-World Use Case + +An AI assistant asked to "deploy our services to a new staging environment" +can generate all required manifests with environment-appropriate settings in +a single command. diff --git a/_examples/kubernetes-manifests/cluster.yaml b/_examples/kubernetes-manifests/cluster.yaml new file mode 100644 index 0000000..27b5756 --- /dev/null +++ b/_examples/kubernetes-manifests/cluster.yaml @@ -0,0 +1,61 @@ +namespace: myapp +project: example-project + +services: + - name: api + image: ghcr.io/example/api + port: 8080 + healthPath: /health + env: + - name: LOG_LEVEL + dev: debug + staging: info + prod: warn + + - name: worker + image: ghcr.io/example/worker + port: 9090 + healthPath: /ready + env: + - name: QUEUE_URL + dev: redis://redis:6379 + staging: redis://redis.staging:6379 + prod: redis://redis.prod:6379 + + - name: frontend + image: ghcr.io/example/frontend + port: 3000 + healthPath: / + env: + - name: API_URL + dev: http://api:8080 + staging: https://api.staging.example.com + prod: https://api.example.com + +environments: + - name: dev + replicas: 1 + resources: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + + - name: staging + replicas: 2 + resources: + cpu: 200m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + - name: prod + replicas: 3 + resources: + cpu: 500m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi diff --git a/_examples/kubernetes-manifests/render.cmd b/_examples/kubernetes-manifests/render.cmd new file mode 100644 index 0000000..2f6cdae --- /dev/null +++ b/_examples/kubernetes-manifests/render.cmd @@ -0,0 +1,30 @@ +@echo off +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set RENDER=%SCRIPT_DIR%..\..\render.exe +set OUTPUT_DIR=%SCRIPT_DIR%output + +:: Build render if needed +if not exist "%RENDER%" ( + echo Building render... + pushd %SCRIPT_DIR%..\.. + go build -o render.exe ./cmd/render + popd +) + +:: Clean and create output directory +if exist "%OUTPUT_DIR%" rmdir /s /q "%OUTPUT_DIR%" +mkdir "%OUTPUT_DIR%" + +echo Generating Kubernetes manifests... +"%RENDER%" "%SCRIPT_DIR%templates" "%SCRIPT_DIR%cluster.yaml" ^ + -o "%OUTPUT_DIR%" + +echo. +echo Output generated in: %OUTPUT_DIR% +echo. +echo Note: Service-specific manifests (deployment, service, configmap) +echo require combining service and environment data. See README.md. +echo. +dir /s /b "%OUTPUT_DIR%" 2>nul diff --git a/_examples/kubernetes-manifests/render.sh b/_examples/kubernetes-manifests/render.sh new file mode 100755 index 0000000..f6c2447 --- /dev/null +++ b/_examples/kubernetes-manifests/render.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RENDER="${SCRIPT_DIR}/../../render" +OUTPUT_DIR="${SCRIPT_DIR}/output" + +# Build render if needed +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd "${SCRIPT_DIR}/../.." && go build -o render ./cmd/render) +fi + +# Clean and create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "Generating Kubernetes manifests..." +"$RENDER" "${SCRIPT_DIR}/templates" "${SCRIPT_DIR}/cluster.yaml" \ + -o "${OUTPUT_DIR}" + +echo "" +echo "Output generated in: ${OUTPUT_DIR}" +echo "" +echo "Note: Service-specific manifests (deployment, service, configmap)" +echo "require combining service and environment data. See README.md." +echo "" +find "$OUTPUT_DIR" -type f diff --git a/_examples/kubernetes-manifests/service-template/configmap.yaml.tmpl b/_examples/kubernetes-manifests/service-template/configmap.yaml.tmpl new file mode 100644 index 0000000..377367a --- /dev/null +++ b/_examples/kubernetes-manifests/service-template/configmap.yaml.tmpl @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .service.name }}-config + namespace: {{ .namespace }}-{{ .env.name }} + labels: + app: {{ .service.name }} + environment: {{ .env.name }} +data: +{{- range .service.env }} + {{ .name }}: "{{ index . $.env.name }}" +{{- end }} diff --git a/_examples/kubernetes-manifests/service-template/deployment.yaml.tmpl b/_examples/kubernetes-manifests/service-template/deployment.yaml.tmpl new file mode 100644 index 0000000..59d2848 --- /dev/null +++ b/_examples/kubernetes-manifests/service-template/deployment.yaml.tmpl @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .service.name }} + namespace: {{ .namespace }}-{{ .env.name }} + labels: + app: {{ .service.name }} + environment: {{ .env.name }} +spec: + replicas: {{ .env.replicas }} + selector: + matchLabels: + app: {{ .service.name }} + template: + metadata: + labels: + app: {{ .service.name }} + environment: {{ .env.name }} + spec: + containers: + - name: {{ .service.name }} + image: {{ .service.image }}:latest + ports: + - containerPort: {{ .service.port }} + resources: + requests: + cpu: {{ .env.resources.cpu }} + memory: {{ .env.resources.memory }} + limits: + cpu: {{ .env.limits.cpu }} + memory: {{ .env.limits.memory }} + livenessProbe: + httpGet: + path: {{ .service.healthPath }} + port: {{ .service.port }} + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: {{ .service.healthPath }} + port: {{ .service.port }} + initialDelaySeconds: 5 + periodSeconds: 5 + envFrom: + - configMapRef: + name: {{ .service.name }}-config diff --git a/_examples/kubernetes-manifests/service-template/service.yaml.tmpl b/_examples/kubernetes-manifests/service-template/service.yaml.tmpl new file mode 100644 index 0000000..71ae786 --- /dev/null +++ b/_examples/kubernetes-manifests/service-template/service.yaml.tmpl @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .service.name }} + namespace: {{ .namespace }}-{{ .env.name }} + labels: + app: {{ .service.name }} + environment: {{ .env.name }} +spec: + type: ClusterIP + ports: + - port: {{ .service.port }} + targetPort: {{ .service.port }} + protocol: TCP + name: http + selector: + app: {{ .service.name }} diff --git a/_examples/kubernetes-manifests/templates/base/namespace.yaml.tmpl b/_examples/kubernetes-manifests/templates/base/namespace.yaml.tmpl new file mode 100644 index 0000000..766ecd1 --- /dev/null +++ b/_examples/kubernetes-manifests/templates/base/namespace.yaml.tmpl @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .namespace }} + labels: + app.kubernetes.io/part-of: {{ .project }} + app.kubernetes.io/managed-by: render diff --git a/_examples/maven-multimodule-grpc/README.md b/_examples/maven-multimodule-grpc/README.md new file mode 100644 index 0000000..b0dd0a6 --- /dev/null +++ b/_examples/maven-multimodule-grpc/README.md @@ -0,0 +1,62 @@ +# Maven Multi-Module Project with gRPC Services + +This example demonstrates scaffolding a Maven multi-module project where each module +contains a complete gRPC service with CLI, admin UI, BFF, and browser frontend. + +## Why Use Render? + +Creating a Maven multi-module project manually involves: +- Writing parent POM with dependency management +- Creating module POMs with proper parent references +- Setting up consistent directory structures +- Configuring protobuf compilation plugins + +With `render`, generate the entire structure from a single data file. + +## Structure Generated + +``` +{project-name}/ +├── pom.xml # Parent POM +└── modules/ + └── {service-name}/ + ├── pom.xml # Module POM + ├── src/main/proto/ + │ └── {service}.proto + ├── src/main/java/.../ + │ ├── server/ + │ │ └── {Service}Server.java + │ ├── client/ + │ │ └── {Service}Client.java + │ └── bff/ + │ └── {Service}BffApplication.java + └── src/main/resources/ + └── static/ + ├── admin/ + │ └── index.html + └── frontend/ + └── index.html +``` + +## Usage + +```bash +# Generate the complete project +render templates project.yaml -o output + +# With custom package paths +render templates project.yaml -o output --force +``` + +## Template Features Demonstrated + +- **Directory Mode**: Single invocation generates complete structure +- **Path Transformation**: `.render.yaml` maps Java package to directory structure +- **Nested Loops**: Services containing multiple entities +- **String Functions**: Package name to path conversion with `replace` + +## Real-World Use Case + +An AI assistant asked to "create a Spring Boot microservices project with three +services" can generate a production-ready Maven structure in seconds, including +proper dependency versions and plugin configurations. diff --git a/_examples/maven-multimodule-grpc/project.yaml b/_examples/maven-multimodule-grpc/project.yaml new file mode 100644 index 0000000..4371169 --- /dev/null +++ b/_examples/maven-multimodule-grpc/project.yaml @@ -0,0 +1,54 @@ +name: ecommerce-platform +groupId: com.example.ecommerce +version: 1.0.0-SNAPSHOT +javaVersion: 21 +springBootVersion: 3.2.2 +grpcVersion: 1.62.0 + +services: + - name: catalog + package: com.example.ecommerce.catalog + port: 9090 + httpPort: 8080 + description: Product catalog management + entities: + - name: Product + fields: + - name: id + type: String + - name: name + type: String + - name: price + type: BigDecimal + - name: category + type: String + + - name: inventory + package: com.example.ecommerce.inventory + port: 9091 + httpPort: 8081 + description: Inventory and stock management + entities: + - name: Stock + fields: + - name: productId + type: String + - name: quantity + type: Integer + - name: warehouse + type: String + + - name: checkout + package: com.example.ecommerce.checkout + port: 9092 + httpPort: 8082 + description: Shopping cart and checkout processing + entities: + - name: Cart + fields: + - name: id + type: String + - name: customerId + type: String + - name: items + type: List diff --git a/_examples/maven-multimodule-grpc/render.cmd b/_examples/maven-multimodule-grpc/render.cmd new file mode 100644 index 0000000..a9b7fb8 --- /dev/null +++ b/_examples/maven-multimodule-grpc/render.cmd @@ -0,0 +1,27 @@ +@echo off +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set RENDER=%SCRIPT_DIR%..\..\render.exe +set OUTPUT_DIR=%SCRIPT_DIR%output + +:: Build render if needed +if not exist "%RENDER%" ( + echo Building render... + pushd %SCRIPT_DIR%..\.. + go build -o render.exe ./cmd/render + popd +) + +:: Clean and create output directory +if exist "%OUTPUT_DIR%" rmdir /s /q "%OUTPUT_DIR%" +mkdir "%OUTPUT_DIR%" + +echo Generating Maven multi-module project... +"%RENDER%" "%SCRIPT_DIR%templates" "%SCRIPT_DIR%project.yaml" ^ + -o "%OUTPUT_DIR%" + +echo. +echo Output generated in: %OUTPUT_DIR% +echo. +dir /s /b "%OUTPUT_DIR%" 2>nul diff --git a/_examples/maven-multimodule-grpc/render.sh b/_examples/maven-multimodule-grpc/render.sh new file mode 100755 index 0000000..4ca8aaa --- /dev/null +++ b/_examples/maven-multimodule-grpc/render.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RENDER="${SCRIPT_DIR}/../../render" +OUTPUT_DIR="${SCRIPT_DIR}/output" + +# Build render if needed +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd "${SCRIPT_DIR}/../.." && go build -o render ./cmd/render) +fi + +# Clean and create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "Generating Maven multi-module project..." +"$RENDER" "${SCRIPT_DIR}/templates" "${SCRIPT_DIR}/project.yaml" \ + -o "${OUTPUT_DIR}" + +echo "" +echo "Output generated in: ${OUTPUT_DIR}" +echo "" +find "$OUTPUT_DIR" -type f diff --git a/_examples/maven-multimodule-grpc/service-template/pom.xml.tmpl b/_examples/maven-multimodule-grpc/service-template/pom.xml.tmpl new file mode 100644 index 0000000..5c80326 --- /dev/null +++ b/_examples/maven-multimodule-grpc/service-template/pom.xml.tmpl @@ -0,0 +1,75 @@ + + + 4.0.0 + + + {{ .groupId }} + {{ .projectName }} + {{ .version }} + ../../pom.xml + + + {{ .name }}-service + {{ .name | title }} Service + {{ .description }} + + + + org.springframework.boot + spring-boot-starter-web + + + io.grpc + grpc-netty-shaded + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + + + + + compile + compile-custom + + + + + + + diff --git a/_examples/maven-multimodule-grpc/service-template/src/main/java/bff/BffApplication.java.tmpl b/_examples/maven-multimodule-grpc/service-template/src/main/java/bff/BffApplication.java.tmpl new file mode 100644 index 0000000..20ae948 --- /dev/null +++ b/_examples/maven-multimodule-grpc/service-template/src/main/java/bff/BffApplication.java.tmpl @@ -0,0 +1,61 @@ +package {{ .package }}.bff; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.Map; + +/** + * Backend-for-Frontend application for {{ .name | pascalCase }} service. + * Provides REST API endpoints that communicate with the gRPC service. + */ +@SpringBootApplication +@RestController +@RequestMapping("/api/{{ .name }}") +public class {{ .name | pascalCase }}BffApplication { + + public static void main(String[] args) { + SpringApplication.run({{ .name | pascalCase }}BffApplication.class, args); + } + + @GetMapping("/health") + public Map health() { + return Map.of("status", "healthy", "service", "{{ .name }}"); + } +{{ range .entities }} + @GetMapping("/{{ .name | lower }}s") + public List> list{{ .name }}s( + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(required = false) String pageToken) { + // TODO: Call gRPC service + return List.of(); + } + + @GetMapping("/{{ .name | lower }}s/{id}") + public Map get{{ .name }}(@PathVariable String id) { + // TODO: Call gRPC service + return Map.of("id", id); + } + + @PostMapping("/{{ .name | lower }}s") + public Map create{{ .name }}(@RequestBody Map body) { + // TODO: Call gRPC service + return body; + } + + @PutMapping("/{{ .name | lower }}s/{id}") + public Map update{{ .name }}( + @PathVariable String id, + @RequestBody Map body) { + // TODO: Call gRPC service + body.put("id", id); + return body; + } + + @DeleteMapping("/{{ .name | lower }}s/{id}") + public void delete{{ .name }}(@PathVariable String id) { + // TODO: Call gRPC service + } +{{ end }} +} diff --git a/_examples/maven-multimodule-grpc/service-template/src/main/java/server/ServiceServer.java.tmpl b/_examples/maven-multimodule-grpc/service-template/src/main/java/server/ServiceServer.java.tmpl new file mode 100644 index 0000000..25f211a --- /dev/null +++ b/_examples/maven-multimodule-grpc/service-template/src/main/java/server/ServiceServer.java.tmpl @@ -0,0 +1,54 @@ +package {{ .package }}.server; + +import io.grpc.stub.StreamObserver; +import org.springframework.stereotype.Service; + +/** + * gRPC server implementation for {{ .name | pascalCase }}Service. + * {{ .description }} + */ +@Service +public class {{ .name | pascalCase }}Server { + + private final int port = {{ .port }}; + + public int getPort() { + return port; + } +{{ range .entities }} + /** + * Retrieves a {{ .name }} by ID. + */ + public void get{{ .name }}(String id) { + // TODO: Implement get{{ .name }} + } + + /** + * Lists all {{ .name }}s with pagination. + */ + public void list{{ .name }}s(int pageSize, String pageToken) { + // TODO: Implement list{{ .name }}s + } + + /** + * Creates a new {{ .name }}. + */ + public void create{{ .name }}() { + // TODO: Implement create{{ .name }} + } + + /** + * Updates an existing {{ .name }}. + */ + public void update{{ .name }}(String id) { + // TODO: Implement update{{ .name }} + } + + /** + * Deletes a {{ .name }} by ID. + */ + public void delete{{ .name }}(String id) { + // TODO: Implement delete{{ .name }} + } +{{ end }} +} diff --git a/_examples/maven-multimodule-grpc/service-template/src/main/proto/service.proto.tmpl b/_examples/maven-multimodule-grpc/service-template/src/main/proto/service.proto.tmpl new file mode 100644 index 0000000..5d7419a --- /dev/null +++ b/_examples/maven-multimodule-grpc/service-template/src/main/proto/service.proto.tmpl @@ -0,0 +1,59 @@ +syntax = "proto3"; + +package {{ .name }}; + +option java_multiple_files = true; +option java_package = "{{ .package }}.proto"; +option java_outer_classname = "{{ .name | pascalCase }}Proto"; + +// {{ .description }} +service {{ .name | pascalCase }}Service { +{{- range .entities }} + rpc Get{{ .name }}(Get{{ .name }}Request) returns ({{ .name }}Response) {} + rpc List{{ .name }}s(List{{ .name }}sRequest) returns (List{{ .name }}sResponse) {} + rpc Create{{ .name }}(Create{{ .name }}Request) returns ({{ .name }}Response) {} + rpc Update{{ .name }}(Update{{ .name }}Request) returns ({{ .name }}Response) {} + rpc Delete{{ .name }}(Delete{{ .name }}Request) returns (Empty) {} +{{- end }} +} + +message Empty {} +{{ range .entities }} +message {{ .name }}Response { +{{- range $i, $f := .fields }} + {{ if eq $f.type "BigDecimal" }}string{{ else if eq $f.type "Integer" }}int32{{ else if hasPrefix $f.type "List" }}repeated string{{ else }}string{{ end }} {{ $f.name | snakeCase }} = {{ add $i 1 }}; +{{- end }} +} + +message Get{{ .name }}Request { + string id = 1; +} + +message List{{ .name }}sRequest { + int32 page_size = 1; + string page_token = 2; +} + +message List{{ .name }}sResponse { + repeated {{ .name }}Response {{ .name | snakeCase }}s = 1; + string next_page_token = 2; +} + +message Create{{ .name }}Request { +{{- range $i, $f := .fields }} +{{- if ne $f.name "id" }} + {{ if eq $f.type "BigDecimal" }}string{{ else if eq $f.type "Integer" }}int32{{ else if hasPrefix $f.type "List" }}repeated string{{ else }}string{{ end }} {{ $f.name | snakeCase }} = {{ add $i 1 }}; +{{- end }} +{{- end }} +} + +message Update{{ .name }}Request { +{{- range $i, $f := .fields }} + {{ if eq $f.type "BigDecimal" }}string{{ else if eq $f.type "Integer" }}int32{{ else if hasPrefix $f.type "List" }}repeated string{{ else }}string{{ end }} {{ $f.name | snakeCase }} = {{ add $i 1 }}; +{{- end }} +} + +message Delete{{ .name }}Request { + string id = 1; +} +{{ end }} diff --git a/_examples/maven-multimodule-grpc/templates/pom.xml.tmpl b/_examples/maven-multimodule-grpc/templates/pom.xml.tmpl new file mode 100644 index 0000000..6f98630 --- /dev/null +++ b/_examples/maven-multimodule-grpc/templates/pom.xml.tmpl @@ -0,0 +1,67 @@ + + + 4.0.0 + + {{ .groupId }} + {{ .name }} + {{ .version }} + pom + + {{ .name | title }} + Multi-module gRPC microservices project + + + {{ .javaVersion }} + {{ .javaVersion }} + {{ .javaVersion }} + UTF-8 + {{ .springBootVersion }} + {{ .grpcVersion }} + 3.25.2 + + + + {{- range .services }} + modules/{{ .name }} + {{- end }} + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + io.grpc + grpc-bom + ${grpc.version} + pom + import + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + + + + diff --git a/_examples/python-fastapi-service/README.md b/_examples/python-fastapi-service/README.md new file mode 100644 index 0000000..4da9f71 --- /dev/null +++ b/_examples/python-fastapi-service/README.md @@ -0,0 +1,40 @@ +# Python FastAPI Service Example + +This example demonstrates how to scaffold a Python FastAPI project with multiple resources using `render`. + +It uses a **two-pass rendering** approach: +1. **Pass 1 (Directory Mode)**: Generates the project skeleton (FastAPI app, requirements, packages) using `project.yaml`. +2. **Pass 2 & 3 (Each Mode)**: Generates individual models and routers for each resource defined in `resources.yaml`. + +## Why Use Render? + +Generating a modern API service involves several repetitive files per resource (Model, Router, Tests). With `render`: +- **Consistency**: All routers follow the same pattern and injection style. +- **Speed**: Add a new resource to `resources.yaml` and `project.yaml`, run the script, and the boilerplate is ready. +- **Automation**: Easily integrable into CI/CD or CLI scaffolding tools. + +## Usage + +Run the render command: + +```bash +./render.sh +``` + +Or manually: + +```bash +# Generate skeleton +../../bin/render templates project.yaml -o output --force + +# Generate resources +../../bin/render resource-template/model.py.tmpl resources.yaml -o "output/app/models/{{.name | snakeCase}}.py" --force +../../bin/render resource-template/router.py.tmpl resources.yaml -o "output/app/routers/{{.name | snakeCase}}.py" --force +``` + +## Structure + +- `project.yaml`: Project metadata and list of resource names for imports. +- `resources.yaml`: Detailed resource definitions (fields, types). +- `templates/`: Project-wide skeleton. +- `resource-template/`: Templates for individual resource files. diff --git a/_examples/python-fastapi-service/project.yaml b/_examples/python-fastapi-service/project.yaml new file mode 100644 index 0000000..a019647 --- /dev/null +++ b/_examples/python-fastapi-service/project.yaml @@ -0,0 +1,6 @@ +name: "inventory-service" +version: "0.1.0" +description: "A simple inventory management service" +resources: + - "item" + - "user" diff --git a/_examples/python-fastapi-service/render.sh b/_examples/python-fastapi-service/render.sh new file mode 100755 index 0000000..fd2968b --- /dev/null +++ b/_examples/python-fastapi-service/render.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Ensure we are in the example directory +cd "$(dirname "$0")" + +# Path to the render binary +RENDER="../../bin/render" + +# Build render if it doesn't exist +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd ../.. && go build -o bin/render ./cmd/render/main.go) +fi + +echo "Pass 1: Generating project skeleton..." +# Use project.yaml which is a map +$RENDER templates project.yaml -o output --force + +echo "Pass 2: Generating resource models..." +# Use resources.yaml which is an array, triggers 'each' mode because of {{ }} in -o +$RENDER resource-template/model.py.tmpl resources.yaml -o "output/app/models/{{.name | snakeCase}}.py" --force + +echo "Pass 3: Generating resource routers..." +$RENDER resource-template/router.py.tmpl resources.yaml -o "output/app/routers/{{.name | snakeCase}}.py" --force + +echo "Generation complete! Check the 'output' directory." diff --git a/_examples/python-fastapi-service/resource-template/model.py.tmpl b/_examples/python-fastapi-service/resource-template/model.py.tmpl new file mode 100644 index 0000000..6153f3d --- /dev/null +++ b/_examples/python-fastapi-service/resource-template/model.py.tmpl @@ -0,0 +1,7 @@ +from pydantic import BaseModel +from typing import Optional + +class {{ .name | pascalCase }}(BaseModel): +{{- range .fields }} + {{ .name }}: {{ .type }} +{{- end }} diff --git a/_examples/python-fastapi-service/resource-template/router.py.tmpl b/_examples/python-fastapi-service/resource-template/router.py.tmpl new file mode 100644 index 0000000..789e499 --- /dev/null +++ b/_examples/python-fastapi-service/resource-template/router.py.tmpl @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException +from app.models.{{ .name | snakeCase }} import {{ .name | pascalCase }} +from typing import List + +router = APIRouter() + +# In-memory storage for demonstration +db: List[{{ .name | pascalCase }}] = [] + +@router.get("/", response_model=List[{{ .name | pascalCase }}]) +async def get_all(): + return db + +@router.post("/", response_model={{ .name | pascalCase }}) +async def create(item: {{ .name | pascalCase }}): + db.append(item) + return item + +@router.get("/{id}", response_model={{ .name | pascalCase }}) +async def get_one(id: int): + # This is a simplified example + if id < len(db): + return db[id] + raise HTTPException(status_code=404, detail="{{ .name }} not found") diff --git a/_examples/python-fastapi-service/resources.yaml b/_examples/python-fastapi-service/resources.yaml new file mode 100644 index 0000000..8f707f7 --- /dev/null +++ b/_examples/python-fastapi-service/resources.yaml @@ -0,0 +1,18 @@ +- name: "Item" + fields: + - name: "id" + type: "int" + - name: "name" + type: "str" + - name: "price" + type: "float" + - name: "in_stock" + type: "bool" +- name: "User" + fields: + - name: "id" + type: "int" + - name: "username" + type: "str" + - name: "email" + type: "str" diff --git a/_examples/python-fastapi-service/templates/app/__init__.py b/_examples/python-fastapi-service/templates/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/_examples/python-fastapi-service/templates/app/models/__init__.py b/_examples/python-fastapi-service/templates/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/_examples/python-fastapi-service/templates/app/routers/__init__.py b/_examples/python-fastapi-service/templates/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/_examples/python-fastapi-service/templates/main.py.tmpl b/_examples/python-fastapi-service/templates/main.py.tmpl new file mode 100644 index 0000000..7d9fd90 --- /dev/null +++ b/_examples/python-fastapi-service/templates/main.py.tmpl @@ -0,0 +1,23 @@ +from fastapi import FastAPI +{{- range .resources }} +from app.routers import {{ . | snakeCase }} +{{- end }} + +app = FastAPI( + title="{{ .name | title }}", + version="{{ .version }}", + description="{{ .description }}" +) + +@app.get("/") +async def root(): + return {"message": "Welcome to {{ .name }}"} + +{{- range .resources }} + +app.include_router( + {{ . | snakeCase }}.router, + prefix="/{{ . | snakeCase | replace "_" "-" }}s", + tags=["{{ . | pascalCase }}"] +) +{{- end }} diff --git a/_examples/terraform-modules/README.md b/_examples/terraform-modules/README.md new file mode 100644 index 0000000..292515f --- /dev/null +++ b/_examples/terraform-modules/README.md @@ -0,0 +1,68 @@ +# Terraform Module Generator + +This example demonstrates generating Terraform modules for infrastructure +components across multiple environments. Define your infrastructure once and +generate consistent, environment-specific configurations. + +## Why Use Render? + +Managing Terraform across environments involves: +- Duplicating modules with environment-specific values +- Risk of drift between environment configurations +- Difficulty maintaining DRY principles + +With `render`, define infrastructure patterns once and generate for all environments. + +## Structure Generated + +``` +terraform/ +├── modules/ +│ ├── vpc/ +│ │ ├── main.tf +│ │ ├── variables.tf +│ │ └── outputs.tf +│ ├── eks/ +│ │ └── ... +│ └── rds/ +│ └── ... +└── environments/ + ├── dev/ + │ ├── main.tf + │ ├── variables.tf + │ └── terraform.tfvars + ├── staging/ + │ └── ... + └── prod/ + └── ... +``` + +## Usage + +```bash +# Generate all Terraform files +render templates infrastructure.yaml -o terraform + +# Preview +render templates infrastructure.yaml -o terraform --dry-run +``` + +## Template Features Demonstrated + +- **Directory Mode**: Complete Terraform structure +- **Nested Loops**: Modules × Environments +- **Environment Variables**: Correct sizing per environment +- **Module References**: Proper dependency handling + +## Security Features + +- Encryption at rest for all data stores +- Private subnets for workloads +- Security groups with minimal access +- KMS keys for sensitive data + +## Real-World Use Case + +An AI assistant asked to "create infrastructure for a new microservice with +database and caching" can generate complete, secure Terraform configurations +following organizational standards. diff --git a/_examples/terraform-modules/env-template/main.tf.tmpl b/_examples/terraform-modules/env-template/main.tf.tmpl new file mode 100644 index 0000000..d6c369c --- /dev/null +++ b/_examples/terraform-modules/env-template/main.tf.tmpl @@ -0,0 +1,72 @@ +# {{ .name }} environment configuration +# Generated by render - regenerate after changes to infrastructure.yaml + +terraform { + required_version = "{{ $.terraformVersion }}" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "{{ $.providerVersion }}" + } + } + + backend "s3" { + bucket = "{{ $.project }}-terraform-state" + key = "{{ .name }}/terraform.tfstate" + region = "{{ $.region }}" + encrypt = true + dynamodb_table = "{{ $.project }}-terraform-locks" + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Project = "{{ $.project }}" + Environment = "{{ .name }}" + ManagedBy = "terraform" + } + } +} + +locals { + environment = "{{ .name }}" +} + +module "vpc" { + source = "../../modules/vpc" + + project = var.project + environment = local.environment + cidr_block = "{{ .settings.vpc.cidr_block }}" +{{- if .settings.vpc.enable_nat_gateway }} + enable_nat_gateway = {{ .settings.vpc.enable_nat_gateway }} +{{- end }} +} + +module "eks" { + source = "../../modules/eks" + + project = var.project + environment = local.environment + cluster_name = "${var.project}-{{ .name }}" + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnet_ids + node_instance_type = "{{ .settings.eks.node_instance_type }}" + node_desired_count = {{ .settings.eks.node_desired_count }} +} + +module "rds" { + source = "../../modules/rds" + + project = var.project + environment = local.environment + identifier = "${var.project}-{{ .name }}" + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnet_ids + instance_class = "{{ .settings.rds.instance_class }}" + allocated_storage = {{ .settings.rds.allocated_storage }} +} diff --git a/_examples/terraform-modules/infrastructure.yaml b/_examples/terraform-modules/infrastructure.yaml new file mode 100644 index 0000000..b2851d6 --- /dev/null +++ b/_examples/terraform-modules/infrastructure.yaml @@ -0,0 +1,108 @@ +project: myapp +region: us-west-2 +provider: aws +providerVersion: "~> 5.0" +terraformVersion: ">= 1.6" + +modules: + - name: vpc + description: VPC with public and private subnets + variables: + - name: cidr_block + type: string + default: '"10.0.0.0/16"' + - name: availability_zones + type: list(string) + default: '["us-west-2a", "us-west-2b", "us-west-2c"]' + - name: enable_nat_gateway + type: bool + default: "true" + outputs: + - name: vpc_id + value: aws_vpc.main.id + - name: private_subnet_ids + value: aws_subnet.private[*].id + - name: public_subnet_ids + value: aws_subnet.public[*].id + + - name: eks + description: Managed Kubernetes cluster + variables: + - name: cluster_name + type: string + - name: vpc_id + type: string + - name: subnet_ids + type: list(string) + - name: node_instance_type + type: string + default: '"t3.medium"' + - name: node_desired_count + type: number + default: "2" + outputs: + - name: cluster_endpoint + value: aws_eks_cluster.main.endpoint + - name: cluster_name + value: aws_eks_cluster.main.name + + - name: rds + description: PostgreSQL database + variables: + - name: identifier + type: string + - name: vpc_id + type: string + - name: subnet_ids + type: list(string) + - name: instance_class + type: string + default: '"db.t3.micro"' + - name: allocated_storage + type: number + default: "20" + - name: engine_version + type: string + default: '"15.4"' + outputs: + - name: endpoint + value: aws_db_instance.main.endpoint + - name: database_name + value: aws_db_instance.main.db_name + +environments: + - name: dev + settings: + vpc: + cidr_block: "10.0.0.0/16" + enable_nat_gateway: false + eks: + node_instance_type: "t3.small" + node_desired_count: 1 + rds: + instance_class: "db.t3.micro" + allocated_storage: 20 + + - name: staging + settings: + vpc: + cidr_block: "10.1.0.0/16" + enable_nat_gateway: true + eks: + node_instance_type: "t3.medium" + node_desired_count: 2 + rds: + instance_class: "db.t3.small" + allocated_storage: 50 + + - name: prod + settings: + vpc: + cidr_block: "10.2.0.0/16" + enable_nat_gateway: true + eks: + node_instance_type: "t3.large" + node_desired_count: 3 + rds: + instance_class: "db.r6g.large" + allocated_storage: 100 diff --git a/_examples/terraform-modules/module-template/main.tf.tmpl b/_examples/terraform-modules/module-template/main.tf.tmpl new file mode 100644 index 0000000..662394e --- /dev/null +++ b/_examples/terraform-modules/module-template/main.tf.tmpl @@ -0,0 +1,129 @@ +# {{ .name }} module +# {{ .description }} + +terraform { + required_version = "{{ $.terraformVersion }}" + + required_providers { + {{ $.provider }} = { + source = "hashicorp/{{ $.provider }}" + version = "{{ $.providerVersion }}" + } + } +} +{{ if eq .name "vpc" }} +resource "aws_vpc" "main" { + cidr_block = var.cidr_block + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${var.project}-vpc" + Environment = var.environment + ManagedBy = "terraform" + } +} + +resource "aws_subnet" "private" { + count = length(var.availability_zones) + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.cidr_block, 4, count.index) + availability_zone = var.availability_zones[count.index] + + tags = { + Name = "${var.project}-private-${count.index}" + Environment = var.environment + Type = "private" + } +} + +resource "aws_subnet" "public" { + count = length(var.availability_zones) + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.cidr_block, 4, count.index + length(var.availability_zones)) + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${var.project}-public-${count.index}" + Environment = var.environment + Type = "public" + } +} +{{ else if eq .name "eks" }} +resource "aws_eks_cluster" "main" { + name = var.cluster_name + role_arn = aws_iam_role.cluster.arn + + vpc_config { + subnet_ids = var.subnet_ids + endpoint_private_access = true + endpoint_public_access = true + } + + depends_on = [ + aws_iam_role_policy_attachment.cluster_policy + ] + + tags = { + Name = var.cluster_name + Environment = var.environment + ManagedBy = "terraform" + } +} + +resource "aws_eks_node_group" "main" { + cluster_name = aws_eks_cluster.main.name + node_group_name = "${var.cluster_name}-nodes" + node_role_arn = aws_iam_role.node.arn + subnet_ids = var.subnet_ids + + instance_types = [var.node_instance_type] + + scaling_config { + desired_size = var.node_desired_count + max_size = var.node_desired_count * 2 + min_size = 1 + } + + depends_on = [ + aws_iam_role_policy_attachment.node_policy + ] +} +{{ else if eq .name "rds" }} +resource "aws_db_instance" "main" { + identifier = var.identifier + engine = "postgres" + engine_version = var.engine_version + instance_class = var.instance_class + + allocated_storage = var.allocated_storage + max_allocated_storage = var.allocated_storage * 2 + storage_type = "gp3" + storage_encrypted = true + + db_name = replace(var.identifier, "-", "_") + username = "admin" + password = random_password.db.result + + vpc_security_group_ids = [aws_security_group.db.id] + db_subnet_group_name = aws_db_subnet_group.main.name + + backup_retention_period = var.environment == "prod" ? 30 : 7 + multi_az = var.environment == "prod" + deletion_protection = var.environment == "prod" + + skip_final_snapshot = var.environment != "prod" + + tags = { + Name = var.identifier + Environment = var.environment + ManagedBy = "terraform" + } +} + +resource "random_password" "db" { + length = 32 + special = false +} +{{ end }} diff --git a/_examples/terraform-modules/module-template/outputs.tf.tmpl b/_examples/terraform-modules/module-template/outputs.tf.tmpl new file mode 100644 index 0000000..4b051b0 --- /dev/null +++ b/_examples/terraform-modules/module-template/outputs.tf.tmpl @@ -0,0 +1,7 @@ +# Outputs for {{ .name }} module +{{ range .outputs }} +output "{{ .name }}" { + description = "{{ .name | title | replace "_" " " }}" + value = {{ .value }} +} +{{ end }} diff --git a/_examples/terraform-modules/module-template/variables.tf.tmpl b/_examples/terraform-modules/module-template/variables.tf.tmpl new file mode 100644 index 0000000..190349e --- /dev/null +++ b/_examples/terraform-modules/module-template/variables.tf.tmpl @@ -0,0 +1,20 @@ +# Variables for {{ .name }} module + +variable "project" { + description = "Project name" + type = string +} + +variable "environment" { + description = "Environment name (dev, staging, prod)" + type = string +} +{{ range .variables }} +variable "{{ .name }}" { + description = "{{ .name | title | replace "_" " " }}" + type = {{ .type }} +{{- if .default }} + default = {{ .default }} +{{- end }} +} +{{ end }} diff --git a/_examples/terraform-modules/render.cmd b/_examples/terraform-modules/render.cmd new file mode 100644 index 0000000..cf53f8d --- /dev/null +++ b/_examples/terraform-modules/render.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set RENDER=%SCRIPT_DIR%..\..\render.exe +set OUTPUT_DIR=%SCRIPT_DIR%output + +:: Build render if needed +if not exist "%RENDER%" ( + echo Building render... + pushd %SCRIPT_DIR%..\.. + go build -o render.exe ./cmd/render + popd +) + +:: Clean and create output directory +if exist "%OUTPUT_DIR%" rmdir /s /q "%OUTPUT_DIR%" +mkdir "%OUTPUT_DIR%" + +echo Generating Terraform modules... +echo. +echo Note: This example demonstrates templates for modules and environments. +echo Full generation requires combining module and environment data. +echo See README.md for details. +echo. + +echo Module template preview (first 20 lines): +type "%SCRIPT_DIR%module-template\main.tf.tmpl" | findstr /n "^" | findstr "^[1-9]:" | findstr /v "^[2-9][0-9]:" + +echo. +echo To generate, prepare per-module YAML files and run: +echo render module-template\main.tf.tmpl vpc.yaml -o output\modules\vpc\main.tf diff --git a/_examples/terraform-modules/render.sh b/_examples/terraform-modules/render.sh new file mode 100755 index 0000000..e4dca1f --- /dev/null +++ b/_examples/terraform-modules/render.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RENDER="${SCRIPT_DIR}/../../render" +OUTPUT_DIR="${SCRIPT_DIR}/output" + +# Build render if needed +if [ ! -f "$RENDER" ]; then + echo "Building render..." + (cd "${SCRIPT_DIR}/../.." && go build -o render ./cmd/render) +fi + +# Clean and create output directory +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +echo "Generating Terraform modules..." +echo "" +echo "Note: This example demonstrates templates for modules and environments." +echo "Full generation requires combining module and environment data." +echo "See README.md for details." +echo "" + +# For demonstration, show what the module template would produce +echo "Module template preview (vpc):" +cat "${SCRIPT_DIR}/module-template/main.tf.tmpl" | head -20 + +echo "" +echo "Environment template preview:" +cat "${SCRIPT_DIR}/env-template/main.tf.tmpl" | head -20 + +echo "" +echo "To generate, prepare per-module YAML files and run:" +echo " render module-template/main.tf.tmpl vpc.yaml -o output/modules/vpc/main.tf" diff --git a/cmd/render/main.go b/cmd/render/main.go new file mode 100644 index 0000000..fafda66 --- /dev/null +++ b/cmd/render/main.go @@ -0,0 +1,8 @@ +// Package main provides the entry point for the render CLI. +package main + +import "github.com/wernerstrydom/render/internal/cli" + +func main() { + cli.Execute() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..17ddc80 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/wernerstrydom/render + +go 1.24.7 + +require ( + github.com/itchyny/gojq v0.12.18 + github.com/spf13/cobra v1.10.2 + golang.org/x/text v0.33.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2b3b6be --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/exitcodes.go b/internal/cli/exitcodes.go new file mode 100644 index 0000000..9c7262e --- /dev/null +++ b/internal/cli/exitcodes.go @@ -0,0 +1,26 @@ +// Package cli provides the command-line interface for render. +package cli + +// Exit codes for the render CLI. +const ( + // ExitSuccess indicates the command completed successfully. + ExitSuccess = 0 + + // ExitRuntimeError indicates a runtime error during template rendering. + ExitRuntimeError = 1 + + // ExitUsageError indicates invalid command-line arguments. + ExitUsageError = 2 + + // ExitInputValidation indicates input validation failed (missing files, malformed data). + ExitInputValidation = 3 + + // ExitPermissionDenied indicates a filesystem permission error. + ExitPermissionDenied = 4 + + // ExitOutputConflict indicates output file exists and --force was not specified. + ExitOutputConflict = 5 + + // ExitSafetyViolation indicates a security issue (path traversal, symlinks). + ExitSafetyViolation = 6 +) diff --git a/internal/cli/render.go b/internal/cli/render.go new file mode 100644 index 0000000..de870f1 --- /dev/null +++ b/internal/cli/render.go @@ -0,0 +1,767 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/spf13/cobra" + "github.com/wernerstrydom/render/internal/config" + "github.com/wernerstrydom/render/internal/data" + "github.com/wernerstrydom/render/internal/engine" + "github.com/wernerstrydom/render/internal/output" + "github.com/wernerstrydom/render/internal/render" +) + +// renderFlags holds the command-line flags for the render command. +type renderFlags struct { + output string + force bool + dryRun bool + control string + jsonOut bool +} + +var flags renderFlags + +// renderResult represents the JSON output format. +type renderResult struct { + Status string `json:"status"` + Files []fileAction `json:"files,omitempty"` + Error string `json:"error,omitempty"` +} + +type fileAction struct { + Path string `json:"path"` + Action string `json:"action"` +} + +// renderMode represents the detected rendering mode. +type renderMode int + +const ( + modeFile renderMode = iota + modeFileIntoDir + modeDirectory + modeEachFile + modeEachDirectory +) + +func (m renderMode) String() string { + switch m { + case modeFile: + return "file" + case modeFileIntoDir: + return "file-into-dir" + case modeDirectory: + return "directory" + case modeEachFile: + return "each-file" + case modeEachDirectory: + return "each-directory" + default: + return "unknown" + } +} + +// runRenderCmd executes the unified render command. +func runRenderCmd(cmd *cobra.Command, args []string) error { + if len(args) != 2 { + return &exitError{ + code: ExitUsageError, + msg: "requires exactly 2 arguments: ", + } + } + + templatePath := args[0] + dataPath := args[1] + + // Validate output flag + if flags.output == "" { + return &exitError{ + code: ExitUsageError, + msg: "required flag --output/-o not set", + } + } + + // Check for symlinks in template source + if err := checkForSymlinks(templatePath); err != nil { + return &exitError{code: ExitSafetyViolation, msg: err.Error()} + } + + // Load data + d, err := data.Load(dataPath) + if err != nil { + return &exitError{ + code: ExitInputValidation, + msg: fmt.Sprintf("failed to load data: %v", err), + } + } + + // Determine template type + tmplInfo, err := os.Stat(templatePath) + if err != nil { + return &exitError{ + code: ExitInputValidation, + msg: fmt.Sprintf("failed to access template: %v", err), + } + } + + // Determine rendering mode + mode := inferMode(tmplInfo.IsDir(), flags.output, d) + + // Execute based on mode + switch mode { + case modeFile: + return executeFileMode(cmd, templatePath, d) + case modeFileIntoDir: + return executeFileIntoDirMode(cmd, templatePath, d) + case modeDirectory: + return executeDirectoryMode(cmd, templatePath, d) + case modeEachFile: + return executeEachFileMode(cmd, templatePath, d) + case modeEachDirectory: + return executeEachDirectoryMode(cmd, templatePath, d) + default: + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("unknown mode: %v", mode), + } + } +} + +// inferMode determines the rendering mode based on inputs. +func inferMode(isDir bool, outputPath string, _ any) renderMode { + isDynamic := strings.Contains(outputPath, "{{") && strings.Contains(outputPath, "}}") + hasTrailingSlash := strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(os.PathSeparator)) + + if isDir { + if isDynamic { + return modeEachDirectory + } + return modeDirectory + } + + // File template + if isDynamic { + return modeEachFile + } + if hasTrailingSlash { + return modeFileIntoDir + } + return modeFile +} + +// executeFileMode renders a single template file to a single output file. +func executeFileMode(cmd *cobra.Command, templatePath string, d any) error { + eng := engine.New() + + // Read template + tmplContent, err := os.ReadFile(templatePath) + if err != nil { + return &exitError{ + code: ExitInputValidation, + msg: fmt.Sprintf("failed to read template: %v", err), + } + } + + // Render template + result, err := eng.RenderString(string(tmplContent), d) + if err != nil { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("failed to render template: %v", err), + } + } + + // Check for collision + collision, err := checkCollision(flags.output, []byte(result)) + if err != nil { + return err + } + + // Dry run - just report what would happen + if flags.dryRun { + return reportDryRun(cmd, []fileAction{{Path: flags.output, Action: "create"}}) + } + + // Skip if content is identical (idempotency) + if collision == collisionIdentical { + return reportSuccess(cmd, []fileAction{{Path: flags.output, Action: "skipped (identical)"}}) + } + + // Write output + writer := output.New(flags.force) + if err := writer.WriteString(flags.output, result); err != nil { + return wrapWriteError(err, flags.output) + } + + return reportSuccess(cmd, []fileAction{{Path: flags.output, Action: "created"}}) +} + +// executeFileIntoDirMode renders a template file into a target directory. +func executeFileIntoDirMode(cmd *cobra.Command, templatePath string, d any) error { + eng := engine.New() + + // Read template + tmplContent, err := os.ReadFile(templatePath) + if err != nil { + return &exitError{ + code: ExitInputValidation, + msg: fmt.Sprintf("failed to read template: %v", err), + } + } + + // Render template + result, err := eng.RenderString(string(tmplContent), d) + if err != nil { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("failed to render template: %v", err), + } + } + + // Determine output filename: strip .tmpl if present + baseName := strings.TrimSuffix(filepath.Base(templatePath), ".tmpl") + outputPath := filepath.Join(strings.TrimSuffix(flags.output, "/"), baseName) + + // Check for collision + collision, err := checkCollision(outputPath, []byte(result)) + if err != nil { + return err + } + + if flags.dryRun { + return reportDryRun(cmd, []fileAction{{Path: outputPath, Action: "create"}}) + } + + // Skip if content is identical (idempotency) + if collision == collisionIdentical { + return reportSuccess(cmd, []fileAction{{Path: outputPath, Action: "skipped (identical)"}}) + } + + // Write output + writer := output.New(flags.force) + if err := writer.WriteString(outputPath, result); err != nil { + return wrapWriteError(err, outputPath) + } + + return reportSuccess(cmd, []fileAction{{Path: outputPath, Action: "created"}}) +} + +// executeDirectoryMode renders a directory of templates. +func executeDirectoryMode(cmd *cobra.Command, templatePath string, d any) error { + eng := engine.New() + + // Load render config + var cfg *config.ParsedConfig + var err error + if flags.control != "" { + cfg, err = config.LoadFile(flags.control, templatePath) + } else { + cfg, err = config.Load(templatePath) + } + if err != nil { + return &exitError{ + code: ExitInputValidation, + msg: fmt.Sprintf("failed to load render config: %v", err), + } + } + + // Check for symlinks in template directory + if err := checkDirForSymlinks(templatePath); err != nil { + return &exitError{code: ExitSafetyViolation, msg: err.Error()} + } + + // Collect all outputs + plan, err := render.Collect(render.CollectConfig{ + TemplateDir: templatePath, + OutputDir: flags.output, + Data: d, + Config: cfg, + Engine: eng, + }) + if err != nil { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("failed to collect outputs: %v", err), + } + } + + // Validate + if errs := plan.Validate(); len(errs) > 0 { + for _, e := range errs { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", e) + } + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("validation failed with %d error(s)", len(errs)), + } + } + + // Check for collisions (skipping identical content and no-overwrite files) + for _, out := range plan.Outputs { + // Skip collision check for no-overwrite files - they're allowed to exist + if !out.Overwrite { + continue + } + _, err := checkCollision(out.OutputPath, out.Content) + if err != nil { + return err + } + } + + if flags.dryRun { + actions := make([]fileAction, len(plan.Outputs)) + for i, out := range plan.Outputs { + action := "render" + if out.CopyFrom != "" { + action = "copy" + } + if !out.Overwrite { + // Check if file exists to determine skip action + if _, err := os.Stat(out.OutputPath); err == nil { + action = "skip (exists, no-overwrite)" + } + } + actions[i] = fileAction{Path: out.OutputPath, Action: action} + } + return reportDryRun(cmd, actions) + } + + // Execute + result, err := plan.Execute(output.New(flags.force)) + if err != nil { + return wrapWriteError(err, "") + } + + // Report what was written + actions := make([]fileAction, len(plan.Outputs)) + for i, out := range plan.Outputs { + if result.Skipped[out.OutputPath] { + actions[i] = fileAction{Path: out.OutputPath, Action: "skipped (exists, no-overwrite)"} + } else if out.CopyFrom != "" { + actions[i] = fileAction{Path: out.OutputPath, Action: "copied"} + } else { + actions[i] = fileAction{Path: out.OutputPath, Action: "rendered"} + } + } + + return reportSuccess(cmd, actions) +} + +// executeEachFileMode renders a template for each item in an array. +func executeEachFileMode(cmd *cobra.Command, templatePath string, d any) error { + eng := engine.New() + + // Read template + tmplContent, err := os.ReadFile(templatePath) + if err != nil { + return &exitError{ + code: ExitInputValidation, + msg: fmt.Sprintf("failed to read template: %v", err), + } + } + + // Get items to iterate over + items := getIterableItems(d) + + // Pre-flight: collect all outputs to check for collisions + type plannedOutput struct { + path string + content string + } + planned := make([]plannedOutput, 0, len(items)) + seenPaths := make(map[string]int) // path -> index in items + + for i, item := range items { + // Render output path + outPath, err := eng.RenderString(flags.output, item) + if err != nil { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("failed to render output path: %v", err), + } + } + outPath = strings.TrimSpace(outPath) + + // Validate output path + if err := validateOutputPath(outPath); err != nil { + return &exitError{code: ExitSafetyViolation, msg: err.Error()} + } + + // Check for internal collision + if prevIdx, exists := seenPaths[outPath]; exists { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("internal collision: items at index %d and %d both produce path %q", prevIdx, i, outPath), + } + } + seenPaths[outPath] = i + + // Render template + result, err := eng.RenderString(string(tmplContent), item) + if err != nil { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("failed to render template: %v", err), + } + } + + planned = append(planned, plannedOutput{path: outPath, content: result}) + } + + // Check for filesystem collisions and track which files can be skipped + skipMap := make(map[int]bool) + for i, p := range planned { + collision, err := checkCollision(p.path, []byte(p.content)) + if err != nil { + return err + } + if collision == collisionIdentical { + skipMap[i] = true + } + } + + if flags.dryRun { + actions := make([]fileAction, len(planned)) + for i, p := range planned { + actions[i] = fileAction{Path: p.path, Action: "create"} + } + return reportDryRun(cmd, actions) + } + + // Write all outputs (skipping identical content) + writer := output.New(flags.force) + var actions []fileAction + for i, p := range planned { + if skipMap[i] { + actions = append(actions, fileAction{Path: p.path, Action: "skipped (identical)"}) + continue + } + if err := writer.WriteString(p.path, p.content); err != nil { + return wrapWriteError(err, p.path) + } + actions = append(actions, fileAction{Path: p.path, Action: "created"}) + } + + return reportSuccess(cmd, actions) +} + +// executeEachDirectoryMode renders a directory template for each item in an array. +func executeEachDirectoryMode(cmd *cobra.Command, templatePath string, d any) error { + eng := engine.New() + + // Load render config + var cfg *config.ParsedConfig + var err error + if flags.control != "" { + cfg, err = config.LoadFile(flags.control, templatePath) + } else { + cfg, err = config.Load(templatePath) + } + if err != nil { + return &exitError{ + code: ExitInputValidation, + msg: fmt.Sprintf("failed to load render config: %v", err), + } + } + + // Check for symlinks + if err := checkDirForSymlinks(templatePath); err != nil { + return &exitError{code: ExitSafetyViolation, msg: err.Error()} + } + + // Get items to iterate over + items := getIterableItems(d) + + // Pre-flight: collect all outputs to check for collisions + type plannedDir struct { + outputDir string + plan *render.Plan + } + allPlanned := make([]plannedDir, 0, len(items)) + seenPaths := make(map[string]int) // output path -> item index + + for i, item := range items { + // Render output directory path + outDir, err := eng.RenderString(flags.output, item) + if err != nil { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("failed to render output path: %v", err), + } + } + outDir = strings.TrimSpace(outDir) + + // Validate output path + if err := validateOutputPath(outDir); err != nil { + return &exitError{code: ExitSafetyViolation, msg: err.Error()} + } + + // Collect outputs for this item + plan, err := render.Collect(render.CollectConfig{ + TemplateDir: templatePath, + OutputDir: outDir, + Data: item, + Config: cfg, + Engine: eng, + }) + if err != nil { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("failed to collect outputs: %v", err), + } + } + + // Check for internal collisions across all items + for _, out := range plan.Outputs { + if prevIdx, exists := seenPaths[out.OutputPath]; exists { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("internal collision: items at index %d and %d both produce path %q", prevIdx, i, out.OutputPath), + } + } + seenPaths[out.OutputPath] = i + } + + // Validate within item + if errs := plan.Validate(); len(errs) > 0 { + return &exitError{ + code: ExitRuntimeError, + msg: fmt.Sprintf("validation failed: %v", errs[0]), + } + } + + allPlanned = append(allPlanned, plannedDir{outputDir: outDir, plan: plan}) + } + + // Check for filesystem collisions (skipping identical content and no-overwrite files) + for _, pd := range allPlanned { + for _, out := range pd.plan.Outputs { + // Skip collision check for no-overwrite files - they're allowed to exist + if !out.Overwrite { + continue + } + _, err := checkCollision(out.OutputPath, out.Content) + if err != nil { + return err + } + } + } + + if flags.dryRun { + var actions []fileAction + for _, pd := range allPlanned { + for _, out := range pd.plan.Outputs { + action := "render" + if out.CopyFrom != "" { + action = "copy" + } + if !out.Overwrite { + // Check if file exists to determine skip action + if _, err := os.Stat(out.OutputPath); err == nil { + action = "skip (exists, no-overwrite)" + } + } + actions = append(actions, fileAction{Path: out.OutputPath, Action: action}) + } + } + return reportDryRun(cmd, actions) + } + + // Execute all plans + writer := output.New(flags.force) + var actions []fileAction + for _, pd := range allPlanned { + result, err := pd.plan.Execute(writer) + if err != nil { + return wrapWriteError(err, "") + } + for _, out := range pd.plan.Outputs { + if result.Skipped[out.OutputPath] { + actions = append(actions, fileAction{Path: out.OutputPath, Action: "skipped (exists, no-overwrite)"}) + } else if out.CopyFrom != "" { + actions = append(actions, fileAction{Path: out.OutputPath, Action: "copied"}) + } else { + actions = append(actions, fileAction{Path: out.OutputPath, Action: "rendered"}) + } + } + } + + return reportSuccess(cmd, actions) +} + +// getIterableItems returns items to iterate over. +// If data is an array, returns the array elements. +// If data is an object, wraps it in a single-element array. +func getIterableItems(d any) []any { + if arr, ok := d.([]any); ok { + return arr + } + return []any{d} +} + +// validateOutputPath checks that an output path is safe. +func validateOutputPath(path string) error { + separator := func(r rune) bool { + return r == '/' || r == '\\' + } + parts := strings.FieldsFunc(path, separator) + if slices.Contains(parts, "..") { + return fmt.Errorf("security error: output path contains directory traversal: %s", path) + } + return nil +} + +// collisionResult represents the result of a collision check. +type collisionResult int + +const ( + collisionNone collisionResult = iota // No collision, proceed with write + collisionIdentical // File exists but content is identical, skip write +) + +// checkCollision checks if a file would collide with an existing file. +// Returns (collisionIdentical, nil) if file exists with identical content (skip write). +// Returns (collisionNone, nil) if file doesn't exist or force is enabled (proceed with write). +// Returns (_, error) if file exists with different content and force not enabled. +func checkCollision(path string, content []byte) (collisionResult, error) { + if !flags.force { + if info, err := os.Stat(path); err == nil { + if info.Mode().IsRegular() { + // Check if content is identical (idempotency) + existing, err := os.ReadFile(path) + if err != nil { + return collisionNone, &exitError{ + code: ExitOutputConflict, + msg: fmt.Sprintf("file already exists (use --force to overwrite): %s", path), + } + } + if string(existing) == string(content) { + // Content is identical, skip the write + return collisionIdentical, nil + } + return collisionNone, &exitError{ + code: ExitOutputConflict, + msg: fmt.Sprintf("file already exists (use --force to overwrite): %s", path), + } + } + } + } + return collisionNone, nil +} + +// checkForSymlinks checks if a path is a symlink. +func checkForSymlinks(path string) error { + info, err := os.Lstat(path) + if err != nil { + return nil // File doesn't exist, which is fine + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("template source contains symlink: %s", path) + } + return nil +} + +// checkDirForSymlinks recursively checks a directory for symlinks. +func checkDirForSymlinks(dir string) error { + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // Check lstat to detect symlinks + linfo, err := os.Lstat(path) + if err != nil { + return err + } + if linfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("template source contains symlink: %s", path) + } + return nil + }) +} + +// wrapWriteError converts a write error to an appropriate exit error. +func wrapWriteError(err error, _ string) error { + errStr := err.Error() + if strings.Contains(errStr, "permission denied") { + return &exitError{ + code: ExitPermissionDenied, + msg: errStr, + } + } + if strings.Contains(errStr, "already exists") || strings.Contains(errStr, "force") { + return &exitError{ + code: ExitOutputConflict, + msg: errStr, + } + } + return &exitError{ + code: ExitRuntimeError, + msg: errStr, + } +} + +// reportDryRun reports what would be done in dry-run mode. +func reportDryRun(cmd *cobra.Command, actions []fileAction) error { + if flags.jsonOut { + result := renderResult{ + Status: "dry-run", + Files: actions, + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dry run - would perform:") + for _, a := range actions { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s\n", a.Action, a.Path) + } + return nil +} + +// reportSuccess reports successful completion. +func reportSuccess(cmd *cobra.Command, actions []fileAction) error { + if flags.jsonOut { + result := renderResult{ + Status: "success", + Files: actions, + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + for _, a := range actions { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", capitalizeFirst(a.Action), a.Path) + } + return nil +} + +// exitError represents an error with a specific exit code. +type exitError struct { + code int + msg string +} + +func (e *exitError) Error() string { + return e.msg +} + +// ExitCode returns the exit code for this error. +func (e *exitError) ExitCode() int { + return e.code +} + +// capitalizeFirst capitalizes the first letter of a string. +func capitalizeFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..29cc7ba --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,59 @@ +// Package cli provides the command-line interface for render. +package cli + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "render ", + Short: "Render text using Go templates and JSON/YAML data", + Long: `render is a CLI tool that uses Go text templates to generate output files +from JSON or YAML data sources. + +It automatically detects the rendering mode based on the template type and +output path: + + - File mode: Single template file → single output file + - Directory mode: Template directory → mirrored output directory + - Each mode: Template + dynamic output path → multiple output files + +The output path determines the mode: + - Static path (out.txt) → file or directory mode + - Dynamic path ({{.id}}.txt) → each mode (iterates over data) + - Trailing slash (output/) → file rendered into directory + +Example usage: + render template.txt.tmpl data.json -o output.txt + render ./templates data.yaml -o ./output + render item.tmpl list.json -o "{{.id}}.txt" + render ./templates data.json -o ./dist --control render.yaml`, + Args: cobra.ExactArgs(2), + RunE: runRenderCmd, + SilenceUsage: true, +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + // Check if the error has an exit code + if exitErr, ok := err.(*exitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + } +} + +func init() { + rootCmd.Flags().StringVarP(&flags.output, "output", "o", "", "Output path (required)") + rootCmd.Flags().BoolVarP(&flags.force, "force", "f", false, "Overwrite existing files") + rootCmd.Flags().BoolVar(&flags.dryRun, "dry-run", false, "Show what would be written without writing") + rootCmd.Flags().StringVar(&flags.control, "control", "", "Explicit path to control file (no auto-discovery)") + rootCmd.Flags().BoolVar(&flags.jsonOut, "json", false, "Machine-readable JSON output") + + if err := rootCmd.MarkFlagRequired("output"); err != nil { + panic(err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c8763a9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,242 @@ +// Package config provides configuration loading for .render.yaml files. +package config + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "text/template" + + "github.com/wernerstrydom/render/internal/funcs" + "gopkg.in/yaml.v3" +) + +// PathMapping represents a path mapping which can be either a simple string +// or an object with path and overwrite options. +type PathMapping struct { + Path string `json:"path" yaml:"path"` + Overwrite *bool `json:"overwrite" yaml:"overwrite"` // nil = true (default) +} + +// UnmarshalYAML implements custom YAML unmarshaling to support both string +// and object formats for path mappings. +func (p *PathMapping) UnmarshalYAML(value *yaml.Node) error { + // Try string first + if value.Kind == yaml.ScalarNode { + p.Path = value.Value + p.Overwrite = nil // default to true + return nil + } + + // Try object format + if value.Kind == yaml.MappingNode { + type rawPathMapping struct { + Path string `yaml:"path"` + Overwrite *bool `yaml:"overwrite"` + } + var raw rawPathMapping + if err := value.Decode(&raw); err != nil { + return err + } + if raw.Path == "" { + return fmt.Errorf("path mapping object must have 'path' field") + } + p.Path = raw.Path + p.Overwrite = raw.Overwrite + return nil + } + + return fmt.Errorf("path mapping must be a string or object, got %v", value.Kind) +} + +// Config represents the raw .render.yaml configuration. +type Config struct { + Paths map[string]PathMapping `json:"paths" yaml:"paths"` +} + +// dirMapping holds a directory prefix mapping with its parsed template. +type dirMapping struct { + prefix string + tmpl *template.Template +} + +// ParsedConfig holds validated, pre-parsed configuration. +type ParsedConfig struct { + fileTemplates map[string]*template.Template // Exact file mappings + dirMappings []dirMapping // Prefix mappings, sorted longest first + noOverwrite map[string]bool // Source paths with overwrite: false +} + +// configFileNames lists the supported config file names in priority order. +var configFileNames = []string{".render.yaml", ".render.yml", "render.json"} + +// Load finds and loads a render config from the template directory. +// Returns nil (not an error) if no config file exists. +func Load(tmplDir string) (*ParsedConfig, error) { + // Try each config file name in order + var configPath string + for _, name := range configFileNames { + path := filepath.Join(tmplDir, name) + if _, err := os.Stat(path); err == nil { + configPath = path + break + } + } + + // No config file found - this is not an error + if configPath == "" { + return nil, nil + } + + return LoadFile(configPath, tmplDir) +} + +// LoadFile loads and parses a config file. +func LoadFile(configPath, tmplDir string) (*ParsedConfig, error) { + content, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + return Parse(content, tmplDir, filepath.Base(configPath)) +} + +// Parse parses config content and validates it against the template directory. +func Parse(content []byte, tmplDir, filename string) (*ParsedConfig, error) { + // First, validate schema by parsing into raw map + var raw map[string]any + if err := yaml.Unmarshal(content, &raw); err != nil { + return nil, fmt.Errorf("%s: invalid YAML: %w", filename, err) + } + + // Check for unknown keys + for key := range raw { + if key != "paths" { + return nil, fmt.Errorf("%s: unknown key %q (only 'paths' allowed)", filename, key) + } + } + + // Parse into typed config + var cfg Config + if err := yaml.Unmarshal(content, &cfg); err != nil { + return nil, fmt.Errorf("%s: failed to parse config: %w", filename, err) + } + + // Empty config is valid but has nothing to transform + if len(cfg.Paths) == 0 { + return &ParsedConfig{ + fileTemplates: make(map[string]*template.Template), + dirMappings: nil, + noOverwrite: make(map[string]bool), + }, nil + } + + // Validate and parse all path mappings + parsed := &ParsedConfig{ + fileTemplates: make(map[string]*template.Template), + dirMappings: nil, + noOverwrite: make(map[string]bool), + } + + funcMap := funcs.Map() + + for src, mapping := range cfg.Paths { + // Validate source path + if err := validateSourcePath(src); err != nil { + return nil, fmt.Errorf("%s: paths[%q]: %w", filename, src, err) + } + + // Check if source exists and determine if it's a file or directory + srcPath := filepath.Join(tmplDir, src) + info, err := os.Stat(srcPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%s: paths[%q]: source does not exist in template directory", filename, src) + } + return nil, fmt.Errorf("%s: paths[%q]: %w", filename, src, err) + } + + // Parse the destination template + tmpl, err := template.New(src).Funcs(funcMap).Parse(mapping.Path) + if err != nil { + return nil, fmt.Errorf("%s: paths[%q]: invalid template syntax: %w", filename, src, err) + } + + // Track overwrite setting (nil or true means overwrite allowed; false means no overwrite) + if mapping.Overwrite != nil && !*mapping.Overwrite { + parsed.noOverwrite[src] = true + } + + if info.IsDir() { + // Directory prefix mapping + parsed.dirMappings = append(parsed.dirMappings, dirMapping{ + prefix: src, + tmpl: tmpl, + }) + } else { + // File mapping + parsed.fileTemplates[src] = tmpl + } + } + + // Sort directory mappings by prefix length (longest first) + sort.Slice(parsed.dirMappings, func(i, j int) bool { + return len(parsed.dirMappings[i].prefix) > len(parsed.dirMappings[j].prefix) + }) + + return parsed, nil +} + +// validateSourcePath checks that a source path is safe. +func validateSourcePath(path string) error { + // Check for absolute path + if filepath.IsAbs(path) { + return fmt.Errorf("source path must be relative (got absolute path)") + } + + // Check for null bytes + if strings.ContainsRune(path, '\x00') { + return fmt.Errorf("source path contains null byte") + } + + // Check for path traversal + parts := strings.FieldsFunc(path, func(r rune) bool { + return r == '/' || r == filepath.Separator + }) + if slices.Contains(parts, "..") { + return fmt.Errorf("source path contains '..' (directory traversal)") + } + + return nil +} + +// ValidateRenderedPath checks that a rendered output path is safe. +func ValidateRenderedPath(path string) error { + // Check for path traversal in rendered output + parts := strings.FieldsFunc(path, func(r rune) bool { + return r == '/' || r == filepath.Separator + }) + if slices.Contains(parts, "..") { + return fmt.Errorf("rendered path contains '..' (directory traversal)") + } + + return nil +} + +// IsEmpty returns true if the config has no path mappings. +func (p *ParsedConfig) IsEmpty() bool { + return p == nil || (len(p.fileTemplates) == 0 && len(p.dirMappings) == 0) +} + +// HasFileMappings returns true if there are exact file mappings. +func (p *ParsedConfig) HasFileMappings() bool { + return p != nil && len(p.fileTemplates) > 0 +} + +// HasDirMappings returns true if there are directory prefix mappings. +func (p *ParsedConfig) HasDirMappings() bool { + return p != nil && len(p.dirMappings) > 0 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..72de515 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,400 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParse_ValidConfig(t *testing.T) { + // Create temp directory with test files + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "package {{ .package }}") + mkdir(t, dir, "src") + writeFile(t, dir, "src/main.go", "package main") + + content := []byte(`paths: + "model.go.tmpl": "{{ .name | snakeCase }}.go" + "src": "pkg/{{ .name }}" +`) + + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(parsed.fileTemplates) != 1 { + t.Errorf("Expected 1 file template, got %d", len(parsed.fileTemplates)) + } + + if len(parsed.dirMappings) != 1 { + t.Errorf("Expected 1 dir mapping, got %d", len(parsed.dirMappings)) + } +} + +func TestParse_EmptyConfig(t *testing.T) { + dir := t.TempDir() + content := []byte(`paths: {}`) + + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if !parsed.IsEmpty() { + t.Error("Expected empty config") + } +} + +func TestParse_UnknownKey(t *testing.T) { + dir := t.TempDir() + content := []byte(`files: + "model.go": "output.go" +`) + + _, err := Parse(content, dir, ".render.yaml") + if err == nil { + t.Fatal("Expected error for unknown key") + } + + if want := `unknown key "files"`; !containsString(err.Error(), want) { + t.Errorf("Error %q should contain %q", err.Error(), want) + } +} + +func TestParse_SourceNotExist(t *testing.T) { + dir := t.TempDir() + content := []byte(`paths: + "missing.go": "output.go" +`) + + _, err := Parse(content, dir, ".render.yaml") + if err == nil { + t.Fatal("Expected error for missing source") + } + + if want := "does not exist"; !containsString(err.Error(), want) { + t.Errorf("Error %q should contain %q", err.Error(), want) + } +} + +func TestParse_InvalidTemplateSyntax(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go", "content") + + content := []byte(`paths: + "model.go": "{{ .name | invalid" +`) + + _, err := Parse(content, dir, ".render.yaml") + if err == nil { + t.Fatal("Expected error for invalid template") + } + + if want := "invalid template syntax"; !containsString(err.Error(), want) { + t.Errorf("Error %q should contain %q", err.Error(), want) + } +} + +func TestValidateSourcePath_AbsolutePath(t *testing.T) { + err := validateSourcePath("/absolute/path") + if err == nil { + t.Fatal("Expected error for absolute path") + } + + if want := "must be relative"; !containsString(err.Error(), want) { + t.Errorf("Error %q should contain %q", err.Error(), want) + } +} + +func TestValidateSourcePath_Traversal(t *testing.T) { + err := validateSourcePath("../secret.txt") + if err == nil { + t.Fatal("Expected error for path traversal") + } + + if want := "directory traversal"; !containsString(err.Error(), want) { + t.Errorf("Error %q should contain %q", err.Error(), want) + } +} + +func TestValidateSourcePath_NullByte(t *testing.T) { + err := validateSourcePath("file\x00.txt") + if err == nil { + t.Fatal("Expected error for null byte") + } + + if want := "null byte"; !containsString(err.Error(), want) { + t.Errorf("Error %q should contain %q", err.Error(), want) + } +} + +func TestValidateSourcePath_Valid(t *testing.T) { + tests := []string{ + "file.txt", + "dir/file.txt", + "a/b/c/d.go", + "src/main/java", + } + + for _, path := range tests { + err := validateSourcePath(path) + if err != nil { + t.Errorf("validateSourcePath(%q) = %v, want nil", path, err) + } + } +} + +func TestValidateRenderedPath_Traversal(t *testing.T) { + err := ValidateRenderedPath("../escaped") + if err == nil { + t.Fatal("Expected error for path traversal") + } + + if want := "directory traversal"; !containsString(err.Error(), want) { + t.Errorf("Error %q should contain %q", err.Error(), want) + } +} + +func TestValidateRenderedPath_Valid(t *testing.T) { + tests := []string{ + "output.txt", + "pkg/model/user.go", + "com/example/service", + } + + for _, path := range tests { + err := ValidateRenderedPath(path) + if err != nil { + t.Errorf("ValidateRenderedPath(%q) = %v, want nil", path, err) + } + } +} + +func TestLoad_NoConfig(t *testing.T) { + dir := t.TempDir() + + cfg, err := Load(dir) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg != nil { + t.Error("Expected nil config when no config file exists") + } +} + +func TestLoad_YAMLConfig(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + writeFile(t, dir, ".render.yaml", `paths: + "model.go.tmpl": "{{ .name }}.go" +`) + + cfg, err := Load(dir) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg == nil { + t.Fatal("Expected config to be loaded") + } + + if !cfg.HasFileMappings() { + t.Error("Expected file mappings") + } +} + +func TestLoad_YMLConfig(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + writeFile(t, dir, ".render.yml", `paths: + "model.go.tmpl": "{{ .name }}.go" +`) + + cfg, err := Load(dir) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg == nil { + t.Fatal("Expected config to be loaded") + } +} + +func TestParsedConfig_IsEmpty(t *testing.T) { + var nilConfig *ParsedConfig + if !nilConfig.IsEmpty() { + t.Error("nil config should be empty") + } + + emptyConfig := &ParsedConfig{} + if !emptyConfig.IsEmpty() { + t.Error("empty config should be empty") + } +} + +func TestShouldSkipConfigFile(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {".render.yaml", true}, + {".render.yml", true}, + {"render.json", true}, + {"other.yaml", false}, + {"config.yaml", false}, + {"src/.render.yaml", false}, // Only top-level + } + + for _, tt := range tests { + got := ShouldSkipConfigFile(tt.path) + if got != tt.want { + t.Errorf("ShouldSkipConfigFile(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestParse_PathMappingStringValue(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + + content := []byte(`paths: + "model.go.tmpl": "{{ .name }}.go" +`) + + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(parsed.fileTemplates) != 1 { + t.Errorf("Expected 1 file template, got %d", len(parsed.fileTemplates)) + } + + // String values should default to overwrite: true (not in noOverwrite set) + if len(parsed.noOverwrite) != 0 { + t.Errorf("Expected 0 noOverwrite entries, got %d", len(parsed.noOverwrite)) + } +} + +func TestParse_PathMappingObjectValue(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + + content := []byte(`paths: + "model.go.tmpl": + path: "{{ .name }}.go" + overwrite: false +`) + + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(parsed.fileTemplates) != 1 { + t.Errorf("Expected 1 file template, got %d", len(parsed.fileTemplates)) + } + + // Should have noOverwrite set for this path + if !parsed.noOverwrite["model.go.tmpl"] { + t.Error("Expected model.go.tmpl in noOverwrite set") + } +} + +func TestParse_PathMappingMixedValues(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + writeFile(t, dir, "handler.go.tmpl", "content") + writeFile(t, dir, "config.yaml.tmpl", "content") + + content := []byte(`paths: + "model.go.tmpl": "{{ .name }}.go" + "handler.go.tmpl": + path: "internal/handler.go" + overwrite: false + "config.yaml.tmpl": + path: "config.yaml" + overwrite: true +`) + + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(parsed.fileTemplates) != 3 { + t.Errorf("Expected 3 file templates, got %d", len(parsed.fileTemplates)) + } + + // Only handler.go.tmpl should be in noOverwrite set + if len(parsed.noOverwrite) != 1 { + t.Errorf("Expected 1 noOverwrite entry, got %d", len(parsed.noOverwrite)) + } + + if !parsed.noOverwrite["handler.go.tmpl"] { + t.Error("Expected handler.go.tmpl in noOverwrite set") + } + + if parsed.noOverwrite["model.go.tmpl"] { + t.Error("model.go.tmpl should not be in noOverwrite set") + } + + if parsed.noOverwrite["config.yaml.tmpl"] { + t.Error("config.yaml.tmpl should not be in noOverwrite set (overwrite: true)") + } +} + +func TestParse_PathMappingObjectMissingPath(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + + content := []byte(`paths: + "model.go.tmpl": + overwrite: false +`) + + _, err := Parse(content, dir, ".render.yaml") + if err == nil { + t.Fatal("Expected error for object without path field") + } + + if want := "must have 'path' field"; !containsString(err.Error(), want) { + t.Errorf("Error %q should contain %q", err.Error(), want) + } +} + +// Helper functions + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } +} + +func mkdir(t *testing.T, dir, name string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStringHelper(s, substr)) +} + +func containsStringHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/config/mapper.go b/internal/config/mapper.go new file mode 100644 index 0000000..1b1dac7 --- /dev/null +++ b/internal/config/mapper.go @@ -0,0 +1,89 @@ +package config + +import ( + "bytes" + "slices" + "strings" +) + +// PathMapper transforms paths based on a ParsedConfig. +type PathMapper struct { + parsed *ParsedConfig +} + +// NewPathMapper creates a PathMapper from a ParsedConfig. +// Returns nil if parsed is nil or empty. +func NewPathMapper(parsed *ParsedConfig) *PathMapper { + if parsed.IsEmpty() { + return nil + } + return &PathMapper{parsed: parsed} +} + +// TransformPath transforms a relative path using the config rules. +// Returns the transformed path, or the original path if no rule matches. +// +// Transformation order: +// 1. Apply exact file match if present → render the output template +// 2. Apply directory prefix match to the result (or original if no file match) +// 3. If no matches, return path unchanged +func (m *PathMapper) TransformPath(relPath string, data any) (string, error) { + if m == nil || m.parsed == nil { + return relPath, nil + } + + result := relPath + + // Step 1: Check for exact file match first + if tmpl, ok := m.parsed.fileTemplates[relPath]; ok { + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + result = buf.String() + + // Validate rendered path + if err := ValidateRenderedPath(result); err != nil { + return "", err + } + } + + // Step 2: Check for directory prefix match on the result + // (longest prefix first due to sorting) + for _, dm := range m.parsed.dirMappings { + if strings.HasPrefix(result, dm.prefix+"/") || result == dm.prefix { + // Render the prefix template + var buf bytes.Buffer + if err := dm.tmpl.Execute(&buf, data); err != nil { + return "", err + } + newPrefix := buf.String() + + // Validate rendered prefix + if err := ValidateRenderedPath(newPrefix); err != nil { + return "", err + } + + // Append the suffix (everything after the prefix) + suffix := strings.TrimPrefix(result, dm.prefix) + return newPrefix + suffix, nil + } + } + + return result, nil +} + +// CanOverwrite returns true if the source path allows overwriting existing files. +// Returns true (default) if no explicit overwrite:false is set for this path. +func (m *PathMapper) CanOverwrite(sourcePath string) bool { + if m == nil || m.parsed == nil { + return true + } + return !m.parsed.noOverwrite[sourcePath] +} + +// ShouldSkipConfigFile returns true if the path is a render config file +// that should not be copied to output. +func ShouldSkipConfigFile(relPath string) bool { + return slices.Contains(configFileNames, relPath) +} diff --git a/internal/config/mapper_test.go b/internal/config/mapper_test.go new file mode 100644 index 0000000..9fda655 --- /dev/null +++ b/internal/config/mapper_test.go @@ -0,0 +1,271 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPathMapper_NilMapper(t *testing.T) { + var mapper *PathMapper + + result, err := mapper.TransformPath("src/main.go", nil) + if err != nil { + t.Fatalf("TransformPath failed: %v", err) + } + + if result != "src/main.go" { + t.Errorf("TransformPath = %q, want %q", result, "src/main.go") + } +} + +func TestPathMapper_NoMatch(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "other.txt", "content") + + content := []byte(`paths: + "other.txt": "renamed.txt" +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + result, err := mapper.TransformPath("unmatched.go", nil) + if err != nil { + t.Fatalf("TransformPath failed: %v", err) + } + + if result != "unmatched.go" { + t.Errorf("TransformPath = %q, want %q", result, "unmatched.go") + } +} + +func TestPathMapper_FileMatch(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + + content := []byte(`paths: + "model.go.tmpl": "{{ .name | snakeCase }}.go" +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + data := map[string]any{"name": "UserProfile"} + result, err := mapper.TransformPath("model.go.tmpl", data) + if err != nil { + t.Fatalf("TransformPath failed: %v", err) + } + + if result != "user_profile.go" { + t.Errorf("TransformPath = %q, want %q", result, "user_profile.go") + } +} + +func TestPathMapper_DirPrefixMatch(t *testing.T) { + dir := t.TempDir() + mkdirAll(t, dir, "server/src/main/java") + writeFile(t, dir, "server/src/main/java/Service.java", "content") + + content := []byte(`paths: + "server/src/main/java": "server/src/main/java/{{ .package | replace \".\" \"/\" }}" +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + data := map[string]any{"package": "com.example.service"} + result, err := mapper.TransformPath("server/src/main/java/Service.java", data) + if err != nil { + t.Fatalf("TransformPath failed: %v", err) + } + + expected := "server/src/main/java/com/example/service/Service.java" + if result != expected { + t.Errorf("TransformPath = %q, want %q", result, expected) + } +} + +func TestPathMapper_FilePrecedence(t *testing.T) { + dir := t.TempDir() + mkdirAll(t, dir, "src") + writeFile(t, dir, "src/special.go", "content") + + // File mapping should take precedence over dir prefix + content := []byte(`paths: + "src/special.go": "renamed/special.go" + "src": "other/{{ .name }}" +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + result, err := mapper.TransformPath("src/special.go", nil) + if err != nil { + t.Fatalf("TransformPath failed: %v", err) + } + + if result != "renamed/special.go" { + t.Errorf("TransformPath = %q, want %q", result, "renamed/special.go") + } +} + +func TestPathMapper_LongestPrefixWins(t *testing.T) { + dir := t.TempDir() + mkdirAll(t, dir, "src/main/java") + writeFile(t, dir, "src/main/java/App.java", "content") + + content := []byte(`paths: + "src": "pkg1" + "src/main": "pkg2" + "src/main/java": "pkg3" +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + result, err := mapper.TransformPath("src/main/java/App.java", nil) + if err != nil { + t.Fatalf("TransformPath failed: %v", err) + } + + // Longest prefix "src/main/java" should win + if result != "pkg3/App.java" { + t.Errorf("TransformPath = %q, want %q", result, "pkg3/App.java") + } +} + +func TestPathMapper_TraversalInRenderedPath(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go", "content") + + content := []byte(`paths: + "model.go": "{{ .path }}" +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + // Attempt to render a path with traversal + data := map[string]any{"path": "../escaped.go"} + _, err = mapper.TransformPath("model.go", data) + if err == nil { + t.Fatal("Expected error for path traversal in rendered output") + } +} + +func TestNewPathMapper_EmptyConfig(t *testing.T) { + mapper := NewPathMapper(nil) + if mapper != nil { + t.Error("Expected nil mapper for nil config") + } + + emptyParsed := &ParsedConfig{} + mapper = NewPathMapper(emptyParsed) + if mapper != nil { + t.Error("Expected nil mapper for empty config") + } +} + +func TestPathMapper_CanOverwrite_NilMapper(t *testing.T) { + var mapper *PathMapper + if !mapper.CanOverwrite("any/path") { + t.Error("nil mapper should return true for CanOverwrite") + } +} + +func TestPathMapper_CanOverwrite_Default(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + + content := []byte(`paths: + "model.go.tmpl": "output.go" +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + // String value should default to overwrite: true + if !mapper.CanOverwrite("model.go.tmpl") { + t.Error("CanOverwrite should return true for path with default overwrite") + } + + // Unspecified paths should also return true + if !mapper.CanOverwrite("other/path") { + t.Error("CanOverwrite should return true for unspecified paths") + } +} + +func TestPathMapper_CanOverwrite_False(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + + content := []byte(`paths: + "model.go.tmpl": + path: "output.go" + overwrite: false +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + // Explicit overwrite: false should return false + if mapper.CanOverwrite("model.go.tmpl") { + t.Error("CanOverwrite should return false for path with overwrite: false") + } +} + +func TestPathMapper_CanOverwrite_ExplicitTrue(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.go.tmpl", "content") + + content := []byte(`paths: + "model.go.tmpl": + path: "output.go" + overwrite: true +`) + parsed, err := Parse(content, dir, ".render.yaml") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + mapper := NewPathMapper(parsed) + + // Explicit overwrite: true should return true + if !mapper.CanOverwrite("model.go.tmpl") { + t.Error("CanOverwrite should return true for path with overwrite: true") + } +} + +// Helper function +func mkdirAll(t *testing.T, dir, path string) { + t.Helper() + fullPath := filepath.Join(dir, path) + if err := os.MkdirAll(fullPath, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } +} diff --git a/internal/data/loader.go b/internal/data/loader.go new file mode 100644 index 0000000..9d3974c --- /dev/null +++ b/internal/data/loader.go @@ -0,0 +1,108 @@ +// Package data provides JSON and YAML data loading functionality. +package data + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Load reads data from a file and returns it as a generic interface. +// It automatically detects the format based on file extension. +func Load(path string) (any, error) { + format, err := detectFormat(path) + if err != nil { + return nil, err + } + + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open data file: %w", err) + } + defer func() { _ = f.Close() }() + + return LoadReader(f, format) +} + +// LoadReader reads data from a reader in the specified format. +func LoadReader(r io.Reader, format string) (any, error) { + content, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("failed to read data: %w", err) + } + + return Parse(content, format) +} + +// Parse parses data from bytes in the specified format. +func Parse(content []byte, format string) (any, error) { + var data any + + switch format { + case "json": + if err := json.Unmarshal(content, &data); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + case "yaml", "yml": + if err := yaml.Unmarshal(content, &data); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + // Convert YAML maps to string-keyed maps for consistency with JSON + data = normalizeYAML(data) + default: + return nil, fmt.Errorf("unsupported data format: %s", format) + } + + return data, nil +} + +// detectFormat determines the data format from the file extension. +// Returns an error for unrecognized file extensions. +func detectFormat(path string) (string, error) { + lower := strings.ToLower(path) + switch { + case strings.HasSuffix(lower, ".json"): + return "json", nil + case strings.HasSuffix(lower, ".yaml"): + return "yaml", nil + case strings.HasSuffix(lower, ".yml"): + return "yaml", nil + default: + return "", fmt.Errorf("unsupported file extension for %q: expected .json, .yaml, or .yml", path) + } +} + +// normalizeYAML converts YAML map[string]any and map[any]any to map[string]any +// for consistency with JSON parsing. +// +// Note: This function does not handle cyclic data structures. However, cycles +// cannot occur in practice because YAML parsers (yaml.v3) do not create cyclic +// structures when unmarshaling - YAML aliases are resolved to separate copies. +func normalizeYAML(v any) any { + switch val := v.(type) { + case map[string]any: + result := make(map[string]any, len(val)) + for k, v := range val { + result[k] = normalizeYAML(v) + } + return result + case map[any]any: + result := make(map[string]any, len(val)) + for k, v := range val { + result[fmt.Sprintf("%v", k)] = normalizeYAML(v) + } + return result + case []any: + result := make([]any, len(val)) + for i, v := range val { + result[i] = normalizeYAML(v) + } + return result + default: + return v + } +} diff --git a/internal/data/loader_test.go b/internal/data/loader_test.go new file mode 100644 index 0000000..c9d7e34 --- /dev/null +++ b/internal/data/loader_test.go @@ -0,0 +1,306 @@ +package data + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParse(t *testing.T) { + t.Run("parse JSON object", func(t *testing.T) { + content := []byte(`{"name": "test", "value": 123}`) + result, err := Parse(content, "json") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("Parse() result is not a map") + } + if m["name"] != "test" { + t.Errorf("Parse() name = %v, want 'test'", m["name"]) + } + if m["value"] != float64(123) { + t.Errorf("Parse() value = %v, want 123", m["value"]) + } + }) + + t.Run("parse JSON array", func(t *testing.T) { + content := []byte(`[1, 2, 3]`) + result, err := Parse(content, "json") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + arr, ok := result.([]any) + if !ok { + t.Fatalf("Parse() result is not an array") + } + if len(arr) != 3 { + t.Errorf("Parse() len = %d, want 3", len(arr)) + } + }) + + t.Run("parse YAML object", func(t *testing.T) { + content := []byte("name: test\nvalue: 123") + result, err := Parse(content, "yaml") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("Parse() result is not a map") + } + if m["name"] != "test" { + t.Errorf("Parse() name = %v, want 'test'", m["name"]) + } + if m["value"] != 123 { + t.Errorf("Parse() value = %v, want 123", m["value"]) + } + }) + + t.Run("parse YAML with nested structures", func(t *testing.T) { + content := []byte(` +name: test +nested: + key: value +items: + - first + - second +`) + result, err := Parse(content, "yaml") + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("Parse() result is not a map") + } + + nested, ok := m["nested"].(map[string]any) + if !ok { + t.Fatalf("Parse() nested is not a map") + } + if nested["key"] != "value" { + t.Errorf("Parse() nested.key = %v, want 'value'", nested["key"]) + } + + items, ok := m["items"].([]any) + if !ok { + t.Fatalf("Parse() items is not an array") + } + if len(items) != 2 { + t.Errorf("Parse() items len = %d, want 2", len(items)) + } + }) + + t.Run("parse invalid JSON", func(t *testing.T) { + content := []byte(`{invalid}`) + _, err := Parse(content, "json") + if err == nil { + t.Error("Parse() should return error for invalid JSON") + } + }) + + t.Run("parse invalid YAML", func(t *testing.T) { + content := []byte(":\n :\n invalid") + _, err := Parse(content, "yaml") + if err == nil { + t.Error("Parse() should return error for invalid YAML") + } + }) + + t.Run("unsupported format", func(t *testing.T) { + content := []byte(`test`) + _, err := Parse(content, "xml") + if err == nil { + t.Error("Parse() should return error for unsupported format") + } + }) +} + +func TestLoadReader(t *testing.T) { + t.Run("load JSON from reader", func(t *testing.T) { + reader := strings.NewReader(`{"key": "value"}`) + result, err := LoadReader(reader, "json") + if err != nil { + t.Fatalf("LoadReader() error = %v", err) + } + + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("LoadReader() result is not a map") + } + if m["key"] != "value" { + t.Errorf("LoadReader() key = %v, want 'value'", m["key"]) + } + }) + + t.Run("load YAML from reader", func(t *testing.T) { + reader := strings.NewReader("key: value") + result, err := LoadReader(reader, "yaml") + if err != nil { + t.Fatalf("LoadReader() error = %v", err) + } + + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("LoadReader() result is not a map") + } + if m["key"] != "value" { + t.Errorf("LoadReader() key = %v, want 'value'", m["key"]) + } + }) +} + +func TestLoad(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "render-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + t.Run("load JSON file", func(t *testing.T) { + path := filepath.Join(tmpDir, "test.json") + content := []byte(`{"name": "test"}`) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + result, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("Load() result is not a map") + } + if m["name"] != "test" { + t.Errorf("Load() name = %v, want 'test'", m["name"]) + } + }) + + t.Run("load YAML file", func(t *testing.T) { + path := filepath.Join(tmpDir, "test.yaml") + content := []byte("name: test") + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + result, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("Load() result is not a map") + } + if m["name"] != "test" { + t.Errorf("Load() name = %v, want 'test'", m["name"]) + } + }) + + t.Run("load YML file", func(t *testing.T) { + path := filepath.Join(tmpDir, "test.yml") + content := []byte("name: test") + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + result, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("Load() result is not a map") + } + if m["name"] != "test" { + t.Errorf("Load() name = %v, want 'test'", m["name"]) + } + }) + + t.Run("load non-existent file", func(t *testing.T) { + _, err := Load(filepath.Join(tmpDir, "nonexistent.json")) + if err == nil { + t.Error("Load() should return error for non-existent file") + } + }) +} + +func TestDetectFormat(t *testing.T) { + validTests := []struct { + path string + expected string + }{ + {"file.json", "json"}, + {"file.JSON", "json"}, + {"file.yaml", "yaml"}, + {"file.YAML", "yaml"}, + {"file.yml", "yaml"}, + {"file.YML", "yaml"}, + } + + for _, tt := range validTests { + t.Run(tt.path, func(t *testing.T) { + result, err := detectFormat(tt.path) + if err != nil { + t.Fatalf("detectFormat(%q) unexpected error: %v", tt.path, err) + } + if result != tt.expected { + t.Errorf("detectFormat(%q) = %q, want %q", tt.path, result, tt.expected) + } + }) + } + + // Test unsupported extensions return errors + unsupportedTests := []string{"file.txt", "file", "file.xml"} + for _, path := range unsupportedTests { + t.Run("unsupported_"+path, func(t *testing.T) { + _, err := detectFormat(path) + if err == nil { + t.Errorf("detectFormat(%q) should return error for unsupported extension", path) + } + }) + } +} + +func TestNormalizeYAML(t *testing.T) { + t.Run("normalize map[string]any", func(t *testing.T) { + input := map[string]any{ + "key": "value", + "nested": map[string]any{ + "inner": "data", + }, + } + result := normalizeYAML(input).(map[string]any) + if result["key"] != "value" { + t.Errorf("normalizeYAML() key = %v, want 'value'", result["key"]) + } + }) + + t.Run("normalize array", func(t *testing.T) { + input := []any{"a", "b", "c"} + result := normalizeYAML(input).([]any) + if len(result) != 3 { + t.Errorf("normalizeYAML() len = %d, want 3", len(result)) + } + }) + + t.Run("normalize scalar", func(t *testing.T) { + if result := normalizeYAML("test"); result != "test" { + t.Errorf("normalizeYAML(string) = %v, want 'test'", result) + } + if result := normalizeYAML(123); result != 123 { + t.Errorf("normalizeYAML(int) = %v, want 123", result) + } + }) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..e3c7df6 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,59 @@ +// Package engine provides the template rendering engine. +package engine + +import ( + "bytes" + "fmt" + "path/filepath" + "text/template" + + "github.com/wernerstrydom/render/internal/funcs" +) + +// Engine handles template parsing and execution. +type Engine struct { + funcMap template.FuncMap +} + +// New creates a new template engine with custom functions. +func New() *Engine { + return &Engine{ + funcMap: funcs.Map(), + } +} + +// RenderString renders a template string with the given data. +func (e *Engine) RenderString(tmpl string, data any) (string, error) { + t, err := template.New("template").Funcs(e.funcMap).Parse(tmpl) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} + +// RenderFile renders a template file with the given data. +func (e *Engine) RenderFile(path string, data any) (string, error) { + t, err := template.New("").Funcs(e.funcMap).ParseFiles(path) + if err != nil { + return "", fmt.Errorf("failed to parse template file %s: %w", path, err) + } + + // ParseFiles creates a template with the base name of the file + t = t.Lookup(filepath.Base(path)) + if t == nil { + return "", fmt.Errorf("template not found after parsing: %s", path) + } + + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template %s: %w", path, err) + } + + return buf.String(), nil +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..5d568ae --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,338 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNew(t *testing.T) { + eng := New() + if eng == nil { + t.Fatal("New() returned nil") + } + if eng.funcMap == nil { + t.Fatal("New() funcMap is nil") + } +} + +func TestRenderString(t *testing.T) { + eng := New() + + t.Run("simple template", func(t *testing.T) { + tmpl := "Hello, {{ .name }}!" + data := map[string]any{"name": "World"} + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + if result != "Hello, World!" { + t.Errorf("RenderString() = %q, want %q", result, "Hello, World!") + } + }) + + t.Run("template with custom functions", func(t *testing.T) { + tmpl := "{{ upper .name }}" + data := map[string]any{"name": "hello"} + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + if result != "HELLO" { + t.Errorf("RenderString() = %q, want %q", result, "HELLO") + } + }) + + t.Run("template with casing functions", func(t *testing.T) { + tests := []struct { + tmpl string + expected string + }{ + {"{{ camelCase .name }}", "helloWorld"}, + {"{{ pascalCase .name }}", "HelloWorld"}, + {"{{ snakeCase .name }}", "hello_world"}, + {"{{ kebabCase .name }}", "hello-world"}, + } + + data := map[string]any{"name": "hello world"} + + for _, tt := range tests { + result, err := eng.RenderString(tt.tmpl, data) + if err != nil { + t.Fatalf("RenderString(%q) error = %v", tt.tmpl, err) + } + if result != tt.expected { + t.Errorf("RenderString(%q) = %q, want %q", tt.tmpl, result, tt.expected) + } + } + }) + + t.Run("template with range", func(t *testing.T) { + tmpl := "{{ range .items }}{{ . }} {{ end }}" + data := map[string]any{"items": []string{"a", "b", "c"}} + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + if result != "a b c " { + t.Errorf("RenderString() = %q, want %q", result, "a b c ") + } + }) + + t.Run("template with conditionals", func(t *testing.T) { + tmpl := "{{ if .enabled }}yes{{ else }}no{{ end }}" + + result1, _ := eng.RenderString(tmpl, map[string]any{"enabled": true}) + if result1 != "yes" { + t.Errorf("RenderString(true) = %q, want 'yes'", result1) + } + + result2, _ := eng.RenderString(tmpl, map[string]any{"enabled": false}) + if result2 != "no" { + t.Errorf("RenderString(false) = %q, want 'no'", result2) + } + }) + + t.Run("template with math", func(t *testing.T) { + tmpl := "{{ add .a .b }}" + data := map[string]any{"a": 5, "b": 3} + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + if result != "8" { + t.Errorf("RenderString() = %q, want '8'", result) + } + }) + + t.Run("template with nested data", func(t *testing.T) { + tmpl := "{{ .user.name }} - {{ .user.email }}" + data := map[string]any{ + "user": map[string]any{ + "name": "Alice", + "email": "alice@example.com", + }, + } + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + if result != "Alice - alice@example.com" { + t.Errorf("RenderString() = %q", result) + } + }) + + t.Run("invalid template syntax", func(t *testing.T) { + tmpl := "{{ .name" + data := map[string]any{"name": "test"} + + _, err := eng.RenderString(tmpl, data) + if err == nil { + t.Error("RenderString() should return error for invalid syntax") + } + }) + + t.Run("missing field", func(t *testing.T) { + tmpl := "{{ .missing }}" + data := map[string]any{"name": "test"} + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + // Go templates output empty string for missing fields + if result != "" { + t.Errorf("RenderString() = %q, want ''", result) + } + }) + + t.Run("template with default function", func(t *testing.T) { + tmpl := `{{ default "default_value" .missing }}` + data := map[string]any{} + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + if result != "default_value" { + t.Errorf("RenderString() = %q, want 'default_value'", result) + } + }) + + t.Run("template with json function", func(t *testing.T) { + tmpl := "{{ toJson .data }}" + data := map[string]any{ + "data": map[string]any{"key": "value"}, + } + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + if result != `{"key":"value"}` { + t.Errorf("RenderString() = %q", result) + } + }) + + t.Run("template with regex", func(t *testing.T) { + tmpl := `{{ regexReplace "[0-9]+" "X" .text }}` + data := map[string]any{"text": "abc123def456"} + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + if result != "abcXdefX" { + t.Errorf("RenderString() = %q, want 'abcXdefX'", result) + } + }) +} + +func TestRenderFile(t *testing.T) { + eng := New() + + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "render-engine-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + t.Run("render file", func(t *testing.T) { + path := filepath.Join(tmpDir, "test.tmpl") + content := []byte("Hello, {{ .name }}!") + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + data := map[string]any{"name": "World"} + result, err := eng.RenderFile(path, data) + if err != nil { + t.Fatalf("RenderFile() error = %v", err) + } + if result != "Hello, World!" { + t.Errorf("RenderFile() = %q, want %q", result, "Hello, World!") + } + }) + + t.Run("render file with custom functions", func(t *testing.T) { + path := filepath.Join(tmpDir, "funcs.tmpl") + content := []byte("{{ upper .name }} {{ snakeCase .title }}") + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + data := map[string]any{"name": "hello", "title": "MyProject"} + result, err := eng.RenderFile(path, data) + if err != nil { + t.Fatalf("RenderFile() error = %v", err) + } + if result != "HELLO my_project" { + t.Errorf("RenderFile() = %q", result) + } + }) + + t.Run("render non-existent file", func(t *testing.T) { + _, err := eng.RenderFile(filepath.Join(tmpDir, "nonexistent.tmpl"), nil) + if err == nil { + t.Error("RenderFile() should return error for non-existent file") + } + }) + + t.Run("render file with invalid template", func(t *testing.T) { + path := filepath.Join(tmpDir, "invalid.tmpl") + content := []byte("{{ .name") + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err := eng.RenderFile(path, map[string]any{"name": "test"}) + if err == nil { + t.Error("RenderFile() should return error for invalid template") + } + }) +} + +func TestComplexTemplates(t *testing.T) { + eng := New() + + t.Run("generate code", func(t *testing.T) { + tmpl := `package {{ .package }} + +type {{ pascalCase .name }} struct { +{{- range .fields }} + {{ pascalCase .name }} {{ .type }} +{{- end }} +} +` + data := map[string]any{ + "package": "models", + "name": "user profile", + "fields": []map[string]any{ + {"name": "user name", "type": "string"}, + {"name": "email address", "type": "string"}, + {"name": "age", "type": "int"}, + }, + } + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + + expected := `package models + +type UserProfile struct { + UserName string + EmailAddress string + Age int +} +` + if result != expected { + t.Errorf("RenderString() =\n%s\nwant:\n%s", result, expected) + } + }) + + t.Run("generate config", func(t *testing.T) { + tmpl := `database: + host: {{ .db.host }} + port: {{ .db.port }} + name: {{ .db.name }} + connection_string: "postgresql://{{ .db.host }}:{{ .db.port }}/{{ .db.name }}" +` + data := map[string]any{ + "db": map[string]any{ + "host": "localhost", + "port": 5432, + "name": "myapp", + }, + } + + result, err := eng.RenderString(tmpl, data) + if err != nil { + t.Fatalf("RenderString() error = %v", err) + } + + if !contains(result, "connection_string: \"postgresql://localhost:5432/myapp\"") { + t.Errorf("RenderString() missing connection string:\n%s", result) + } + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr, 0)) +} + +func containsAt(s, substr string, start int) bool { + for i := start; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/funcs/funcs.go b/internal/funcs/funcs.go new file mode 100644 index 0000000..9a0c55e --- /dev/null +++ b/internal/funcs/funcs.go @@ -0,0 +1,1194 @@ +// Package funcs provides custom template functions for text manipulation. +package funcs + +import ( + "encoding/json" + "fmt" + "maps" + "math" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + "text/template" + "unicode" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + "golang.org/x/text/unicode/norm" +) + +// Map returns a template.FuncMap with all custom functions. +func Map() template.FuncMap { + return template.FuncMap{ + // Casing functions + "lower": strings.ToLower, + "upper": strings.ToUpper, + "title": toTitle, + "camelCase": toCamelCase, + "pascalCase": toPascalCase, + "snakeCase": toSnakeCase, + "kebabCase": toKebabCase, + "upperSnakeCase": toUpperSnakeCase, + "upperKebabCase": toUpperKebabCase, + + // Trimming functions + "trim": strings.TrimSpace, + "trimPrefix": strings.TrimPrefix, + "trimSuffix": strings.TrimSuffix, + "trimLeft": strings.TrimLeft, + "trimRight": strings.TrimRight, + "trimChars": strings.Trim, + + // String manipulation + "replace": replace, + "replaceN": replaceN, + "contains": strings.Contains, + "hasPrefix": strings.HasPrefix, + "hasSuffix": strings.HasSuffix, + "repeat": strings.Repeat, + "reverse": reverseString, + "substr": substring, + "truncate": truncate, + "padLeft": padLeft, + "padRight": padRight, + "center": center, + "wrap": wordWrap, + "indent": indent, + "nindent": nindent, + + // Splitting and joining + "split": strings.Split, + "splitN": strings.SplitN, + "join": join, + "lines": lines, + "first": first, + "last": last, + "rest": rest, + "initial": initial, + "nth": nth, + + // Concatenation + "concat": concat, + "cat": cat, + + // Conversion functions + "toString": toString, + "toInt": toInt, + "toInt64": toInt64, + "toFloat": toFloat, + "toBool": toBool, + "toJson": toJSON, + "toPrettyJson": toPrettyJSON, + "fromJson": fromJSON, + + // Unicode normalization + "nfc": normalizeNFC, + "nfd": normalizeNFD, + "nfkc": normalizeNFKC, + "nfkd": normalizeNFKD, + "ascii": toASCII, + "slug": toSlug, + + // Formatting + "quote": quote, + "squote": squote, + "printf": fmt.Sprintf, + + // Comparison and logic + "eq": eq, + "ne": ne, + "lt": lt, + "le": le, + "gt": gt, + "ge": ge, + "and": and, + "or": or, + "not": not, + "default": defaultVal, + "empty": empty, + "coalesce": coalesce, + "ternary": ternary, + + // Collection functions + "list": list, + "dict": dict, + "keys": keys, + "values": values, + "hasKey": hasKey, + "get": get, + "set": set, + "unset": unset, + "merge": merge, + "append": appendList, + "prepend": prependList, + "uniq": uniq, + "sortAlpha": sortAlpha, + "len": length, + + // Math functions + "add": add, + "sub": sub, + "mul": mul, + "div": div, + "mod": mod, + "max": max, + "min": min, + "floor": floor, + "ceil": ceil, + "round": round, + + // Regex functions + "regexMatch": regexMatch, + "regexFind": regexFind, + "regexFindAll": regexFindAll, + "regexReplace": regexReplace, + "regexSplit": regexSplit, + + // Counting + "count": count, + "countWords": countWords, + "countLines": countLines, + } +} + +// Casing functions + +func toTitle(s string) string { + caser := cases.Title(language.English) + return caser.String(strings.ToLower(s)) +} + +func toCamelCase(s string) string { + words := splitIntoWords(s) + if len(words) == 0 { + return "" + } + result := strings.ToLower(words[0]) + for _, word := range words[1:] { + result += capitalizeFirst(word) + } + return result +} + +func toPascalCase(s string) string { + words := splitIntoWords(s) + var result string + for _, word := range words { + result += capitalizeFirst(word) + } + return result +} + +func toSnakeCase(s string) string { + words := splitIntoWords(s) + for i := range words { + words[i] = strings.ToLower(words[i]) + } + return strings.Join(words, "_") +} + +func toKebabCase(s string) string { + words := splitIntoWords(s) + for i := range words { + words[i] = strings.ToLower(words[i]) + } + return strings.Join(words, "-") +} + +func toUpperSnakeCase(s string) string { + words := splitIntoWords(s) + for i := range words { + words[i] = strings.ToUpper(words[i]) + } + return strings.Join(words, "_") +} + +func toUpperKebabCase(s string) string { + words := splitIntoWords(s) + for i := range words { + words[i] = strings.ToUpper(words[i]) + } + return strings.Join(words, "-") +} + +// splitIntoWords splits a string into words, handling various formats +func splitIntoWords(s string) []string { + // First, normalize unicode + s = normalizeNFC(s) + + // Handle existing separators (space, underscore, hyphen) + s = strings.ReplaceAll(s, "_", " ") + s = strings.ReplaceAll(s, "-", " ") + + // Insert space before uppercase letters in camelCase/PascalCase + var result strings.Builder + runes := []rune(s) + for i, r := range runes { + if i > 0 && unicode.IsUpper(r) { + prev := runes[i-1] + // Insert space if previous is lowercase, or if we're at the start of a new word + // in an acronym (e.g., "XMLParser" -> "XML Parser") + if unicode.IsLower(prev) { + result.WriteRune(' ') + } else if i+1 < len(runes) && unicode.IsLower(runes[i+1]) { + result.WriteRune(' ') + } + } + result.WriteRune(r) + } + + // Split by spaces and filter empty strings + parts := strings.Fields(result.String()) + var words []string + for _, p := range parts { + if p != "" { + words = append(words, p) + } + } + return words +} + +func capitalizeFirst(s string) string { + if s == "" { + return "" + } + runes := []rune(strings.ToLower(s)) + runes[0] = unicode.ToUpper(runes[0]) + return string(runes) +} + +// String manipulation functions + +// replace replaces all occurrences of old with new in s. +// Arguments are ordered (old, new, s) for template pipeline compatibility. +func replace(old, new, s string) string { + return strings.ReplaceAll(s, old, new) +} + +// replaceN replaces n occurrences of old with new in s. +// Arguments are ordered (old, new, n, s) for template pipeline compatibility. +func replaceN(old, new string, n int, s string) string { + return strings.Replace(s, old, new, n) +} + +func reverseString(s string) string { + runes := []rune(s) + slices.Reverse(runes) + return string(runes) +} + +func substring(s string, start, end int) string { + runes := []rune(s) + if start < 0 { + start = len(runes) + start + } + if end < 0 { + end = len(runes) + end + } + if start < 0 { + start = 0 + } + if end > len(runes) { + end = len(runes) + } + if start >= end { + return "" + } + return string(runes[start:end]) +} + +func truncate(s string, length int) string { + runes := []rune(s) + if len(runes) <= length { + return s + } + return string(runes[:length]) +} + +func padLeft(s string, length int, pad string) string { + if pad == "" { + pad = " " + } + sRunes := []rune(s) + sLen := len(sRunes) + // If string is already >= target length, return as-is + if sLen >= length { + return s + } + // Calculate padding needed + padRunes := []rune(pad) + padLen := len(padRunes) + needed := length - sLen + // Build result efficiently + result := make([]rune, length) + // Fill padding from the start + for i := 0; i < needed; i++ { + result[i] = padRunes[i%padLen] + } + // Copy original string + copy(result[needed:], sRunes) + return string(result) +} + +func padRight(s string, length int, pad string) string { + if pad == "" { + pad = " " + } + runes := []rune(s) + // If string is already >= target length, return as-is + if len(runes) >= length { + return s + } + // Pad to target length + padRunes := []rune(pad) + for len(runes) < length { + runes = append(runes, padRunes...) + } + // Trim excess padding from the right + return string(runes[:length]) +} + +func center(s string, length int, pad string) string { + if pad == "" { + pad = " " + } + sLen := len([]rune(s)) + if sLen >= length { + return s + } + leftPad := (length - sLen) / 2 + rightPad := length - sLen - leftPad + return strings.Repeat(pad, leftPad) + s + strings.Repeat(pad, rightPad) +} + +func wordWrap(s string, width int) string { + if width <= 0 { + return s + } + var result strings.Builder + currentLine := 0 + words := strings.Fields(s) + for _, word := range words { + wordLen := len([]rune(word)) + if currentLine+wordLen > width && currentLine > 0 { + result.WriteString("\n") + currentLine = 0 + } + if currentLine > 0 { + result.WriteString(" ") + currentLine++ + } + result.WriteString(word) + currentLine += wordLen + } + return result.String() +} + +func indent(spaces int, s string) string { + pad := strings.Repeat(" ", spaces) + return pad + strings.ReplaceAll(s, "\n", "\n"+pad) +} + +func nindent(spaces int, s string) string { + return "\n" + indent(spaces, s) +} + +// Splitting and joining functions + +func join(sep string, items any) string { + switch v := items.(type) { + case []string: + return strings.Join(v, sep) + case []any: + strs := make([]string, len(v)) + for i, item := range v { + strs[i] = toString(item) + } + return strings.Join(strs, sep) + default: + return toString(items) + } +} + +func lines(s string) []string { + return strings.Split(s, "\n") +} + +func first(items any) any { + switch v := items.(type) { + case []any: + if len(v) > 0 { + return v[0] + } + case []string: + if len(v) > 0 { + return v[0] + } + case string: + if len(v) > 0 { + return string([]rune(v)[0]) + } + } + return nil +} + +func last(items any) any { + switch v := items.(type) { + case []any: + if len(v) > 0 { + return v[len(v)-1] + } + case []string: + if len(v) > 0 { + return v[len(v)-1] + } + case string: + runes := []rune(v) + if len(runes) > 0 { + return string(runes[len(runes)-1]) + } + } + return nil +} + +func rest(items any) any { + switch v := items.(type) { + case []any: + if len(v) > 1 { + return v[1:] + } + return []any{} + case []string: + if len(v) > 1 { + return v[1:] + } + return []string{} + case string: + runes := []rune(v) + if len(runes) > 1 { + return string(runes[1:]) + } + return "" + } + return nil +} + +func initial(items any) any { + switch v := items.(type) { + case []any: + if len(v) > 1 { + return v[:len(v)-1] + } + return []any{} + case []string: + if len(v) > 1 { + return v[:len(v)-1] + } + return []string{} + case string: + runes := []rune(v) + if len(runes) > 1 { + return string(runes[:len(runes)-1]) + } + return "" + } + return nil +} + +func nth(n int, items any) any { + switch v := items.(type) { + case []any: + if n >= 0 && n < len(v) { + return v[n] + } + case []string: + if n >= 0 && n < len(v) { + return v[n] + } + case string: + runes := []rune(v) + if n >= 0 && n < len(runes) { + return string(runes[n]) + } + } + return nil +} + +// Concatenation functions + +func concat(items ...string) string { + return strings.Join(items, "") +} + +func cat(items ...any) string { + strs := make([]string, len(items)) + for i, item := range items { + strs[i] = toString(item) + } + return strings.Join(strs, " ") +} + +// Conversion functions + +func toString(v any) string { + switch val := v.(type) { + case string: + return val + case []byte: + return string(val) + case nil: + return "" + case fmt.Stringer: + return val.String() + default: + return fmt.Sprintf("%v", v) + } +} + +func toInt(v any) (int, error) { + switch val := v.(type) { + case int: + return val, nil + case int64: + return int(val), nil + case float64: + return int(val), nil + case string: + i, err := strconv.Atoi(val) + if err != nil { + return 0, fmt.Errorf("cannot convert %q to int: %w", val, err) + } + return i, nil + case bool: + if val { + return 1, nil + } + return 0, nil + default: + return 0, fmt.Errorf("cannot convert %T to int", v) + } +} + +func toInt64(v any) (int64, error) { + switch val := v.(type) { + case int: + return int64(val), nil + case int64: + return val, nil + case float64: + return int64(val), nil + case string: + i, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return 0, fmt.Errorf("cannot convert %q to int64: %w", val, err) + } + return i, nil + case bool: + if val { + return 1, nil + } + return 0, nil + default: + return 0, fmt.Errorf("cannot convert %T to int64", v) + } +} + +func toFloat(v any) (float64, error) { + switch val := v.(type) { + case float64: + return val, nil + case float32: + return float64(val), nil + case int: + return float64(val), nil + case int64: + return float64(val), nil + case string: + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return 0, fmt.Errorf("cannot convert %q to float: %w", val, err) + } + return f, nil + default: + return 0, fmt.Errorf("cannot convert %T to float", v) + } +} + +func toBool(v any) (bool, error) { + switch val := v.(type) { + case bool: + return val, nil + case string: + b, err := strconv.ParseBool(val) + if err != nil { + return false, fmt.Errorf("cannot convert %q to bool: %w", val, err) + } + return b, nil + case int: + return val != 0, nil + case int64: + return val != 0, nil + case float64: + return val != 0, nil + default: + return v != nil, nil + } +} + +// Internal helper functions for math operations (panic on invalid input, +// which is caught by the template engine and returned as an error) +func mustFloat(v any) float64 { + switch val := v.(type) { + case float64: + return val + case float32: + return float64(val) + case int: + return float64(val) + case int64: + return float64(val) + case string: + f, err := strconv.ParseFloat(val, 64) + if err != nil { + panic(fmt.Sprintf("cannot convert %q to float: %v", val, err)) + } + return f + default: + panic(fmt.Sprintf("cannot convert %T to float", v)) + } +} + +func mustInt(v any) int { + switch val := v.(type) { + case int: + return val + case int64: + return int(val) + case float64: + return int(val) + case string: + i, err := strconv.Atoi(val) + if err != nil { + panic(fmt.Sprintf("cannot convert %q to int: %v", val, err)) + } + return i + case bool: + if val { + return 1 + } + return 0 + default: + panic(fmt.Sprintf("cannot convert %T to int", v)) + } +} + +func mustBool(v any) bool { + switch val := v.(type) { + case bool: + return val + case string: + b, err := strconv.ParseBool(val) + if err != nil { + panic(fmt.Sprintf("cannot convert %q to bool: %v", val, err)) + } + return b + case int: + return val != 0 + case int64: + return val != 0 + case float64: + return val != 0 + default: + panic(fmt.Sprintf("cannot convert %T to bool", v)) + } +} + +func toJSON(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("failed to marshal to JSON: %w", err) + } + return string(b), nil +} + +func toPrettyJSON(v any) (string, error) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal to JSON: %w", err) + } + return string(b), nil +} + +func fromJSON(s string) (any, error) { + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + return v, nil +} + +// Unicode normalization functions + +func normalizeNFC(s string) string { + return norm.NFC.String(s) +} + +func normalizeNFD(s string) string { + return norm.NFD.String(s) +} + +func normalizeNFKC(s string) string { + return norm.NFKC.String(s) +} + +func normalizeNFKD(s string) string { + return norm.NFKD.String(s) +} + +func toASCII(s string) string { + // Normalize to NFKD to decompose characters + s = norm.NFKD.String(s) + // Remove non-ASCII characters + var result strings.Builder + for _, r := range s { + if r < 128 { + result.WriteRune(r) + } + } + return result.String() +} + +func toSlug(s string) string { + // Normalize and convert to ASCII + s = toASCII(s) + // Convert to lowercase + s = strings.ToLower(s) + // Replace non-alphanumeric with hyphens + reg := regexp.MustCompile(`[^a-z0-9]+`) + s = reg.ReplaceAllString(s, "-") + // Trim hyphens from ends + s = strings.Trim(s, "-") + return s +} + +// Formatting functions + +func quote(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` +} + +func squote(s string) string { + return `'` + strings.ReplaceAll(s, `'`, `\'`) + `'` +} + +// Comparison and logic functions + +func eq(a, b any) bool { + return reflect.DeepEqual(a, b) +} + +func ne(a, b any) bool { + return !reflect.DeepEqual(a, b) +} + +func lt(a, b any) bool { + return mustFloat(a) < mustFloat(b) +} + +func le(a, b any) bool { + return mustFloat(a) <= mustFloat(b) +} + +func gt(a, b any) bool { + return mustFloat(a) > mustFloat(b) +} + +func ge(a, b any) bool { + return mustFloat(a) >= mustFloat(b) +} + +func and(a, b any) bool { + return mustBool(a) && mustBool(b) +} + +func or(a, b any) bool { + return mustBool(a) || mustBool(b) +} + +func not(a any) bool { + return !mustBool(a) +} + +func defaultVal(def, val any) any { + if empty(val) { + return def + } + return val +} + +func empty(v any) bool { + if v == nil { + return true + } + switch val := v.(type) { + case string: + return val == "" + case []any: + return len(val) == 0 + case []string: + return len(val) == 0 + case map[string]any: + return len(val) == 0 + case bool: + return !val + case int: + return val == 0 + case int64: + return val == 0 + case float64: + return val == 0 + default: + return false + } +} + +func coalesce(items ...any) any { + for _, item := range items { + if !empty(item) { + return item + } + } + return nil +} + +func ternary(cond bool, trueVal, falseVal any) any { + if cond { + return trueVal + } + return falseVal +} + +// Collection functions + +func list(items ...any) []any { + return items +} + +func dict(pairs ...any) map[string]any { + m := make(map[string]any) + for i := 0; i+1 < len(pairs); i += 2 { + key := toString(pairs[i]) + m[key] = pairs[i+1] + } + return m +} + +func keys(m any) []string { + switch v := m.(type) { + case map[string]any: + return slices.Collect(maps.Keys(v)) + default: + return nil + } +} + +func values(m any) []any { + switch v := m.(type) { + case map[string]any: + return slices.Collect(maps.Values(v)) + default: + return nil + } +} + +func hasKey(m any, key string) bool { + switch v := m.(type) { + case map[string]any: + _, ok := v[key] + return ok + default: + return false + } +} + +func get(m any, key string) any { + switch v := m.(type) { + case map[string]any: + return v[key] + default: + return nil + } +} + +func set(m any, key string, val any) map[string]any { + switch v := m.(type) { + case map[string]any: + // Create a copy to avoid modifying the input map + result := maps.Clone(v) + result[key] = val + return result + default: + return map[string]any{key: val} + } +} + +func unset(m any, key string) map[string]any { + switch v := m.(type) { + case map[string]any: + // Create a copy to avoid modifying the input map + result := make(map[string]any, len(v)) + for k, val := range v { + if k != key { + result[k] = val + } + } + return result + default: + return map[string]any{} + } +} + +func merge(inputMaps ...any) map[string]any { + result := make(map[string]any) + for _, m := range inputMaps { + if v, ok := m.(map[string]any); ok { + maps.Copy(result, v) + } + } + return result +} + +func appendList(items any, val any) []any { + switch v := items.(type) { + case []any: + return append(v, val) + default: + return []any{items, val} + } +} + +func prependList(items any, val any) []any { + switch v := items.(type) { + case []any: + return append([]any{val}, v...) + default: + return []any{val, items} + } +} + +func uniq(items any) []any { + switch v := items.(type) { + case []any: + seen := make(map[string]bool) + result := make([]any, 0) + for _, item := range v { + key := fmt.Sprintf("%v", item) + if !seen[key] { + seen[key] = true + result = append(result, item) + } + } + return result + case []string: + seen := make(map[string]bool) + result := make([]any, 0) + for _, item := range v { + if !seen[item] { + seen[item] = true + result = append(result, item) + } + } + return result + default: + return []any{items} + } +} + +func sortAlpha(items any) []string { + var strs []string + switch v := items.(type) { + case []any: + for _, item := range v { + strs = append(strs, toString(item)) + } + case []string: + // Make a copy to avoid modifying the input + strs = make([]string, len(v)) + copy(strs, v) + default: + return nil + } + slices.Sort(strs) + return strs +} + +func length(items any) int { + switch v := items.(type) { + case string: + return len([]rune(v)) + case []any: + return len(v) + case []string: + return len(v) + case map[string]any: + return len(v) + default: + return 0 + } +} + +// Math functions + +func add(a, b any) any { + af, bf := mustFloat(a), mustFloat(b) + result := af + bf + if result == float64(int(result)) { + return int(result) + } + return result +} + +func sub(a, b any) any { + af, bf := mustFloat(a), mustFloat(b) + result := af - bf + if result == float64(int(result)) { + return int(result) + } + return result +} + +func mul(a, b any) any { + af, bf := mustFloat(a), mustFloat(b) + result := af * bf + if result == float64(int(result)) { + return int(result) + } + return result +} + +func div(a, b any) (any, error) { + af, bf := mustFloat(a), mustFloat(b) + if bf == 0 { + return 0, fmt.Errorf("division by zero in div operation") + } + result := af / bf + if result == float64(int(result)) { + return int(result), nil + } + return result, nil +} + +func mod(a, b any) (int, error) { + bi := mustInt(b) + if bi == 0 { + return 0, fmt.Errorf("division by zero in mod operation") + } + return mustInt(a) % bi, nil +} + +func max(items ...any) any { + if len(items) == 0 { + return nil + } + maxVal := mustFloat(items[0]) + for _, item := range items[1:] { + if v := mustFloat(item); v > maxVal { + maxVal = v + } + } + if maxVal == float64(int(maxVal)) { + return int(maxVal) + } + return maxVal +} + +func min(items ...any) any { + if len(items) == 0 { + return nil + } + minVal := mustFloat(items[0]) + for _, item := range items[1:] { + if v := mustFloat(item); v < minVal { + minVal = v + } + } + if minVal == float64(int(minVal)) { + return int(minVal) + } + return minVal +} + +func floor(v any) int { + return int(math.Floor(mustFloat(v))) +} + +func ceil(v any) int { + return int(math.Ceil(mustFloat(v))) +} + +func round(v any) int { + return int(math.Round(mustFloat(v))) +} + +// Regex functions +// These functions return (result, error) to properly surface regex compilation errors +// Go's text/template will catch the error and report it to the user + +func regexMatch(pattern, s string) (bool, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return false, fmt.Errorf("invalid regex pattern %q: %w", pattern, err) + } + return re.MatchString(s), nil +} + +func regexFind(pattern, s string) (string, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return "", fmt.Errorf("invalid regex pattern %q: %w", pattern, err) + } + return re.FindString(s), nil +} + +func regexFindAll(pattern, s string, n int) ([]string, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex pattern %q: %w", pattern, err) + } + return re.FindAllString(s, n), nil +} + +func regexReplace(pattern, replacement, s string) (string, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return "", fmt.Errorf("invalid regex pattern %q: %w", pattern, err) + } + return re.ReplaceAllString(s, replacement), nil +} + +func regexSplit(pattern, s string, n int) ([]string, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex pattern %q: %w", pattern, err) + } + return re.Split(s, n), nil +} + +// Counting functions + +func count(substr, s string) int { + return strings.Count(s, substr) +} + +func countWords(s string) int { + return len(strings.Fields(s)) +} + +func countLines(s string) int { + if s == "" { + return 0 + } + return strings.Count(s, "\n") + 1 +} diff --git a/internal/funcs/funcs_test.go b/internal/funcs/funcs_test.go new file mode 100644 index 0000000..946592e --- /dev/null +++ b/internal/funcs/funcs_test.go @@ -0,0 +1,899 @@ +package funcs + +import ( + "testing" +) + +func TestCasingFunctions(t *testing.T) { + tests := []struct { + name string + fn func(string) string + input string + expected string + }{ + // camelCase + {"camelCase from space", toCamelCase, "hello world", "helloWorld"}, + {"camelCase from snake", toCamelCase, "hello_world", "helloWorld"}, + {"camelCase from kebab", toCamelCase, "hello-world", "helloWorld"}, + {"camelCase from pascal", toCamelCase, "HelloWorld", "helloWorld"}, + {"camelCase single word", toCamelCase, "hello", "hello"}, + {"camelCase empty", toCamelCase, "", ""}, + {"camelCase with acronym", toCamelCase, "XMLParser", "xmlParser"}, + + // pascalCase + {"pascalCase from space", toPascalCase, "hello world", "HelloWorld"}, + {"pascalCase from snake", toPascalCase, "hello_world", "HelloWorld"}, + {"pascalCase from kebab", toPascalCase, "hello-world", "HelloWorld"}, + {"pascalCase from camel", toPascalCase, "helloWorld", "HelloWorld"}, + {"pascalCase single word", toPascalCase, "hello", "Hello"}, + {"pascalCase empty", toPascalCase, "", ""}, + + // snakeCase + {"snakeCase from space", toSnakeCase, "hello world", "hello_world"}, + {"snakeCase from camel", toSnakeCase, "helloWorld", "hello_world"}, + {"snakeCase from pascal", toSnakeCase, "HelloWorld", "hello_world"}, + {"snakeCase from kebab", toSnakeCase, "hello-world", "hello_world"}, + {"snakeCase single word", toSnakeCase, "hello", "hello"}, + {"snakeCase empty", toSnakeCase, "", ""}, + + // kebabCase + {"kebabCase from space", toKebabCase, "hello world", "hello-world"}, + {"kebabCase from camel", toKebabCase, "helloWorld", "hello-world"}, + {"kebabCase from pascal", toKebabCase, "HelloWorld", "hello-world"}, + {"kebabCase from snake", toKebabCase, "hello_world", "hello-world"}, + {"kebabCase single word", toKebabCase, "hello", "hello"}, + {"kebabCase empty", toKebabCase, "", ""}, + + // upperSnakeCase + {"upperSnakeCase from space", toUpperSnakeCase, "hello world", "HELLO_WORLD"}, + {"upperSnakeCase from camel", toUpperSnakeCase, "helloWorld", "HELLO_WORLD"}, + {"upperSnakeCase single word", toUpperSnakeCase, "hello", "HELLO"}, + + // upperKebabCase + {"upperKebabCase from space", toUpperKebabCase, "hello world", "HELLO-WORLD"}, + {"upperKebabCase from camel", toUpperKebabCase, "helloWorld", "HELLO-WORLD"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.fn(tt.input) + if result != tt.expected { + t.Errorf("got %q, want %q", result, tt.expected) + } + }) + } +} + +func TestStringManipulation(t *testing.T) { + t.Run("reverseString", func(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "olleh"}, + {"", ""}, + {"a", "a"}, + {"ab", "ba"}, + {"日本語", "語本日"}, + } + for _, tt := range tests { + result := reverseString(tt.input) + if result != tt.expected { + t.Errorf("reverseString(%q) = %q, want %q", tt.input, result, tt.expected) + } + } + }) + + t.Run("substring", func(t *testing.T) { + tests := []struct { + input string + start int + end int + expected string + }{ + {"hello", 0, 5, "hello"}, + {"hello", 1, 4, "ell"}, + {"hello", 0, 0, ""}, + {"hello", -2, 5, "lo"}, + {"hello", 0, -1, "hell"}, + {"hello", 0, 100, "hello"}, + {"日本語", 0, 2, "日本"}, + } + for _, tt := range tests { + result := substring(tt.input, tt.start, tt.end) + if result != tt.expected { + t.Errorf("substring(%q, %d, %d) = %q, want %q", tt.input, tt.start, tt.end, result, tt.expected) + } + } + }) + + t.Run("truncate", func(t *testing.T) { + tests := []struct { + input string + length int + expected string + }{ + {"hello world", 5, "hello"}, + {"hello", 10, "hello"}, + {"hello", 0, ""}, + {"日本語", 2, "日本"}, + } + for _, tt := range tests { + result := truncate(tt.input, tt.length) + if result != tt.expected { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.length, result, tt.expected) + } + } + }) + + t.Run("padLeft", func(t *testing.T) { + tests := []struct { + input string + length int + pad string + expected string + }{ + {"hello", 10, " ", " hello"}, + {"hello", 10, "0", "00000hello"}, + {"hello", 3, " ", "hello"}, // string longer than target, return as-is + {"hello", 5, " ", "hello"}, + } + for _, tt := range tests { + result := padLeft(tt.input, tt.length, tt.pad) + if result != tt.expected { + t.Errorf("padLeft(%q, %d, %q) = %q, want %q", tt.input, tt.length, tt.pad, result, tt.expected) + } + } + }) + + t.Run("padRight", func(t *testing.T) { + tests := []struct { + input string + length int + pad string + expected string + }{ + {"hello", 10, " ", "hello "}, + {"hello", 10, "0", "hello00000"}, + {"hello", 3, " ", "hello"}, // string longer than target, return as-is + {"hello", 5, " ", "hello"}, + } + for _, tt := range tests { + result := padRight(tt.input, tt.length, tt.pad) + if result != tt.expected { + t.Errorf("padRight(%q, %d, %q) = %q, want %q", tt.input, tt.length, tt.pad, result, tt.expected) + } + } + }) + + t.Run("center", func(t *testing.T) { + tests := []struct { + input string + length int + pad string + expected string + }{ + {"hello", 11, " ", " hello "}, + {"hello", 10, "-", "--hello---"}, + {"hello", 5, " ", "hello"}, + {"hello", 3, " ", "hello"}, + } + for _, tt := range tests { + result := center(tt.input, tt.length, tt.pad) + if result != tt.expected { + t.Errorf("center(%q, %d, %q) = %q, want %q", tt.input, tt.length, tt.pad, result, tt.expected) + } + } + }) + + t.Run("indent", func(t *testing.T) { + tests := []struct { + spaces int + input string + expected string + }{ + {2, "hello", " hello"}, + {2, "hello\nworld", " hello\n world"}, + {0, "hello", "hello"}, + } + for _, tt := range tests { + result := indent(tt.spaces, tt.input) + if result != tt.expected { + t.Errorf("indent(%d, %q) = %q, want %q", tt.spaces, tt.input, result, tt.expected) + } + } + }) + + t.Run("nindent", func(t *testing.T) { + result := nindent(2, "hello") + expected := "\n hello" + if result != expected { + t.Errorf("nindent(2, %q) = %q, want %q", "hello", result, expected) + } + }) +} + +func TestSplittingJoining(t *testing.T) { + t.Run("join", func(t *testing.T) { + tests := []struct { + sep string + items any + expected string + }{ + {", ", []string{"a", "b", "c"}, "a, b, c"}, + {"-", []any{"a", "b", "c"}, "a-b-c"}, + {"", []string{"a", "b"}, "ab"}, + } + for _, tt := range tests { + result := join(tt.sep, tt.items) + if result != tt.expected { + t.Errorf("join(%q, %v) = %q, want %q", tt.sep, tt.items, result, tt.expected) + } + } + }) + + t.Run("first", func(t *testing.T) { + if result := first([]any{"a", "b", "c"}); result != "a" { + t.Errorf("first([]any) = %v, want 'a'", result) + } + if result := first([]string{"a", "b", "c"}); result != "a" { + t.Errorf("first([]string) = %v, want 'a'", result) + } + if result := first("abc"); result != "a" { + t.Errorf("first(string) = %v, want 'a'", result) + } + if result := first([]any{}); result != nil { + t.Errorf("first(empty) = %v, want nil", result) + } + }) + + t.Run("last", func(t *testing.T) { + if result := last([]any{"a", "b", "c"}); result != "c" { + t.Errorf("last([]any) = %v, want 'c'", result) + } + if result := last([]string{"a", "b", "c"}); result != "c" { + t.Errorf("last([]string) = %v, want 'c'", result) + } + if result := last("abc"); result != "c" { + t.Errorf("last(string) = %v, want 'c'", result) + } + }) + + t.Run("rest", func(t *testing.T) { + result := rest([]any{"a", "b", "c"}).([]any) + if len(result) != 2 || result[0] != "b" || result[1] != "c" { + t.Errorf("rest([]any) = %v, want [b c]", result) + } + }) + + t.Run("initial", func(t *testing.T) { + result := initial([]any{"a", "b", "c"}).([]any) + if len(result) != 2 || result[0] != "a" || result[1] != "b" { + t.Errorf("initial([]any) = %v, want [a b]", result) + } + }) + + t.Run("nth", func(t *testing.T) { + if result := nth(1, []any{"a", "b", "c"}); result != "b" { + t.Errorf("nth(1, []any) = %v, want 'b'", result) + } + if result := nth(10, []any{"a", "b", "c"}); result != nil { + t.Errorf("nth(10, []any) = %v, want nil", result) + } + }) +} + +func TestConversionFunctions(t *testing.T) { + t.Run("toString", func(t *testing.T) { + tests := []struct { + input any + expected string + }{ + {"hello", "hello"}, + {123, "123"}, + {12.5, "12.5"}, + {true, "true"}, + {nil, ""}, + {[]byte("bytes"), "bytes"}, + } + for _, tt := range tests { + result := toString(tt.input) + if result != tt.expected { + t.Errorf("toString(%v) = %q, want %q", tt.input, result, tt.expected) + } + } + }) + + t.Run("toInt", func(t *testing.T) { + tests := []struct { + input any + expected int + }{ + {123, 123}, + {int64(123), 123}, + {float64(123.9), 123}, + {"123", 123}, + {true, 1}, + {false, 0}, + } + for _, tt := range tests { + result, err := toInt(tt.input) + if err != nil { + t.Errorf("toInt(%v) unexpected error: %v", tt.input, err) + continue + } + if result != tt.expected { + t.Errorf("toInt(%v) = %d, want %d", tt.input, result, tt.expected) + } + } + + // Test error case + _, err := toInt("not a number") + if err == nil { + t.Error("toInt('not a number') should return error") + } + }) + + t.Run("toFloat", func(t *testing.T) { + tests := []struct { + input any + expected float64 + }{ + {123, 123.0}, + {12.5, 12.5}, + {"12.5", 12.5}, + {int64(123), 123.0}, + } + for _, tt := range tests { + result, err := toFloat(tt.input) + if err != nil { + t.Errorf("toFloat(%v) unexpected error: %v", tt.input, err) + continue + } + if result != tt.expected { + t.Errorf("toFloat(%v) = %f, want %f", tt.input, result, tt.expected) + } + } + + // Test error case + _, err := toFloat("not a number") + if err == nil { + t.Error("toFloat('not a number') should return error") + } + }) + + t.Run("toBool", func(t *testing.T) { + tests := []struct { + input any + expected bool + }{ + {true, true}, + {false, false}, + {"true", true}, + {"false", false}, + {1, true}, + {0, false}, + {1.0, true}, + {0.0, false}, + } + for _, tt := range tests { + result, err := toBool(tt.input) + if err != nil { + t.Errorf("toBool(%v) unexpected error: %v", tt.input, err) + continue + } + if result != tt.expected { + t.Errorf("toBool(%v) = %v, want %v", tt.input, result, tt.expected) + } + } + + // Test error case + _, err := toBool("not a bool") + if err == nil { + t.Error("toBool('not a bool') should return error") + } + }) + + t.Run("toJSON", func(t *testing.T) { + result, err := toJSON(map[string]any{"key": "value"}) + if err != nil { + t.Fatalf("toJSON() unexpected error: %v", err) + } + expected := `{"key":"value"}` + if result != expected { + t.Errorf("toJSON() = %q, want %q", result, expected) + } + }) + + t.Run("fromJSON", func(t *testing.T) { + result, err := fromJSON(`{"key":"value"}`) + if err != nil { + t.Fatalf("fromJSON() unexpected error: %v", err) + } + m := result.(map[string]any) + if m["key"] != "value" { + t.Errorf("fromJSON() key = %v, want 'value'", m["key"]) + } + + // Test error case + _, err = fromJSON("invalid json") + if err == nil { + t.Error("fromJSON('invalid json') should return error") + } + }) +} + +func TestUnicodeFunctions(t *testing.T) { + t.Run("toASCII", func(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "hello"}, + {"café", "cafe"}, + {"naïve", "naive"}, + {"日本語", ""}, + } + for _, tt := range tests { + result := toASCII(tt.input) + if result != tt.expected { + t.Errorf("toASCII(%q) = %q, want %q", tt.input, result, tt.expected) + } + } + }) + + t.Run("toSlug", func(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Hello World", "hello-world"}, + {"Hello World", "hello-world"}, + {"Hello---World", "hello-world"}, + {"Café Naïve", "cafe-naive"}, + {" hello ", "hello"}, + } + for _, tt := range tests { + result := toSlug(tt.input) + if result != tt.expected { + t.Errorf("toSlug(%q) = %q, want %q", tt.input, result, tt.expected) + } + } + }) +} + +func TestLogicFunctions(t *testing.T) { + t.Run("eq", func(t *testing.T) { + if !eq("a", "a") { + t.Error("eq('a', 'a') should be true") + } + if eq("a", "b") { + t.Error("eq('a', 'b') should be false") + } + if !eq(1, 1) { + t.Error("eq(1, 1) should be true") + } + }) + + t.Run("ne", func(t *testing.T) { + if !ne("a", "b") { + t.Error("ne('a', 'b') should be true") + } + if ne("a", "a") { + t.Error("ne('a', 'a') should be false") + } + }) + + t.Run("lt/le/gt/ge", func(t *testing.T) { + if !lt(1, 2) { + t.Error("lt(1, 2) should be true") + } + if !le(1, 1) { + t.Error("le(1, 1) should be true") + } + if !gt(2, 1) { + t.Error("gt(2, 1) should be true") + } + if !ge(1, 1) { + t.Error("ge(1, 1) should be true") + } + }) + + t.Run("and/or/not", func(t *testing.T) { + if !and(true, true) { + t.Error("and(true, true) should be true") + } + if and(true, false) { + t.Error("and(true, false) should be false") + } + if !or(true, false) { + t.Error("or(true, false) should be true") + } + if or(false, false) { + t.Error("or(false, false) should be false") + } + if !not(false) { + t.Error("not(false) should be true") + } + }) + + t.Run("default", func(t *testing.T) { + if result := defaultVal("default", ""); result != "default" { + t.Errorf("defaultVal with empty = %v, want 'default'", result) + } + if result := defaultVal("default", "value"); result != "value" { + t.Errorf("defaultVal with value = %v, want 'value'", result) + } + }) + + t.Run("empty", func(t *testing.T) { + if !empty("") { + t.Error("empty('') should be true") + } + if !empty(nil) { + t.Error("empty(nil) should be true") + } + if !empty([]any{}) { + t.Error("empty([]) should be true") + } + if empty("hello") { + t.Error("empty('hello') should be false") + } + }) + + t.Run("coalesce", func(t *testing.T) { + if result := coalesce("", nil, "value", "other"); result != "value" { + t.Errorf("coalesce() = %v, want 'value'", result) + } + }) + + t.Run("ternary", func(t *testing.T) { + if result := ternary(true, "yes", "no"); result != "yes" { + t.Errorf("ternary(true) = %v, want 'yes'", result) + } + if result := ternary(false, "yes", "no"); result != "no" { + t.Errorf("ternary(false) = %v, want 'no'", result) + } + }) +} + +func TestCollectionFunctions(t *testing.T) { + t.Run("list", func(t *testing.T) { + result := list("a", "b", "c") + if len(result) != 3 || result[0] != "a" { + t.Errorf("list() = %v, want [a b c]", result) + } + }) + + t.Run("dict", func(t *testing.T) { + result := dict("key1", "value1", "key2", "value2") + if result["key1"] != "value1" || result["key2"] != "value2" { + t.Errorf("dict() = %v", result) + } + }) + + t.Run("keys", func(t *testing.T) { + m := map[string]any{"a": 1, "b": 2} + result := keys(m) + if len(result) != 2 { + t.Errorf("keys() len = %d, want 2", len(result)) + } + }) + + t.Run("values", func(t *testing.T) { + m := map[string]any{"a": 1, "b": 2} + result := values(m) + if len(result) != 2 { + t.Errorf("values() len = %d, want 2", len(result)) + } + }) + + t.Run("hasKey", func(t *testing.T) { + m := map[string]any{"key": "value"} + if !hasKey(m, "key") { + t.Error("hasKey() should be true") + } + if hasKey(m, "missing") { + t.Error("hasKey() should be false for missing key") + } + }) + + t.Run("get", func(t *testing.T) { + m := map[string]any{"key": "value"} + if result := get(m, "key"); result != "value" { + t.Errorf("get() = %v, want 'value'", result) + } + }) + + t.Run("set", func(t *testing.T) { + m := map[string]any{"key": "value"} + result := set(m, "new", "newvalue") + if result["new"] != "newvalue" { + t.Errorf("set() = %v", result) + } + }) + + t.Run("merge", func(t *testing.T) { + m1 := map[string]any{"a": 1} + m2 := map[string]any{"b": 2} + result := merge(m1, m2) + if result["a"] != 1 || result["b"] != 2 { + t.Errorf("merge() = %v", result) + } + }) + + t.Run("append", func(t *testing.T) { + result := appendList([]any{"a", "b"}, "c") + if len(result) != 3 || result[2] != "c" { + t.Errorf("append() = %v", result) + } + }) + + t.Run("uniq", func(t *testing.T) { + result := uniq([]any{"a", "b", "a", "c", "b"}) + if len(result) != 3 { + t.Errorf("uniq() len = %d, want 3", len(result)) + } + }) + + t.Run("sortAlpha", func(t *testing.T) { + result := sortAlpha([]any{"c", "a", "b"}) + if result[0] != "a" || result[1] != "b" || result[2] != "c" { + t.Errorf("sortAlpha() = %v, want [a b c]", result) + } + }) + + t.Run("length", func(t *testing.T) { + if result := length("hello"); result != 5 { + t.Errorf("length(string) = %d, want 5", result) + } + if result := length([]any{1, 2, 3}); result != 3 { + t.Errorf("length([]any) = %d, want 3", result) + } + if result := length(map[string]any{"a": 1}); result != 1 { + t.Errorf("length(map) = %d, want 1", result) + } + }) +} + +func TestMathFunctions(t *testing.T) { + t.Run("add", func(t *testing.T) { + if result := add(1, 2); result != 3 { + t.Errorf("add(1, 2) = %v, want 3", result) + } + // add returns int when result is whole number + if result := add(1.5, 2.5); result != 4 { + t.Errorf("add(1.5, 2.5) = %v, want 4", result) + } + // add returns float when result has decimal + if result := add(1.5, 2.3); result != 3.8 { + t.Errorf("add(1.5, 2.3) = %v, want 3.8", result) + } + }) + + t.Run("sub", func(t *testing.T) { + if result := sub(5, 3); result != 2 { + t.Errorf("sub(5, 3) = %v, want 2", result) + } + }) + + t.Run("mul", func(t *testing.T) { + if result := mul(3, 4); result != 12 { + t.Errorf("mul(3, 4) = %v, want 12", result) + } + }) + + t.Run("div", func(t *testing.T) { + result, err := div(10, 2) + if err != nil { + t.Errorf("div(10, 2) unexpected error: %v", err) + } + if result != 5 { + t.Errorf("div(10, 2) = %v, want 5", result) + } + + // Test division by zero returns error + _, err = div(10, 0) + if err == nil { + t.Error("div(10, 0) should return error") + } + }) + + t.Run("mod", func(t *testing.T) { + result, err := mod(10, 3) + if err != nil { + t.Errorf("mod(10, 3) unexpected error: %v", err) + } + if result != 1 { + t.Errorf("mod(10, 3) = %v, want 1", result) + } + + // Test division by zero + _, err = mod(10, 0) + if err == nil { + t.Error("mod(10, 0) should return error") + } + }) + + t.Run("max", func(t *testing.T) { + if result := max(1, 5, 3); result != 5 { + t.Errorf("max(1, 5, 3) = %v, want 5", result) + } + }) + + t.Run("min", func(t *testing.T) { + if result := min(1, 5, 3); result != 1 { + t.Errorf("min(1, 5, 3) = %v, want 1", result) + } + }) + + t.Run("floor", func(t *testing.T) { + if result := floor(3.7); result != 3 { + t.Errorf("floor(3.7) = %v, want 3", result) + } + }) + + t.Run("ceil", func(t *testing.T) { + if result := ceil(3.2); result != 4 { + t.Errorf("ceil(3.2) = %v, want 4", result) + } + }) + + t.Run("round", func(t *testing.T) { + if result := round(3.4); result != 3 { + t.Errorf("round(3.4) = %v, want 3", result) + } + if result := round(3.6); result != 4 { + t.Errorf("round(3.6) = %v, want 4", result) + } + }) +} + +func TestRegexFunctions(t *testing.T) { + t.Run("regexMatch", func(t *testing.T) { + result, err := regexMatch(`\d+`, "abc123def") + if err != nil { + t.Fatalf("regexMatch() unexpected error: %v", err) + } + if !result { + t.Error("regexMatch should match digits") + } + + result, err = regexMatch(`\d+`, "abcdef") + if err != nil { + t.Fatalf("regexMatch() unexpected error: %v", err) + } + if result { + t.Error("regexMatch should not match") + } + + // Test invalid regex + _, err = regexMatch(`[invalid`, "test") + if err == nil { + t.Error("regexMatch() should return error for invalid regex") + } + }) + + t.Run("regexFind", func(t *testing.T) { + result, err := regexFind(`\d+`, "abc123def") + if err != nil { + t.Fatalf("regexFind() unexpected error: %v", err) + } + if result != "123" { + t.Errorf("regexFind() = %q, want '123'", result) + } + + // Test invalid regex + _, err = regexFind(`[invalid`, "test") + if err == nil { + t.Error("regexFind() should return error for invalid regex") + } + }) + + t.Run("regexFindAll", func(t *testing.T) { + result, err := regexFindAll(`\d+`, "a1b2c3", -1) + if err != nil { + t.Fatalf("regexFindAll() unexpected error: %v", err) + } + if len(result) != 3 { + t.Errorf("regexFindAll() len = %d, want 3", len(result)) + } + + // Test invalid regex + _, err = regexFindAll(`[invalid`, "test", -1) + if err == nil { + t.Error("regexFindAll() should return error for invalid regex") + } + }) + + t.Run("regexReplace", func(t *testing.T) { + result, err := regexReplace(`\d+`, "X", "a1b2c3") + if err != nil { + t.Fatalf("regexReplace() unexpected error: %v", err) + } + if result != "aXbXcX" { + t.Errorf("regexReplace() = %q, want 'aXbXcX'", result) + } + + // Test invalid regex + _, err = regexReplace(`[invalid`, "X", "test") + if err == nil { + t.Error("regexReplace() should return error for invalid regex") + } + }) + + t.Run("regexSplit", func(t *testing.T) { + result, err := regexSplit(`\d+`, "a1b2c3", -1) + if err != nil { + t.Fatalf("regexSplit() unexpected error: %v", err) + } + if len(result) != 4 || result[0] != "a" { + t.Errorf("regexSplit() = %v", result) + } + + // Test invalid regex + _, err = regexSplit(`[invalid`, "test", -1) + if err == nil { + t.Error("regexSplit() should return error for invalid regex") + } + }) +} + +func TestCountingFunctions(t *testing.T) { + t.Run("count", func(t *testing.T) { + if result := count("o", "hello world"); result != 2 { + t.Errorf("count('o', 'hello world') = %d, want 2", result) + } + }) + + t.Run("countWords", func(t *testing.T) { + if result := countWords("hello world foo"); result != 3 { + t.Errorf("countWords() = %d, want 3", result) + } + }) + + t.Run("countLines", func(t *testing.T) { + if result := countLines("line1\nline2\nline3"); result != 3 { + t.Errorf("countLines() = %d, want 3", result) + } + if result := countLines(""); result != 0 { + t.Errorf("countLines('') = %d, want 0", result) + } + }) +} + +func TestQuoteFunctions(t *testing.T) { + t.Run("quote", func(t *testing.T) { + if result := quote("hello"); result != `"hello"` { + t.Errorf("quote() = %q, want '\"hello\"'", result) + } + if result := quote(`say "hi"`); result != `"say \"hi\""` { + t.Errorf("quote() with quotes = %q", result) + } + }) + + t.Run("squote", func(t *testing.T) { + if result := squote("hello"); result != `'hello'` { + t.Errorf("squote() = %q, want \"'hello'\"", result) + } + }) +} + +func TestFuncMap(t *testing.T) { + funcMap := Map() + + // Verify that the map contains expected functions + expectedFuncs := []string{ + "lower", "upper", "camelCase", "snakeCase", "kebabCase", + "trim", "replace", "split", "join", + "toInt", "toString", "toJson", + "add", "sub", "mul", "div", + "regexMatch", "regexReplace", + } + + for _, name := range expectedFuncs { + if _, ok := funcMap[name]; !ok { + t.Errorf("FuncMap missing function: %s", name) + } + } +} diff --git a/internal/output/writer.go b/internal/output/writer.go new file mode 100644 index 0000000..45145c8 --- /dev/null +++ b/internal/output/writer.go @@ -0,0 +1,220 @@ +// Package output provides file writing functionality with change detection. +package output + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" +) + +// Writer handles file output with optional overwrite protection and change detection. +type Writer struct { + force bool +} + +// New creates a new Writer. +func New(force bool) *Writer { + return &Writer{force: force} +} + +// Write writes content to a file. +// If force is false and the file exists, it returns an error. +// If the file exists and content is unchanged, it skips writing to preserve timestamps. +func (w *Writer) Write(path string, content []byte) error { + // Ensure parent directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Check if file exists + if info, err := os.Stat(path); err == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf("path exists but is not a regular file: %s", path) + } + + // File exists - check if we should overwrite + if !w.force { + return fmt.Errorf("file already exists (use --force to overwrite): %s", path) + } + + // Check if content has changed + existingContent, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read existing file %s: %w", path, err) + } + + if bytes.Equal(existingContent, content) { + // Content unchanged, skip writing + return nil + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat file %s: %w", path, err) + } + + // Write the file + if err := os.WriteFile(path, content, 0644); err != nil { + return fmt.Errorf("failed to write file %s: %w", path, err) + } + + return nil +} + +// WriteString writes string content to a file. +func (w *Writer) WriteString(path string, content string) error { + return w.Write(path, []byte(content)) +} + +// Copy copies a file from src to dst, preserving source file permissions. +// If force is false and the destination exists, it returns an error. +// If the destination exists and content is unchanged, it skips copying. +func (w *Writer) Copy(src, dst string) error { + // Get source file info for permissions + srcInfo, err := os.Stat(src) + if err != nil { + return fmt.Errorf("failed to stat source file %s: %w", src, err) + } + + // Read source file + content, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("failed to read source file %s: %w", src, err) + } + + // Use WriteWithPerm to preserve source permissions + return w.WriteWithPerm(dst, content, srcInfo.Mode().Perm()) +} + +// WriteWithPerm writes content to a file with specified permissions. +func (w *Writer) WriteWithPerm(path string, content []byte, perm os.FileMode) error { + // Ensure parent directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Check if file exists + if info, err := os.Stat(path); err == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf("path exists but is not a regular file: %s", path) + } + + // File exists - check if we should overwrite + if !w.force { + return fmt.Errorf("file already exists (use --force to overwrite): %s", path) + } + + // Check if content has changed + existingContent, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read existing file %s: %w", path, err) + } + + if bytes.Equal(existingContent, content) { + // Content unchanged, skip writing + return nil + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat file %s: %w", path, err) + } + + // Write the file with specified permissions + if err := os.WriteFile(path, content, perm); err != nil { + return fmt.Errorf("failed to write file %s: %w", path, err) + } + + return nil +} + +// CopyReader copies content from a reader to a file. +func (w *Writer) CopyReader(r io.Reader, dst string) error { + content, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("failed to read content: %w", err) + } + + return w.Write(dst, content) +} + +// WriteIfNotExists writes content to a file only if the file doesn't exist. +// Returns (true, nil) if the file already exists and was skipped. +// Returns (false, nil) if the file was written successfully. +// Returns (false, error) on failure. +func (w *Writer) WriteIfNotExists(path string, content []byte, perm os.FileMode) (skipped bool, err error) { + // Check if file already exists + if _, err := os.Stat(path); err == nil { + // File exists, skip writing + return true, nil + } else if !os.IsNotExist(err) { + return false, fmt.Errorf("failed to stat file %s: %w", path, err) + } + + // File doesn't exist, create it + // Ensure parent directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return false, fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + if err := os.WriteFile(path, content, perm); err != nil { + return false, fmt.Errorf("failed to write file %s: %w", path, err) + } + + return false, nil +} + +// CopyIfNotExists copies a file only if the destination doesn't exist. +// Returns (true, nil) if the destination already exists and was skipped. +// Returns (false, nil) if the file was copied successfully. +// Returns (false, error) on failure. +func (w *Writer) CopyIfNotExists(src, dst string) (skipped bool, err error) { + // Check if destination already exists + if _, err := os.Stat(dst); err == nil { + // File exists, skip copying + return true, nil + } else if !os.IsNotExist(err) { + return false, fmt.Errorf("failed to stat file %s: %w", dst, err) + } + + // Get source file info for permissions + srcInfo, err := os.Stat(src) + if err != nil { + return false, fmt.Errorf("failed to stat source file %s: %w", src, err) + } + + // Read source file + content, err := os.ReadFile(src) + if err != nil { + return false, fmt.Errorf("failed to read source file %s: %w", src, err) + } + + // Ensure parent directory exists + dir := filepath.Dir(dst) + if err := os.MkdirAll(dir, 0755); err != nil { + return false, fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Write to destination + if err := os.WriteFile(dst, content, srcInfo.Mode().Perm()); err != nil { + return false, fmt.Errorf("failed to write file %s: %w", dst, err) + } + + return false, nil +} + +// Exists checks if a file exists. +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// IsDir checks if a path is a directory. +func IsDir(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go new file mode 100644 index 0000000..e6990ee --- /dev/null +++ b/internal/output/writer_test.go @@ -0,0 +1,470 @@ +package output + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestNew(t *testing.T) { + w := New(false) + if w == nil { + t.Fatal("New() returned nil") + } + if w.force { + t.Error("New(false) should have force=false") + } + + w = New(true) + if !w.force { + t.Error("New(true) should have force=true") + } +} + +func TestWrite(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "render-output-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + t.Run("write new file", func(t *testing.T) { + w := New(false) + path := filepath.Join(tmpDir, "new.txt") + content := []byte("hello world") + + err := w.Write(path, content) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + written, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(written) != "hello world" { + t.Errorf("Write() content = %q, want %q", string(written), "hello world") + } + }) + + t.Run("write creates parent directories", func(t *testing.T) { + w := New(false) + path := filepath.Join(tmpDir, "nested", "dir", "file.txt") + content := []byte("nested content") + + err := w.Write(path, content) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + written, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(written) != "nested content" { + t.Errorf("Write() content = %q", string(written)) + } + }) + + t.Run("write fails without force on existing file", func(t *testing.T) { + w := New(false) + path := filepath.Join(tmpDir, "existing.txt") + + // Create existing file + if err := os.WriteFile(path, []byte("original"), 0644); err != nil { + t.Fatalf("Failed to create existing file: %v", err) + } + + err := w.Write(path, []byte("new content")) + if err == nil { + t.Error("Write() should return error when file exists and force=false") + } + + // Verify original content unchanged + content, _ := os.ReadFile(path) + if string(content) != "original" { + t.Errorf("Write() modified file content: %q", string(content)) + } + }) + + t.Run("write succeeds with force on existing file", func(t *testing.T) { + w := New(true) + path := filepath.Join(tmpDir, "force.txt") + + // Create existing file + if err := os.WriteFile(path, []byte("original"), 0644); err != nil { + t.Fatalf("Failed to create existing file: %v", err) + } + + err := w.Write(path, []byte("new content")) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + content, _ := os.ReadFile(path) + if string(content) != "new content" { + t.Errorf("Write() content = %q, want 'new content'", string(content)) + } + }) + + t.Run("write skips unchanged content", func(t *testing.T) { + w := New(true) + path := filepath.Join(tmpDir, "unchanged.txt") + + // Create existing file + originalContent := []byte("same content") + if err := os.WriteFile(path, originalContent, 0644); err != nil { + t.Fatalf("Failed to create existing file: %v", err) + } + + // Get original modification time + origInfo, _ := os.Stat(path) + origModTime := origInfo.ModTime() + + // Wait a bit to ensure time would change if file is written + time.Sleep(10 * time.Millisecond) + + // Write same content + err := w.Write(path, originalContent) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Check modification time unchanged + newInfo, _ := os.Stat(path) + if !newInfo.ModTime().Equal(origModTime) { + t.Error("Write() should not modify file when content unchanged") + } + }) + + t.Run("write updates changed content", func(t *testing.T) { + w := New(true) + path := filepath.Join(tmpDir, "changed.txt") + + // Create existing file + if err := os.WriteFile(path, []byte("original"), 0644); err != nil { + t.Fatalf("Failed to create existing file: %v", err) + } + + // Get original modification time + origInfo, _ := os.Stat(path) + origModTime := origInfo.ModTime() + + // Wait a bit + time.Sleep(10 * time.Millisecond) + + // Write different content + err := w.Write(path, []byte("changed")) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Check content changed + content, _ := os.ReadFile(path) + if string(content) != "changed" { + t.Errorf("Write() content = %q, want 'changed'", string(content)) + } + + // Check modification time changed + newInfo, _ := os.Stat(path) + if newInfo.ModTime().Equal(origModTime) { + t.Error("Write() should modify file when content changed") + } + }) +} + +func TestWriteString(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "render-output-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + w := New(false) + path := filepath.Join(tmpDir, "string.txt") + + err = w.WriteString(path, "string content") + if err != nil { + t.Fatalf("WriteString() error = %v", err) + } + + content, _ := os.ReadFile(path) + if string(content) != "string content" { + t.Errorf("WriteString() content = %q", string(content)) + } +} + +func TestCopy(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "render-output-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + t.Run("copy file", func(t *testing.T) { + w := New(false) + src := filepath.Join(tmpDir, "source.txt") + dst := filepath.Join(tmpDir, "dest.txt") + + // Create source file + if err := os.WriteFile(src, []byte("source content"), 0644); err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + + err := w.Copy(src, dst) + if err != nil { + t.Fatalf("Copy() error = %v", err) + } + + content, _ := os.ReadFile(dst) + if string(content) != "source content" { + t.Errorf("Copy() content = %q", string(content)) + } + }) + + t.Run("copy non-existent file", func(t *testing.T) { + w := New(false) + src := filepath.Join(tmpDir, "nonexistent.txt") + dst := filepath.Join(tmpDir, "dest2.txt") + + err := w.Copy(src, dst) + if err == nil { + t.Error("Copy() should return error for non-existent source") + } + }) + + t.Run("copy fails without force", func(t *testing.T) { + w := New(false) + src := filepath.Join(tmpDir, "src2.txt") + dst := filepath.Join(tmpDir, "dst2.txt") + + // Create both files + if err := os.WriteFile(src, []byte("source"), 0644); err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + if err := os.WriteFile(dst, []byte("dest"), 0644); err != nil { + t.Fatalf("Failed to create dest file: %v", err) + } + + err := w.Copy(src, dst) + if err == nil { + t.Error("Copy() should return error when dest exists and force=false") + } + }) + + t.Run("copy succeeds with force", func(t *testing.T) { + w := New(true) + src := filepath.Join(tmpDir, "src3.txt") + dst := filepath.Join(tmpDir, "dst3.txt") + + // Create both files + if err := os.WriteFile(src, []byte("new source"), 0644); err != nil { + t.Fatalf("Failed to create source file: %v", err) + } + if err := os.WriteFile(dst, []byte("old dest"), 0644); err != nil { + t.Fatalf("Failed to create dest file: %v", err) + } + + err := w.Copy(src, dst) + if err != nil { + t.Fatalf("Copy() error = %v", err) + } + + content, _ := os.ReadFile(dst) + if string(content) != "new source" { + t.Errorf("Copy() content = %q", string(content)) + } + }) +} + +func TestExists(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "render-output-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + path := filepath.Join(tmpDir, "exists.txt") + + if Exists(path) { + t.Error("Exists() should return false for non-existent file") + } + + if err := os.WriteFile(path, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + if !Exists(path) { + t.Error("Exists() should return true for existing file") + } +} + +func TestIsDir(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "render-output-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + if !IsDir(tmpDir) { + t.Error("IsDir() should return true for directory") + } + + file := filepath.Join(tmpDir, "file.txt") + if err := os.WriteFile(file, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + if IsDir(file) { + t.Error("IsDir() should return false for file") + } + + if IsDir(filepath.Join(tmpDir, "nonexistent")) { + t.Error("IsDir() should return false for non-existent path") + } +} + +func TestWriteIfNotExists(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "render-output-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + t.Run("creates file if not exists", func(t *testing.T) { + w := New(false) + path := filepath.Join(tmpDir, "newfile.txt") + content := []byte("new content") + + skipped, err := w.WriteIfNotExists(path, content, 0644) + if err != nil { + t.Fatalf("WriteIfNotExists error = %v", err) + } + if skipped { + t.Error("WriteIfNotExists should not skip for new file") + } + + written, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(written) != "new content" { + t.Errorf("Content = %q, want %q", string(written), "new content") + } + }) + + t.Run("skips if file exists", func(t *testing.T) { + w := New(false) + path := filepath.Join(tmpDir, "existing.txt") + + // Create existing file + if err := os.WriteFile(path, []byte("original"), 0644); err != nil { + t.Fatalf("Failed to create existing file: %v", err) + } + + skipped, err := w.WriteIfNotExists(path, []byte("new content"), 0644) + if err != nil { + t.Fatalf("WriteIfNotExists error = %v", err) + } + if !skipped { + t.Error("WriteIfNotExists should skip for existing file") + } + + // Verify content unchanged + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + if string(content) != "original" { + t.Errorf("Content = %q, want %q (should be unchanged)", string(content), "original") + } + }) + + t.Run("creates parent directories", func(t *testing.T) { + w := New(false) + path := filepath.Join(tmpDir, "nested", "dir", "file.txt") + content := []byte("nested content") + + skipped, err := w.WriteIfNotExists(path, content, 0644) + if err != nil { + t.Fatalf("WriteIfNotExists error = %v", err) + } + if skipped { + t.Error("WriteIfNotExists should not skip for new file") + } + + written, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(written) != "nested content" { + t.Errorf("Content = %q", string(written)) + } + }) +} + +func TestCopyIfNotExists(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "render-output-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + t.Run("copies file if dest not exists", func(t *testing.T) { + w := New(false) + src := filepath.Join(tmpDir, "source.txt") + dst := filepath.Join(tmpDir, "dest.txt") + + if err := os.WriteFile(src, []byte("source content"), 0644); err != nil { + t.Fatalf("Failed to create source: %v", err) + } + + skipped, err := w.CopyIfNotExists(src, dst) + if err != nil { + t.Fatalf("CopyIfNotExists error = %v", err) + } + if skipped { + t.Error("CopyIfNotExists should not skip for new file") + } + + content, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("Failed to read dest: %v", err) + } + if string(content) != "source content" { + t.Errorf("Content = %q, want %q", string(content), "source content") + } + }) + + t.Run("skips if dest exists", func(t *testing.T) { + w := New(false) + src := filepath.Join(tmpDir, "src2.txt") + dst := filepath.Join(tmpDir, "dst2.txt") + + if err := os.WriteFile(src, []byte("source"), 0644); err != nil { + t.Fatalf("Failed to create source: %v", err) + } + if err := os.WriteFile(dst, []byte("original dest"), 0644); err != nil { + t.Fatalf("Failed to create dest: %v", err) + } + + skipped, err := w.CopyIfNotExists(src, dst) + if err != nil { + t.Fatalf("CopyIfNotExists error = %v", err) + } + if !skipped { + t.Error("CopyIfNotExists should skip for existing file") + } + + // Verify dest unchanged + content, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("Failed to read dest: %v", err) + } + if string(content) != "original dest" { + t.Errorf("Content = %q, want %q (should be unchanged)", string(content), "original dest") + } + }) +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..feed619 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,265 @@ +// Package render provides two-phase template rendering with validation. +package render + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/wernerstrydom/render/internal/config" + "github.com/wernerstrydom/render/internal/engine" + "github.com/wernerstrydom/render/internal/output" +) + +// Output represents a single file to be written. +type Output struct { + SourcePath string // Relative path in template dir + OutputPath string // Absolute path in output dir + Content []byte // Rendered content (nil for copied files) + CopyFrom string // Source path if copying verbatim (empty if rendered) + Permissions os.FileMode // File permissions to apply + Overwrite bool // Whether to overwrite existing files (default true) +} + +// Plan represents the complete rendering operation. +type Plan struct { + Outputs []Output +} + +// CollectConfig configures the Collect function. +type CollectConfig struct { + TemplateDir string + OutputDir string + Data any + Config *config.ParsedConfig // nil = no path transformation + Engine *engine.Engine +} + +// Collect walks the template directory and builds a Plan. +// It collects all outputs into memory for validation before any writes. +func Collect(cfg CollectConfig) (*Plan, error) { + // Resolve template directory to absolute path + tmplDirAbs, err := filepath.Abs(cfg.TemplateDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve template directory: %w", err) + } + + // Verify template directory exists + info, err := os.Stat(tmplDirAbs) + if err != nil { + return nil, fmt.Errorf("failed to access template directory: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("template path is not a directory: %s", cfg.TemplateDir) + } + + // Resolve output directory to absolute path + outDirAbs, err := filepath.Abs(cfg.OutputDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve output directory: %w", err) + } + + // Create path mapper if config exists + mapper := config.NewPathMapper(cfg.Config) + + plan := &Plan{ + Outputs: make([]Output, 0), + } + + err = filepath.Walk(tmplDirAbs, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path from template directory + relPath, err := filepath.Rel(tmplDirAbs, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + // Skip the root directory + if relPath == "." { + return nil + } + + // Skip config files + if config.ShouldSkipConfigFile(relPath) { + return nil + } + + // Security: Ensure relative path doesn't escape + if strings.Contains(relPath, "..") { + return fmt.Errorf("security error: path contains directory traversal: %s", relPath) + } + + // Transform path using config + outputRelPath := relPath + if mapper != nil { + outputRelPath, err = mapper.TransformPath(relPath, cfg.Data) + if err != nil { + return fmt.Errorf("failed to transform path %s: %w", relPath, err) + } + } + + // Calculate output path + outPath := filepath.Join(outDirAbs, outputRelPath) + + // Security: Verify output path is within output directory + if !strings.HasPrefix(outPath, outDirAbs) { + return fmt.Errorf("security error: output path %s is outside output directory", outPath) + } + + // Handle directories - just ensure they'll be created + if info.IsDir() { + return nil + } + + // Determine if this file can overwrite existing files + canOverwrite := true + if mapper != nil { + canOverwrite = mapper.CanOverwrite(relPath) + } + + // Check if file is a template + if strings.HasSuffix(path, ".tmpl") { + // Strip .tmpl extension for output + outPath = strings.TrimSuffix(outPath, ".tmpl") + + // Read and render template + tmplContent, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read template %s: %w", relPath, err) + } + + result, err := cfg.Engine.RenderString(string(tmplContent), cfg.Data) + if err != nil { + return fmt.Errorf("failed to render template %s: %w", relPath, err) + } + + plan.Outputs = append(plan.Outputs, Output{ + SourcePath: relPath, + OutputPath: outPath, + Content: []byte(result), + Permissions: 0644, + Overwrite: canOverwrite, + }) + } else { + // Non-template file - will be copied + srcInfo, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to stat source file %s: %w", relPath, err) + } + + plan.Outputs = append(plan.Outputs, Output{ + SourcePath: relPath, + OutputPath: outPath, + CopyFrom: path, + Permissions: srcInfo.Mode().Perm(), + Overwrite: canOverwrite, + }) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return plan, nil +} + +// Validate checks the Plan for collisions and security issues. +// Returns all errors found (doesn't stop at first error). +func (p *Plan) Validate() []error { + var errs []error + seen := make(map[string]string) // outputPath → sourcePath + + for _, out := range p.Outputs { + // Check for collisions + if existing, ok := seen[out.OutputPath]; ok { + errs = append(errs, fmt.Errorf( + "output path collision: %q produced by both:\n - %s\n - %s", + out.OutputPath, existing, out.SourcePath)) + } + seen[out.OutputPath] = out.SourcePath + } + + return errs +} + +// Preview returns a human-readable summary of planned outputs. +func (p *Plan) Preview() string { + var sb strings.Builder + sb.WriteString("Planned outputs:\n") + for _, out := range p.Outputs { + action := "render" + if out.CopyFrom != "" { + action = "copy" + } + sb.WriteString(fmt.Sprintf(" [%s] %s → %s\n", + action, out.SourcePath, out.OutputPath)) + } + return sb.String() +} + +// ExecuteResult contains information about executed outputs. +type ExecuteResult struct { + Skipped map[string]bool // Paths that were skipped due to no-overwrite +} + +// Execute writes all files in the Plan. +// Returns ExecuteResult with information about skipped files. +func (p *Plan) Execute(writer *output.Writer) (*ExecuteResult, error) { + result := &ExecuteResult{ + Skipped: make(map[string]bool), + } + + for _, out := range p.Outputs { + // Ensure parent directory exists + dir := filepath.Dir(out.OutputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + if out.CopyFrom != "" { + // Copy file + if !out.Overwrite { + skipped, err := writer.CopyIfNotExists(out.CopyFrom, out.OutputPath) + if err != nil { + return nil, err + } + if skipped { + result.Skipped[out.OutputPath] = true + } + } else { + if err := writer.Copy(out.CopyFrom, out.OutputPath); err != nil { + return nil, err + } + } + } else { + // Write rendered content + if !out.Overwrite { + skipped, err := writer.WriteIfNotExists(out.OutputPath, out.Content, out.Permissions) + if err != nil { + return nil, err + } + if skipped { + result.Skipped[out.OutputPath] = true + } + } else { + if err := writer.WriteWithPerm(out.OutputPath, out.Content, out.Permissions); err != nil { + return nil, err + } + } + } + } + + return result, nil +} + +// OutputCount returns the number of outputs in the plan. +func (p *Plan) OutputCount() int { + return len(p.Outputs) +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..cdf1888 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,449 @@ +package render + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/wernerstrydom/render/internal/config" + "github.com/wernerstrydom/render/internal/engine" + "github.com/wernerstrydom/render/internal/output" +) + +func TestCollect_BasicTemplateDir(t *testing.T) { + dir := t.TempDir() + + // Create template directory + tmplDir := filepath.Join(dir, "templates") + mkdir(t, tmplDir) + writeFile(t, tmplDir, "config.yaml.tmpl", "name: {{ .name }}") + writeFile(t, tmplDir, "static.txt", "static content") + + outDir := filepath.Join(dir, "output") + + plan, err := Collect(CollectConfig{ + TemplateDir: tmplDir, + OutputDir: outDir, + Data: map[string]any{"name": "TestApp"}, + Engine: engine.New(), + }) + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + + if len(plan.Outputs) != 2 { + t.Errorf("Expected 2 outputs, got %d", len(plan.Outputs)) + } + + // Check that template was rendered + var foundTemplate, foundStatic bool + for _, out := range plan.Outputs { + if strings.HasSuffix(out.OutputPath, "config.yaml") { + foundTemplate = true + if out.CopyFrom != "" { + t.Error("Template should have Content, not CopyFrom") + } + if string(out.Content) != "name: TestApp" { + t.Errorf("Content = %q, want %q", string(out.Content), "name: TestApp") + } + } + if strings.HasSuffix(out.OutputPath, "static.txt") { + foundStatic = true + if out.CopyFrom == "" { + t.Error("Static file should have CopyFrom") + } + } + } + + if !foundTemplate { + t.Error("Template output not found") + } + if !foundStatic { + t.Error("Static output not found") + } +} + +func TestCollect_WithConfig(t *testing.T) { + dir := t.TempDir() + + // Create template directory with config + tmplDir := filepath.Join(dir, "templates") + mkdir(t, tmplDir) + writeFile(t, tmplDir, "model.go.tmpl", "package {{ .package }}") + writeFile(t, tmplDir, ".render.yaml", `paths: + "model.go.tmpl": "{{ .name | snakeCase }}.go" +`) + + outDir := filepath.Join(dir, "output") + + cfg, err := config.Load(tmplDir) + if err != nil { + t.Fatalf("Config load failed: %v", err) + } + + plan, err := Collect(CollectConfig{ + TemplateDir: tmplDir, + OutputDir: outDir, + Data: map[string]any{"name": "UserProfile", "package": "models"}, + Config: cfg, + Engine: engine.New(), + }) + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + + // Should have only 1 output (config file is skipped) + if len(plan.Outputs) != 1 { + t.Errorf("Expected 1 output, got %d", len(plan.Outputs)) + } + + // Check the output path was transformed + if len(plan.Outputs) > 0 { + out := plan.Outputs[0] + if !strings.HasSuffix(out.OutputPath, "user_profile.go") { + t.Errorf("OutputPath = %q, should end with user_profile.go", out.OutputPath) + } + } +} + +func TestCollect_SkipsConfigFile(t *testing.T) { + dir := t.TempDir() + + tmplDir := filepath.Join(dir, "templates") + mkdir(t, tmplDir) + writeFile(t, tmplDir, "file.txt", "content") + writeFile(t, tmplDir, ".render.yaml", "paths: {}") + + outDir := filepath.Join(dir, "output") + + plan, err := Collect(CollectConfig{ + TemplateDir: tmplDir, + OutputDir: outDir, + Data: nil, + Engine: engine.New(), + }) + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + + // Should only have 1 output (file.txt), not .render.yaml + if len(plan.Outputs) != 1 { + t.Errorf("Expected 1 output, got %d", len(plan.Outputs)) + } + + for _, out := range plan.Outputs { + if strings.Contains(out.SourcePath, ".render.yaml") { + t.Error("Config file should be skipped") + } + } +} + +func TestPlan_Validate_NoCollisions(t *testing.T) { + plan := &Plan{ + Outputs: []Output{ + {SourcePath: "a.go", OutputPath: "/out/a.go"}, + {SourcePath: "b.go", OutputPath: "/out/b.go"}, + }, + } + + errs := plan.Validate() + if len(errs) != 0 { + t.Errorf("Expected no errors, got %v", errs) + } +} + +func TestPlan_Validate_Collision(t *testing.T) { + plan := &Plan{ + Outputs: []Output{ + {SourcePath: "a.go", OutputPath: "/out/same.go"}, + {SourcePath: "b.go", OutputPath: "/out/same.go"}, + }, + } + + errs := plan.Validate() + if len(errs) != 1 { + t.Fatalf("Expected 1 error, got %d", len(errs)) + } + + if !strings.Contains(errs[0].Error(), "collision") { + t.Errorf("Error should mention collision: %v", errs[0]) + } +} + +func TestPlan_Preview(t *testing.T) { + plan := &Plan{ + Outputs: []Output{ + {SourcePath: "template.go.tmpl", OutputPath: "/out/model.go", Content: []byte("content")}, + {SourcePath: "static.txt", OutputPath: "/out/static.txt", CopyFrom: "/src/static.txt"}, + }, + } + + preview := plan.Preview() + + if !strings.Contains(preview, "[render]") { + t.Error("Preview should show render action for template") + } + if !strings.Contains(preview, "[copy]") { + t.Error("Preview should show copy action for static file") + } + if !strings.Contains(preview, "template.go.tmpl") { + t.Error("Preview should show source path") + } +} + +func TestPlan_Execute(t *testing.T) { + dir := t.TempDir() + + // Create source file for copy + srcFile := filepath.Join(dir, "source.txt") + if err := os.WriteFile(srcFile, []byte("source content"), 0644); err != nil { + t.Fatalf("Failed to write source: %v", err) + } + + outDir := filepath.Join(dir, "output") + + plan := &Plan{ + Outputs: []Output{ + { + SourcePath: "template.go.tmpl", + OutputPath: filepath.Join(outDir, "rendered.go"), + Content: []byte("rendered content"), + Permissions: 0644, + }, + { + SourcePath: "static.txt", + OutputPath: filepath.Join(outDir, "copied.txt"), + CopyFrom: srcFile, + Permissions: 0644, + }, + }, + } + + writer := output.New(true) + if _, err := plan.Execute(writer); err != nil { + t.Fatalf("Execute failed: %v", err) + } + + // Verify rendered file + rendered, err := os.ReadFile(filepath.Join(outDir, "rendered.go")) + if err != nil { + t.Fatalf("Failed to read rendered: %v", err) + } + if string(rendered) != "rendered content" { + t.Errorf("Rendered content = %q, want %q", string(rendered), "rendered content") + } + + // Verify copied file + copied, err := os.ReadFile(filepath.Join(outDir, "copied.txt")) + if err != nil { + t.Fatalf("Failed to read copied: %v", err) + } + if string(copied) != "source content" { + t.Errorf("Copied content = %q, want %q", string(copied), "source content") + } +} + +func TestCollect_PreservesDirectoryStructure(t *testing.T) { + dir := t.TempDir() + + tmplDir := filepath.Join(dir, "templates") + mkdir(t, filepath.Join(tmplDir, "a", "b", "c")) + writeFile(t, tmplDir, "a/b/c/deep.txt", "deep") + + outDir := filepath.Join(dir, "output") + + plan, err := Collect(CollectConfig{ + TemplateDir: tmplDir, + OutputDir: outDir, + Data: nil, + Engine: engine.New(), + }) + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + + if len(plan.Outputs) != 1 { + t.Fatalf("Expected 1 output, got %d", len(plan.Outputs)) + } + + // Check that nested structure is preserved + out := plan.Outputs[0] + expected := filepath.Join(outDir, "a", "b", "c", "deep.txt") + if out.OutputPath != expected { + t.Errorf("OutputPath = %q, want %q", out.OutputPath, expected) + } +} + +func TestCollect_OverwriteField(t *testing.T) { + dir := t.TempDir() + + // Create template directory with config + tmplDir := filepath.Join(dir, "templates") + mkdir(t, tmplDir) + writeFile(t, tmplDir, "regular.go.tmpl", "package main") + writeFile(t, tmplDir, "protected.go.tmpl", "package protected") + writeFile(t, tmplDir, ".render.yaml", `paths: + "regular.go.tmpl": "regular.go" + "protected.go.tmpl": + path: "protected.go" + overwrite: false +`) + + outDir := filepath.Join(dir, "output") + + cfg, err := config.Load(tmplDir) + if err != nil { + t.Fatalf("Config load failed: %v", err) + } + + plan, err := Collect(CollectConfig{ + TemplateDir: tmplDir, + OutputDir: outDir, + Data: map[string]any{}, + Config: cfg, + Engine: engine.New(), + }) + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + + if len(plan.Outputs) != 2 { + t.Fatalf("Expected 2 outputs, got %d", len(plan.Outputs)) + } + + // Check Overwrite field is set correctly + for _, out := range plan.Outputs { + if strings.HasSuffix(out.OutputPath, "regular.go") { + if !out.Overwrite { + t.Error("regular.go should have Overwrite=true") + } + } + if strings.HasSuffix(out.OutputPath, "protected.go") { + if out.Overwrite { + t.Error("protected.go should have Overwrite=false") + } + } + } +} + +func TestPlan_Execute_NoOverwrite(t *testing.T) { + dir := t.TempDir() + outDir := filepath.Join(dir, "output") + mkdir(t, outDir) + + // Create existing file that should be protected + protectedPath := filepath.Join(outDir, "protected.go") + if err := os.WriteFile(protectedPath, []byte("original content"), 0644); err != nil { + t.Fatalf("Failed to write protected file: %v", err) + } + + plan := &Plan{ + Outputs: []Output{ + { + SourcePath: "regular.go.tmpl", + OutputPath: filepath.Join(outDir, "regular.go"), + Content: []byte("new content"), + Permissions: 0644, + Overwrite: true, + }, + { + SourcePath: "protected.go.tmpl", + OutputPath: protectedPath, + Content: []byte("new content"), + Permissions: 0644, + Overwrite: false, // Should not overwrite existing file + }, + }, + } + + writer := output.New(true) // force=true, but Overwrite=false should still skip + result, err := plan.Execute(writer) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + // Check that protected file was skipped + if !result.Skipped[protectedPath] { + t.Error("Protected file should be in Skipped map") + } + + // Verify protected file content unchanged + content, err := os.ReadFile(protectedPath) + if err != nil { + t.Fatalf("Failed to read protected file: %v", err) + } + if string(content) != "original content" { + t.Errorf("Protected file content = %q, want %q", string(content), "original content") + } + + // Verify regular file was created + regularContent, err := os.ReadFile(filepath.Join(outDir, "regular.go")) + if err != nil { + t.Fatalf("Failed to read regular file: %v", err) + } + if string(regularContent) != "new content" { + t.Errorf("Regular file content = %q, want %q", string(regularContent), "new content") + } +} + +func TestPlan_Execute_NoOverwrite_NewFile(t *testing.T) { + dir := t.TempDir() + outDir := filepath.Join(dir, "output") + mkdir(t, outDir) + + // Protected file doesn't exist yet + protectedPath := filepath.Join(outDir, "protected.go") + + plan := &Plan{ + Outputs: []Output{ + { + SourcePath: "protected.go.tmpl", + OutputPath: protectedPath, + Content: []byte("new content"), + Permissions: 0644, + Overwrite: false, // But file doesn't exist, so should create + }, + }, + } + + writer := output.New(true) + result, err := plan.Execute(writer) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + // Should NOT be in skipped (file was created since it didn't exist) + if result.Skipped[protectedPath] { + t.Error("Protected file should NOT be in Skipped map when it didn't exist") + } + + // Verify file was created + content, err := os.ReadFile(protectedPath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + if string(content) != "new content" { + t.Errorf("Content = %q, want %q", string(content), "new content") + } +} + +// Helper functions + +func mkdir(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } +} + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("Failed to create parent: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write: %v", err) + } +} diff --git a/internal/walker/walker.go b/internal/walker/walker.go new file mode 100644 index 0000000..4ea3b6b --- /dev/null +++ b/internal/walker/walker.go @@ -0,0 +1,174 @@ +// Package walker provides directory traversal and template rendering functionality. +package walker + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/wernerstrydom/render/internal/engine" + "github.com/wernerstrydom/render/internal/output" +) + +// Config holds configuration for directory walking and rendering. +type Config struct { + // TemplateDir is the source directory containing templates. + TemplateDir string + + // OutputDir is the destination directory for rendered output. + OutputDir string + + // Data is the template data to use for rendering. + Data any + + // Engine is the template rendering engine. + Engine *engine.Engine + + // Writer is the output writer. + Writer *output.Writer + + // TransformPath is an optional callback to transform relative paths. + // If nil, paths are used as-is. + // The function receives the relative path and data, and returns the transformed path. + TransformPath func(relPath string, data any) (string, error) + + // ValidateSymlinks enables symlink security validation. + // When true, symlinks that resolve outside the template directory cause an error. + ValidateSymlinks bool + + // OnRendered is called after a template file is rendered. + // Parameters: source relative path, destination absolute path. + OnRendered func(srcRel, dstAbs string) + + // OnCopied is called after a non-template file is copied. + // Parameters: source relative path, destination absolute path. + OnCopied func(srcRel, dstAbs string) +} + +// Walk traverses a template directory, rendering .tmpl files and copying others. +func Walk(cfg Config) error { + // Resolve the template directory to an absolute path + tmplDirAbs, err := filepath.Abs(cfg.TemplateDir) + if err != nil { + return fmt.Errorf("failed to resolve template directory path: %w", err) + } + + // Verify template directory exists + info, err := os.Stat(tmplDirAbs) + if err != nil { + return fmt.Errorf("failed to access template directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("template path is not a directory: %s", cfg.TemplateDir) + } + + // Resolve symlinks for the template directory for consistent comparison + var tmplDirReal string + if cfg.ValidateSymlinks { + tmplDirReal, err = filepath.EvalSymlinks(tmplDirAbs) + if err != nil { + return fmt.Errorf("failed to resolve template directory symlinks: %w", err) + } + } + + // Resolve output directory to absolute path + outDirAbs, err := filepath.Abs(cfg.OutputDir) + if err != nil { + return fmt.Errorf("failed to resolve output directory path: %w", err) + } + + return filepath.Walk(tmplDirAbs, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Security: Resolve symlinks and verify the real path is within the template directory + if cfg.ValidateSymlinks { + realPath, err := filepath.EvalSymlinks(path) + if err != nil { + return fmt.Errorf("failed to resolve symlink %s: %w", path, err) + } + if !strings.HasPrefix(realPath, tmplDirReal) && realPath != tmplDirReal { + return fmt.Errorf("security error: path %s resolves outside template directory", path) + } + } + + // Get relative path from template directory + relPath, err := filepath.Rel(tmplDirAbs, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + // Skip the root directory + if relPath == "." { + return nil + } + + // Security: Ensure relative path doesn't escape output directory + if strings.Contains(relPath, "..") { + return fmt.Errorf("security error: path contains directory traversal: %s", relPath) + } + + // Transform path if callback is provided + outputRelPath := relPath + if cfg.TransformPath != nil { + outputRelPath, err = cfg.TransformPath(relPath, cfg.Data) + if err != nil { + return fmt.Errorf("failed to transform path %s: %w", relPath, err) + } + } + + // Calculate output path + outPath := filepath.Join(outDirAbs, outputRelPath) + + // Security: Verify output path is within output directory + if cfg.ValidateSymlinks && !strings.HasPrefix(outPath, outDirAbs) { + return fmt.Errorf("security error: output path %s is outside output directory", outPath) + } + + // Handle directories + if info.IsDir() { + if err := os.MkdirAll(outPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", outPath, err) + } + return nil + } + + // Check if file is a template + if strings.HasSuffix(path, ".tmpl") { + // Strip .tmpl extension for output + outPath = strings.TrimSuffix(outPath, ".tmpl") + + // Read and render template + tmplContent, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read template %s: %w", path, err) + } + + result, err := cfg.Engine.RenderString(string(tmplContent), cfg.Data) + if err != nil { + return fmt.Errorf("failed to render template %s: %w", path, err) + } + + if err := cfg.Writer.WriteString(outPath, result); err != nil { + return err + } + + if cfg.OnRendered != nil { + cfg.OnRendered(relPath, outPath) + } + } else { + // Copy file verbatim + if err := cfg.Writer.Copy(path, outPath); err != nil { + return err + } + + if cfg.OnCopied != nil { + cfg.OnCopied(relPath, outPath) + } + } + + return nil + }) +} diff --git a/internal/walker/walker_test.go b/internal/walker/walker_test.go new file mode 100644 index 0000000..5895af0 --- /dev/null +++ b/internal/walker/walker_test.go @@ -0,0 +1,172 @@ +package walker + +import ( + "os" + "path/filepath" + "testing" + + "github.com/wernerstrydom/render/internal/engine" + "github.com/wernerstrydom/render/internal/output" +) + +func TestWalk(t *testing.T) { + // Create temporary directories + tmpDir := t.TempDir() + tmplDir := filepath.Join(tmpDir, "templates") + outDir := filepath.Join(tmpDir, "output") + + // Create template directory structure + if err := os.MkdirAll(filepath.Join(tmplDir, "subdir"), 0755); err != nil { + t.Fatalf("failed to create template directories: %v", err) + } + + // Create a template file + tmplContent := "Hello, {{.name}}!" + if err := os.WriteFile(filepath.Join(tmplDir, "greeting.txt.tmpl"), []byte(tmplContent), 0644); err != nil { + t.Fatalf("failed to write template file: %v", err) + } + + // Create a non-template file + staticContent := "This is static content" + if err := os.WriteFile(filepath.Join(tmplDir, "static.txt"), []byte(staticContent), 0644); err != nil { + t.Fatalf("failed to write static file: %v", err) + } + + // Create a template in subdirectory + subTmplContent := "Sub: {{.value}}" + if err := os.WriteFile(filepath.Join(tmplDir, "subdir", "sub.txt.tmpl"), []byte(subTmplContent), 0644); err != nil { + t.Fatalf("failed to write sub template file: %v", err) + } + + // Track callbacks + var rendered, copied []string + + // Walk + cfg := Config{ + TemplateDir: tmplDir, + OutputDir: outDir, + Data: map[string]any{"name": "World", "value": "test"}, + Engine: engine.New(), + Writer: output.New(false), + ValidateSymlinks: true, + OnRendered: func(srcRel, dstAbs string) { + rendered = append(rendered, srcRel) + }, + OnCopied: func(srcRel, dstAbs string) { + copied = append(copied, srcRel) + }, + } + + if err := Walk(cfg); err != nil { + t.Fatalf("Walk failed: %v", err) + } + + // Verify rendered files + greetingPath := filepath.Join(outDir, "greeting.txt") + content, err := os.ReadFile(greetingPath) + if err != nil { + t.Errorf("failed to read greeting.txt: %v", err) + } else if string(content) != "Hello, World!" { + t.Errorf("greeting.txt content = %q, want %q", string(content), "Hello, World!") + } + + subPath := filepath.Join(outDir, "subdir", "sub.txt") + content, err = os.ReadFile(subPath) + if err != nil { + t.Errorf("failed to read subdir/sub.txt: %v", err) + } else if string(content) != "Sub: test" { + t.Errorf("subdir/sub.txt content = %q, want %q", string(content), "Sub: test") + } + + // Verify copied files + staticPath := filepath.Join(outDir, "static.txt") + content, err = os.ReadFile(staticPath) + if err != nil { + t.Errorf("failed to read static.txt: %v", err) + } else if string(content) != staticContent { + t.Errorf("static.txt content = %q, want %q", string(content), staticContent) + } + + // Verify callbacks + if len(rendered) != 2 { + t.Errorf("rendered count = %d, want 2", len(rendered)) + } + if len(copied) != 1 { + t.Errorf("copied count = %d, want 1", len(copied)) + } +} + +func TestWalkWithPathTransform(t *testing.T) { + // Create temporary directories + tmpDir := t.TempDir() + tmplDir := filepath.Join(tmpDir, "templates") + outDir := filepath.Join(tmpDir, "output") + + // Create template directory + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("failed to create template directory: %v", err) + } + + // Create a template file with templated filename + tmplContent := "Content for {{.id}}" + if err := os.WriteFile(filepath.Join(tmplDir, "{{.id}}.txt.tmpl"), []byte(tmplContent), 0644); err != nil { + t.Fatalf("failed to write template file: %v", err) + } + + eng := engine.New() + + cfg := Config{ + TemplateDir: tmplDir, + OutputDir: outDir, + Data: map[string]any{"id": "user-123"}, + Engine: eng, + Writer: output.New(false), + TransformPath: func(relPath string, data any) (string, error) { + return eng.RenderString(relPath, data) + }, + } + + if err := Walk(cfg); err != nil { + t.Fatalf("Walk failed: %v", err) + } + + // Verify the file was created with transformed name + expectedPath := filepath.Join(outDir, "user-123.txt") + content, err := os.ReadFile(expectedPath) + if err != nil { + t.Errorf("failed to read user-123.txt: %v", err) + } else if string(content) != "Content for user-123" { + t.Errorf("user-123.txt content = %q, want %q", string(content), "Content for user-123") + } +} + +func TestWalkDirectoryTraversal(t *testing.T) { + // Create temporary directories + tmpDir := t.TempDir() + tmplDir := filepath.Join(tmpDir, "templates") + outDir := filepath.Join(tmpDir, "output") + + // Create template directory + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("failed to create template directory: %v", err) + } + + // Create a simple template file + if err := os.WriteFile(filepath.Join(tmplDir, "test.txt"), []byte("test"), 0644); err != nil { + t.Fatalf("failed to write template file: %v", err) + } + + cfg := Config{ + TemplateDir: tmplDir, + OutputDir: outDir, + Data: map[string]any{}, + Engine: engine.New(), + Writer: output.New(false), + ValidateSymlinks: true, + } + + // This should work fine + if err := Walk(cfg); err != nil { + t.Fatalf("Walk failed: %v", err) + } +} diff --git a/test/acceptance/acceptance_test.go b/test/acceptance/acceptance_test.go new file mode 100644 index 0000000..41fee9d --- /dev/null +++ b/test/acceptance/acceptance_test.go @@ -0,0 +1,533 @@ +// Package acceptance provides end-to-end acceptance tests for the render CLI. +// These tests compile the binary and execute it as a user would, verifying +// the outputs without access to internal code. +package acceptance + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +var ( + binaryPath string + buildOnce sync.Once + buildErr error +) + +// ensureBinary builds the render binary if it doesn't exist. +func ensureBinary(t *testing.T) string { + t.Helper() + + buildOnce.Do(func() { + // Find the project root (two levels up from test/acceptance) + _, filename, _, _ := runtime.Caller(0) + projectRoot := filepath.Join(filepath.Dir(filename), "..", "..") + + // Binary path + binaryName := "render" + if runtime.GOOS == "windows" { + binaryName = "render.exe" + } + binaryPath = filepath.Join(projectRoot, "bin", "test", binaryName) + + // Create bin/test directory + if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil { + buildErr = err + return + } + + // Build the binary + cmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/render") + cmd.Dir = projectRoot + output, err := cmd.CombinedOutput() + if err != nil { + buildErr = &buildError{output: string(output), err: err} + return + } + }) + + if buildErr != nil { + t.Fatalf("Failed to build binary: %v", buildErr) + } + + return binaryPath +} + +type buildError struct { + output string + err error +} + +func (e *buildError) Error() string { + return e.err.Error() + ": " + e.output +} + +// runRender executes the render binary with the given arguments. +func runRender(t *testing.T, args ...string) (string, string, error) { + t.Helper() + + binary := ensureBinary(t) + cmd := exec.Command(binary, args...) + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +// getExitCode extracts exit code from error. +func getExitCode(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 1 +} + +// createTempDir creates a temporary directory for test files. +func createTempDir(t *testing.T) string { + t.Helper() + + dir, err := os.MkdirTemp("", "render-acceptance-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + + return dir +} + +// writeFile writes content to a file in the given directory. +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + + return path +} + +// readFile reads content from a file. +func readFile(t *testing.T, path string) string { + t.Helper() + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Failed to read file %s: %v", path, err) + } + + return string(content) +} + +// fileExists checks if a file exists. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// TestHelp verifies the help command works. +func TestHelp(t *testing.T) { + stdout, _, err := runRender(t, "--help") + if err != nil { + t.Fatalf("render --help failed: %v", err) + } + + expectedPhrases := []string{ + "render", + "template", + "--output", + "--force", + "--dry-run", + } + + for _, phrase := range expectedPhrases { + if !strings.Contains(stdout, phrase) { + t.Errorf("Help output missing %q", phrase) + } + } +} + +// TestFileBasic tests basic file rendering. +func TestFileBasic(t *testing.T) { + dir := createTempDir(t) + + // Create template + tmpl := writeFile(t, dir, "template.txt", "Hello, {{ .name }}!") + + // Create data + data := writeFile(t, dir, "data.json", `{"name": "World"}`) + + // Output path + output := filepath.Join(dir, "output.txt") + + // Run render with new syntax + stdout, stderr, err := runRender(t, tmpl, data, "-o", output) + + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify output + content := readFile(t, output) + if content != "Hello, World!" { + t.Errorf("Output content = %q, want %q", content, "Hello, World!") + } +} + +// TestFileWithCustomFunctions tests file rendering with custom template functions. +func TestFileWithCustomFunctions(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", `{{ upper .name }} +{{ lower .name }} +{{ camelCase .title }} +{{ snakeCase .title }} +{{ kebabCase .title }}`) + + data := writeFile(t, dir, "data.json", `{ + "name": "Hello World", + "title": "My Awesome Project" + }`) + + output := filepath.Join(dir, "output.txt") + + _, _, err := runRender(t, tmpl, data, "-o", output) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + content := readFile(t, output) + expectedLines := []string{ + "HELLO WORLD", + "hello world", + "myAwesomeProject", + "my_awesome_project", + "my-awesome-project", + } + + for _, line := range expectedLines { + if !strings.Contains(content, line) { + t.Errorf("Output missing %q:\n%s", line, content) + } + } +} + +// TestFileWithYAML tests file rendering with YAML data. +func TestFileWithYAML(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "{{ .name }} - {{ .version }}") + + data := writeFile(t, dir, "data.yaml", `name: MyApp +version: 1.0.0`) + + output := filepath.Join(dir, "output.txt") + + _, _, err := runRender(t, tmpl, data, "-o", output) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + content := readFile(t, output) + if content != "MyApp - 1.0.0" { + t.Errorf("Output content = %q, want %q", content, "MyApp - 1.0.0") + } +} + +// TestFileOverwriteProtection tests that files aren't overwritten without --force. +func TestFileOverwriteProtection(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "new content") + data := writeFile(t, dir, "data.json", `{}`) + output := writeFile(t, dir, "output.txt", "original content") + + // Run without --force + _, stderr, err := runRender(t, tmpl, data, "-o", output) + if err == nil { + t.Fatal("render should fail without --force when file exists") + } + + // Check exit code is 5 (OutputConflict) + if exitCode := getExitCode(err); exitCode != 5 { + t.Errorf("Expected exit code 5, got %d", exitCode) + } + + if !strings.Contains(stderr, "exists") && !strings.Contains(stderr, "force") { + t.Errorf("Error message should mention file exists: %s", stderr) + } + + // Verify original content unchanged + content := readFile(t, output) + if content != "original content" { + t.Errorf("File was modified without --force: %q", content) + } +} + +// TestFileForceOverwrite tests that --force allows overwriting. +func TestFileForceOverwrite(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "new content") + data := writeFile(t, dir, "data.json", `{}`) + output := writeFile(t, dir, "output.txt", "original content") + + // Run with --force + _, _, err := runRender(t, tmpl, data, "-o", output, "--force") + if err != nil { + t.Fatalf("render --force failed: %v", err) + } + + content := readFile(t, output) + if content != "new content" { + t.Errorf("File content = %q, want %q", content, "new content") + } +} + +// TestFileMissingTemplate tests error handling for missing template. +func TestFileMissingTemplate(t *testing.T) { + dir := createTempDir(t) + + data := writeFile(t, dir, "data.json", `{}`) + output := filepath.Join(dir, "output.txt") + + _, _, err := runRender(t, filepath.Join(dir, "nonexistent.txt"), data, "-o", output) + + if err == nil { + t.Fatal("render should fail with missing template") + } + + // Check exit code is 3 (InputValidation) + if exitCode := getExitCode(err); exitCode != 3 { + t.Errorf("Expected exit code 3, got %d", exitCode) + } +} + +// TestFileMissingData tests error handling for missing data file. +func TestFileMissingData(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "{{ .name }}") + output := filepath.Join(dir, "output.txt") + + _, _, err := runRender(t, tmpl, filepath.Join(dir, "nonexistent.json"), "-o", output) + + if err == nil { + t.Fatal("render should fail with missing data file") + } + + // Check exit code is 3 (InputValidation) + if exitCode := getExitCode(err); exitCode != 3 { + t.Errorf("Expected exit code 3, got %d", exitCode) + } +} + +// TestFileMissingOutput tests that missing output flag fails. +func TestFileMissingOutput(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "{{ .name }}") + data := writeFile(t, dir, "data.json", `{}`) + + _, _, err := runRender(t, tmpl, data) + if err == nil { + t.Fatal("render should fail without output flag") + } +} + +// TestContentIdempotency tests that identical content skips write. +func TestContentIdempotency(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "Hello, World!") + data := writeFile(t, dir, "data.json", `{}`) + output := writeFile(t, dir, "output.txt", "Hello, World!") + + // Get original modification time + info, _ := os.Stat(output) + origModTime := info.ModTime() + + // Run render (without --force, but content is identical) + _, _, err := runRender(t, tmpl, data, "-o", output) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Check file wasn't rewritten (modification time should be same) + info, _ = os.Stat(output) + if !info.ModTime().Equal(origModTime) { + t.Log("Note: File was rewritten even though content is identical") + } + + // Verify content is still correct + content := readFile(t, output) + if content != "Hello, World!" { + t.Errorf("Content changed unexpectedly: %q", content) + } +} + +// TestArrayDataToSingleFile tests rendering array data to a single file. +func TestArrayDataToSingleFile(t *testing.T) { + dir := createTempDir(t) + + // Template that iterates over array + tmpl := writeFile(t, dir, "template.txt", `{{ range . }}{{ .name }} +{{ end }}`) + data := writeFile(t, dir, "data.json", `[{"name": "Alice"}, {"name": "Bob"}]`) + output := filepath.Join(dir, "output.txt") + + _, _, err := runRender(t, tmpl, data, "-o", output) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + content := readFile(t, output) + if !strings.Contains(content, "Alice") || !strings.Contains(content, "Bob") { + t.Errorf("Output missing expected names: %s", content) + } +} + +// TestFileIntoDirectory tests rendering a file into a directory (trailing slash). +func TestFileIntoDirectory(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt.tmpl", "Hello!") + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "outdir") + "/" + + _, _, err := runRender(t, tmpl, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Output should be outdir/template.txt (with .tmpl stripped) + outputFile := filepath.Join(dir, "outdir", "template.txt") + if !fileExists(outputFile) { + t.Errorf("Expected file at %s", outputFile) + } + + content := readFile(t, outputFile) + if content != "Hello!" { + t.Errorf("Content = %q, want %q", content, "Hello!") + } +} + +// TestIterativeFileRendering tests each mode with dynamic output path. +func TestIterativeFileRendering(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "item.tmpl", "Name: {{ .name }}") + data := writeFile(t, dir, "data.json", `[{"id": "1", "name": "Alice"}, {"id": "2", "name": "Bob"}]`) + + outputPattern := filepath.Join(dir, "{{.id}}.txt") + + stdout, stderr, err := runRender(t, tmpl, data, "-o", outputPattern) + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify both files were created + file1 := filepath.Join(dir, "1.txt") + file2 := filepath.Join(dir, "2.txt") + + if !fileExists(file1) { + t.Errorf("File not created: %s", file1) + } else { + content := readFile(t, file1) + if content != "Name: Alice" { + t.Errorf("File 1 content = %q, want %q", content, "Name: Alice") + } + } + + if !fileExists(file2) { + t.Errorf("File not created: %s", file2) + } else { + content := readFile(t, file2) + if content != "Name: Bob" { + t.Errorf("File 2 content = %q, want %q", content, "Name: Bob") + } + } +} + +// TestInternalPathCollision tests that internal collision is detected. +func TestInternalPathCollision(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "item.tmpl", "{{ .name }}") + // Both items produce the same output path! + data := writeFile(t, dir, "data.json", `[{"id": "same", "name": "Alice"}, {"id": "same", "name": "Bob"}]`) + + outputPattern := filepath.Join(dir, "{{.id}}.txt") + + _, stderr, err := runRender(t, tmpl, data, "-o", outputPattern) + if err == nil { + t.Fatal("render should fail with internal path collision") + } + + // Check exit code is 1 (RuntimeError) + if exitCode := getExitCode(err); exitCode != 1 { + t.Errorf("Expected exit code 1, got %d", exitCode) + } + + if !strings.Contains(stderr, "collision") { + t.Errorf("Error should mention collision: %s", stderr) + } +} + +// TestDryRun tests dry-run mode. +func TestDryRun(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "Hello!") + data := writeFile(t, dir, "data.json", `{}`) + output := filepath.Join(dir, "output.txt") + + stdout, _, err := runRender(t, tmpl, data, "-o", output, "--dry-run") + if err != nil { + t.Fatalf("render --dry-run failed: %v", err) + } + + // File should NOT be created + if fileExists(output) { + t.Error("File was created in dry-run mode") + } + + // Output should indicate what would happen + if !strings.Contains(stdout, "Dry run") && !strings.Contains(stdout, "create") { + t.Errorf("Dry run output should indicate what would happen: %s", stdout) + } +} + +// TestJSONOutput tests JSON output mode. +func TestJSONOutput(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "Hello!") + data := writeFile(t, dir, "data.json", `{}`) + output := filepath.Join(dir, "output.txt") + + stdout, _, err := runRender(t, tmpl, data, "-o", output, "--json") + if err != nil { + t.Fatalf("render --json failed: %v", err) + } + + if !strings.Contains(stdout, `"status"`) || !strings.Contains(stdout, `"success"`) { + t.Errorf("JSON output missing expected fields: %s", stdout) + } +} diff --git a/test/acceptance/config_test.go b/test/acceptance/config_test.go new file mode 100644 index 0000000..30b6c3a --- /dev/null +++ b/test/acceptance/config_test.go @@ -0,0 +1,491 @@ +package acceptance + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestConfigWithoutFile tests backwards compatibility - no config file. +func TestConfigWithoutFile(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "config.yaml.tmpl", "name: {{ .name }}") + writeFile(t, tmplDir, "static.txt", "static content") + + data := writeFile(t, dir, "data.json", `{"name": "TestApp"}`) + outputDir := filepath.Join(dir, "output") + + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify output + if !fileExists(filepath.Join(outputDir, "config.yaml")) { + t.Error("config.yaml not created") + } + if !fileExists(filepath.Join(outputDir, "static.txt")) { + t.Error("static.txt not created") + } +} + +// TestConfigWithPathTransformation tests path transformation via .render.yaml. +func TestConfigWithPathTransformation(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + // Create template file + writeFile(t, tmplDir, "model.go.tmpl", `package {{ .package }} + +type {{ .name | pascalCase }} struct {}`) + + // Create config file + writeFile(t, tmplDir, ".render.yaml", `paths: + "model.go.tmpl": "{{ .name | snakeCase }}.go" +`) + + data := writeFile(t, dir, "data.json", `{"name": "UserProfile", "package": "models"}`) + outputDir := filepath.Join(dir, "output") + + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify renamed output + expectedPath := filepath.Join(outputDir, "user_profile.go") + if !fileExists(expectedPath) { + t.Errorf("Expected file %s not created", expectedPath) + } + + // Verify content + content := readFile(t, expectedPath) + if !strings.Contains(content, "type UserProfile struct") { + t.Errorf("Content missing expected struct: %s", content) + } +} + +// TestConfigWithDirPrefixMapping tests directory prefix mapping. +func TestConfigWithDirPrefixMapping(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(filepath.Join(tmplDir, "server", "src", "main", "java"), 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + // Create template file in nested directory + writeFile(t, tmplDir, "server/src/main/java/ServiceImpl.java.tmpl", `package {{ .package }}; + +public class {{ .displayName }}ServiceImpl { + // {{ .displayName }} implementation +}`) + + // Create config with directory prefix mapping + writeFile(t, tmplDir, ".render.yaml", `paths: + "server/src/main/java/ServiceImpl.java.tmpl": "server/src/main/java/{{ .displayName }}ServiceImpl.java" + "server/src/main/java": "server/src/main/java/{{ .package | replace \".\" \"/\" }}" +`) + + data := writeFile(t, dir, "data.json", `{ + "id": "taxonomy", + "displayName": "Taxonomy", + "package": "com.example.taxonomy.server" +}`) + + outputDir := filepath.Join(dir, "output") + + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify output path was transformed + expectedPath := filepath.Join(outputDir, "server", "src", "main", "java", + "com", "example", "taxonomy", "server", "TaxonomyServiceImpl.java") + if !fileExists(expectedPath) { + t.Errorf("Expected file %s not created. Stdout: %s", expectedPath, stdout) + } + + // Verify content + if fileExists(expectedPath) { + content := readFile(t, expectedPath) + if !strings.Contains(content, "package com.example.taxonomy.server;") { + t.Errorf("Content missing package: %s", content) + } + if !strings.Contains(content, "class TaxonomyServiceImpl") { + t.Errorf("Content missing class: %s", content) + } + } +} + +// TestConfigSkipped tests that .render.yaml is not copied to output. +func TestConfigSkipped(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "file.txt", "content") + writeFile(t, tmplDir, ".render.yaml", `paths: {}`) + writeFile(t, tmplDir, ".render.yml", `paths: {}`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + _, _, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Verify config files are NOT copied + if fileExists(filepath.Join(outputDir, ".render.yaml")) { + t.Error(".render.yaml should not be copied to output") + } + if fileExists(filepath.Join(outputDir, ".render.yml")) { + t.Error(".render.yml should not be copied to output") + } + + // Verify regular file IS copied + if !fileExists(filepath.Join(outputDir, "file.txt")) { + t.Error("file.txt should be copied to output") + } +} + +// TestConfigUnknownKey tests error for unknown config keys. +func TestConfigUnknownKey(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "file.txt", "content") + writeFile(t, tmplDir, ".render.yaml", `files: + "file.txt": "output.txt" +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + _, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err == nil { + t.Fatal("Expected error for unknown config key") + } + + if !strings.Contains(stderr, "unknown key") { + t.Errorf("Expected 'unknown key' in error: %s", stderr) + } +} + +// TestConfigInvalidTemplate tests error for invalid template syntax. +func TestConfigInvalidTemplate(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "file.txt", "content") + writeFile(t, tmplDir, ".render.yaml", `paths: + "file.txt": "{{ .name | invalid" +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + _, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err == nil { + t.Fatal("Expected error for invalid template syntax") + } + + if !strings.Contains(stderr, "invalid template syntax") && !strings.Contains(stderr, "template") { + t.Errorf("Expected template error in stderr: %s", stderr) + } +} + +// TestConfigSourceNotExist tests error for non-existent source. +func TestConfigSourceNotExist(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, ".render.yaml", `paths: + "missing.txt": "output.txt" +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + _, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err == nil { + t.Fatal("Expected error for non-existent source") + } + + if !strings.Contains(stderr, "does not exist") { + t.Errorf("Expected 'does not exist' in error: %s", stderr) + } +} + +// TestExplicitControlFile tests --control flag for explicit control file. +func TestExplicitControlFile(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "model.go.tmpl", `package main`) + + // Create control file outside template directory + controlFile := writeFile(t, dir, "custom-render.yaml", `paths: + "model.go.tmpl": "custom_output.go" +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + _, _, err := runRender(t, tmplDir, data, "-o", outputDir, "--control", controlFile) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Verify renamed output using explicit control file + expectedPath := filepath.Join(outputDir, "custom_output.go") + if !fileExists(expectedPath) { + t.Errorf("Expected file %s not created", expectedPath) + } +} + +// TestConfigOverwriteFalse tests that overwrite: false preserves existing files. +func TestConfigOverwriteFalse(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + // Create template files + writeFile(t, tmplDir, "regular.txt.tmpl", "Regular content: {{ .name }}") + writeFile(t, tmplDir, "protected.txt.tmpl", "Protected content: {{ .name }}") + + // Create config with overwrite: false for protected file + writeFile(t, tmplDir, ".render.yaml", `paths: + "regular.txt.tmpl": "regular.txt" + "protected.txt.tmpl": + path: "protected.txt" + overwrite: false +`) + + data := writeFile(t, dir, "data.json", `{"name": "TestApp"}`) + outputDir := filepath.Join(dir, "output") + + // First render - both files created + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("First render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify both files created + regularPath := filepath.Join(outputDir, "regular.txt") + protectedPath := filepath.Join(outputDir, "protected.txt") + + if !fileExists(regularPath) { + t.Fatal("regular.txt not created") + } + if !fileExists(protectedPath) { + t.Fatal("protected.txt not created") + } + + // Modify the protected file (simulating user customization) + if err := os.WriteFile(protectedPath, []byte("User customized content"), 0644); err != nil { + t.Fatalf("Failed to write customized content: %v", err) + } + + // Second render with --force - protected file should be preserved + stdout, stderr, err = runRender(t, tmplDir, data, "-o", outputDir, "--force") + if err != nil { + t.Fatalf("Second render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify protected file content unchanged + protectedContent := readFile(t, protectedPath) + if protectedContent != "User customized content" { + t.Errorf("Protected file was overwritten. Content = %q, want %q", protectedContent, "User customized content") + } + + // Verify stdout mentions skipped (case-insensitive check) + lowerStdout := strings.ToLower(stdout) + if !strings.Contains(lowerStdout, "skipped") || !strings.Contains(lowerStdout, "no-overwrite") { + t.Errorf("Output should mention skipped file: %s", stdout) + } +} + +// TestConfigOverwriteFalseDryRun tests dry-run output for overwrite: false files. +func TestConfigOverwriteFalseDryRun(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "protected.txt.tmpl", "Protected content") + writeFile(t, tmplDir, ".render.yaml", `paths: + "protected.txt.tmpl": + path: "protected.txt" + overwrite: false +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + // Create existing file + if err := os.MkdirAll(outputDir, 0755); err != nil { + t.Fatalf("Failed to create output dir: %v", err) + } + writeFile(t, outputDir, "protected.txt", "Existing content") + + // Dry run should show skip action + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir, "--dry-run") + if err != nil { + t.Fatalf("Dry run failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + if !strings.Contains(stdout, "skip") && !strings.Contains(stdout, "no-overwrite") { + t.Errorf("Dry run should show skip action: %s", stdout) + } +} + +// TestConfigOverwriteFalseJSON tests JSON output for overwrite: false files. +func TestConfigOverwriteFalseJSON(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "protected.txt.tmpl", "Protected content") + writeFile(t, tmplDir, ".render.yaml", `paths: + "protected.txt.tmpl": + path: "protected.txt" + overwrite: false +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + // Create existing file + if err := os.MkdirAll(outputDir, 0755); err != nil { + t.Fatalf("Failed to create output dir: %v", err) + } + writeFile(t, outputDir, "protected.txt", "Existing content") + + // Render with --force --json should show skipped in JSON + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir, "--force", "--json") + if err != nil { + t.Fatalf("JSON render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + if !strings.Contains(stdout, "skipped") || !strings.Contains(stdout, "no-overwrite") { + t.Errorf("JSON output should show skipped action: %s", stdout) + } +} + +// TestConfigOverwriteFalseNewFile tests that overwrite: false still creates new files. +func TestConfigOverwriteFalseNewFile(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "protected.txt.tmpl", "Protected content: {{ .name }}") + writeFile(t, tmplDir, ".render.yaml", `paths: + "protected.txt.tmpl": + path: "protected.txt" + overwrite: false +`) + + data := writeFile(t, dir, "data.json", `{"name": "TestApp"}`) + outputDir := filepath.Join(dir, "output") + + // Render - file doesn't exist, should be created + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("Render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + protectedPath := filepath.Join(outputDir, "protected.txt") + if !fileExists(protectedPath) { + t.Fatal("Protected file should be created when it doesn't exist") + } + + content := readFile(t, protectedPath) + if !strings.Contains(content, "Protected content: TestApp") { + t.Errorf("Content = %q, want to contain 'Protected content: TestApp'", content) + } +} + +// TestConfigMixedPathFormats tests mixing string and object path formats. +func TestConfigMixedPathFormats(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "string-format.txt.tmpl", "String format") + writeFile(t, tmplDir, "object-format.txt.tmpl", "Object format") + writeFile(t, tmplDir, "explicit-true.txt.tmpl", "Explicit true") + + // Mix string and object formats in config + writeFile(t, tmplDir, ".render.yaml", `paths: + "string-format.txt.tmpl": "string-format.txt" + "object-format.txt.tmpl": + path: "object-format.txt" + overwrite: false + "explicit-true.txt.tmpl": + path: "explicit-true.txt" + overwrite: true +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("Render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify all files created + for _, name := range []string{"string-format.txt", "object-format.txt", "explicit-true.txt"} { + path := filepath.Join(outputDir, name) + if !fileExists(path) { + t.Errorf("File %s not created", name) + } + } +} diff --git a/test/acceptance/dir_test.go b/test/acceptance/dir_test.go new file mode 100644 index 0000000..a8b92d3 --- /dev/null +++ b/test/acceptance/dir_test.go @@ -0,0 +1,354 @@ +package acceptance + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestDirBasic tests basic directory rendering. +func TestDirBasic(t *testing.T) { + dir := createTempDir(t) + + // Create template directory structure + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "config.yaml.tmpl", `name: {{ .name }} +version: {{ .version }}`) + + writeFile(t, tmplDir, "readme.md.tmpl", `# {{ .name }} + +Version: {{ .version }}`) + + // Create data file + data := writeFile(t, dir, "data.json", `{ + "name": "MyProject", + "version": "1.0.0" + }`) + + // Output directory + outputDir := filepath.Join(dir, "output") + + // Run render + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify config.yaml (note: .tmpl extension stripped) + configPath := filepath.Join(outputDir, "config.yaml") + if !fileExists(configPath) { + t.Errorf("config.yaml not created") + } else { + content := readFile(t, configPath) + if !strings.Contains(content, "name: MyProject") { + t.Errorf("config.yaml missing name: %s", content) + } + if !strings.Contains(content, "version: 1.0.0") { + t.Errorf("config.yaml missing version: %s", content) + } + } + + // Verify readme.md + readmePath := filepath.Join(outputDir, "readme.md") + if !fileExists(readmePath) { + t.Errorf("readme.md not created") + } else { + content := readFile(t, readmePath) + if !strings.Contains(content, "# MyProject") { + t.Errorf("readme.md missing title: %s", content) + } + } +} + +// TestDirCopiesNonTemplates tests that non-.tmpl files are copied verbatim. +func TestDirCopiesNonTemplates(t *testing.T) { + dir := createTempDir(t) + + // Create template directory with mixed files + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + // Template file + writeFile(t, tmplDir, "config.txt.tmpl", "name={{ .name }}") + + // Non-template files (should be copied verbatim) + writeFile(t, tmplDir, "static.txt", "This is {{ .name }} static content") + writeFile(t, tmplDir, "binary.bin", "\x00\x01\x02\x03") + + data := writeFile(t, dir, "data.json", `{"name": "Test"}`) + outputDir := filepath.Join(dir, "output") + + _, _, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Verify template was rendered + configContent := readFile(t, filepath.Join(outputDir, "config.txt")) + if configContent != "name=Test" { + t.Errorf("Template not rendered: %q", configContent) + } + + // Verify static file was copied verbatim (not rendered) + staticContent := readFile(t, filepath.Join(outputDir, "static.txt")) + if staticContent != "This is {{ .name }} static content" { + t.Errorf("Static file was modified: %q", staticContent) + } + + // Verify binary file was copied + binaryContent := readFile(t, filepath.Join(outputDir, "binary.bin")) + if binaryContent != "\x00\x01\x02\x03" { + t.Errorf("Binary file was modified") + } +} + +// TestDirPreservesStructure tests that directory structure is preserved. +func TestDirPreservesStructure(t *testing.T) { + dir := createTempDir(t) + + // Create nested template directory structure + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(filepath.Join(tmplDir, "src", "components"), 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + if err := os.MkdirAll(filepath.Join(tmplDir, "config"), 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + writeFile(t, tmplDir, "root.txt.tmpl", "root: {{ .name }}") + writeFile(t, tmplDir, "src/main.go.tmpl", "package {{ .package }}") + writeFile(t, tmplDir, "src/components/button.tsx.tmpl", "// {{ .name }} button") + writeFile(t, tmplDir, "config/settings.json.tmpl", `{"app": "{{ .name }}"}`) + + data := writeFile(t, dir, "data.json", `{ + "name": "MyApp", + "package": "main" + }`) + + outputDir := filepath.Join(dir, "output") + + _, _, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Verify all files exist in correct locations + expectedFiles := []string{ + "root.txt", + "src/main.go", + "src/components/button.tsx", + "config/settings.json", + } + + for _, f := range expectedFiles { + path := filepath.Join(outputDir, f) + if !fileExists(path) { + t.Errorf("File not created: %s", f) + } + } + + // Verify content of nested file + mainContent := readFile(t, filepath.Join(outputDir, "src", "main.go")) + if mainContent != "package main" { + t.Errorf("Nested template not rendered: %q", mainContent) + } +} + +// TestDirWithYAML tests dir rendering with YAML data. +func TestDirWithYAML(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "output.txt.tmpl", "{{ .app.name }} v{{ .app.version }}") + + data := writeFile(t, dir, "data.yaml", `app: + name: TestApp + version: 2.0.0`) + + outputDir := filepath.Join(dir, "output") + + _, _, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + content := readFile(t, filepath.Join(outputDir, "output.txt")) + if content != "TestApp v2.0.0" { + t.Errorf("Output = %q, want %q", content, "TestApp v2.0.0") + } +} + +// TestDirOverwriteProtection tests overwrite protection for directories. +func TestDirOverwriteProtection(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + writeFile(t, tmplDir, "file.txt.tmpl", "new content") + + data := writeFile(t, dir, "data.json", `{}`) + + outputDir := filepath.Join(dir, "output") + writeFile(t, outputDir, "file.txt", "original content") + + // Run without --force + _, _, err := runRender(t, tmplDir, data, "-o", outputDir) + if err == nil { + t.Fatal("render should fail without --force when files exist") + } + + // Verify original unchanged + content := readFile(t, filepath.Join(outputDir, "file.txt")) + if content != "original content" { + t.Errorf("File was modified without --force") + } +} + +// TestDirForceOverwrite tests --force with directories. +func TestDirForceOverwrite(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + writeFile(t, tmplDir, "file.txt.tmpl", "new content") + + data := writeFile(t, dir, "data.json", `{}`) + + outputDir := filepath.Join(dir, "output") + writeFile(t, outputDir, "file.txt", "original content") + + // Run with --force + _, _, err := runRender(t, tmplDir, data, "-o", outputDir, "--force") + if err != nil { + t.Fatalf("render --force failed: %v", err) + } + + content := readFile(t, filepath.Join(outputDir, "file.txt")) + if content != "new content" { + t.Errorf("File content = %q, want %q", content, "new content") + } +} + +// TestDirNotDirectory tests error when template is not a directory. +func TestDirNotDirectory(t *testing.T) { + dir := createTempDir(t) + + // Create a file, not a directory - this will be treated as file mode + tmpl := writeFile(t, dir, "notdir.txt", "content") + data := writeFile(t, dir, "data.json", `{}`) + output := filepath.Join(dir, "output.txt") + + // This will work as file mode since template is a file + _, _, err := runRender(t, tmpl, data, "-o", output) + if err != nil { + t.Fatalf("render should work with file template: %v", err) + } +} + +// TestDirEmptyDirectory tests rendering an empty template directory. +func TestDirEmptyDirectory(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + // Should succeed even with empty directory + _, _, err := runRender(t, tmplDir, data, "-o", outputDir) + if err != nil { + t.Fatalf("render failed with empty directory: %v", err) + } +} + +// TestIterativeDirectoryRendering tests each mode with directory templates. +func TestIterativeDirectoryRendering(t *testing.T) { + dir := createTempDir(t) + + // Create template directory + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "config.txt.tmpl", "name={{ .name }}") + writeFile(t, tmplDir, "static.txt", "static") + + data := writeFile(t, dir, "data.json", `[{"id": "1", "name": "Alice"}, {"id": "2", "name": "Bob"}]`) + + // Dynamic output path for each mode + outputPattern := filepath.Join(dir, "output-{{.id}}") + + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputPattern) + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify directories were created + for _, id := range []string{"1", "2"} { + outDir := filepath.Join(dir, "output-"+id) + if !fileExists(filepath.Join(outDir, "config.txt")) { + t.Errorf("config.txt not created in %s", outDir) + } + if !fileExists(filepath.Join(outDir, "static.txt")) { + t.Errorf("static.txt not created in %s", outDir) + } + } + + // Verify content + content1 := readFile(t, filepath.Join(dir, "output-1", "config.txt")) + if content1 != "name=Alice" { + t.Errorf("Content = %q, want %q", content1, "name=Alice") + } + content2 := readFile(t, filepath.Join(dir, "output-2", "config.txt")) + if content2 != "name=Bob" { + t.Errorf("Content = %q, want %q", content2, "name=Bob") + } +} + +// TestDirWithDryRun tests dry-run mode with directories. +func TestDirWithDryRun(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + writeFile(t, tmplDir, "file.txt.tmpl", "content") + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + stdout, _, err := runRender(t, tmplDir, data, "-o", outputDir, "--dry-run") + if err != nil { + t.Fatalf("render --dry-run failed: %v", err) + } + + // Output directory should NOT be created + if fileExists(outputDir) { + t.Error("Output directory was created in dry-run mode") + } + + if !strings.Contains(stdout, "Dry run") && !strings.Contains(stdout, "render") { + t.Errorf("Dry run output should indicate what would happen: %s", stdout) + } +} diff --git a/test/acceptance/each_test.go b/test/acceptance/each_test.go new file mode 100644 index 0000000..6b41729 --- /dev/null +++ b/test/acceptance/each_test.go @@ -0,0 +1,301 @@ +package acceptance + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestEachBasicFile tests each mode with a file template and array data. +func TestEachBasicFile(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "user.txt.tmpl", `Name: {{ .name }} +ID: {{ .id }}`) + + // Create data with array (directly, no jq query needed) + data := writeFile(t, dir, "users.json", `[ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + {"id": 3, "name": "Charlie"} + ]`) + + // Output pattern with template expression (triggers each mode) + outputPattern := filepath.Join(dir, "output", "user-{{.id}}.txt") + + stdout, stderr, err := runRender(t, tmpl, data, "-o", outputPattern) + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify files were created + for i := 1; i <= 3; i++ { + path := filepath.Join(dir, "output", fmt.Sprintf("user-%d.txt", i)) + if !fileExists(path) { + t.Errorf("File not created: %s", path) + } + } + + // Verify content + content := readFile(t, filepath.Join(dir, "output", "user-1.txt")) + if !strings.Contains(content, "Name: Alice") || !strings.Contains(content, "ID: 1") { + t.Errorf("Unexpected content: %s", content) + } +} + +// TestEachWithCasingFunctions tests each mode with casing functions. +func TestEachWithCasingFunctions(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "component.txt.tmpl", `{{ pascalCase .name }}Component`) + + data := writeFile(t, dir, "components.json", `[ + {"name": "user_profile"}, + {"name": "shopping-cart"}, + {"name": "navigation menu"} + ]`) + + outputPattern := filepath.Join(dir, "output", "{{kebabCase .name}}.txt") + + _, _, err := runRender(t, tmpl, data, "-o", outputPattern) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Verify files with kebab-case names + expectedFiles := []string{ + "user-profile.txt", + "shopping-cart.txt", + "navigation-menu.txt", + } + + for _, f := range expectedFiles { + path := filepath.Join(dir, "output", f) + if !fileExists(path) { + t.Errorf("File not created: %s", f) + } + } +} + +// TestEachWithDirectory tests each mode with a directory template. +func TestEachWithDirectory(t *testing.T) { + dir := createTempDir(t) + + // Create template directory + tmplDir := filepath.Join(dir, "service-template") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "main.go.tmpl", `package {{ .name }}`) + writeFile(t, tmplDir, "README.md.tmpl", `# {{ .name }} Service`) + writeFile(t, tmplDir, "config.json", `{"service": "{{ .name }}"}`) + + data := writeFile(t, dir, "services.json", `[ + {"name": "auth"}, + {"name": "users"} + ]`) + + // Dynamic output path (triggers each-directory mode) + outputPattern := filepath.Join(dir, "output", "{{.name}}") + + stdout, stderr, err := runRender(t, tmplDir, data, "-o", outputPattern) + if err != nil { + t.Fatalf("render failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + // Verify directory structure for each service + for _, name := range []string{"auth", "users"} { + serviceDir := filepath.Join(dir, "output", name) + + mainPath := filepath.Join(serviceDir, "main.go") + if !fileExists(mainPath) { + t.Errorf("main.go not created in %s", name) + } else { + content := readFile(t, mainPath) + if !strings.Contains(content, "package "+name) { + t.Errorf("main.go content wrong: %s", content) + } + } + + readmePath := filepath.Join(serviceDir, "README.md") + if !fileExists(readmePath) { + t.Errorf("README.md not created in %s", name) + } + } +} + +// TestEachWithYAML tests each mode with YAML data. +func TestEachWithYAML(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "config.txt.tmpl", `name: {{ .name }} +port: {{ .port }}`) + + data := writeFile(t, dir, "services.yaml", `- name: web + port: 8080 +- name: api + port: 3000`) + + outputPattern := filepath.Join(dir, "output", "{{.name}}.yaml") + + _, _, err := runRender(t, tmpl, data, "-o", outputPattern) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Verify files + webContent := readFile(t, filepath.Join(dir, "output", "web.yaml")) + if !strings.Contains(webContent, "port: 8080") { + t.Errorf("Wrong content: %s", webContent) + } + + apiContent := readFile(t, filepath.Join(dir, "output", "api.yaml")) + if !strings.Contains(apiContent, "port: 3000") { + t.Errorf("Wrong content: %s", apiContent) + } +} + +// TestEachEmptyArray tests each mode with empty array. +func TestEachEmptyArray(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "item.txt.tmpl", "{{ .name }}") + data := writeFile(t, dir, "data.json", `[]`) + + outputPattern := filepath.Join(dir, "output", "{{.name}}.txt") + + // Should succeed but create no files + _, _, err := runRender(t, tmpl, data, "-o", outputPattern) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Output directory might not exist or be empty + if fileExists(filepath.Join(dir, "output")) { + entries, err := os.ReadDir(filepath.Join(dir, "output")) + if err == nil && len(entries) > 0 { + t.Errorf("Expected no output files, found %d", len(entries)) + } + } +} + +// TestEachForceOverwrite tests --force with each mode. +func TestEachForceOverwrite(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "item.txt.tmpl", "new: {{ .name }}") + data := writeFile(t, dir, "data.json", `[{"id": "1", "name": "Alice"}]`) + + // Pre-create the output file with different content + outputPattern := filepath.Join(dir, "{{.id}}.txt") + writeFile(t, dir, "1.txt", "original content") + + // Without force should fail + _, _, err := runRender(t, tmpl, data, "-o", outputPattern) + if err == nil { + t.Fatal("render should fail without --force") + } + + // With force should succeed + _, _, err = runRender(t, tmpl, data, "-o", outputPattern, "--force") + if err != nil { + t.Fatalf("render --force failed: %v", err) + } + + content := readFile(t, filepath.Join(dir, "1.txt")) + if !strings.Contains(content, "new: Alice") { + t.Errorf("Content not updated: %s", content) + } +} + +// TestEachWithObjectData tests each mode with object data (treated as single item). +func TestEachWithObjectData(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "item.txt.tmpl", "{{ .name }}") + // Object data - will be treated as single item array + data := writeFile(t, dir, "data.json", `{"id": "only", "name": "Single"}`) + + outputPattern := filepath.Join(dir, "{{.id}}.txt") + + _, _, err := runRender(t, tmpl, data, "-o", outputPattern) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + // Should create exactly one file + if !fileExists(filepath.Join(dir, "only.txt")) { + t.Error("File not created for single object") + } + + content := readFile(t, filepath.Join(dir, "only.txt")) + if content != "Single" { + t.Errorf("Content = %q, want %q", content, "Single") + } +} + +// TestEachDryRun tests dry-run with each mode. +func TestEachDryRun(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "item.txt.tmpl", "{{ .name }}") + data := writeFile(t, dir, "data.json", `[{"id": "1", "name": "Alice"}, {"id": "2", "name": "Bob"}]`) + + outputPattern := filepath.Join(dir, "output", "{{.id}}.txt") + + stdout, _, err := runRender(t, tmpl, data, "-o", outputPattern, "--dry-run") + if err != nil { + t.Fatalf("render --dry-run failed: %v", err) + } + + // No files should be created + if fileExists(filepath.Join(dir, "output")) { + t.Error("Output directory should not exist in dry-run mode") + } + + if !strings.Contains(stdout, "Dry run") { + t.Errorf("Output should indicate dry run: %s", stdout) + } +} + +// TestEachInternalCollision tests that internal collision is detected. +func TestEachInternalCollision(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "item.txt.tmpl", "{{ .name }}") + // Both items produce the same output path + data := writeFile(t, dir, "data.json", `[{"id": "same", "name": "Alice"}, {"id": "same", "name": "Bob"}]`) + + outputPattern := filepath.Join(dir, "{{.id}}.txt") + + _, stderr, err := runRender(t, tmpl, data, "-o", outputPattern) + if err == nil { + t.Fatal("render should fail with internal path collision") + } + + if !strings.Contains(stderr, "collision") { + t.Errorf("Error should mention collision: %s", stderr) + } +} + +// TestEachWithJSON tests JSON output mode. +func TestEachWithJSON(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "item.txt.tmpl", "{{ .name }}") + data := writeFile(t, dir, "data.json", `[{"id": "1", "name": "Alice"}]`) + + outputPattern := filepath.Join(dir, "{{.id}}.txt") + + stdout, _, err := runRender(t, tmpl, data, "-o", outputPattern, "--json") + if err != nil { + t.Fatalf("render --json failed: %v", err) + } + + if !strings.Contains(stdout, `"status"`) || !strings.Contains(stdout, `"success"`) { + t.Errorf("JSON output missing expected fields: %s", stdout) + } +} diff --git a/test/acceptance/failure_test.go b/test/acceptance/failure_test.go new file mode 100644 index 0000000..2b2bbb5 --- /dev/null +++ b/test/acceptance/failure_test.go @@ -0,0 +1,404 @@ +package acceptance + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestInvalidTemplateSyntax tests error handling for invalid Go template syntax. +func TestInvalidTemplateSyntax(t *testing.T) { + dir := createTempDir(t) + + tests := []struct { + name string + template string + errMsg string + }{ + { + name: "unclosed action", + template: "Hello {{ .name", + errMsg: "unclosed action", + }, + { + name: "unclosed range", + template: "{{ range .items }}item{{ end", + errMsg: "unclosed action", + }, + { + name: "invalid function", + template: "{{ nonexistentFunc .name }}", + errMsg: "not defined", + }, + { + name: "mismatched end", + template: "{{ if .x }}yes{{ else }}no{{ end }}{{ end }}", + errMsg: "unexpected", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl := writeFile(t, dir, "template.txt", tt.template) + data := writeFile(t, dir, "data.json", `{"name": "test", "items": [], "x": true}`) + output := filepath.Join(dir, "output.txt") + + _, stderr, err := runRender(t, tmpl, data, "-o", output) + if err == nil { + t.Fatal("should fail with invalid template syntax") + } + + if !strings.Contains(strings.ToLower(stderr), strings.ToLower(tt.errMsg)) { + t.Errorf("error should mention %q, got: %s", tt.errMsg, stderr) + } + }) + } +} + +// TestInvalidJSONData tests error handling for invalid JSON data. +func TestInvalidJSONData(t *testing.T) { + dir := createTempDir(t) + + tests := []struct { + name string + content string + }{ + {"unclosed object", `{"name": "test"`}, + {"unclosed array", `{"items": [1, 2, 3`}, + {"missing colon", `{"name" "test"}`}, + {"trailing comma", `{"name": "test",}`}, + {"unquoted key", `{name: "test"}`}, + {"single quotes", `{'name': 'test'}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl := writeFile(t, dir, "template.txt", "{{ .name }}") + data := writeFile(t, dir, "data.json", tt.content) + output := filepath.Join(dir, "output.txt") + + _, stderr, err := runRender(t, tmpl, data, "-o", output) + if err == nil { + t.Fatal("should fail with invalid JSON") + } + + if !strings.Contains(strings.ToLower(stderr), "json") && + !strings.Contains(strings.ToLower(stderr), "parse") && + !strings.Contains(strings.ToLower(stderr), "invalid") { + t.Errorf("error should mention JSON parsing issue, got: %s", stderr) + } + }) + } +} + +// TestInvalidYAMLData tests error handling for invalid YAML data. +func TestInvalidYAMLData(t *testing.T) { + dir := createTempDir(t) + + tests := []struct { + name string + content string + }{ + {"tabs in indentation", "name: test\n\t\tvalue: bad"}, + {"duplicate keys", "name: first\nname: second"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl := writeFile(t, dir, "template.txt", "{{ .name }}") + data := writeFile(t, dir, "data.yaml", tt.content) + output := filepath.Join(dir, "output.txt") + + _, stderr, err := runRender(t, tmpl, data, "-o", output) + // Note: Some YAML parsers are lenient, so we just check that it either fails + // or produces unexpected results. For strict validation, the error should occur. + if err != nil { + // Good - it detected the issue + if !strings.Contains(strings.ToLower(stderr), "yaml") && + !strings.Contains(strings.ToLower(stderr), "parse") && + !strings.Contains(strings.ToLower(stderr), "invalid") && + !strings.Contains(strings.ToLower(stderr), "error") { + t.Logf("Error message: %s", stderr) + } + } + // Some YAML issues might not cause errors (e.g., duplicate keys just overwrite) + // which is acceptable behavior + }) + } +} + +// TestUnsupportedDataFormat tests error handling for unsupported file extensions. +func TestUnsupportedDataFormat(t *testing.T) { + dir := createTempDir(t) + + tests := []struct { + name string + ext string + }{ + {"xml", "data.xml"}, + {"txt", "data.txt"}, + {"toml", "data.toml"}, + {"no extension", "data"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl := writeFile(t, dir, "template.txt", "{{ .name }}") + data := writeFile(t, dir, tt.ext, `name: test`) + output := filepath.Join(dir, "output.txt") + + _, stderr, err := runRender(t, tmpl, data, "-o", output) + if err == nil { + t.Fatal("should fail with unsupported data format") + } + + if !strings.Contains(strings.ToLower(stderr), "unsupported") && + !strings.Contains(strings.ToLower(stderr), "extension") && + !strings.Contains(strings.ToLower(stderr), "format") { + t.Errorf("error should mention unsupported format, got: %s", stderr) + } + }) + } +} + +// TestTemplateExecutionError tests error handling when template execution fails. +func TestTemplateExecutionError(t *testing.T) { + dir := createTempDir(t) + + tests := []struct { + name string + template string + data string + }{ + { + name: "nil map access", + template: "{{ .user.name }}", + data: `{"user": null}`, + }, + { + name: "index out of range", + template: "{{ index .items 10 }}", + data: `{"items": ["a", "b"]}`, + }, + { + name: "call on nil", + template: "{{ .func }}", + data: `{"func": null}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl := writeFile(t, dir, "template.txt", tt.template) + data := writeFile(t, dir, "data.json", tt.data) + output := filepath.Join(dir, "output.txt") + + _, _, err := runRender(t, tmpl, data, "-o", output) + // Some of these might not error depending on Go template behavior + // The important thing is that the tool doesn't crash + _ = err + }) + } +} + +// TestDirModeWithInvalidTemplates tests dir mode with invalid templates. +func TestDirModeWithInvalidTemplates(t *testing.T) { + dir := createTempDir(t) + + // Create template directory with one invalid template + tmplDir := filepath.Join(dir, "templates") + writeFile(t, tmplDir, "valid.txt.tmpl", "{{ .name }}") + writeFile(t, tmplDir, "invalid.txt.tmpl", "{{ .name") // Invalid syntax + + data := writeFile(t, dir, "data.json", `{"name": "test"}`) + outputDir := filepath.Join(dir, "output") + + _, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err == nil { + t.Fatal("should fail with invalid template in directory") + } + + if !strings.Contains(strings.ToLower(stderr), "unclosed") && + !strings.Contains(strings.ToLower(stderr), "invalid") && + !strings.Contains(strings.ToLower(stderr), "error") { + t.Errorf("error should mention template issue, got: %s", stderr) + } +} + +// TestOutputCollision tests that render dir fails when two templates +// produce the same output path. +func TestOutputCollision(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + // Create two templates that will produce the same output via .render.yaml + writeFile(t, tmplDir, "user.go.tmpl", "package user") + writeFile(t, tmplDir, "account.go.tmpl", "package account") + + // Configure both to map to the same output path + writeFile(t, tmplDir, ".render.yaml", `paths: + "user.go.tmpl": "output.go" + "account.go.tmpl": "output.go" +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + _, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err == nil { + t.Fatal("should fail with output path collision") + } + + if !strings.Contains(strings.ToLower(stderr), "collision") { + t.Errorf("error should mention collision, got: %s", stderr) + } +} + +// TestPathTraversal tests that path traversal is rejected. +func TestPathTraversal(t *testing.T) { + dir := createTempDir(t) + + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + writeFile(t, tmplDir, "file.txt", "content") + // Try to traverse outside the output directory + writeFile(t, tmplDir, ".render.yaml", `paths: + "file.txt": "../../../etc/passwd" +`) + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + _, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + if err == nil { + t.Fatal("should fail with path traversal attempt") + } + + // Should either mention traversal or security + if !strings.Contains(strings.ToLower(stderr), "traversal") && + !strings.Contains(strings.ToLower(stderr), "security") && + !strings.Contains(strings.ToLower(stderr), "outside") { + t.Errorf("error should mention security issue, got: %s", stderr) + } +} + +// TestSymlinkOutsideTemplateDir tests that symlinks are rejected. +func TestSymlinkOutsideTemplateDir(t *testing.T) { + // Skip on Windows where symlinks require special permissions + if os.Getenv("GOOS") == "windows" { + t.Skip("Skipping symlink test on Windows") + } + + dir := createTempDir(t) + + // Create a sensitive file outside the template directory + sensitiveFile := writeFile(t, dir, "sensitive.txt", "SECRET_DATA") + + // Create template directory + tmplDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + // Create a symlink pointing outside the template directory + symlinkPath := filepath.Join(tmplDir, "linked.txt") + if err := os.Symlink(sensitiveFile, symlinkPath); err != nil { + t.Fatalf("Failed to create symlink: %v", err) + } + + data := writeFile(t, dir, "data.json", `{}`) + outputDir := filepath.Join(dir, "output") + + _, stderr, err := runRender(t, tmplDir, data, "-o", outputDir) + + // With the new command, symlinks should be rejected + if err == nil { + t.Fatal("render should fail when template contains symlinks") + } + + if !strings.Contains(strings.ToLower(stderr), "symlink") { + t.Errorf("error should mention symlink, got: %s", stderr) + } +} + +// TestConversionErrors tests error handling for conversion functions. +func TestConversionErrors(t *testing.T) { + dir := createTempDir(t) + + tests := []struct { + name string + template string + data string + }{ + { + name: "toInt with invalid string", + template: `{{ toInt .value }}`, + data: `{"value": "not a number"}`, + }, + { + name: "toFloat with invalid string", + template: `{{ toFloat .value }}`, + data: `{"value": "not a float"}`, + }, + { + name: "toBool with invalid string", + template: `{{ toBool .value }}`, + data: `{"value": "not a bool"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl := writeFile(t, dir, "template.txt", tt.template) + data := writeFile(t, dir, "data.json", tt.data) + output := filepath.Join(dir, "output.txt") + + _, _, err := runRender(t, tmpl, data, "-o", output) + if err == nil { + t.Fatalf("should fail with conversion error for: %s", tt.name) + } + }) + } +} + +// TestModDivisionByZero tests error handling for mod with division by zero. +func TestModDivisionByZero(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", `{{ mod .a .b }}`) + data := writeFile(t, dir, "data.json", `{"a": 10, "b": 0}`) + output := filepath.Join(dir, "output.txt") + + _, stderr, err := runRender(t, tmpl, data, "-o", output) + if err == nil { + t.Fatal("should fail with division by zero") + } + + if !strings.Contains(strings.ToLower(stderr), "division") && + !strings.Contains(strings.ToLower(stderr), "zero") { + t.Errorf("error should mention division by zero, got: %s", stderr) + } +} + +// TestEmptyDataFile tests handling of empty data files. +func TestEmptyDataFile(t *testing.T) { + dir := createTempDir(t) + + tmpl := writeFile(t, dir, "template.txt", "Hello, {{ .name }}!") + data := writeFile(t, dir, "data.json", "") + output := filepath.Join(dir, "output.txt") + + _, _, err := runRender(t, tmpl, data, "-o", output) + // Empty JSON/YAML file should cause an error + if err == nil { + t.Log("Note: empty data file was accepted (might be valid depending on implementation)") + } +}