← Back to changelog

Full Diff · v0.0.11-preview.39

Changes since v0.0.11

.githooks/pre-commit
@@ -1,3 +1,6 @@
#!/bin/sh
-dotnet format --verify-no-changes
-dotnet test
+
+echo "Running Olav pre-commit..."
+
+dotnet format --verify-no-changes || exit 1
+dotnet build -c Debug || exit 1
.githooks/pre-push
new file mode 100755
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+echo "Running Olav pre-push..."
+
+dotnet format --verify-no-changes || exit 1
+dotnet build -c Release || exit 1
+dotnet test --configuration Release
+
+if [ $? -ne 0 ]; then
+ echo "Tests failed"
+ exit 1
+fi
workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
@@ -0,0 +1,74 @@
+name: Build
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ required: true
+ type: string
+ publish_to_nuget_org:
+ required: false
+ type: boolean
+ default: false
+ secrets:
+ NUGET_API_KEY:
+ required: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: write
+ packages: write
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - run: git fetch --tags
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Bump version in csproj
+ run: |
+ sed -i -E "s#<Version>[^<]*</Version>#<Version>${{ inputs.version }}</Version>#g" \
+ src/Olav.Cli/Olav.Cli.csproj
+
+ - run: dotnet restore
+ - run: dotnet build -c Release --no-restore
+
+ - name: Pack
+ run: |
+ dotnet pack -c Release \
+ -p:PackageVersion=${{ inputs.version }} \
+ --no-build \
+ -o ./nupkg
+
+ - name: Commit and tag
+ run: |
+ git config user.name "github-actions"
+ git config user.email "actions@github.com"
+ git add src/Olav.Cli/Olav.Cli.csproj
+ git commit -m "chore: bump version to ${{ inputs.version }}"
+ git pull --rebase origin main
+ git push origin main
+ git tag v${{ inputs.version }}
+ git push origin v${{ inputs.version }}
+
+ - name: Publish to GitHub Packages
+ run: |
+ dotnet nuget push ./nupkg/*.nupkg \
+ --skip-duplicate \
+ --api-key ${{ secrets.GITHUB_TOKEN }} \
+ --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
+
+ - name: Publish to nuget.org
+ if: inputs.publish_to_nuget_org
+ run: |
+ dotnet nuget push ./nupkg/*.nupkg \
+ --api-key ${{ secrets.NUGET_API_KEY }} \
+ --source https://api.nuget.org/v3/index.json
workflows/ci.yml b/.github/workflows/ci.yml
@@ -3,10 +3,11 @@ name: CI
on:
push:
branches:
- - "**"
+ - "main"
pull_request:
branches:
- main
+
jobs:
build:
runs-on: ubuntu-latest
@@ -33,3 +34,10 @@ jobs:
- name: Build solution
run: dotnet build --configuration Release --no-restore
+
+ - name: Install Olav CLI
+ run: ./scripts/install-local.sh
+
+ - name: Run tests with coverage
+ timeout-minutes: 5
+ run: dotnet test --configuration Release --no-build --verbosity diagnostic -- xUnit.MaxParallelThreads=1
workflows/docfx.yml b/.github/workflows/docfx.yml
new file mode 100644
@@ -0,0 +1,424 @@
+name: Documentation
+
+on:
+ workflow_call:
+
+jobs:
+ docs:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - run: git fetch --tags
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Resolve slot
+ run: |
+ REF="${GITHUB_REF}"
+ if [[ "$REF" =~ ^refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
+ echo "SLOT=releases/v${BASH_REMATCH[1]}" >> $GITHUB_ENV
+ echo "CURRENT_TAG=v${BASH_REMATCH[1]}" >> $GITHUB_ENV
+ echo "IS_RELEASE=true" >> $GITHUB_ENV
+ elif [[ "$REF" =~ ^refs/tags/(.*-rc\..*)$ ]]; then
+ echo "SLOT=release-candidate" >> $GITHUB_ENV
+ echo "CURRENT_TAG=${BASH_REMATCH[1]}" >> $GITHUB_ENV
+ echo "IS_RELEASE=false" >> $GITHUB_ENV
+ elif [[ "$REF" =~ ^refs/tags/(.*-preview\..*)$ ]]; then
+ echo "SLOT=preview" >> $GITHUB_ENV
+ echo "CURRENT_TAG=${BASH_REMATCH[1]}" >> $GITHUB_ENV
+ echo "IS_RELEASE=false" >> $GITHUB_ENV
+ else
+ CURRENT_TAG=$(git for-each-ref \
+ --sort=-creatordate \
+ --format='%(refname:short)' \
+ refs/tags \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-(rc|preview)\.[0-9]+$' \
+ | head -1)
+
+ if [[ -z "$CURRENT_TAG" ]]; then
+ CURRENT_TAG="HEAD"
+ fi
+
+ if [[ "$CURRENT_TAG" =~ -rc\. ]]; then
+ echo "SLOT=release-candidate" >> $GITHUB_ENV
+ else
+ echo "SLOT=preview" >> $GITHUB_ENV
+ fi
+ echo "CURRENT_TAG=${CURRENT_TAG}" >> $GITHUB_ENV
+ echo "IS_RELEASE=false" >> $GITHUB_ENV
+ fi
+
+ # ── FULL MODE ────────────────────────────────────────────────────────────
+
+ - name: Install DocFX
+ if: env.IS_RELEASE == 'true'
+ run: dotnet tool install -g docfx
+
+ - name: Build XML docs
+ if: env.IS_RELEASE == 'true'
+ run: |
+ dotnet build src/Olav.Cli/Olav.Cli.csproj \
+ -c Release /p:GenerateDocumentationFile=true
+
+ - name: Build DocFX site
+ if: env.IS_RELEASE == 'true'
+ run: docfx docs/docfx.json
+
+ # ── CHANGELOG MODE ───────────────────────────────────────────────────────
+
+ - name: Generate changelog and diff pages
+ if: env.IS_RELEASE == 'false'
+ run: |
+ CURRENT="${{ env.CURRENT_TAG }}"
+
+ LAST_RELEASE=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
+ | sort -rV \
+ | head -1)
+
+ if [[ -z "$LAST_RELEASE" ]]; then
+ RANGE="$CURRENT"
+ SINCE_LABEL="the beginning"
+ DIFF_BASE="$(git rev-list --max-parents=0 HEAD)"
+ else
+ RANGE="${LAST_RELEASE}..${CURRENT}"
+ SINCE_LABEL="$LAST_RELEASE"
+ DIFF_BASE="$LAST_RELEASE"
+ fi
+
+ mkdir -p docs/_site
+
+ # ── Aggregate file stat table ─────────────────────────────────────────
+ FILE_ROWS=$(git diff --stat "$DIFF_BASE" "$CURRENT" \
+ | grep '|' \
+ | sed 's/^ *//' \
+ | awk '
+ /\|/ {
+ split($0, a, "|");
+ file = a[1];
+ detail = a[2];
+ gsub(/^ +| +$/, "", file);
+ gsub(/^ +/, "", detail);
+ sub(/^[0-9]+ */, "", detail);
+
+ bar = detail;
+ gsub(/[^+\-]/, "", bar);
+
+ ins = (length(bar) > 0) ? split(bar, tmp, "+") - 1 : 0;
+ del = (length(bar) > 0) ? split(bar, tmp, "-") - 1 : 0;
+
+ print "<tr><td class=\"file\">" file "</td><td class=\"bar\">" bar "</td><td class=\"ins\">+" ins "</td><td class=\"del\">-" del "</td></tr>"
+ }
+ ')
+
+ STAT_SUMMARY=$(git diff --stat "$DIFF_BASE" "$CURRENT" | tail -1 | sed 's/^ *//')
+
+ if [[ -z "$FILE_ROWS" ]]; then
+ FILE_ROWS='<tr><td colspan="4" class="empty">No changes found.</td></tr>'
+ fi
+
+ # ── Full diff → diff.html ─────────────────────────────────────────────
+ DIFF_BODY=$(git diff "$DIFF_BASE" "$CURRENT" \
+ -- \
+ ':(exclude)*.csproj' \
+ ':(exclude)*.slnx' \
+ ':(exclude)*.sln' \
+ | sed \
+ -e 's/&/\&amp;/g' \
+ -e 's/</\&lt;/g' \
+ -e 's/>/\&gt;/g' \
+ | awk '
+ /^diff --git/ {
+ if (open) print "</div></div>"
+ # extract filename from "diff --git a/foo b/foo"
+ match($0, /b\/(.+)$/, arr)
+ fname = arr[1]
+ print "<div class=\"file-block\">"
+ print "<div class=\"file-header\"><span class=\"file-name\">" fname "</span></div>"
+ print "<div class=\"file-body\">"
+ open = 1
+ next
+ }
+ /^index / { next }
+ /^--- / { next }
+ /^\+\+\+ / { next }
+ /^Binary / { print "<div class=\"binary\">Binary file changed</div>"; next }
+ /^@@/ {
+ print "<div class=\"hunk\">" $0 "</div>"
+ next
+ }
+ /^\+/ { print "<div class=\"add\">" $0 "</div>"; next }
+ /^-/ { print "<div class=\"del\">" $0 "</div>"; next }
+ { print "<div class=\"ctx\">" $0 "</div>" }
+ END { if (open) print "</div></div>" }
+ ')
+
+ cat > docs/_site/diff.html <<DIFFEOF
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Full Diff — ${CURRENT} | Olav</title>
+ <style>
+ *, *::before, *::after { box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ max-width: 1100px; margin: 60px auto; padding: 0 24px;
+ color: #1a1a1a; background: #fff;
+ }
+ .back { font-size: 0.85rem; color: #888; text-decoration: none; display: inline-block; margin-bottom: 32px; }
+ .back:hover { color: #0969da; }
+ header { margin-bottom: 40px; }
+ h1 { font-size: 1.6rem; margin: 0 0 4px; }
+ .subtitle { color: #666; font-size: 0.95rem; margin: 0; }
+
+ .file-block { margin-bottom: 32px; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
+ .file-header {
+ padding: 10px 16px; background: #f6f8fa;
+ border-bottom: 1px solid #e5e7eb;
+ font-family: monospace; font-size: 0.85rem;
+ }
+ .file-name { font-weight: 600; color: #1a1a1a; }
+ .file-body { overflow-x: auto; }
+ .file-body div { font-family: monospace; font-size: 0.82rem; padding: 1px 16px; white-space: pre; line-height: 1.5; }
+ .hunk { background: #dbeafe; color: #1e40af; }
+ .add { background: #dcfce7; color: #14532d; }
+ .del { background: #fee2e2; color: #7f1d1d; }
+ .ctx { color: #374151; }
+ .binary { padding: 8px 16px; color: #888; font-style: italic; font-size: 0.85rem; }
+ </style>
+ </head>
+ <body>
+ <a class="back" href="index.html">← Back to changelog</a>
+ <header>
+ <h1>Full Diff · ${CURRENT}</h1>
+ <p class="subtitle">Changes since ${SINCE_LABEL}</p>
+ </header>
+ ${DIFF_BODY}
+ </body>
+ </html>
+ DIFFEOF
+
+ # ── index.html ────────────────────────────────────────────────────────
+ cat > docs/_site/index.html <<IDXEOF
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Changelog — ${CURRENT} | Olav</title>
+ <style>
+ *, *::before, *::after { box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ max-width: 900px; margin: 60px auto; padding: 0 24px;
+ color: #1a1a1a; background: #fff;
+ }
+ header { margin-bottom: 40px; }
+ h1 { font-size: 1.6rem; margin: 0 0 4px; }
+ .subtitle { color: #666; font-size: 0.95rem; margin: 0 0 12px; }
+ .diff-link {
+ display: inline-block; margin-top: 4px;
+ font-size: 0.88rem; color: #0969da; text-decoration: none;
+ }
+ .diff-link:hover { text-decoration: underline; }
+ .back { font-size: 0.85rem; color: #888; text-decoration: none; display: inline-block; margin-bottom: 32px; }
+ .back:hover { color: #0969da; }
+ .summary { font-size: 0.85rem; color: #555; margin-bottom: 24px; font-family: monospace; }
+ table.diff-stat {
+ width: 100%; border-collapse: collapse;
+ font-size: 0.85rem; font-family: monospace;
+ border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;
+ }
+ table.diff-stat thead th {
+ text-align: left; padding: 8px 12px;
+ font-size: 0.75rem; color: #888;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f6f8fa;
+ }
+ table.diff-stat tbody tr:hover { background: #f6f8fa; }
+ table.diff-stat td { padding: 5px 12px; vertical-align: middle; }
+ td.file { color: #1a1a1a; word-break: break-all; }
+ td.bar { font-family: monospace; letter-spacing: -1px; color: #555; }
+ td.ins { color: #1a7f37; font-weight: 600; white-space: nowrap; }
+ td.del { color: #cf222e; font-weight: 600; white-space: nowrap; }
+ .empty { color: #888; font-style: italic; }
+ </style>
+ </head>
+ <body>
+ <a class="back" href="../../">← All versions</a>
+ <header>
+ <h1>Changelog · ${CURRENT}</h1>
+ <p class="subtitle">Changes since ${SINCE_LABEL}</p>
+ <a class="diff-link" href="diff.html">View full diff →</a>
+ </header>
+ <p class="summary">${STAT_SUMMARY}</p>
+ <table class="diff-stat">
+ <thead><tr><th>File</th><th>Changes</th><th>+</th><th>-</th></tr></thead>
+ <tbody>${FILE_ROWS}</tbody>
+ </table>
+ </body>
+ </html>
+ IDXEOF
+
+ # ── SHARED: deploy to gh-pages ───────────────────────────────────────────
+
+ - name: Ensure gh-pages branch exists
+ run: |
+ if ! git ls-remote --exit-code --heads origin gh-pages; then
+ git config user.name "github-actions"
+ git config user.email "actions@github.com"
+ git checkout --orphan gh-pages
+ git rm -rf . --quiet
+ touch .nojekyll
+ git add .nojekyll
+ git commit -m "chore: init gh-pages"
+ git push origin gh-pages
+ git checkout -
+ fi
+
+ - uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+ path: gh-pages
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Deploy slot
+ run: |
+ SLOT="${{ env.SLOT }}"
+ DEST="gh-pages/$SLOT"
+
+ touch gh-pages/.nojekyll
+
+ if [[ "${{ env.IS_RELEASE }}" == "true" && -d "$DEST" ]]; then
+ echo "Release docs for $SLOT already exist — skipping."
+ exit 0
+ fi
+
+ rm -rf "$DEST"
+ mkdir -p "$DEST"
+ cp -r docs/_site/. "$DEST/"
+
+ - name: Update versions manifest
+ if: env.IS_RELEASE == 'true'
+ run: |
+ MANIFEST="gh-pages/versions.json"
+ SLOT="${{ env.SLOT }}"
+ node -e "
+ const fs = require('fs');
+ let versions = [];
+ try { versions = JSON.parse(fs.readFileSync('$MANIFEST', 'utf8')); } catch {}
+ if (!versions.includes('$SLOT')) versions.unshift('$SLOT');
+ fs.writeFileSync('$MANIFEST', JSON.stringify(versions, null, 2));
+ "
+
+ - name: Generate index pages
+ run: |
+ VERSIONS_JSON="gh-pages/versions.json"
+ RELEASES=$(node -e "
+ const fs = require('fs');
+ let versions = [];
+ try { versions = JSON.parse(fs.readFileSync('$VERSIONS_JSON', 'utf8')); } catch {}
+ console.log(versions.map(v =>
+ '<li><a href=\"' + v + '/\">' + v.replace('releases/', '') + '</a></li>'
+ ).join('\n'));
+ ")
+
+ cat > gh-pages/index.html <<'HTMLEOF'
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Olav Documentation</title>
+ <style>
+ body { font-family: sans-serif; max-width: 800px; margin: 60px auto; padding: 0 24px; color: #1a1a1a; }
+ h1 { font-size: 2rem; margin-bottom: 8px; }
+ p { color: #555; margin-bottom: 32px; }
+ h2 { font-size: 1.1rem; text-transform: uppercase; letter-spacing: 0.05em; color: #888; margin-bottom: 12px; }
+ ul { list-style: none; padding: 0; margin: 0 0 40px; }
+ li { margin-bottom: 8px; }
+ a { color: #0969da; text-decoration: none; font-size: 1rem; }
+ a:hover { text-decoration: underline; }
+ </style>
+ </head>
+ <body>
+ <h1>Olav</h1>
+ <p>Documentation for all versions of Olav.</p>
+ <h2>Releases</h2>
+ <ul>
+ RELEASES_PLACEHOLDER
+ </ul>
+ <h2>Pre-release</h2>
+ <ul>
+ <li><a href="release-candidate/">Release Candidate (changelog)</a></li>
+ <li><a href="preview/">Preview (changelog)</a></li>
+ </ul>
+ </body>
+ </html>
+ HTMLEOF
+
+ if [[ -z "$RELEASES" ]]; then
+ RELEASES='<li><em>No releases yet.</em></li>'
+ fi
+ sed -i "s|RELEASES_PLACEHOLDER|$RELEASES|" gh-pages/index.html
+
+ mkdir -p gh-pages/releases
+ RELEASE_LINKS=$(ls -1d gh-pages/releases/v*/ 2>/dev/null \
+ | sed 's|gh-pages/releases/||;s|/||' \
+ | sort -rV \
+ | sed 's|.*|<li><a href="&/">&</a></li>|')
+
+ cat > gh-pages/releases/index.html <<'HTMLEOF'
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Releases | Olav</title>
+ <style>
+ body { font-family: sans-serif; max-width: 800px; margin: 60px auto; padding: 0 24px; color: #1a1a1a; }
+ h1 { font-size: 2rem; margin-bottom: 8px; }
+ p { color: #555; margin-bottom: 32px; }
+ ul { list-style: none; padding: 0; }
+ li { margin-bottom: 8px; }
+ a { color: #0969da; text-decoration: none; font-size: 1rem; }
+ a:hover { text-decoration: underline; }
+ .back { font-size: 0.9rem; color: #888; margin-bottom: 32px; display: block; }
+ </style>
+ </head>
+ <body>
+ <a class="back" href="../">← Back</a>
+ <h1>Releases</h1>
+ <ul>
+ RELEASE_LINKS_PLACEHOLDER
+ </ul>
+ </body>
+ </html>
+ HTMLEOF
+
+ if [[ -z "$RELEASE_LINKS" ]]; then
+ RELEASE_LINKS='<li><em>No releases yet.</em></li>'
+ fi
+ sed -i "s|RELEASE_LINKS_PLACEHOLDER|$RELEASE_LINKS|" gh-pages/releases/index.html
+
+ - name: Commit and push gh-pages
+ run: |
+ cd gh-pages
+ git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
+ git config user.name "github-actions"
+ git config user.email "actions@github.com"
+ git add .
+ git diff --cached --quiet && echo "Nothing to commit" && exit 0
+ git commit -m "docs: deploy ${{ env.SLOT }}"
+ git push origin gh-pages
workflows/preview.yml b/.github/workflows/preview.yml
@@ -6,12 +6,11 @@ on:
- main
jobs:
- preview:
+ version:
runs-on: ubuntu-latest
- permissions:
- contents: write
- packages: write
+ outputs:
+ version: ${{ steps.compute.outputs.version }}
steps:
- uses: actions/checkout@v4
@@ -24,56 +23,23 @@ jobs:
with:
dotnet-version: "10.0.x"
- - name: Extract base version
+ - name: Compute preview version
+ id: compute
run: |
- BASE_VERSION=$(dotnet msbuild -nologo -v:q -getProperty:VersionPrefix)
-
- if [ -z "$BASE_VERSION" ]; then
- BASE_VERSION=$(dotnet msbuild -nologo -v:q -getProperty:Version)
- fi
-
- echo "BASE_VERSION=$BASE_VERSION" >> $GITHUB_ENV
-
- - name: Compute preview number
- run: |
- COUNT=$(git tag -l "v${BASE_VERSION}-preview.*" | wc -l)
- NEXT=$((COUNT+1))
-
- VERSION="${BASE_VERSION}-preview.${NEXT}"
-
- echo "VERSION=$VERSION" >> $GITHUB_ENV
- echo "Preview version: $VERSION"
-
- - name: Restore
- run: dotnet restore
-
- - name: Build
- run: dotnet build -c Release --no-restore
-
- - name: Pack
- run: |
- dotnet pack \
- -c Release \
- -p:PackageVersion=${{ env.VERSION }} \
- --no-build \
- -o ./nupkg
-
- - name: Create preview tag
- run: |
- git config user.name "github-actions"
- git config user.email "actions@github.com"
-
- if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
- echo "Preview tag already exists"
- exit 1
- fi
-
- git tag v${VERSION}
- git push origin v${VERSION}
-
- - name: Publish preview
- run: |
- dotnet nuget push ./nupkg/*.nupkg \
- --skip-duplicate \
- --api-key ${{ secrets.GITHUB_TOKEN }} \
- --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
+ BASE=$(dotnet msbuild src/Olav.Cli/Olav.Cli.csproj -nologo -v:q -getProperty:Version \
+ | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
+ COUNT=$(git tag -l "v${BASE}-preview.*" | wc -l | tr -d ' ')
+ NEXT=$((COUNT + 1))
+ echo "version=${BASE}-preview.${NEXT}" >> $GITHUB_OUTPUT
+
+ build:
+ needs: version
+ uses: ./.github/workflows/build.yml
+ with:
+ version: ${{ needs.version.outputs.version }}
+ secrets: inherit
+
+ docs:
+ needs: build
+ uses: ./.github/workflows/docfx.yml
+ secrets: inherit
workflows/release-candidate.yml b/.github/workflows/release-candidate.yml
@@ -4,64 +4,53 @@ on:
workflow_dispatch:
inputs:
preview_tag:
- description: "Preview tag to promote"
+ description: "Preview tag to promote (e.g. v0.0.9-preview.3)"
required: true
jobs:
- rc:
+ version:
runs-on: ubuntu-latest
- permissions:
- contents: write
- packages: write
+ outputs:
+ version: ${{ steps.compute.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ github.event.inputs.preview_tag }}
+ ref: main
fetch-depth: 0
- run: git fetch --tags
- - name: Extract version
- run: |
- TAG=${{ github.event.inputs.preview_tag }}
-
- BASE=$(echo $TAG | sed -E 's/v([0-9]+\.[0-9]+\.[0-9]+)-preview\.[0-9]+/\1/')
-
- COUNT=$(git tag -l "v$BASE-rc.*" | wc -l)
- NEXT=$((COUNT+1))
-
- VERSION="$BASE-rc.$NEXT"
-
- echo "VERSION=$VERSION" >> $GITHUB_ENV
-
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- - run: dotnet restore
- - run: dotnet build -c Release --no-restore
-
- - name: Pack
+ - name: Validate preview tag exists
run: |
- dotnet pack \
- -c Release \
- -p:PackageVersion=${VERSION} \
- --no-build \
- -o ./nupkg
-
- - name: Publish RC
+ TAG="${{ github.event.inputs.preview_tag }}"
+ if ! git rev-parse "$TAG" >/dev/null 2>&1; then
+ echo "Tag $TAG does not exist"
+ exit 1
+ fi
+
+ - name: Compute RC version
+ id: compute
run: |
- dotnet nuget push ./nupkg/*.nupkg \
- --skip-duplicate \
- --api-key ${{ secrets.GITHUB_TOKEN }} \
- --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
-
- - name: Create RC tag
- run: |
- git config user.name "github-actions"
- git config user.email "actions@github.com"
-
- git tag v${VERSION}
- git push origin v${VERSION}
+ BASE=$(dotnet msbuild src/Olav.Cli/Olav.Cli.csproj -nologo -v:q -getProperty:Version \
+ | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
+ COUNT=$(git tag -l "v${BASE}-rc.*" | wc -l | tr -d ' ')
+ NEXT=$((COUNT + 1))
+ echo "version=${BASE}-rc.${NEXT}" >> $GITHUB_OUTPUT
+
+ build:
+ needs: version
+ uses: ./.github/workflows/build.yml
+ with:
+ version: ${{ needs.version.outputs.version }}
+ secrets: inherit
+
+ docs:
+ needs: build
+ uses: ./.github/workflows/docfx.yml
+ secrets: inherit
workflows/release.yml b/.github/workflows/release.yml
@@ -4,15 +4,24 @@ on:
workflow_dispatch:
inputs:
rc_tag:
- description: "RC tag to promote (ex: v0.0.9-rc.5)"
+ description: "RC tag to promote (e.g. v0.0.9-rc.2)"
required: true
+ bump:
+ description: "Version component to bump (patch, minor, major). Defaults to patch."
+ required: false
+ default: "patch"
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
jobs:
- release:
+ version:
runs-on: ubuntu-latest
- permissions:
- contents: write
+ outputs:
+ version: ${{ steps.compute.outputs.version }}
steps:
- uses: actions/checkout@v4
@@ -25,95 +34,70 @@ jobs:
with:
dotnet-version: "10.0.x"
- - name: Store RC tag
+ - name: Validate and compute release version
+ id: compute
run: |
TAG="${{ github.event.inputs.rc_tag }}"
-
- if [[ ! $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
- echo "Invalid RC tag format"
+ if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-rc\.[0-9]+$ ]]; then
+ echo "Invalid RC tag: $TAG"
exit 1
fi
-
- echo "RC_TAG=$TAG" >> $GITHUB_ENV
- echo "RC tag: $TAG"
-
- - name: Read current version
- run: |
- CURRENT=$(dotnet msbuild -nologo -v:q -getProperty:Version)
- echo "CURRENT=$CURRENT" >> $GITHUB_ENV
-
- - name: Compute next version
- run: |
- IFS='.' read -r MAJOR MINOR PATCH <<< "${{ env.CURRENT }}"
-
- NEXT_PATCH=$((PATCH+1))
- NEXT="$MAJOR.$MINOR.$NEXT_PATCH"
-
- echo "NEXT=$NEXT" >> $GITHUB_ENV
- echo "Next version: $NEXT"
-
- - name: Validate RC
- run: |
- if [[ "${{ env.RC_TAG }}" != v"${{ env.CURRENT }}"-rc.* ]]; then
- echo "RC tag does not match current version"
+ BASE="${BASH_REMATCH[1]}"
+ CURRENT=$(dotnet msbuild src/Olav.Cli/Olav.Cli.csproj -nologo -v:q -getProperty:Version \
+ | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
+ if [[ "$BASE" != "$CURRENT" ]]; then
+ echo "RC tag base $BASE does not match csproj version $CURRENT"
exit 1
fi
- - name: Update version
- run: |
- sed -i -E "s#<Version>.*</Version>#<Version>${{ env.NEXT }}</Version>#g" BaseDDD.Cli.csproj
-
- - name: Commit bump
- run: |
- git config user.name "github-actions"
- git config user.email "actions@github.com"
+ IFS='.' read -r MAJ MIN PAT <<< "$BASE"
+ BUMP="${{ github.event.inputs.bump }}"
+ case "$BUMP" in
+ major) echo "version=$((MAJ + 1)).0.0" >> $GITHUB_OUTPUT ;;
+ minor) echo "version=$MAJ.$((MIN + 1)).0" >> $GITHUB_OUTPUT ;;
+ *) echo "version=$MAJ.$MIN.$((PAT + 1))" >> $GITHUB_OUTPUT ;;
+ esac
+
+ build:
+ needs: version
+ uses: ./.github/workflows/build.yml
+ with:
+ version: ${{ needs.version.outputs.version }}
+ publish_to_nuget_org: true
+ secrets: inherit
+
+ github-release:
+ needs: [version, build]
+ runs-on: ubuntu-latest
- git add BaseDDD.Cli.csproj
- git commit -m "chore: bump version to ${{ env.NEXT }}"
- git push origin main --force
+ permissions:
+ contents: write
- - name: Create tag
- run: |
- if git rev-parse "v${{ env.NEXT }}" >/dev/null 2>&1; then
- echo "Tag already exists!"
- exit 1
- fi
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- git tag v${{ env.NEXT }}
- git push origin v${{ env.NEXT }}
+ - run: git fetch --tags
- name: Generate release notes
id: notes
uses: actions/github-script@v7
with:
script: |
- const response = await github.rest.repos.generateReleaseNotes({
+ const { data } = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
- tag_name: "v${{ env.NEXT }}"
+ tag_name: "v${{ needs.version.outputs.version }}"
});
+ core.setOutput("notes", data.body);
- core.setOutput("notes", response.data.body);
-
- - name: Create GitHub Release
- uses: softprops/action-gh-release@v2
+ - uses: softprops/action-gh-release@v2
with:
- tag_name: v${{ env.NEXT }}
- name: v${{ env.NEXT }}
+ tag_name: v${{ needs.version.outputs.version }}
+ name: v${{ needs.version.outputs.version }}
body: ${{ steps.notes.outputs.notes }}
- - run: dotnet restore
- - run: dotnet build -c Release --no-restore
-
- - name: Pack
- run: |
- dotnet pack \
- -c Release \
- -p:PackageVersion=${{ env.NEXT }} \
- --no-build \
- -o ./nupkg
-
- - name: Publish
- run: |
- dotnet nuget push ./nupkg/*.nupkg \
- --api-key ${{ secrets.NUGET_API_KEY }} \
- --source https://api.nuget.org/v3/index.json
+ docs:
+ needs: build
+ uses: ./.github/workflows/docfx.yml
+ secrets: inherit
.gitignore
@@ -3,3 +3,4 @@
**/bin
**/obj
publish
+**/TestResults/
Commands/LintCommand.cs
deleted file mode 100644
@@ -1,28 +0,0 @@
-// <copyright file="LintCommand.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
-// Licensed under the MIT License. See LICENSE file in the project root for full license information.
-// </copyright>
-namespace BaseDDD.Commands;
-
-using BaseDDD.Infrastructure;
-
-/// <summary>
-/// BaseDDD lint project cli command.
-/// </summary>
-public static class LintCommand
-{
- /// <summary>
- /// Executes lint on project via cli tool.
- /// </summary>
- public static void Execute()
- {
- string root = Directory.GetCurrentDirectory();
-
- FileSystem.ValidateDirectory(root, "src");
- FileSystem.ValidateDirectory(root, "tests");
- FileSystem.ValidateDirectory(root, "docker");
-
- FileSystem.ValidateFile(root, "Directory.Build.props");
- FileSystem.ValidateFile(root, "stylecop.json");
- }
-}
Commands/VerifyCommand.cs
deleted file mode 100644
@@ -1,26 +0,0 @@
-// <copyright file="VerifyCommand.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
-// Licensed under the MIT License. See LICENSE file in the project root for full license information.
-// </copyright>
-namespace BaseDDD.Commands;
-
-using BaseDDD.Infrastructure;
-
-/// <summary>
-/// BaseDDD verify project structure command.
-/// </summary>
-public static class VerifyCommand
-{
- /// <summary>
- /// Executes verify on project via cli tool.
- /// </summary>
- public static void Execute()
- {
- LintCommand.Execute();
-
- string root = Directory.GetCurrentDirectory();
-
- DotnetRunner.Run("build", root);
- DotnetRunner.Run("test", root);
- }
-}
Directory.Build.props
@@ -1,9 +1,50 @@
<Project>
+
+ <!-- GLOBAL METADATA -->
+ <PropertyGroup>
+ <Authors>Olav</Authors>
+ <RepositoryUrl>https://github.com/lucascaovilla/olav</RepositoryUrl>
+ <PackageLicenseExpression>MIT</PackageLicenseExpression>
+ <PublishRepositoryUrl>true</PublishRepositoryUrl>
+ <IncludeSymbols>true</IncludeSymbols>
+ <SymbolPackageFormat>snupkg</SymbolPackageFormat>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <CollectCoverage>true</CollectCoverage>
+ <CoverletOutputFormat>cobertura</CoverletOutputFormat>
+ <Threshold>80</Threshold>
+ <ThresholdType>line</ThresholdType>
+ <ThresholdStat>Total</ThresholdStat>
+ </PropertyGroup>
+
+ <!-- GLOBAL BUILD RULES -->
<PropertyGroup>
- <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
- <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+
+ <!-- Documentation -->
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+
+ <!-- Enforce warnings -->
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ <WarningsAsErrors>CS1591</WarningsAsErrors>
+
+ <!-- Analyzer behavior -->
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
+
+ <!-- CI -->
+ <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
+
+ <!-- STYLECOP -->
+ <ItemGroup>
+ <PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+
+ <AdditionalFiles Include="$(MSBuildThisFileDirectory)stylecop.json" />
+ </ItemGroup>
+
</Project>
LICENSE
new file mode 100644
@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c) 2026 caovilla
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
README.md
@@ -1,32 +1,247 @@
-How to start developing:
+# Olav
-Must run:
+[![CI](https://github.com/lucascaovilla/olav/actions/workflows/ci.yml/badge.svg)](https://github.com/lucascaovilla/olav/actions/workflows/ci.yml)
+[![NuGet](https://img.shields.io/nuget/v/Olav.Cli.svg)](https://www.nuget.org/packages/Olav.Cli)
+[![NuGet Downloads](https://img.shields.io/nuget/dt/Olav.Cli.svg)](https://www.nuget.org/packages/Olav.Cli)
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
+
+> **Scaffold production-grade .NET APIs with strict Domain-Driven Design — from zero to architecture in one command.**
+
+---
+
+## Why Olav?
+
+**Sir Nils Olav III** is a king penguin living at Edinburgh Zoo. He holds the rank of Brigadier Sir Nils Olav III, Knight of the Order of St. Olav, in the Norwegian King's Guard — a military rank he inherited after decades of ceremonial inspection and promotion by the Norwegian Royal Guard. He did not earn it through battle. He earned it by showing up, standing straight, and being inspected and found correct every single time.
+
+**Saint Olav II** — Olav Haraldsson — was a Viking king who unified Norway under a single set of laws and a single faith. He didn't ask for permission. He built the structure first and let history decide whether it was right. It was. He's Norway's patron saint.
+
+Olav the tool borrows from both. It doesn't fight you. It doesn't ask questions. It shows up, stands straight, and generates a project that passes inspection before you've written a single line of business logic. Like the saint, it imposes structure not to constrain you — but because structure is what makes things last.
+
+---
+
+## What is Olav?
+
+Olav is a .NET CLI tool that generates production-ready API projects following strict **Domain-Driven Design (DDD)** architecture. Think of it as the [FastAPI](https://fastapi.tiangolo.com/) of the .NET world — opinionated, fast to start, and designed to enforce good practices from day one rather than letting them drift in over time.
+
+It is aimed at **beginner to mid-level .NET developers** who want to start building APIs the right way without spending days configuring architecture, tests, Docker, and CI/CD pipelines from scratch.
+
+A single command gives you:
+
+- A layered DDD solution (`Domain`, `Application`, `Infrastructure`, `Web`)
+- Architecture tests that **fail the build** if your layers talk to the wrong neighbours
+- Integration tests wired and ready
+- Observability middleware out of the box
+- Docker and Docker Compose configuration
+- GitHub Actions CI/CD pipeline
+- Git hooks enforcing code quality before every push
+- A `olav.json` contract file tracking your project's template version
+
+---
+
+## Prerequisites
+
+| Tool | Version |
+| -------- | ------------------ |
+| .NET SDK | 10.0+ |
+| Docker | Any recent version |
+| Git | Any recent version |
+
+---
+
+## Installation
+
+```bash
+dotnet tool install --global Olav.Cli
+```
+
+---
+
+## Quick Start
+
+```bash
+olav new MyApi
+```
+
+That's it. You now have a fully structured, architecture-tested, Docker-ready .NET API.
+
+### Options
+
+```bash
+olav new MyApi --owner "Acme Corp" --license "MIT"
+```
+
+| Option | Default | Description |
+| ----------- | ---------------- | ---------------------------------------------------- |
+| `--owner` | Your OS username | Sets the copyright owner in file headers and license |
+| `--license` | `MIT` | Sets the license type |
+
+---
+
+## Generated Project Structure
+
+```
+MyApi/
+├── src/
+│ ├── MyApi.Domain/ # Entities, value objects, domain events — no dependencies
+│ ├── MyApi.Application/ # Use cases, handlers, interfaces — depends only on Domain
+│ ├── MyApi.Infrastructure/ # Repositories, services, external integrations
+│ └── MyApi.Web/ # API controllers, middleware, observability, entry point
+├── tests/
+│ ├── MyApi.ArchitectureTests/ # Enforces DDD layer rules at build time
+│ └── MyApi.IntegrationTests/ # Integration tests wired and ready
+├── docker/
+├── Directory.Build.props
+├── Directory.Packages.props
+├── global.json
+└── olav.json # Template version contract
+```
+
+---
+
+## Architecture Enforcement
+
+This is the core of what Olav gives you. The generated `ArchitectureTests` project runs on every build and **fails loudly** if your code breaks DDD rules. There is no silent drift.
+
+Rules enforced out of the box:
+
+| Rule | What it prevents |
+| -------------------------------------- | ------------------------------------------------------- |
+| `Domain` has no outward dependencies | Domain referencing Infrastructure or Application |
+| `Application` depends only on `Domain` | Application importing Infrastructure directly |
+| All handlers live in `Application` | Business logic leaking into Web or Infrastructure |
+| All services live in `Infrastructure` | Infrastructure concerns bleeding into Application |
+| `Web` is the only entry point | Controllers or middleware defined outside the Web layer |
+
+In a future version these rules will also be enforced at development time via **Roslyn analyzers**, catching violations before the build even runs.
+
+---
+
+## Commands
+
+### `olav new`
+
+Generates a new DDD API project.
+
+```bash
+olav new MyApi
+olav new MyApi --owner "Acme Corp" --license "Apache-2.0"
+```
+
+### `olav lint`
+
+Validates your project's folder structure and layer organisation against Olav's rules.
+
+```bash
+olav lint
+```
+
+Run this in CI to catch structural regressions before they merge.
+
+### `olav verify`
+
+Validates architecture rules and confirms all required tests are present and passing.
+
+```bash
+olav verify
+```
+
+Stricter than lint — this is your full architectural health check.
+
+### `olav migrate`
+
+Shows pending template upgrades for your generated project. Use `--apply` to write the changes.
+
+```bash
+olav migrate # dry run — shows what would change
+olav migrate --apply # applies the migration
+```
+
+Olav tracks the template version your project was generated with inside `olav.json`. When the tool is updated with new conventions, `migrate` brings your project up to date without you having to start over.
+
+### `olav doctor` _(planned)_
+
+Diagnoses missing observability configuration, bad environment setup, or misconfigured tooling.
+
+---
+
+## The `olav.json` Contract
+
+Every generated project contains an `olav.json` at its root:
+
+```json
+{
+ "toolVersion": "0.1.0",
+ "templateVersion": "1.0",
+ "createdAt": "2025-01-15T10:30:00Z",
+ "updatedAt": "2025-01-15T10:30:00Z"
+}
+```
+
+This file is the handshake between your project and the tool. It lets `verify`, `lint`, and `migrate` know exactly what version of the conventions your project was built against — and what needs updating when conventions evolve.
+
+---
+
+## Roadmap
+
+Olav is at `v0.0.11`. The foundation is solid. Here's what's coming:
+
+| Feature | Description |
+| ------------------------------ | ----------------------------------------------------------------------- |
+| **Roslyn Enforcement** | Catch layer violations at dev time, not just build time |
+| **Doctor Mode** | Diagnose observability gaps, missing config, and setup issues |
+| **Plugin System** | Add infrastructure plugins (Postgres, Redis, RabbitMQ) with one command |
+| **Modular Monolith** | Generate modular monolith structures alongside standard DDD |
+| **Multi-Environment Support** | Environment-aware configuration scaffolding |
+| **Upgrade Command** | Upgrade generated projects to newer Olav conventions |
+| **Security Enforcement** | Enforce secure defaults in generated projects |
+| **Observability Hardening** | Structured logging, tracing, and metrics wired by default |
+| **Code Scaffolding** | Generate entities, handlers, and repositories inside existing projects |
+| **API Contract Enforcement** | Enforce API versioning and contract stability |
+| **Performance Baseline Suite** | Generate performance benchmarks alongside architecture tests |
+| **Modes System** | Switch between DDD modes (strict, relaxed, modular) |
+| **Documentation Generator** | Auto-generate API documentation from your domain model |
+| **Benchmark Suite** | Built-in benchmarking scaffolding with BenchmarkDotNet |
+
+---
+
+## Contributing
+
+Olav is open source and welcomes contributions. To get started locally:
+
+```bash
+git clone https://github.com/lucascaovilla/olav
+cd olav
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
+chmod +x .githooks/pre-push
+```
+
+To install locally for testing:
-Install locally for testing:
+```bash
chmod +x scripts/install-local.sh
./scripts/install-local.sh
+```
-Manually install:
+Or manually:
+
+```bash
dotnet build
dotnet pack
-dotnet tool uninstall --global baseddd.cli
-dotnet tool update --global --add-source ./nupkg BaseDDD.Cli
-
-baseddd new <ProjectName>
-baseddd lint
-baseddd verify
-baseddd doctor
-
-| Command | Responsibility |
-| ------- | ---------------------------------------- |
-| new | Generate strict project structure |
-| lint | Validate folder + layer rules |
-| verify | Validate architecture + required tests |
-| doctor | Diagnose missing observability or config |
-
-Usage:
-baseddd new MyApi --owner "<OwnerName>" --license "<License>"
-Owner is defaulted to OS base dir name
-License is defaulted to MIT
+dotnet tool uninstall --global Olav.Cli
+dotnet tool update --global --add-source ./nupkg Olav.Cli
+```
+
+---
+
+## License
+
+MIT © [Lucas Caovilla](https://github.com/lucascaovilla)
+
+---
+
+<p align="center">
+ Named after <a href="https://en.wikipedia.org/wiki/Nils_Olav">Sir Nils Olav III</a>, Brigadier of the Norwegian King's Guard,<br/>
+ and <a href="https://en.wikipedia.org/wiki/Olaf_II_of_Norway">Saint Olav II</a>, Viking king and patron saint of Norway.<br/>
+ Both imposed order. Both were right.
+</p>
docs/docfx.json
new file mode 100644
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
+ "metadata": [
+ {
+ "src": [
+ {
+ "src": "../",
+ "files": ["src/Olav.Cli/Olav.Cli.csproj"]
+ }
+ ]
+ }
+ ],
+ "build": {
+ "content": [
+ {
+ "files": ["**/*.{md,yml}"],
+ "exclude": ["_site/**"]
+ }
+ ],
+ "output": "_site",
+ "template": ["default", "modern"],
+ "globalMetadata": {
+ "_appName": "Olav",
+ "_appTitle": "Olav",
+ "_enableSearch": true,
+ "pdf": false
+ }
+ }
+}
docs/getting-started.md
new file mode 100644
@@ -0,0 +1 @@
+# Getting Started
\ No newline at end of file
docs/index.md
new file mode 100644
@@ -0,0 +1,11 @@
+---
+_layout: landing
+---
+
+# This is the **HOMEPAGE**.
+
+Refer to [Markdown](http://daringfireball.net/projects/markdown/) for how to write markdown files.
+
+## Quick Start Notes:
+
+1. Add images to the *images* folder if the file is referencing an image.
\ No newline at end of file
docs/introduction.md
new file mode 100644
@@ -0,0 +1 @@
+# Introduction
\ No newline at end of file
docs/toc.yml
new file mode 100644
@@ -0,0 +1,8 @@
+- name: Introduction
+ href: introduction.md
+- name: Getting Started
+ href: getting-started.md
+- name: Docs
+ href: docs/
+- name: API Reference
+ href: api/
scripts/install-local.sh
@@ -1,6 +1,11 @@
#!/usr/bin/env bash
set -e
-dotnet pack -c Release -o ./nupkg
-dotnet tool uninstall -g baseddd.cli || true
-dotnet tool install -g --add-source ./nupkg baseddd.cli
+VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Olav.Cli/Olav.Cli.csproj)
+
+rm -Rf ./nupkg ./obj
+dotnet pack src/Olav.Cli/Olav.Cli.csproj -c Release -o ./nupkg
+dotnet tool uninstall -g olav.cli || true
+dotnet tool install -g --add-source ./nupkg olav.cli --version "$VERSION"
+
+echo "Installed olav version $VERSION"
src/Olav.Cli/Commands/LintCommand.cs
new file mode 100644
@@ -0,0 +1,35 @@
+// <copyright file="LintCommand.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Commands;
+
+using Olav.Generation;
+using Olav.Helpers;
+using Olav.Verifiers;
+
+/// <summary>
+/// Olav lint project cli command.
+/// </summary>
+public static class LintCommand
+{
+ /// <summary>
+ /// Executes lint on project via cli tool.
+ /// </summary>
+ public static void Execute()
+ {
+ string root = ProjectRootHelper.FindProjectRoot(Directory.GetCurrentDirectory());
+
+ new VersionEnforcementGenerator(root).Check();
+
+ ProjectVerifier.VerifyDirectory(root, "src");
+
+ ProjectVerifier.VerifyDirectory(root, "tests");
+ ProjectVerifier.VerifyDirectory(root, "docker");
+
+ ProjectVerifier.VerifyFile(root, "Directory.Build.props");
+ ProjectVerifier.VerifyFile(root, "stylecop.json");
+
+ Console.WriteLine("Basic project structure verification passed!");
+ }
+}
src/Olav.Cli/Commands/MigrateCommand.cs
new file mode 100644
@@ -0,0 +1,26 @@
+// <copyright file="MigrateCommand.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Commands;
+
+using Olav.Generation;
+using Olav.Helpers;
+
+/// <summary>
+/// Olav migrate generated project's version cli command.
+/// </summary>
+public static class MigrateCommand
+{
+ /// <summary>
+ /// Executes migrate on project via cli tool.
+ /// </summary>
+ /// <param name="args">CLI arguments. Pass --apply to write changes.</param>
+ public static void Execute(string[] args)
+ {
+ bool apply = Array.Exists(args, a => a.Equals("--apply", StringComparison.OrdinalIgnoreCase));
+ string root = ProjectRootHelper.FindProjectRoot(Directory.GetCurrentDirectory());
+
+ new VersionEnforcementGenerator(root).Migrate(dryRun: !apply);
+ }
+}
src/Olav.Cli/Commands/NewCommand.cs
similarity index 59%
rename from Commands/NewCommand.cs
rename to src/Olav.Cli/Commands/NewCommand.cs
@@ -1,14 +1,13 @@
-// <copyright file="NewCommand.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="NewCommand.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Commands;
+namespace Olav.Commands;
-using BaseDDD.Generation;
-using BaseDDD.Infrastructure;
+using Olav.Generation;
/// <summary>
-/// BaseDDD new project cli command.
+/// Olav new project cli command.
/// </summary>
public static class NewCommand
{
@@ -36,37 +35,18 @@ public static class NewCommand
return;
}
- Console.WriteLine($"Creating BaseDDD project: {name}");
+ Console.WriteLine($"Creating Olav project: {name}");
- CreateBaseStructure(root);
-
- // Solution
+ new BaseStructureGenerator(root).Generate();
+ new VersionEnforcementGenerator(root).Generate();
new ProjectGenerator(name, root).Generate();
new TestGenerator(name, root).Generate();
new SolutionGenerator(name, root).Generate();
new FileTemplateGenerator(name, root, owner, license).Generate();
new ObservabilityGenerator(name, root, owner, license).Generate();
+ new GitGenerator(root).Generate();
- InitializeGit(root);
-
- Console.WriteLine("BaseDDD solution created successfully.");
- }
-
- private static void CreateBaseStructure(string root)
- {
- FileSystem.CreateDirectory(root);
- FileSystem.CreateDirectory(Path.Combine(root, "src"));
- FileSystem.CreateDirectory(Path.Combine(root, "tests"));
- FileSystem.CreateDirectory(Path.Combine(root, "docker"));
- }
-
- private static void InitializeGit(string root)
- {
- GitRunner.Run("init", root);
- GitRunner.Run("branch -m main", root);
- GitRunner.Run("config core.hooksPath .githooks", root);
- GitRunner.Run("add .", root);
- GitRunner.Run("commit -m \"Initial BaseDDD structure\"", root);
+ Console.WriteLine("Olav solution created successfully.");
}
private static string? GetOption(string[] args, string option)
src/Olav.Cli/Commands/VerifyCommand.cs
new file mode 100644
@@ -0,0 +1,29 @@
+// <copyright file="VerifyCommand.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Commands;
+
+using Olav.Helpers;
+using Olav.Verifiers;
+
+/// <summary>
+/// Olav verify project structure command.
+/// </summary>
+public static class VerifyCommand
+{
+ /// <summary>
+ /// Executes verify on project via cli tool.
+ /// </summary>
+ public static void Execute()
+ {
+ LintCommand.Execute();
+
+ string root = ProjectRootHelper.FindProjectRoot(Directory.GetCurrentDirectory());
+
+ DotnetVerifier.VerifyCommand(root, "build");
+ DotnetVerifier.VerifyCommand(root, "test");
+
+ Console.WriteLine("Project full verification passed!");
+ }
+}
src/Olav.Cli/Generation/BaseStructureGenerator.cs
new file mode 100644
@@ -0,0 +1,40 @@
+// <copyright file="BaseStructureGenerator.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Generation;
+
+using System.IO;
+using Olav.Infrastructure;
+
+/// <summary>
+/// Generates base folder structure for a Olav project.
+/// </summary>
+public class BaseStructureGenerator
+{
+ private readonly string root;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BaseStructureGenerator"/> class.
+ /// Class initializer.
+ /// </summary>
+ /// <param name="root">Repository's root.</param>
+ public BaseStructureGenerator(string root)
+ {
+ this.root = root;
+ }
+
+ /// <summary>
+ /// Centralized Generate method to properly create all base folders.
+ /// </summary>
+ public void Generate()
+ {
+ FileSystem.CreateDirectory(this.root);
+ FileSystem.CreateDirectory(Path.Combine(this.root, "src"));
+ FileSystem.CreateDirectory(Path.Combine(this.root, "tests"));
+ FileSystem.CreateDirectory(Path.Combine(this.root, "docker"));
+ FileSystem.CreateDirectory(Path.Combine(this.root, ".githooks"));
+ FileSystem.CreateDirectory(Path.Combine(this.root, ".github"));
+ FileSystem.CreateDirectory(Path.Combine(this.root, ".github/workflows"));
+ }
+}
\ No newline at end of file
src/Olav.Cli/Generation/FileTemplateGenerator.cs
similarity index 71%
rename from Generation/FileTemplateGenerator.cs
rename to src/Olav.Cli/Generation/FileTemplateGenerator.cs
@@ -1,16 +1,17 @@
-// <copyright file="FileTemplateGenerator.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="FileTemplateGenerator.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Generation;
+namespace Olav.Generation;
-using BaseDDD.Infrastructure;
-using BaseDDD.Templates;
+using System.IO;
+using Olav.Infrastructure;
+using Olav.Templates;
/// <summary>
-/// Generates all template-based files for a BaseDDD project.
+/// Generates all template-based files for a Olav project.
/// </summary>
-public sealed class FileTemplateGenerator
+public class FileTemplateGenerator
{
private readonly string name;
private readonly string root;
@@ -40,6 +41,8 @@ public sealed class FileTemplateGenerator
{
this.GenerateRootFiles();
this.GenerateGithub();
+ this.GenerateGit();
+ this.GenerateDocker();
this.GenerateWebFiles();
this.GenerateArchitectureTests();
this.GenerateIntegrationTests();
@@ -80,6 +83,48 @@ public sealed class FileTemplateGenerator
CiYmlTemplate.Generate());
}
+ private void GenerateGit()
+ {
+ FileSystem.WriteFile(
+ Path.Combine(this.root, ".gitignore"),
+ GitignoreTemplate.Generate());
+
+ FileSystem.WriteFile(
+ Path.Combine(this.root, ".githooks/pre-commit"),
+ PreCommitTemplate.Generate());
+
+ FileSystem.WriteFile(
+ Path.Combine(this.root, ".githooks/pre-push"),
+ PrePushTemplate.Generate());
+ }
+
+ private void GenerateDocker()
+ {
+ FileSystem.WriteFile(
+ Path.Combine(this.root, "docker/Dockerfile"),
+ DockerfileTemplate.Generate(this.name));
+
+ FileSystem.WriteFile(
+ Path.Combine(this.root, "docker/.dockerignore"),
+ DockerignoreTemplate.Generate());
+
+ FileSystem.WriteFile(
+ Path.Combine(this.root, "docker/docker-compose.yml"),
+ DockerComposeTemplate.GeneratePrd(this.name));
+
+ FileSystem.WriteFile(
+ Path.Combine(this.root, "docker/docker-compose.staging.yml"),
+ DockerComposeTemplate.GenerateStaging(this.name));
+
+ FileSystem.WriteFile(
+ Path.Combine(this.root, "docker/docker-compose.dev.yml"),
+ DockerComposeTemplate.GenerateDev(this.name));
+
+ FileSystem.WriteFile(
+ Path.Combine(this.root, "docker/docker-compose.local.yml"),
+ DockerComposeTemplate.GenerateLocal(this.name));
+ }
+
private void GenerateWebFiles()
{
string webPath = Path.Combine(this.root, "src", $"{this.name}.Web");
src/Olav.Cli/Generation/GitGenerator.cs
new file mode 100644
@@ -0,0 +1,47 @@
+// <copyright file="GitGenerator.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Generation;
+
+using Olav.Infrastructure;
+
+/// <summary>
+/// Generates base .git with initial commit.
+/// </summary>
+public class GitGenerator
+{
+ private readonly string root;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GitGenerator"/> class.
+ /// Class initializer.
+ /// </summary>
+ /// <param name="root">Repository's root.</param>
+ public GitGenerator(string root)
+ {
+ this.root = root;
+ }
+
+ /// <summary>
+ /// Centralized Generate method to properly start .git with initial commit.
+ /// </summary>
+ public void Generate()
+ {
+ GitRunner.Run("init", this.root);
+ GitRunner.Run("branch -m main", this.root);
+ GitRunner.Run("config core.hooksPath .githooks", this.root);
+
+ if (!OperatingSystem.IsWindows())
+ {
+ ProcessRunner.Run("chmod", "+x .githooks/pre-commit", this.root);
+ ProcessRunner.Run("chmod", "+x .githooks/pre-push", this.root);
+ }
+
+ GitRunner.Run("config user.email \"olav@olav.com\"", this.root);
+ GitRunner.Run("config user.name \"Olav\"", this.root);
+
+ GitRunner.Run("add .", this.root);
+ GitRunner.Run("commit -m \"Initial Olav structure\"", this.root);
+ }
+}
\ No newline at end of file
src/Olav.Cli/Generation/ObservabilityGenerator.cs
similarity index 87%
rename from Generation/ObservabilityGenerator.cs
rename to src/Olav.Cli/Generation/ObservabilityGenerator.cs
@@ -1,14 +1,15 @@
-// <copyright file="ObservabilityGenerator.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="ObservabilityGenerator.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Generation;
+namespace Olav.Generation;
-using BaseDDD.Infrastructure;
-using BaseDDD.Templates;
+using System.IO;
+using Olav.Infrastructure;
+using Olav.Templates;
/// <summary>
-/// Generates observability files for a BaseDDD project.
+/// Generates observability files for a Olav project.
/// </summary>
public class ObservabilityGenerator
{
src/Olav.Cli/Generation/ProjectGenerator.cs
similarity index 83%
rename from Generation/ProjectGenerator.cs
rename to src/Olav.Cli/Generation/ProjectGenerator.cs
@@ -1,13 +1,14 @@
-// <copyright file="ProjectGenerator.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="ProjectGenerator.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Generation;
+namespace Olav.Generation;
-using BaseDDD.Infrastructure;
+using System.IO;
+using Olav.Infrastructure;
/// <summary>
-/// Generates BaseDDD project.
+/// Generates Olav project.
/// </summary>
public class ProjectGenerator
{
@@ -64,11 +65,6 @@ public class ProjectGenerator
/// </summary>
private void AddPackages()
{
- DotnetRunner.Run($"add src/{this.name}.Domain/{this.name}.Domain.csproj package StyleCop.Analyzers", this.root);
- DotnetRunner.Run($"add src/{this.name}.Application/{this.name}.Application.csproj package StyleCop.Analyzers", this.root);
- DotnetRunner.Run($"add src/{this.name}.Infrastructure/{this.name}.Infrastructure.csproj package StyleCop.Analyzers", this.root);
-
- DotnetRunner.Run($"add src/{this.name}.Web/{this.name}.Web.csproj package StyleCop.Analyzers", this.root);
DotnetRunner.Run($"add src/{this.name}.Web/{this.name}.Web.csproj package Serilog.AspNetCore", this.root);
DotnetRunner.Run($"add src/{this.name}.Web/{this.name}.Web.csproj package Serilog.Sinks.Console", this.root);
src/Olav.Cli/Generation/SolutionGenerator.cs
similarity index 90%
rename from Generation/SolutionGenerator.cs
rename to src/Olav.Cli/Generation/SolutionGenerator.cs
@@ -1,13 +1,14 @@
-// <copyright file="SolutionGenerator.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="SolutionGenerator.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Generation;
+namespace Olav.Generation;
-using BaseDDD.Infrastructure;
+using System.IO;
+using Olav.Infrastructure;
/// <summary>
-/// Generates BaseDDD project solution.
+/// Generates Olav project solution.
/// </summary>
public class SolutionGenerator
{
src/Olav.Cli/Generation/TestGenerator.cs
similarity index 89%
rename from Generation/TestGenerator.cs
rename to src/Olav.Cli/Generation/TestGenerator.cs
@@ -1,15 +1,15 @@
-// <copyright file="TestGenerator.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="TestGenerator.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Generation;
+namespace Olav.Generation;
using System.IO;
using System.Xml.Linq;
-using BaseDDD.Infrastructure;
+using Olav.Infrastructure;
/// <summary>
-/// Generates tests files for a BaseDDD project.
+/// Generates tests files for a Olav project.
/// </summary>
public class TestGenerator
{
@@ -78,9 +78,6 @@ public class TestGenerator
/// </summary>
private void AddPackages()
{
- DotnetRunner.Run($"add tests/{this.name}.ArchitectureTests/{this.name}.ArchitectureTests.csproj package StyleCop.Analyzers", this.root);
- DotnetRunner.Run($"add tests/{this.name}.IntegrationTests/{this.name}.IntegrationTests.csproj package StyleCop.Analyzers", this.root);
-
DotnetRunner.Run($"add tests/{this.name}.ArchitectureTests/{this.name}.ArchitectureTests.csproj package coverlet.collector", this.root);
DotnetRunner.Run($"add tests/{this.name}.IntegrationTests/{this.name}.IntegrationTests.csproj package coverlet.collector", this.root);
src/Olav.Cli/Generation/VersionEnforcementGenerator.cs
new file mode 100644
@@ -0,0 +1,108 @@
+// <copyright file="VersionEnforcementGenerator.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Generation;
+
+using System.Text.Json;
+using Olav.Infrastructure;
+using Olav.Templates;
+
+/// <summary>
+/// Generates and migrates the olav.json version enforcement file.
+/// </summary>
+/// <remarks>
+/// Initializes a new instance of the <see cref="VersionEnforcementGenerator"/> class.
+/// </remarks>
+/// <param name="root">Repository's root.</param>
+public class VersionEnforcementGenerator(string root)
+{
+ private readonly string root = root;
+ private readonly string toolVersion = VersionConstants.ToolVersion;
+ private readonly string templateVersion = VersionConstants.TemplateVersion;
+
+ /// <summary>
+ /// Generates a fresh olav.json at the project root.
+ /// Fails loudly if one already exists.
+ /// </summary>
+ public void Generate()
+ {
+ string path = Path.Combine(this.root, "olav.json");
+
+ if (File.Exists(path))
+ {
+ throw new InvalidOperationException(
+ $"olav.json already exists at '{path}'. " +
+ $"This directory is already a Olav project. " +
+ $"To upgrade it, run 'olav migrate' instead.");
+ }
+
+ DateTime now = DateTime.UtcNow;
+
+ FileSystem.WriteFile(
+ path,
+ OlavJsonTemplate.Generate(this.toolVersion, this.templateVersion, now, now));
+
+ Console.WriteLine($"Project template created on version {this.templateVersion} with tool version {this.toolVersion}.");
+ }
+
+ /// <summary>
+ /// Migrates olav.json to the current template version,
+ /// preserving the original createdAt timestamp.
+ /// </summary>
+ /// <param name="dryRun">When true, prints the plan without writing anything.</param>
+ public void Migrate(bool dryRun)
+ {
+ string path = Path.Combine(this.root, "olav.json");
+
+ if (!File.Exists(path))
+ {
+ throw new InvalidOperationException(
+ $"olav.json not found at '{path}'. Is this a Olav project?");
+ }
+
+ using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(path));
+ JsonElement jsonRoot = doc.RootElement;
+
+ string previousTemplateVersion = jsonRoot.GetProperty("templateVersion").GetString()
+ ?? throw new InvalidOperationException("olav.json is missing 'templateVersion'.");
+
+ DateTime createdAt = jsonRoot.GetProperty("createdAt").GetDateTime();
+
+ MigrationRunner runner = MigrationRunner.Create();
+
+ if (dryRun)
+ {
+ runner.DryRun(this.root, previousTemplateVersion, this.templateVersion);
+ return;
+ }
+
+ runner.Apply(this.root, previousTemplateVersion, this.templateVersion);
+
+ FileSystem.WriteFile(
+ path,
+ OlavJsonTemplate.Generate(this.toolVersion, this.templateVersion, createdAt, DateTime.UtcNow));
+
+ Console.WriteLine($"Project template migrated from version {previousTemplateVersion} to {this.templateVersion} with tool version {this.toolVersion}.");
+ }
+
+ /// <summary>
+ /// Reads olav.json and checks compatibility against the current tool version.
+ /// No-ops if olav.json does not exist (pre-versioning projects).
+ /// </summary>
+ public void Check()
+ {
+ string path = Path.Combine(this.root, "olav.json");
+
+ if (!File.Exists(path))
+ {
+ return;
+ }
+
+ using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(path));
+ string templateVersion = doc.RootElement.GetProperty("templateVersion").GetString()
+ ?? throw new InvalidOperationException("olav.json is missing 'templateVersion'.");
+
+ CompatibilityChecker.Check(templateVersion);
+ }
+}
src/Olav.Cli/Helpers/ProjectRootHelper.cs
new file mode 100644
@@ -0,0 +1,41 @@
+// <copyright file="ProjectRootHelper.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Helpers;
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using System.Collections.Generic;
+
+/// <summary>
+/// Helper to find project's root and projects from a solution.
+/// </summary>
+public static class ProjectRootHelper
+{
+ /// <summary>
+ /// Finds the project root folder by locating any solution XML file (.slnx)
+ /// and returns its full path.
+ /// </summary>
+ /// <param name="startDirectory">Directory to start search from.</param>
+ /// <returns>Root directory containing a solution file.</returns>
+ public static string FindProjectRoot(string startDirectory)
+ {
+ DirectoryInfo? dir = new DirectoryInfo(startDirectory);
+
+ while (dir != null)
+ {
+ FileInfo? solutionFile = dir.GetFiles("*.slnx").FirstOrDefault();
+ if (solutionFile != null)
+ {
+ return dir.FullName;
+ }
+
+ dir = dir.Parent;
+ }
+
+ throw new InvalidOperationException($"Cannot find any solution file (*.slnx) from '{startDirectory}'.");
+ }
+}
src/Olav.Cli/Infrastructure/CompatibilityChecker.cs
new file mode 100644
@@ -0,0 +1,64 @@
+// <copyright file="CompatibilityChecker.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Infrastructure;
+
+/// <summary>
+/// Checks a project's template version against the tool's supported range.
+/// </summary>
+public static class CompatibilityChecker
+{
+ /// <summary>
+ /// Checks compatibility of the given project template version.
+ /// Warns to stdout for outdated versions, throws for unsupported ones.
+ /// </summary>
+ /// <param name="projectTemplateVersion">The templateVersion value from the project's olav.json.</param>
+ /// <exception cref="InvalidOperationException">
+ /// Thrown when the version is below minimum supported or newer than the tool knows.
+ /// </exception>
+ public static void Check(string projectTemplateVersion)
+ {
+ Version project = Parse(projectTemplateVersion);
+ Version current = Parse(VersionConstants.TemplateVersion);
+ Version min = Parse(VersionConstants.MinSupportedTemplateVersion);
+
+ if (project > current)
+ {
+ throw new InvalidOperationException(
+ $"This project uses template version {projectTemplateVersion}, " +
+ $"but this tool only supports up to {VersionConstants.TemplateVersion}. " +
+ $"Please update the Olav CLI.");
+ }
+
+ if (project < min)
+ {
+ throw new InvalidOperationException(
+ $"This project uses template version {projectTemplateVersion}, " +
+ $"which is below the minimum supported version {VersionConstants.MinSupportedTemplateVersion}. " +
+ $"Run 'olav migrate --apply' to upgrade before continuing.");
+ }
+
+ if (project < current)
+ {
+ Console.WriteLine(
+ $"Warning: this project uses template version {projectTemplateVersion} " +
+ $"(current: {VersionConstants.TemplateVersion}). " +
+ $"Run 'olav migrate' to see what would change.");
+ }
+ }
+
+ private static Version Parse(string raw)
+ {
+ string normalised = raw.Count(c => c == '.') switch
+ {
+ 0 => raw + ".0.0",
+ 1 => raw + ".0",
+ _ => raw,
+ };
+
+ return Version.TryParse(normalised, out Version? v)
+ ? v
+ : throw new InvalidOperationException($"Cannot parse version '{raw}'.");
+ }
+}
src/Olav.Cli/Infrastructure/DotnetRunner.cs
similarity index 71%
rename from Infrastructure/DotnetRunner.cs
rename to src/Olav.Cli/Infrastructure/DotnetRunner.cs
@@ -1,8 +1,8 @@
-// <copyright file="DotnetRunner.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="DotnetRunner.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Infrastructure;
+namespace Olav.Infrastructure;
using System.Diagnostics;
@@ -18,7 +18,7 @@ public static class DotnetRunner
/// <param name="workingDirectory">Reference directory to run.</param>
public static void Run(string args, string workingDirectory)
{
- Process process = new Process
+ Process process = new Process()
{
StartInfo = new ProcessStartInfo
{
@@ -32,11 +32,16 @@ public static class DotnetRunner
};
process.Start();
+
+ string stdout = process.StandardOutput.ReadToEnd();
+ string stderr = process.StandardError.ReadToEnd();
+
process.WaitForExit();
if (process.ExitCode != 0)
{
- throw new Exception($"dotnet {args} failed.");
+ throw new Exception(
+ $"dotnet {args} failed.\n\nSTDOUT:\n{stdout}\n\nSTDERR:\n{stderr}");
}
}
}
src/Olav.Cli/Infrastructure/FileSystem.cs
similarity index 76%
rename from Infrastructure/FileSystem.cs
rename to src/Olav.Cli/Infrastructure/FileSystem.cs
@@ -1,8 +1,8 @@
-// <copyright file="FileSystem.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="FileSystem.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Infrastructure;
+namespace Olav.Infrastructure;
/// <summary>
/// Provides filesystem level interaction.
@@ -16,15 +16,25 @@ public static class FileSystem
/// <param name="content">Content to be written.</param>
public static void WriteFile(string path, string content)
{
- string? dir = Path.GetDirectoryName(path);
+ try
+ {
+ string? directory = Path.GetDirectoryName(path);
+
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(path, content);
- if (dir == null)
+ Console.WriteLine($"[OK] {path}");
+ }
+ catch (Exception ex)
{
- throw new InvalidOperationException();
+ Console.WriteLine($"[ERROR] Failed to write {path}");
+ Console.WriteLine(ex.ToString());
+ throw;
}
-
- Directory.CreateDirectory(dir);
- File.WriteAllText(path, content);
}
/// <summary>
src/Olav.Cli/Infrastructure/GitRunner.cs
similarity index 65%
rename from Infrastructure/GitRunner.cs
rename to src/Olav.Cli/Infrastructure/GitRunner.cs
@@ -1,8 +1,8 @@
-// <copyright file="GitRunner.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="GitRunner.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Infrastructure;
+namespace Olav.Infrastructure;
using System.Diagnostics;
@@ -32,11 +32,21 @@ public static class GitRunner
};
process.Start();
+
+ string stdout = process.StandardOutput.ReadToEnd();
+ string stderr = process.StandardError.ReadToEnd();
+
process.WaitForExit();
if (process.ExitCode != 0)
{
- throw new Exception($"git {args} failed.");
+ if (args.StartsWith("commit") &&
+ (stdout.Contains("nothing to commit") || stdout.Contains("nothing added to commit")))
+ {
+ return;
+ }
+
+ throw new Exception($"git {args} failed.\n\nSTDOUT:\n{stdout}\n\nSTDERR:\n{stderr}");
}
}
}
src/Olav.Cli/Infrastructure/MigrationRunner.cs
new file mode 100644
@@ -0,0 +1,106 @@
+// <copyright file="MigrationRunner.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Infrastructure;
+
+using Olav.Migrations;
+
+/// <summary>
+/// Chains and executes <see cref="IMigrationStep"/> instances to bring a project
+/// from its current template version up to the tool's current template version.
+/// </summary>
+public sealed class MigrationRunner
+{
+ private readonly IReadOnlyList<IMigrationStep> steps;
+
+ private MigrationRunner(IReadOnlyList<IMigrationStep> steps)
+ {
+ this.steps = steps;
+ }
+
+ /// <summary>
+ /// Creates a runner pre-registered with all known migration steps.
+ /// </summary>
+ /// <returns>List of known migrations.</returns>
+ public static MigrationRunner Create()
+ {
+ return new ([new MigrationStep_1_0_To_1_1()]);
+ }
+
+ /// <summary>
+ /// Prints what would change without applying anything.
+ /// </summary>
+ /// <param name="root">Project root path.</param>
+ /// <param name="fromVersion">Current template version of the project.</param>
+ /// <param name="toVersion">Target template version.</param>
+ public void DryRun(string root, string fromVersion, string toVersion)
+ {
+ List<IMigrationStep> chain = this.BuildChain(fromVersion, toVersion);
+
+ if (chain.Count == 0)
+ {
+ Console.WriteLine($"Project is already at template version {toVersion}. Nothing to migrate.");
+ return;
+ }
+
+ Console.WriteLine($"Dry run: {fromVersion} → {toVersion}");
+ Console.WriteLine();
+
+ foreach (IMigrationStep step in chain)
+ {
+ Console.WriteLine($" [{step.FromVersion} → {step.ToVersion}]");
+ foreach (string line in step.Describe())
+ {
+ Console.WriteLine($" - {line}");
+ }
+
+ Console.WriteLine();
+ }
+
+ Console.WriteLine("Run 'olav migrate --apply' to apply these changes.");
+ }
+
+ /// <summary>
+ /// Applies all required migration steps in order.
+ /// olav.json is written by the caller (VersionEnforcementGenerator) after this returns.
+ /// </summary>
+ /// <param name="root">Project root path.</param>
+ /// <param name="fromVersion">Current template version of the project.</param>
+ /// <param name="toVersion">Target template version.</param>
+ public void Apply(string root, string fromVersion, string toVersion)
+ {
+ List<IMigrationStep> chain = this.BuildChain(fromVersion, toVersion);
+
+ if (chain.Count == 0)
+ {
+ Console.WriteLine($"Project is already at template version {toVersion}. Nothing to migrate.");
+ return;
+ }
+
+ foreach (IMigrationStep step in chain)
+ {
+ Console.WriteLine($" Applying [{step.FromVersion} → {step.ToVersion}]...");
+ step.Apply(root);
+ }
+ }
+
+ private List<IMigrationStep> BuildChain(string fromVersion, string toVersion)
+ {
+ List<IMigrationStep> chain = new List<IMigrationStep>();
+ string current = fromVersion;
+
+ while (current != toVersion)
+ {
+ IMigrationStep step = this.steps.FirstOrDefault(s => s.FromVersion == current)
+ ?? throw new InvalidOperationException(
+ $"No migration step found from '{current}'. " +
+ $"Migration chain is broken — please update the Olav CLI.");
+
+ chain.Add(step);
+ current = step.ToVersion;
+ }
+
+ return chain;
+ }
+}
src/Olav.Cli/Infrastructure/ProcessRunner.cs
new file mode 100644
@@ -0,0 +1,48 @@
+// <copyright file="ProcessRunner.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Infrastructure;
+
+using System.Diagnostics;
+
+/// <summary>
+/// Runner for generic system commands.
+/// </summary>
+public static class ProcessRunner
+{
+ /// <summary>
+ /// Runs generic system commands.
+ /// </summary>
+ /// <param name="fileName">Name of the command file to be run.</param>
+ /// <param name="arguments">Arguments to the command.</param>
+ /// <param name="workingDirectory">Reference directory to run.</param>
+ /// <returns>Returns command output.</returns>
+ public static string Run(string fileName, string arguments, string workingDirectory)
+ {
+ using Process process = new Process()
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ },
+ };
+
+ process.Start();
+ string output = process.StandardOutput.ReadToEnd();
+ string error = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ if (process.ExitCode != 0)
+ {
+ throw new Exception($"Command failed: {fileName} {arguments}\nSTDOUT:\n{output}\nSTDERR:\n{error}");
+ }
+
+ return output;
+ }
+}
src/Olav.Cli/Migrations/IMigrationStep.cs
new file mode 100644
@@ -0,0 +1,28 @@
+// <copyright file="IMigrationStep.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Migrations;
+
+/// <summary>
+/// A single migration step that upgrades a project from one template version to the next.
+/// </summary>
+public interface IMigrationStep
+{
+ /// <summary>Gets the template version this step migrates from.</summary>
+ public string FromVersion { get; }
+
+ /// <summary>Gets the template version this step migrates to.</summary>
+ public string ToVersion { get; }
+
+ /// <summary>
+ /// Returns a human-readable description of every change this step will make.
+ /// Used for dry-run output.
+ /// </summary>
+ /// <returns>List of changes to be applied.</returns>
+ public IReadOnlyList<string> Describe();
+
+ /// <summary>Applies the migration to the project at the given root.</summary>
+ /// <param name="root">Repository's root path.</param>
+ public void Apply(string root);
+}
src/Olav.Cli/Migrations/MigrationStep_1_0_To_1_1.cs
new file mode 100644
@@ -0,0 +1,30 @@
+// <copyright file="MigrationStep_1_0_To_1_1.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Migrations;
+
+/// <summary>
+/// Placeholder migration from template 1.0 to 1.1.
+/// Populate Describe() and Apply() when 1.1 is defined.
+/// </summary>
+internal sealed class MigrationStep_1_0_To_1_1 : IMigrationStep
+{
+ /// <inheritdoc/>
+ public string FromVersion => "1.0";
+
+ /// <inheritdoc/>
+ public string ToVersion => "1.1";
+
+ /// <inheritdoc/>
+ public IReadOnlyList<string> Describe()
+ {
+ return new List<string> { "Placeholder: no changes defined yet for 1.0 → 1.1." };
+ }
+
+ /// <inheritdoc/>
+ public void Apply(string root)
+ {
+ // No-op until 1.1 template changes are defined.
+ }
+}
\ No newline at end of file
src/Olav.Cli/Program.cs
similarity index 63%
rename from Program.cs
rename to src/Olav.Cli/Program.cs
@@ -1,15 +1,20 @@
-// <copyright file="Program.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="Program.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD;
-
using System;
-using BaseDDD.Commands;
+using System.Runtime.CompilerServices;
+using Olav.Commands;
+
+[assembly: InternalsVisibleTo("Olav.IntegrationTests")]
+[assembly: InternalsVisibleTo("Olav.ArchitectureTests")]
+[assembly: InternalsVisibleTo("Olav.UnitTests")]
+
+namespace Olav;
/// <summary>
-/// Entry point for the BaseDDD CLI.
+/// Entry point for the Olav CLI.
/// </summary>
internal static class Program
{
@@ -17,7 +22,7 @@ internal static class Program
/// Application entry point.
/// </summary>
/// <param name="args">Command-line arguments.</param>
- private static void Main(string[] args)
+ internal static void Main(string[] args)
{
if (args.Length == 0)
{
@@ -40,7 +45,9 @@ internal static class Program
case "verify":
VerifyCommand.Execute();
break;
-
+ case "migrate":
+ MigrateCommand.Execute(args);
+ break;
default:
PrintHelp();
break;
@@ -51,12 +58,12 @@ internal static class Program
{
Console.WriteLine(
"""
- BaseDDD CLI
+ Olav CLI
Usage:
- baseddd new <ProjectName>
- baseddd lint
- baseddd verify
+ olav new <ProjectName>
+ olav lint
+ olav verify
""");
}
}
src/Olav.Cli/Templates/CheckMissingToolTemplate.cs
new file mode 100644
@@ -0,0 +1,28 @@
+// <copyright file="CheckMissingToolTemplate.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Templates;
+
+/// <summary>
+/// Provides CheckMissingTool script template.
+/// </summary>
+public static class CheckMissingToolTemplate
+{
+ /// <summary>
+ /// Returns content of generated CheckMissingTool script.
+ /// </summary>
+ /// <returns>CheckMissingTool script content.</returns>
+ public static string Generate()
+ {
+ return """
+ echo "Running CheckMissingTool script checks..."
+
+ olav lint
+ if [ $? -ne 0 ]; then
+ echo "Verification failed"
+ exit 1
+ fi
+ """;
+ }
+}
src/Olav.Cli/Templates/CiYmlTemplate.cs
similarity index 77%
rename from Templates/CiYmlTemplate.cs
rename to src/Olav.Cli/Templates/CiYmlTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="CiYmlTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="CiYmlTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides ci.yml file template.
@@ -39,14 +39,14 @@ public static class CiYmlTemplate
with:
dotnet-version: "10.0.x"
- - name: Install BaseDDD CLI
- run: dotnet tool install -g BaseDDD.Cli
+ - name: Install Olav CLI
+ run: dotnet tool install -g Olav.Cli
- name: Restore dependencies
run: dotnet restore
- - name: Run BaseDDD verification
- run: baseddd verify
+ - name: Run Olav verification
+ run: olav verify
""";
}
}
src/Olav.Cli/Templates/CorrelationMiddlewareTemplate.cs
similarity index 91%
rename from Templates/CorrelationMiddlewareTemplate.cs
rename to src/Olav.Cli/Templates/CorrelationMiddlewareTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="CorrelationMiddlewareTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="CorrelationMiddlewareTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides CorrelationMiddleware.cs file template.
@@ -29,7 +29,7 @@ public static class CorrelationMiddlewareTemplate
/// <summary>
/// Middleware responsible for injecting correlation id into requests.
/// </summary>
- public sealed class CorrelationMiddleware
+ public class CorrelationMiddleware
{
private const string HeaderName = "X-Correlation-Id";
@@ -65,7 +65,7 @@ public static class CorrelationMiddlewareTemplate
context.Response.Headers[HeaderName] = correlationId;
- using Activity activity = new Activity("BaseDDD.Request");
+ using Activity activity = new Activity("Olav.Request");
activity.SetIdFormat(ActivityIdFormat.W3C);
activity.Start();
activity.SetTag("correlation.id", correlationId);
src/Olav.Cli/Templates/DependencyRulesTestsTemplate.cs
similarity index 94%
rename from Templates/DependencyRulesTestsTemplate.cs
rename to src/Olav.Cli/Templates/DependencyRulesTestsTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="DependencyRulesTestsTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="DependencyRulesTestsTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides DependencyRulesTests.cs file template.
@@ -28,7 +28,7 @@ public static class DependencyRulesTestsTemplate
/// <summary>
/// Enforces Clean Architecture dependency rules.
/// </summary>
- public sealed class DependencyRulesTests
+ public class DependencyRulesTests
{
private static readonly Assembly DomainAssembly =
Assembly.Load("{{name}}.Domain");
src/Olav.Cli/Templates/DirectoryBuildPropsTemplate.cs
similarity index 90%
rename from Templates/DirectoryBuildPropsTemplate.cs
rename to src/Olav.Cli/Templates/DirectoryBuildPropsTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="DirectoryBuildPropsTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="DirectoryBuildPropsTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides Directory.Build.props file template.
src/Olav.Cli/Templates/DirectoryPackagePropsTemplate.cs
similarity index 85%
rename from Templates/DirectoryPackagePropsTemplate.cs
rename to src/Olav.Cli/Templates/DirectoryPackagePropsTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="DirectoryPackagePropsTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="DirectoryPackagePropsTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides Directory.Package.props file template.
src/Olav.Cli/Templates/DockerComposeTemplate.cs
new file mode 100644
@@ -0,0 +1,110 @@
+// <copyright file="DockerComposeTemplate.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Templates;
+
+/// <summary>
+/// Provides docker-compose files templates.
+/// </summary>
+public static class DockerComposeTemplate
+{
+ /// <summary>
+ /// Returns content of generated production docker compose.
+ /// </summary>
+ /// <param name="name">Repository name.</param>
+ /// <returns>Content of production docker compose.</returns>
+ public static string GeneratePrd(string name)
+ {
+ return $"""
+ services:
+ api:
+ image: {name.ToLowerInvariant()}-api:latest
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
+ ports:
+ - "8080:8080"
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Production
+ """;
+ }
+
+ /// <summary>
+ /// Returns content of generated staging docker compose.
+ /// </summary>
+ /// <param name="name">Repository name.</param>
+ /// <returns>Content of staging docker compose.</returns>
+ public static string GenerateStaging(string name)
+ {
+ return $"""
+ services:
+ api:
+ image: {name.ToLowerInvariant()}-api:staging
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
+ ports:
+ - "8081:8080"
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Staging
+ """;
+ }
+
+ /// <summary>
+ /// Returns content of generated development docker compose.
+ /// </summary>
+ /// <param name="name">Repository name.</param>
+ /// <returns>Content of development docker compose.</returns>
+ public static string GenerateDev(string name)
+ {
+ return $"""
+ services:
+ api:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
+ volumes:
+ - ..:/src
+ working_dir: /src
+ command: dotnet watch run --project src/{name.ToLowerInvariant()}.Api
+ ports:
+ - "8082:8080"
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ """;
+ }
+
+ /// <summary>
+ /// Returns content of generated local docker compose.
+ /// </summary>
+ /// <param name="name">Repository name.</param>
+ /// <returns>Content of local docker compose.</returns>
+ public static string GenerateLocal(string name)
+ {
+ return $"""
+ services:
+ api:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
+ ports:
+ - "8080:8080"
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - ConnectionStrings__Default=Host=postgres;Port=5432;Database={name.ToLowerInvariant()};Username=postgres;Password=postgres
+ depends_on:
+ - postgres
+
+ postgres:
+ image: postgres:16
+ restart: always
+ environment:
+ POSTGRES_DB: {name.ToLowerInvariant()}
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - "5432:5432"
+ """;
+ }
+}
src/Olav.Cli/Templates/DockerfileTemplate.cs
new file mode 100644
@@ -0,0 +1,38 @@
+// <copyright file="DockerfileTemplate.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Templates;
+
+/// <summary>
+/// Provides Dockerfile file template.
+/// </summary>
+public static class DockerfileTemplate
+{
+ /// <summary>
+ /// Returns content of generated Dockerfile.
+ /// </summary>
+ /// <param name="name">Repository name.</param>
+ /// <returns>Dockerfile file content.</returns>
+ public static string Generate(string name)
+ {
+ return $"""
+ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+ WORKDIR /src
+
+ COPY . .
+ RUN dotnet restore
+ RUN dotnet publish -c Release -o /app/publish
+
+ FROM mcr.microsoft.com/dotnet/aspnet:10.0
+ WORKDIR /app
+
+ COPY --from=build /app/publish .
+
+ ENV ASPNETCORE_URLS=http://+:8080
+ EXPOSE 8080
+
+ ENTRYPOINT ["dotnet", "{name}.Api.dll"]
+ """;
+ }
+}
src/Olav.Cli/Templates/DockerignoreTemplate.cs
new file mode 100644
@@ -0,0 +1,29 @@
+// <copyright file="DockerignoreTemplate.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Templates;
+
+/// <summary>
+/// Provides .dockerignore file template.
+/// </summary>
+public static class DockerignoreTemplate
+{
+ /// <summary>
+ /// Returns content of generated .dockerignore.
+ /// </summary>
+ /// <returns>.dockerignore file content.</returns>
+ public static string Generate()
+ {
+ return """
+ bin/
+ obj/
+ .git/
+ .gitignore
+ Dockerfile*
+ docker-compose*
+ node_modules/
+ *.md
+ """;
+ }
+}
src/Olav.Cli/Templates/EditorConfigTemplate.cs
similarity index 89%
rename from Templates/EditorConfigTemplate.cs
rename to src/Olav.Cli/Templates/EditorConfigTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="EditorConfigTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="EditorConfigTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides .editorconfig file template.
src/Olav.Cli/Templates/FileHeaderTemplate.cs
similarity index 88%
rename from Templates/FileHeaderTemplate.cs
rename to src/Olav.Cli/Templates/FileHeaderTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="FileHeaderTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="FileHeaderTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides common file header generation.
src/Olav.Cli/Templates/GitignoreTemplate.cs
new file mode 100644
@@ -0,0 +1,27 @@
+// <copyright file="GitignoreTemplate.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Templates;
+
+/// <summary>
+/// Provides .gitignore file template.
+/// </summary>
+public static class GitignoreTemplate
+{
+ /// <summary>
+ /// Returns content of generated .gitignore.
+ /// </summary>
+ /// <returns>.gitignore file content.</returns>
+ public static string Generate()
+ {
+ return """
+ .env
+ **/nupkg
+ **/bin
+ **/obj
+ publish
+ **/TestResults/
+ """;
+ }
+}
src/Olav.Cli/Templates/GlobalJsonTemplate.cs
similarity index 83%
rename from Templates/GlobalJsonTemplate.cs
rename to src/Olav.Cli/Templates/GlobalJsonTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="GlobalJsonTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="GlobalJsonTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides global.json.cs file template.
src/Olav.Cli/Templates/InitialIntegrationTestTemplate.cs
similarity index 91%
rename from Templates/InitialIntegrationTestTemplate.cs
rename to src/Olav.Cli/Templates/InitialIntegrationTestTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="InitialIntegrationTestTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="InitialIntegrationTestTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides InitialIntegrationTest.cs file template.
@@ -26,7 +26,7 @@ public static class InitialIntegrationTestTemplate
/// <summary>
/// Basic integration test placeholder.
/// </summary>
- public sealed class InitialIntegrationTests
+ public class InitialIntegrationTests
{
/// <summary>
/// Ensures test project is working.
src/Olav.Cli/Templates/LicenseTemplate.cs
similarity index 94%
rename from Templates/LicenseTemplate.cs
rename to src/Olav.Cli/Templates/LicenseTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="LicenseTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="LicenseTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides License file template.
src/Olav.Cli/Templates/ObservabilityExtensionsTemplate.cs
similarity index 80%
rename from Templates/ObservabilityExtensionsTemplate.cs
rename to src/Olav.Cli/Templates/ObservabilityExtensionsTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="ObservabilityExtensionsTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="ObservabilityExtensionsTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides ObservabilityExtensions file template.
@@ -36,11 +36,11 @@ public static class ObservabilityExtensionsTemplate
private static bool middlewareApplied;
/// <summary>
- /// Adds mandatory BaseDDD observability configuration.
+ /// Adds mandatory Olav observability configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <returns>Updated service collection.</returns>
- public static IServiceCollection AddBaseDDDObservability(this IServiceCollection services)
+ public static IServiceCollection AddOlavObservability(this IServiceCollection services)
{
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
@@ -71,12 +71,12 @@ public static class ObservabilityExtensionsTemplate
/// </summary>
/// <param name="app">Application builder.</param>
/// <returns>Updated application builder.</returns>
- public static IApplicationBuilder UseBaseDDDObservability(this IApplicationBuilder app)
+ public static IApplicationBuilder UseOlavObservability(this IApplicationBuilder app)
{
if (!configured)
{
throw new InvalidOperationException(
- "BaseDDD Observability not configured. Call AddBaseDDDObservability().");
+ "Olav Observability not configured. Call AddOlavObservability().");
}
app.UseMiddleware<CorrelationMiddleware>();
@@ -87,15 +87,15 @@ public static class ObservabilityExtensionsTemplate
}
/// <summary>
- /// Verifies that BaseDDD observability was fully configured.
+ /// Verifies that Olav observability was fully configured.
/// </summary>
- public static void EnsureBaseDDDCompliance()
+ public static void EnsureOlavCompliance()
{
if (!configured || !middlewareApplied)
{
throw new InvalidOperationException(
- "BaseDDD observability not fully configured. " +
- "Ensure AddBaseDDDObservability() and UseBaseDDDObservability() are called.");
+ "Olav observability not fully configured. " +
+ "Ensure AddOlavObservability() and UseOlavObservability() are called.");
}
}
}
src/Olav.Cli/Templates/ObservabilityRulesTestsTemplate.cs
similarity index 87%
rename from Templates/ObservabilityRulesTestsTemplate.cs
rename to src/Olav.Cli/Templates/ObservabilityRulesTestsTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="ObservabilityRulesTestsTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="ObservabilityRulesTestsTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides ObservabilityRulesTests.cs file template.
@@ -28,9 +28,9 @@ public static class ObservabilityRulesTestsTemplate
using Xunit;
/// <summary>
- /// Enforces mandatory BaseDDD observability rules.
+ /// Enforces mandatory Olav observability rules.
/// </summary>
- public sealed class ObservabilityRulesTests
+ public class ObservabilityRulesTests
{
/// <summary>
/// Ensures Observability namespace exists.
@@ -78,9 +78,9 @@ public static class ObservabilityRulesTestsTemplate
string content = File.ReadAllText(programPath);
- Assert.Contains("AddBaseDDDObservability", content);
- Assert.Contains("UseBaseDDDObservability", content);
- Assert.Contains("EnsureBaseDDDCompliance", content);
+ Assert.Contains("AddOlavObservability", content);
+ Assert.Contains("UseOlavObservability", content);
+ Assert.Contains("EnsureOlavCompliance", content);
}
}
""";
src/Olav.Cli/Templates/OlavJsonTemplate.cs
new file mode 100644
@@ -0,0 +1,38 @@
+// <copyright file="OlavJsonTemplate.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Templates;
+
+/// <summary>
+/// Provides olav.json template.
+/// </summary>
+public static class OlavJsonTemplate
+{
+ /// <summary>
+ /// Returns content of generated olav.json file.
+ /// </summary>
+ /// <param name="toolVersion">Olav tool current version.</param>
+ /// <param name="templateVersion">Olav written template version.</param>
+ /// <param name="createdAt">Datetime of generation of the project.</param>
+ /// <param name="updatedAt">Datetime of last update on the project by Olav tool.</param>
+ /// <returns>olav.json file content.</returns>
+ public static string Generate(
+ string toolVersion,
+ string templateVersion,
+ DateTime createdAt,
+ DateTime updatedAt)
+ {
+ string createdAtIso = createdAt.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ");
+ string updatedAtIso = updatedAt.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ");
+
+ return $$"""
+ {
+ "toolVersion": "{{toolVersion}}",
+ "templateVersion": "{{templateVersion}}",
+ "createdAt": "{{createdAtIso}}",
+ "updatedAt": "{{updatedAtIso}}"
+ }
+ """;
+ }
+}
src/Olav.Cli/Templates/PreCommitTemplate.cs
new file mode 100644
@@ -0,0 +1,32 @@
+// <copyright file="PreCommitTemplate.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Templates;
+
+/// <summary>
+/// Provides pre-commit file template.
+/// </summary>
+public static class PreCommitTemplate
+{
+ /// <summary>
+ /// Returns content of generated pre-commit.
+ /// </summary>
+ /// <returns>pre-commit file content.</returns>
+ public static string Generate()
+ {
+ return $"""
+ #!/bin/sh
+
+ {CheckMissingToolTemplate.Generate()}
+
+ echo "Running pre-commit checks..."
+
+ olav lint
+ if [ $? -ne 0 ]; then
+ echo "Lint failed"
+ exit 1
+ fi
+ """;
+ }
+}
src/Olav.Cli/Templates/PrePushTemplate.cs
new file mode 100644
@@ -0,0 +1,32 @@
+// <copyright file="PrePushTemplate.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.Templates;
+
+/// <summary>
+/// Provides pre-push file template.
+/// </summary>
+public static class PrePushTemplate
+{
+ /// <summary>
+ /// Returns content of generated pre-push.
+ /// </summary>
+ /// <returns>pre-push file content.</returns>
+ public static string Generate()
+ {
+ return $"""
+ #!/bin/sh
+
+ {CheckMissingToolTemplate.Generate()}
+
+ echo "Running pre-push checks..."
+
+ olav verify
+ if [ $? -ne 0 ]; then
+ echo "Verification failed"
+ exit 1
+ fi
+ """;
+ }
+}
src/Olav.Cli/Templates/ProgramFileTemplate.cs
similarity index 84%
rename from Templates/ProgramFileTemplate.cs
rename to src/Olav.Cli/Templates/ProgramFileTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="ProgramFileTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="ProgramFileTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides Program.cs file template.
@@ -40,14 +40,14 @@ public static class ProgramFileTemplate
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
- builder.Services.AddBaseDDDObservability();
+ builder.Services.AddOlavObservability();
WebApplication app = builder.Build();
- app.UseBaseDDDObservability();
+ app.UseOlavObservability();
app.UseAuthorization();
app.MapControllers();
- ObservabilityExtensions.EnsureBaseDDDCompliance();
+ ObservabilityExtensions.EnsureOlavCompliance();
app.Run();
}
src/Olav.Cli/Templates/StyleCopJsonTemplate.cs
similarity index 88%
rename from Templates/StyleCopJsonTemplate.cs
rename to src/Olav.Cli/Templates/StyleCopJsonTemplate.cs
@@ -1,8 +1,8 @@
-// <copyright file="StyleCopJsonTemplate.cs" company="BaseDDD">
-// Copyright (c) BaseDDD.
+// <copyright file="StyleCopJsonTemplate.cs" company="Olav">
+// Copyright (c) Olav.
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
-namespace BaseDDD.Templates;
+namespace Olav.Templates;
/// <summary>
/// Provides stylecop.json file template.
src/Olav.Cli/Verifiers/DotnetVerifier.cs
new file mode 100644
@@ -0,0 +1,24 @@
+// <copyright file="DotnetVerifier.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+
+namespace Olav.Verifiers;
+
+using Olav.Infrastructure;
+
+/// <summary>
+/// Generic verifier for dotnet structure.
+/// </summary>
+public static class DotnetVerifier
+{
+ /// <summary>
+ /// Runs dotnet commands .
+ /// </summary>
+ /// <param name="root">Root reference folder.</param>
+ /// <param name="command">Dotnet command to be verified.</param>
+ public static void VerifyCommand(string root, string command)
+ {
+ DotnetRunner.Run(command, root);
+ }
+}
src/Olav.Cli/Verifiers/ProjectVerifier.cs
new file mode 100644
@@ -0,0 +1,42 @@
+// <copyright file="ProjectVerifier.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+
+namespace Olav.Verifiers;
+
+/// <summary>
+/// Generic verifier for project structure.
+/// </summary>
+public static class ProjectVerifier
+{
+ /// <summary>
+ /// Verifies existence of given file on root folder.
+ /// </summary>
+ /// <param name="root">Root reference folder.</param>
+ /// <param name="relativePath">Relative path of to the file on root.</param>
+ public static void VerifyFile(string root, string relativePath)
+ {
+ string fullPath = Path.Combine(root, relativePath);
+
+ if (!File.Exists(fullPath))
+ {
+ throw new InvalidOperationException($"Expected file not found: {fullPath}");
+ }
+ }
+
+ /// <summary>
+ /// Verifies existence of given directory on root folder.
+ /// </summary>
+ /// <param name="root">Root reference folder.</param>
+ /// <param name="relativePath">Relative path of to the directory on root.</param>
+ public static void VerifyDirectory(string root, string relativePath)
+ {
+ string fullPath = Path.Combine(root, relativePath);
+
+ if (!Directory.Exists(fullPath))
+ {
+ throw new InvalidOperationException($"Expected directory not found: {fullPath}");
+ }
+ }
+}
stylecop.json
@@ -1,8 +1,8 @@
{
"settings": {
"documentationRules": {
- "companyName": "BaseDDD",
- "copyrightText": "Copyright (c) BaseDDD.\r\nLicensed under the MIT License. See LICENSE file in the project root for full license information."
+ "companyName": "Olav",
+ "copyrightText": "Copyright (c) Olav.\r\nLicensed under the MIT License. See LICENSE file in the project root for full license information."
}
}
}
\ No newline at end of file
tests/Directory.Build.props
new file mode 100644
@@ -0,0 +1,25 @@
+<Project>
+
+ <PropertyGroup>
+ <IsTestProject>true</IsTestProject>
+ <NoWarn>1591</NoWarn>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="xunit" Version="2.7.0" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
+
+ <!-- Coverage -->
+ <PackageReference Include="coverlet.collector" Version="6.0.0" />
+
+ <!-- Template validation -->
+ <PackageReference Include="YamlDotNet" Version="13.7.1" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <AdditionalFiles Include="../../stylecop.json" />
+ <None Include="../../README.md" Pack="true" PackagePath="\" />
+ </ItemGroup>
+
+</Project>
tests/Olav.ArchitectureTests/Architecture/LayerDependencyTests.cs
new file mode 100644
@@ -0,0 +1,48 @@
+using NetArchTest.Rules;
+using Xunit;
+using Olav.Testing.Extensions;
+
+namespace Olav.ArchitectureTests.Architecture;
+
+public class LayerDependencyTests
+{
+ private const string BaseNamespace = "Olav";
+
+ [Fact]
+ public void Commands_Should_Not_Depend_On_Infrastructure()
+ {
+ Types.InAssembly(typeof(Olav.Program).Assembly)
+ .That()
+ .ResideInNamespace($"{BaseNamespace}.Commands")
+ .ShouldNot()
+ .HaveDependencyOn($"{BaseNamespace}.Infrastructure")
+ .GetResult()
+ .AssertSuccessful("Commands that depend on Infrastructure");
+ }
+
+ [Fact]
+ public void Generation_Should_Not_Depend_On_Commands()
+ {
+ Types.InAssembly(typeof(Olav.Program).Assembly)
+ .That()
+ .ResideInNamespace($"{BaseNamespace}.Generation")
+ .ShouldNot()
+ .HaveDependencyOn($"{BaseNamespace}.Commands")
+ .GetResult()
+ .AssertSuccessful("Generators that depend on Commands");
+ }
+
+ [Fact]
+ public void Infrastructure_Should_Not_Depend_On_Commands_Or_Generation()
+ {
+ Types.InAssembly(typeof(Olav.Program).Assembly)
+ .That()
+ .ResideInNamespace($"{BaseNamespace}.Infrastructure")
+ .ShouldNot()
+ .HaveDependencyOnAny(
+ $"{BaseNamespace}.Commands",
+ $"{BaseNamespace}.Generation")
+ .GetResult()
+ .AssertSuccessful("Infrastructure that depends on Commands or Generators");
+ }
+}
tests/Olav.ArchitectureTests/Architecture/NamingConventionTests.cs
new file mode 100644
@@ -0,0 +1,44 @@
+using NetArchTest.Rules;
+using Xunit;
+using Olav.Testing.Extensions;
+
+namespace Olav.ArchitectureTests.Architecture;
+
+public class NamingConventionTests
+{
+ [Fact]
+ public void Commands_Should_End_With_Command()
+ {
+ Types.InAssembly(typeof(Olav.Program).Assembly)
+ .That()
+ .ResideInNamespace("Olav.Commands")
+ .Should()
+ .HaveNameEndingWith("Command")
+ .GetResult()
+ .AssertSuccessful("Commands that don't end with 'Command'");
+ }
+
+ [Fact]
+ public void Generators_Should_End_With_Generator()
+ {
+ Types.InAssembly(typeof(Olav.Program).Assembly)
+ .That()
+ .ResideInNamespace("Olav.Generation")
+ .Should()
+ .HaveNameEndingWith("Generator")
+ .GetResult()
+ .AssertSuccessful("Generators that don't end with 'Generator'");
+ }
+
+ [Fact]
+ public void Templates_Should_End_With_Template()
+ {
+ Types.InAssembly(typeof(Olav.Program).Assembly)
+ .That()
+ .ResideInNamespace("Olav.Templates")
+ .Should()
+ .HaveNameEndingWith("Template")
+ .GetResult()
+ .AssertSuccessful("Templates that don't end with 'Template'");
+ }
+}
tests/Olav.ArchitectureTests/Architecture/NoForbiddenDependenciesTests.cs
new file mode 100644
@@ -0,0 +1,43 @@
+using NetArchTest.Rules;
+using Xunit;
+using System.Linq;
+using Olav.Testing.Extensions;
+
+namespace Olav.ArchitectureTests.Architecture;
+
+public class NoForbiddenDependenciesTests
+{
+ private const string BaseNamespace = "Olav";
+
+ [Fact]
+ public void Only_Generation_Should_Use_System_IO()
+ {
+ Types.InAssembly(typeof(Program).Assembly)
+ .That()
+ .DoNotResideInNamespace("Olav.Generation")
+ .And()
+ .DoNotResideInNamespace("Olav.Infrastructure")
+ .And()
+ .DoNotResideInNamespace("Olav.Commands")
+ .And()
+ .DoNotResideInNamespace("Olav.Verifiers")
+ .And()
+ .DoNotResideInNamespace("Olav.Helpers")
+ .ShouldNot()
+ .HaveDependencyOn("System.IO")
+ .GetResult()
+ .AssertSuccessful("Types using System.IO");
+ }
+
+ [Fact]
+ public void Only_Infrastructure_Should_Use_System_Diagnostics()
+ {
+ Types.InAssembly(typeof(Olav.Program).Assembly)
+ .That()
+ .DoNotResideInNamespace($"{BaseNamespace}.Infrastructure")
+ .ShouldNot()
+ .HaveDependencyOn("System.Diagnostics")
+ .GetResult()
+ .AssertSuccessful("Infrastructures that don't use System.Diagnostics");
+ }
+}
tests/Olav.ArchitectureTests/Architecture/TemplatesIsolationTests.cs
new file mode 100644
@@ -0,0 +1,70 @@
+using Olav.Testing.Extensions;
+using NetArchTest.Rules;
+using Xunit;
+
+namespace Olav.ArchitectureTests.Architecture;
+
+public class TemplatesIsolationTests
+{
+ private const string BaseNamespace = "Olav";
+
+ [Fact]
+ public void Templates_Should_Not_Depend_On_Commands()
+ {
+ Types.InAssembly(typeof(Program).Assembly)
+ .That()
+ .ResideInNamespace($"{BaseNamespace}.Templates")
+ .ShouldNot()
+ .HaveDependencyOn($"{BaseNamespace}.Commands")
+ .GetResult()
+ .AssertSuccessful("Templates that depend on Commands");
+ }
+
+ [Fact]
+ public void Templates_Should_Not_Depend_On_Infrastructure()
+ {
+ Types.InAssembly(typeof(Program).Assembly)
+ .That()
+ .ResideInNamespace($"{BaseNamespace}.Templates")
+ .ShouldNot()
+ .HaveDependencyOn($"{BaseNamespace}.Infrastructure")
+ .GetResult()
+ .AssertSuccessful("Templates that depend on Infrastructure");
+ }
+
+ [Fact]
+ public void Templates_Should_Not_Depend_On_Generation()
+ {
+ Types.InAssembly(typeof(Program).Assembly)
+ .That()
+ .ResideInNamespace($"{BaseNamespace}.Templates")
+ .ShouldNot()
+ .HaveDependencyOn($"{BaseNamespace}.Generation")
+ .GetResult()
+ .AssertSuccessful("Templates that depend on Generation");
+ }
+
+ [Fact]
+ public void Templates_Should_Not_Depend_On_Helpers()
+ {
+ Types.InAssembly(typeof(Program).Assembly)
+ .That()
+ .ResideInNamespace($"{BaseNamespace}.Templates")
+ .ShouldNot()
+ .HaveDependencyOn($"{BaseNamespace}.Helpers")
+ .GetResult()
+ .AssertSuccessful("Templates that depend on Helpers");
+ }
+
+ [Fact]
+ public void Templates_Should_Not_Depend_On_Verifiers()
+ {
+ Types.InAssembly(typeof(Program).Assembly)
+ .That()
+ .ResideInNamespace($"{BaseNamespace}.Templates")
+ .ShouldNot()
+ .HaveDependencyOn($"{BaseNamespace}.Verifiers")
+ .GetResult()
+ .AssertSuccessful("Templates that depend on Verifiers");
+ }
+}
tests/Olav.IntegrationTests/Cli/DockerBuild_EndToEndTests.cs
new file mode 100644
@@ -0,0 +1,39 @@
+using System;
+using System.IO;
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+using Olav.Infrastructure;
+
+namespace Olav.IntegrationTests.Cli;
+
+[Collection("GeneratedProject")]
+public class DockerBuild_EndToEndTests(GeneratedProjectFixture fixture)
+{
+ private readonly GeneratedProjectFixture _fixture = fixture;
+
+ [Fact]
+ public void Dockerfile_Should_Build_Successfully()
+ {
+ string dockerPath = Path.Combine(this._fixture.ProjectPath, "docker");
+
+ Assert.True(File.Exists(Path.Combine(dockerPath, "Dockerfile")));
+
+ Console.WriteLine("[Docker] Starting image build...");
+ ProcessRunner.Run("docker", "compose -f docker-compose.local.yml build --no-cache --progress=plain", dockerPath);
+ Console.WriteLine("[Docker] Image build completed.");
+ }
+
+ [Fact]
+ public void Docker_Compose_Should_Validate_Successfully()
+ {
+ string dockerPath = Path.Combine(this._fixture.ProjectPath, "docker");
+
+ string[] composeFiles = ["docker-compose.yml", "docker-compose.staging.yml", "docker-compose.dev.yml", "docker-compose.local.yml"];
+ foreach (string f in composeFiles)
+ {
+ Console.WriteLine($"[Docker] Validating {f}...");
+ ProcessRunner.Run("docker", $"compose -f {f} config", dockerPath);
+ Console.WriteLine($"[Docker] {f} is valid.");
+ }
+ }
+}
tests/Olav.IntegrationTests/Cli/LintCommand_EndToEndTests.cs
new file mode 100644
@@ -0,0 +1,35 @@
+using System;
+using System.IO;
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+
+namespace Olav.IntegrationTests.Cli;
+
+[Collection("GeneratedProject")]
+public class LintCommand_EndToEndTests(GeneratedProjectFixture fixture)
+{
+ private readonly GeneratedProjectFixture fixture = fixture;
+
+ [Fact]
+ public void Should_Run_Without_Error()
+ {
+ Directory.SetCurrentDirectory(this.fixture.ProjectPath);
+
+ Exception ex = Record.Exception(() =>
+ Program.Main(["lint"])
+ );
+
+ Assert.Null(ex);
+ }
+
+ [Fact]
+ public void Should_Pass_When_OlavJson_Is_Present_And_Current()
+ {
+ Directory.SetCurrentDirectory(this.fixture.ProjectPath);
+
+ Exception exception = Record.Exception(() =>
+ Program.Main(["lint"]));
+
+ Assert.Null(exception);
+ }
+}
tests/Olav.IntegrationTests/Cli/MigrateCommand_EndToEndTests.cs
new file mode 100644
@@ -0,0 +1,44 @@
+// <copyright file="MigrateCommand_EndToEndTests.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.IntegrationTests.Cli;
+
+using System.IO;
+using Olav.IntegrationTests.Generation.Fixtures;
+using Xunit;
+
+[Collection("GeneratedProject")]
+public class MigrateCommand_EndToEndTests(GeneratedProjectFixture fixture)
+{
+ private readonly GeneratedProjectFixture fixture = fixture;
+
+ [Fact]
+ public void Should_Have_OlavJson_After_New()
+ {
+ Assert.True(File.Exists(Path.Combine(this.fixture.ProjectPath, "olav.json")));
+ }
+
+ [Fact]
+ public void OlavJson_Should_Contain_Expected_Fields()
+ {
+ string content = File.ReadAllText(Path.Combine(this.fixture.ProjectPath, "olav.json"));
+ using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(content);
+ System.Text.Json.JsonElement root = doc.RootElement;
+
+ Assert.True(root.TryGetProperty("toolVersion", out _));
+ Assert.True(root.TryGetProperty("templateVersion", out _));
+ Assert.True(root.TryGetProperty("createdAt", out _));
+ Assert.True(root.TryGetProperty("updatedAt", out _));
+ }
+
+ [Fact]
+ public void OlavJson_Should_Contain_Current_TemplateVersion()
+ {
+ string content = File.ReadAllText(Path.Combine(this.fixture.ProjectPath, "olav.json"));
+ using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(content);
+ string templateVersion = doc.RootElement.GetProperty("templateVersion").GetString()!;
+
+ Assert.Equal(VersionConstants.TemplateVersion, templateVersion);
+ }
+}
tests/Olav.IntegrationTests/Cli/NewCommand_EndToEndTests.cs
new file mode 100644
@@ -0,0 +1,89 @@
+// NewCommand_EndToEndTests.cs — absorbs the deleted generator tests
+using System.IO;
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+
+namespace Olav.IntegrationTests.Cli;
+
+[Collection("GeneratedProject")]
+public class NewCommand_EndToEndTests(GeneratedProjectFixture fixture)
+{
+ private readonly GeneratedProjectFixture fixture = fixture;
+
+ [Fact]
+ public void Should_Create_Project_Structure()
+ {
+ string root = this.fixture.ProjectPath;
+
+ Assert.True(Directory.Exists(root));
+ Assert.True(Directory.Exists(Path.Combine(root, "src")));
+ Assert.True(Directory.Exists(Path.Combine(root, "tests")));
+ Assert.True(Directory.Exists(Path.Combine(root, "docker")));
+ }
+
+ [Fact]
+ public void Should_Create_Solution_File()
+ {
+ string root = this.fixture.ProjectPath;
+ string[] slnFiles = Directory.GetFiles(root, "*.slnx");
+
+ Assert.Single(slnFiles);
+ }
+
+ [Fact]
+ public void Should_Create_Source_Projects()
+ {
+ string src = Path.Combine(this.fixture.ProjectPath, "src");
+ string name = this.fixture.ProjectName;
+
+ Assert.True(Directory.Exists(Path.Combine(src, $"{name}.Domain")));
+ Assert.True(Directory.Exists(Path.Combine(src, $"{name}.Application")));
+ Assert.True(Directory.Exists(Path.Combine(src, $"{name}.Infrastructure")));
+ Assert.True(Directory.Exists(Path.Combine(src, $"{name}.Web")));
+ }
+
+ [Fact]
+ public void Should_Create_Test_Projects()
+ {
+ string tests = Path.Combine(this.fixture.ProjectPath, "tests");
+ string name = this.fixture.ProjectName;
+
+ Assert.True(Directory.Exists(Path.Combine(tests, $"{name}.ArchitectureTests")));
+ Assert.True(Directory.Exists(Path.Combine(tests, $"{name}.IntegrationTests")));
+ }
+
+ [Fact]
+ public void Should_Enforce_Coverage_In_Test_Projects()
+ {
+ string tests = Path.Combine(this.fixture.ProjectPath, "tests");
+ string name = this.fixture.ProjectName;
+
+ foreach (string proj in new[] { $"{name}.ArchitectureTests", $"{name}.IntegrationTests" })
+ {
+ string csproj = Path.Combine(tests, proj, $"{proj}.csproj");
+ string content = File.ReadAllText(csproj);
+
+ Assert.Contains("<CollectCoverage>true</CollectCoverage>", content);
+ Assert.Contains("<Threshold>100</Threshold>", content);
+ }
+ }
+
+ [Fact]
+ public void Should_Initialize_Git_Repository()
+ {
+ string root = this.fixture.ProjectPath;
+
+ Assert.True(Directory.Exists(Path.Combine(root, ".git")));
+ Assert.True(File.Exists(Path.Combine(root, ".git", "HEAD")));
+
+ string config = File.ReadAllText(Path.Combine(root, ".git", "config"));
+ Assert.Contains("hooksPath", config);
+ Assert.Contains(".githooks", config);
+ }
+
+ [Fact]
+ public void Should_Create_OlavJson_At_Root()
+ {
+ Assert.True(File.Exists(Path.Combine(this.fixture.ProjectPath, "olav.json")));
+ }
+}
tests/Olav.IntegrationTests/Cli/VerifyCommand_EndToEndTests.cs
new file mode 100644
@@ -0,0 +1,24 @@
+using System;
+using System.IO;
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+
+namespace Olav.IntegrationTests.Cli;
+
+[Collection("GeneratedProject")]
+public class VerifyCommand_EndToEndTests(GeneratedProjectFixture fixture)
+{
+ private readonly GeneratedProjectFixture _fixture = fixture;
+
+ [Fact]
+ public void Should_Run_Without_Error()
+ {
+ Directory.SetCurrentDirectory(this._fixture.ProjectPath);
+
+ Exception ex = Record.Exception(() =>
+ Program.Main(["verify"])
+ );
+
+ Assert.Null(ex);
+ }
+}
tests/Olav.IntegrationTests/Generation/CiPipelineGenerationTests.cs
new file mode 100644
@@ -0,0 +1,24 @@
+using System.IO;
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+
+namespace Olav.IntegrationTests.Generation;
+
+[Collection("GeneratedProject")]
+public class CiPipelineGenerationTests
+{
+ private readonly GeneratedProjectFixture _fixture;
+
+ public CiPipelineGenerationTests(GeneratedProjectFixture fixture)
+ {
+ this._fixture = fixture;
+ }
+
+ [Fact]
+ public void Should_Generate_CI_Workflow()
+ {
+ string ciPath = Path.Combine(this._fixture.ProjectPath, ".github/workflows");
+
+ Assert.True(File.Exists(Path.Combine(ciPath, "ci.yml")));
+ }
+}
tests/Olav.IntegrationTests/Generation/Collections/GeneratedProjectCollection.cs
new file mode 100644
@@ -0,0 +1,9 @@
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+
+namespace Olav.IntegrationTests.Generation.Collections;
+
+[CollectionDefinition("GeneratedProject", DisableParallelization = true)]
+public class GeneratedProjectCollection : ICollectionFixture<GeneratedProjectFixture>
+{
+}
tests/Olav.IntegrationTests/Generation/DockerFilesGenerationTests.cs
new file mode 100644
@@ -0,0 +1,25 @@
+using System.IO;
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+
+namespace Olav.IntegrationTests.Generation;
+
+[Collection("GeneratedProject")]
+public class DockerFilesGenerationTests
+{
+ private readonly GeneratedProjectFixture _fixture;
+
+ public DockerFilesGenerationTests(GeneratedProjectFixture fixture)
+ {
+ this._fixture = fixture;
+ }
+
+ [Fact]
+ public void Should_Generate_Docker_Files()
+ {
+ string dockerPath = Path.Combine(this._fixture.ProjectPath, "docker");
+
+ Assert.True(File.Exists(Path.Combine(dockerPath, "Dockerfile")));
+ Assert.True(File.Exists(Path.Combine(dockerPath, "docker-compose.yml")));
+ }
+}
\ No newline at end of file
tests/Olav.IntegrationTests/Generation/Fixtures/GeneratedProjectFixture.cs
new file mode 100644
@@ -0,0 +1,32 @@
+using System;
+using System.IO;
+
+namespace Olav.IntegrationTests.Generation.Fixtures;
+
+public class GeneratedProjectFixture : IDisposable
+{
+ public string Root { get; }
+ public string ProjectPath { get; }
+ public string ProjectName { get; } = "TestProject";
+ private readonly string originalDirectory;
+
+ public GeneratedProjectFixture()
+ {
+ this.originalDirectory = Directory.GetCurrentDirectory();
+
+ Root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(Root);
+
+ Directory.SetCurrentDirectory(Root);
+
+ Olav.Program.Main(["new", ProjectName]);
+
+ ProjectPath = Path.Combine(Root, ProjectName);
+ }
+
+ public void Dispose()
+ {
+ Directory.SetCurrentDirectory(this.originalDirectory);
+ Directory.Delete(Root, true);
+ }
+}
tests/Olav.IntegrationTests/Generation/GeneratedProjectStructureTests.cs
new file mode 100644
@@ -0,0 +1,26 @@
+using System.IO;
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+
+namespace Olav.IntegrationTests.Generation;
+
+[Collection("GeneratedProject")]
+public class GeneratedProjectStructureTests
+{
+ private readonly GeneratedProjectFixture _fixture;
+
+ public GeneratedProjectStructureTests(GeneratedProjectFixture fixture)
+ {
+ this._fixture = fixture;
+ }
+
+ [Fact]
+ public void Should_Generate_Expected_Folders_And_Files()
+ {
+ string projectPath = Path.Combine(this._fixture.ProjectPath);
+
+ Assert.True(File.Exists(Path.Combine(projectPath, $"{this._fixture.ProjectName}.slnx")));
+ Assert.True(Directory.Exists(Path.Combine(projectPath, "src")));
+ Assert.True(Directory.Exists(Path.Combine(projectPath, "tests")));
+ }
+}
tests/Olav.IntegrationTests/Generation/GitHooksGenerationTests.cs
new file mode 100644
@@ -0,0 +1,25 @@
+using System.IO;
+using Xunit;
+using Olav.IntegrationTests.Generation.Fixtures;
+
+namespace Olav.IntegrationTests.Generation;
+
+[Collection("GeneratedProject")]
+public class GitHooksGenerationTests
+{
+ private readonly GeneratedProjectFixture _fixture;
+
+ public GitHooksGenerationTests(GeneratedProjectFixture fixture)
+ {
+ this._fixture = fixture;
+ }
+
+ [Fact]
+ public void Should_Create_GitHooks()
+ {
+ string hooksPath = Path.Combine(this._fixture.ProjectPath, ".githooks");
+
+ Assert.True(File.Exists(Path.Combine(hooksPath, "pre-commit")));
+ Assert.True(File.Exists(Path.Combine(hooksPath, "pre-push")));
+ }
+}
tests/Olav.Testing/Extensions/TestExtensions.cs
new file mode 100644
@@ -0,0 +1,32 @@
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Collections.Generic;
+using Xunit;
+using NetArchTest.Rules;
+
+namespace Olav.Testing.Extensions;
+
+public static class TestExtensions
+{
+ public static void AssertSuccessful(
+ this TestResult result,
+ string? message = null,
+ [CallerFilePath] string file = "",
+ [CallerLineNumber] int line = 0)
+ {
+ if (result.IsSuccessful)
+ return;
+
+ IEnumerable<string?> failures = result.FailingTypes
+ .Select(t => t.FullName ?? t.Name);
+
+ string details = string.Join("\n", failures);
+
+ string location = $"{file}:{line}";
+
+ Assert.Fail(
+ $"{message ?? "Architecture rule violated"}\n" +
+ $"Location: {location}\n\n" +
+ $"{details}");
+ }
+}
\ No newline at end of file
tests/Olav.UnitTests/Generation/BaseStructureGeneratorTests.cs
new file mode 100644
@@ -0,0 +1,47 @@
+using System;
+using System.IO;
+using Xunit;
+using Olav.Generation;
+
+namespace Olav.UnitTests.Generation;
+
+public class BaseStructureGeneratorTests
+{
+ [Fact]
+ public void Generate_Should_Create_All_Expected_Directories()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+
+ BaseStructureGenerator generator = new(root);
+
+ generator.Generate();
+
+ AssertDirectoryExists(root);
+ AssertDirectoryExists(Path.Combine(root, "src"));
+ AssertDirectoryExists(Path.Combine(root, "tests"));
+ AssertDirectoryExists(Path.Combine(root, "docker"));
+ AssertDirectoryExists(Path.Combine(root, ".githooks"));
+ AssertDirectoryExists(Path.Combine(root, ".github"));
+ AssertDirectoryExists(Path.Combine(root, ".github", "workflows"));
+ }
+
+ [Fact]
+ public void Generate_Should_Not_Throw_When_Directories_Already_Exist()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+
+ Directory.CreateDirectory(Path.Combine(root, "src"));
+ Directory.CreateDirectory(Path.Combine(root, "tests"));
+
+ BaseStructureGenerator generator = new(root);
+
+ Exception exception = Record.Exception(generator.Generate);
+
+ Assert.Null(exception);
+ }
+
+ private static void AssertDirectoryExists(string path)
+ {
+ Assert.True(Directory.Exists(path), $"Expected directory not found: {path}");
+ }
+}
\ No newline at end of file
tests/Olav.UnitTests/Generation/FileTemplateGeneratorTests.cs
new file mode 100644
@@ -0,0 +1,61 @@
+using System;
+using System.IO;
+using Xunit;
+using Olav.Generation;
+
+namespace Olav.UnitTests.Generation;
+
+public class FileTemplateGeneratorTests
+{
+ [Fact]
+ public void Generate_Should_Create_All_Expected_Files()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+
+ FileTemplateGenerator generator = new(
+ name: "TestApp",
+ root: root,
+ owner: "TestOwner",
+ license: "MIT");
+
+ generator.Generate();
+
+ AssertFileExists(root, ".editorconfig");
+ AssertFileExists(root, "stylecop.json");
+ AssertFileExists(root, "LICENSE");
+ AssertFileExists(root, "Directory.Build.props");
+ AssertFileExists(root, "Directory.Packages.props");
+ AssertFileExists(root, "global.json");
+
+ AssertFileExists(root, ".gitignore");
+ AssertFileExists(root, ".githooks/pre-commit");
+ AssertFileExists(root, ".githooks/pre-push");
+
+ AssertFileExists(root, ".github/workflows/ci.yml");
+
+ AssertFileExists(root, "docker/Dockerfile");
+ AssertFileExists(root, "docker/docker-compose.yml");
+ AssertFileExists(root, "docker/docker-compose.staging.yml");
+ AssertFileExists(root, "docker/docker-compose.dev.yml");
+ AssertFileExists(root, "docker/docker-compose.local.yml");
+
+ AssertFileExists(root, "src/TestApp.Web/Program.cs");
+
+ AssertFileNotEmpty(root, "global.json");
+ AssertFileNotEmpty(root, ".github/workflows/ci.yml");
+ AssertFileNotEmpty(root, "Directory.Build.props");
+ AssertFileNotEmpty(root, "docker/docker-compose.yml");
+ }
+
+ private static void AssertFileExists(string root, string relativePath)
+ {
+ string path = Path.Combine(root, relativePath);
+ Assert.True(File.Exists(path), $"Expected file not found: {relativePath}");
+ }
+
+ private static void AssertFileNotEmpty(string root, string relativePath)
+ {
+ string content = File.ReadAllText(Path.Combine(root, relativePath));
+ Assert.False(string.IsNullOrWhiteSpace(content), $"File is empty: {relativePath}");
+ }
+}
\ No newline at end of file
tests/Olav.UnitTests/Generation/GitGeneratorTests.cs
new file mode 100644
@@ -0,0 +1,53 @@
+using System;
+using System.IO;
+using Xunit;
+using Olav.Generation;
+
+namespace Olav.UnitTests.Generation;
+
+public class GitGeneratorTests
+{
+ [Fact]
+ public void Generate_Should_Initialize_Git_Repository_And_Configure_Hooks()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+
+ File.WriteAllText(Path.Combine(root, "README.md"), "test");
+
+ Directory.CreateDirectory(Path.Combine(root, ".githooks"));
+ File.WriteAllText(Path.Combine(root, ".githooks/pre-commit"), "echo test");
+ File.WriteAllText(Path.Combine(root, ".githooks/pre-push"), "echo test");
+
+ new GitGenerator(root).Generate();
+
+ string gitDir = Path.Combine(root, ".git");
+ Assert.True(Directory.Exists(gitDir), ".git folder not created");
+ Assert.True(File.Exists(Path.Combine(gitDir, "HEAD")), "HEAD not found");
+ Assert.True(File.Exists(Path.Combine(gitDir, "config")), "config not found");
+
+ string config = File.ReadAllText(Path.Combine(gitDir, "config"));
+ Assert.Contains("hooksPath", config);
+ Assert.Contains(".githooks", config);
+ }
+
+ [Fact]
+ public void Generate_Should_Be_Idempotent()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+
+ File.WriteAllText(Path.Combine(root, "README.md"), "test");
+
+ Directory.CreateDirectory(Path.Combine(root, ".githooks"));
+ File.WriteAllText(Path.Combine(root, ".githooks/pre-commit"), "echo test");
+ File.WriteAllText(Path.Combine(root, ".githooks/pre-push"), "echo test");
+
+ GitGenerator generator = new(root);
+ generator.Generate();
+
+ Exception exception = Record.Exception(generator.Generate);
+
+ Assert.Null(exception);
+ }
+}
tests/Olav.UnitTests/Generation/ObservabilityGeneratorTests.cs
new file mode 100644
@@ -0,0 +1,41 @@
+using System;
+using System.IO;
+using Olav.Generation;
+using Xunit;
+
+namespace Olav.UnitTests.Generation;
+
+public class ObservabilityGeneratorTests
+{
+ [Fact]
+ public void Should_Create_Observability_Files()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string projectName = "TestProject";
+
+ ObservabilityGenerator generator = new(
+ projectName,
+ root,
+ "owner",
+ "MIT");
+
+ generator.Generate();
+
+ string observabilityPath = Path.Combine(
+ root,
+ "src",
+ $"{projectName}.Web",
+ "Observability");
+
+ Assert.True(Directory.Exists(observabilityPath));
+
+ string correlationFile = Path.Combine(observabilityPath, "CorrelationMiddleware.cs");
+ string extensionsFile = Path.Combine(observabilityPath, "ObservabilityExtensions.cs");
+
+ Assert.True(File.Exists(correlationFile));
+ Assert.True(File.Exists(extensionsFile));
+
+ Assert.False(string.IsNullOrWhiteSpace(File.ReadAllText(correlationFile)));
+ Assert.False(string.IsNullOrWhiteSpace(File.ReadAllText(extensionsFile)));
+ }
+}
tests/Olav.UnitTests/Generation/VersionEnforcementGeneratorTests.cs
new file mode 100644
@@ -0,0 +1,137 @@
+// <copyright file="VersionEnforcementGeneratorTests.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.UnitTests.Generation;
+
+using System;
+using System.IO;
+using Olav.Generation;
+using Xunit;
+
+public class VersionEnforcementGeneratorTests
+{
+ [Fact]
+ public void Generate_Should_Create_OlavJson()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+
+ new VersionEnforcementGenerator(root).Generate();
+
+ Assert.True(File.Exists(Path.Combine(root, "olav.json")));
+ }
+
+ [Fact]
+ public void Generate_Should_Write_Valid_Json()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+
+ new VersionEnforcementGenerator(root).Generate();
+
+ string content = File.ReadAllText(Path.Combine(root, "olav.json"));
+ Templates.Helpers.TemplateValidationHelper.ValidateJson(content);
+ }
+
+ [Fact]
+ public void Generate_Should_Throw_When_OlavJson_Already_Exists()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+ File.WriteAllText(Path.Combine(root, "olav.json"), "{}");
+
+ InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
+ () => new VersionEnforcementGenerator(root).Generate());
+
+ Assert.Contains("olav migrate", exception.Message);
+ }
+
+ [Fact]
+ public void Generate_Should_Set_CreatedAt_Equal_To_UpdatedAt()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+
+ new VersionEnforcementGenerator(root).Generate();
+
+ string content = File.ReadAllText(Path.Combine(root, "olav.json"));
+ using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(content);
+ string createdAt = doc.RootElement.GetProperty("createdAt").GetString()!;
+ string updatedAt = doc.RootElement.GetProperty("updatedAt").GetString()!;
+
+ Assert.Equal(createdAt, updatedAt);
+ }
+
+ [Fact]
+ public void Migrate_Should_Throw_When_OlavJson_Is_Missing()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+
+ InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
+ () => new VersionEnforcementGenerator(root).Migrate(dryRun: false));
+
+ Assert.Contains("olav.json not found", exception.Message);
+ }
+
+ [Fact]
+ public void Migrate_DryRun_Should_Not_Modify_File()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+ new VersionEnforcementGenerator(root).Generate();
+
+ string before = File.ReadAllText(Path.Combine(root, "olav.json"));
+
+ new VersionEnforcementGenerator(root).Migrate(dryRun: true);
+
+ string after = File.ReadAllText(Path.Combine(root, "olav.json"));
+ Assert.Equal(before, after);
+ }
+
+ [Fact]
+ public void Migrate_Should_Preserve_CreatedAt()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+ new VersionEnforcementGenerator(root).Generate();
+
+ string before = File.ReadAllText(Path.Combine(root, "olav.json"));
+ using System.Text.Json.JsonDocument docBefore = System.Text.Json.JsonDocument.Parse(before);
+ string createdAtBefore = docBefore.RootElement.GetProperty("createdAt").GetString()!;
+
+ new VersionEnforcementGenerator(root).Migrate(dryRun: false);
+
+ string after = File.ReadAllText(Path.Combine(root, "olav.json"));
+ using System.Text.Json.JsonDocument docAfter = System.Text.Json.JsonDocument.Parse(after);
+ string createdAtAfter = docAfter.RootElement.GetProperty("createdAt").GetString()!;
+
+ Assert.Equal(createdAtBefore, createdAtAfter);
+ }
+
+ [Fact]
+ public void Check_Should_Not_Throw_When_OlavJson_Is_Missing()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+
+ Exception exception = Record.Exception(
+ () => new VersionEnforcementGenerator(root).Check());
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void Check_Should_Not_Throw_When_OlavJson_Is_Current()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(root);
+ new VersionEnforcementGenerator(root).Generate();
+
+ Exception exception = Record.Exception(
+ () => new VersionEnforcementGenerator(root).Check());
+
+ Assert.Null(exception);
+ }
+}
tests/Olav.UnitTests/Infrastructure/CompatibilityCheckerTests.cs
new file mode 100644
@@ -0,0 +1,47 @@
+// <copyright file="CompatibilityCheckerTests.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.UnitTests.Infrastructure;
+
+using System;
+using Olav.Infrastructure;
+using Xunit;
+
+public class CompatibilityCheckerTests
+{
+ [Fact]
+ public void Check_Should_Not_Throw_When_Version_Is_Current()
+ {
+ string current = VersionConstants.TemplateVersion;
+
+ Exception exception = Record.Exception(() => CompatibilityChecker.Check(current));
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void Check_Should_Throw_When_Project_Version_Is_Newer_Than_Tool()
+ {
+ InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
+ () => CompatibilityChecker.Check("999.0"));
+
+ Assert.Contains("Please update the Olav CLI", exception.Message);
+ }
+
+ [Fact]
+ public void Check_Should_Throw_When_Project_Version_Is_Below_Minimum()
+ {
+ InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
+ () => CompatibilityChecker.Check("0.1"));
+
+ Assert.Contains("olav migrate --apply", exception.Message);
+ }
+
+ [Fact]
+ public void Check_Should_Throw_When_Version_Is_Unparseable()
+ {
+ Assert.Throws<InvalidOperationException>(
+ () => CompatibilityChecker.Check("not-a-version"));
+ }
+}
tests/Olav.UnitTests/Infrastructure/DotnetRunnerTests.cs
new file mode 100644
@@ -0,0 +1,17 @@
+using System;
+using Xunit;
+
+namespace Olav.UnitTests.Infrastructure;
+
+public class DotnetRunnerTests
+{
+ [Fact]
+ public void Run_Should_Execute_Dotnet_Command()
+ {
+ Exception ex = Record.Exception(() =>
+ Olav.Infrastructure.DotnetRunner.Run("--version", ".")
+ );
+
+ Assert.Null(ex);
+ }
+}
tests/Olav.UnitTests/Infrastructure/FileSystemTests.cs
new file mode 100644
@@ -0,0 +1,40 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace Olav.UnitTests.Infrastructure;
+
+public class FileSystemTests
+{
+ [Fact]
+ public void WriteFile_Should_Create_File_And_Content()
+ {
+ string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "test.txt");
+
+ Olav.Infrastructure.FileSystem.WriteFile(path, "content");
+
+ Assert.True(File.Exists(path));
+ Assert.Equal("content", File.ReadAllText(path));
+ }
+
+ [Fact]
+ public void CreateDirectory_Should_Create_Directory()
+ {
+ string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+
+ Olav.Infrastructure.FileSystem.CreateDirectory(path);
+
+ Assert.True(Directory.Exists(path));
+ }
+
+ [Fact]
+ public void DeleteIfExists_Should_Delete_File()
+ {
+ string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ File.WriteAllText(path, "test");
+
+ Olav.Infrastructure.FileSystem.DeleteIfExists(path);
+
+ Assert.False(File.Exists(path));
+ }
+}
tests/Olav.UnitTests/Infrastructure/GitRunnerTests.cs
new file mode 100644
@@ -0,0 +1,19 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace Olav.UnitTests.Infrastructure;
+
+public class GitRunnerTests
+{
+ [Fact]
+ public void Run_Should_Initialize_Git_Repo()
+ {
+ string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(path);
+
+ Olav.Infrastructure.GitRunner.Run("init", path);
+
+ Assert.True(Directory.Exists(Path.Combine(path, ".git")));
+ }
+}
tests/Olav.UnitTests/Infrastructure/MigrationRunnerTests.cs
new file mode 100644
@@ -0,0 +1,67 @@
+// <copyright file="MigrationRunnerTests.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.UnitTests.Infrastructure;
+
+using System;
+using System.IO;
+using Olav.Infrastructure;
+using Xunit;
+
+public class MigrationRunnerTests
+{
+ [Fact]
+ public void DryRun_Should_Not_Throw_When_Already_At_Current_Version()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string current = VersionConstants.TemplateVersion;
+
+ MigrationRunner runner = MigrationRunner.Create();
+
+ Exception exception = Record.Exception(
+ () => runner.DryRun(root, current, current));
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void Apply_Should_Not_Throw_When_Already_At_Current_Version()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string current = VersionConstants.TemplateVersion;
+
+ MigrationRunner runner = MigrationRunner.Create();
+
+ Exception exception = Record.Exception(
+ () => runner.Apply(root, current, current));
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void Apply_Should_Throw_When_Chain_Is_Broken()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+
+ MigrationRunner runner = MigrationRunner.Create();
+
+ InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
+ () => runner.Apply(root, "0.1", VersionConstants.TemplateVersion));
+
+ Assert.Contains("Migration chain is broken", exception.Message);
+ }
+
+ [Fact]
+ public void DryRun_Should_Throw_When_Chain_Is_Broken()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+
+ MigrationRunner runner = MigrationRunner.Create();
+
+ InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
+ () => runner.DryRun(root, "0.1", VersionConstants.TemplateVersion));
+
+ Assert.Contains("Migration chain is broken", exception.Message);
+ }
+}
tests/Olav.UnitTests/Infrastructure/ProcessRunnerTests.cs
new file mode 100644
@@ -0,0 +1,17 @@
+using System;
+using Xunit;
+
+namespace Olav.UnitTests.Infrastructure;
+
+public class ProcessRunnerTests
+{
+ [Fact]
+ public void Run_Should_Execute_Command()
+ {
+ Exception ex = Record.Exception(() =>
+ Olav.Infrastructure.ProcessRunner.Run("dotnet", "--version", ".")
+ );
+
+ Assert.Null(ex);
+ }
+}
tests/Olav.UnitTests/Templates/CiYmlTemplateTests.cs
new file mode 100644
@@ -0,0 +1,16 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+using Olav.Templates;
+
+namespace Olav.UnitTests.Templates;
+
+public class CiYmlTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_Yaml()
+ {
+ string content = CiYmlTemplate.Generate();
+
+ TemplateValidationHelper.ValidateYaml(content);
+ }
+}
tests/Olav.UnitTests/Templates/DirectoryBuildPropsTemplateTests.cs
new file mode 100644
@@ -0,0 +1,15 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+
+namespace Olav.UnitTests.Templates;
+
+public class DirectoryBuildPropsTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_MSBuild()
+ {
+ string content = Olav.Templates.DirectoryBuildPropsTemplate.Generate();
+
+ TemplateValidationHelper.ValidateMsBuild(content);
+ }
+}
tests/Olav.UnitTests/Templates/DirectoryPackagePropsTemplateTests.cs
new file mode 100644
@@ -0,0 +1,15 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+
+namespace Olav.UnitTests.Templates;
+
+public class DirectoryPackagePropsTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_MSBuild()
+ {
+ string content = Olav.Templates.DirectoryPackagePropsTemplate.Generate();
+
+ TemplateValidationHelper.ValidateMsBuild(content);
+ }
+}
tests/Olav.UnitTests/Templates/DockerComposeTemplateTests.cs
new file mode 100644
@@ -0,0 +1,24 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+
+namespace Olav.UnitTests.Templates;
+
+public class DockerComposeTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_Compose()
+ {
+ string contentPrd = Olav.Templates.DockerComposeTemplate.GeneratePrd("Test");
+ TemplateValidationHelper.ValidateDockerCompose(contentPrd);
+
+ string contentStaging = Olav.Templates.DockerComposeTemplate.GenerateStaging("Test");
+ TemplateValidationHelper.ValidateDockerCompose(contentStaging);
+
+ string contentDev = Olav.Templates.DockerComposeTemplate.GenerateDev("Test");
+ TemplateValidationHelper.ValidateDockerCompose(contentDev);
+
+ string contentLocal = Olav.Templates.DockerComposeTemplate.GenerateLocal("Test");
+ TemplateValidationHelper.ValidateDockerCompose(contentLocal);
+
+ }
+}
tests/Olav.UnitTests/Templates/DockerfileTemplateTests.cs
new file mode 100644
@@ -0,0 +1,19 @@
+using Xunit;
+using System;
+
+namespace Olav.UnitTests.Templates;
+
+public class DockerfileTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_Dockerfile()
+ {
+ string content = Olav.Templates.DockerfileTemplate.Generate("Test");
+
+ Assert.Contains("FROM", content, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("WORKDIR", content, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("COPY", content, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("RUN", content, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("dotnet publish", content, StringComparison.OrdinalIgnoreCase);
+ }
+}
tests/Olav.UnitTests/Templates/EditorConfigTemplateTests.cs
new file mode 100644
@@ -0,0 +1,16 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+
+namespace Olav.UnitTests.Templates;
+
+public class EditorConfigTemplateTests
+{
+ [Fact]
+ public void Should_Not_Be_Empty()
+ {
+ string content = Olav.Templates.EditorConfigTemplate.Generate();
+
+ Assert.False(string.IsNullOrWhiteSpace(content));
+ Assert.Contains("root", content);
+ }
+}
tests/Olav.UnitTests/Templates/GitignoreTemplateTests.cs
new file mode 100644
@@ -0,0 +1,15 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+
+namespace Olav.UnitTests.Templates;
+
+public class GitignoreTemplateTests
+{
+ [Fact]
+ public void Should_Not_Be_Empty()
+ {
+ string content = Olav.Templates.GitignoreTemplate.Generate();
+
+ Assert.False(string.IsNullOrWhiteSpace(content));
+ }
+}
tests/Olav.UnitTests/Templates/GlobalJsonTemplateTests.cs
new file mode 100644
@@ -0,0 +1,15 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+
+namespace Olav.UnitTests.Templates;
+
+public class GlobalJsonTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_Json()
+ {
+ string content = Olav.Templates.GlobalJsonTemplate.Generate();
+
+ TemplateValidationHelper.ValidateJson(content);
+ }
+}
tests/Olav.UnitTests/Templates/Helpers/TemplateValidationHelper.cs
new file mode 100644
@@ -0,0 +1,104 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Xml;
+using System.Text.Json;
+using YamlDotNet.RepresentationModel;
+
+namespace Olav.UnitTests.Templates.Helpers;
+
+public static class TemplateValidationHelper
+{
+ public static bool IsToolAvailable(string tool)
+ {
+ try
+ {
+ ProcessStartInfo psi = new()
+ {
+ FileName = tool,
+ Arguments = "--version",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+
+ using Process process = Process.Start(psi);
+ if (process is null) return false;
+
+ process.WaitForExit(3000);
+ return process.ExitCode == 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public static void ValidateDockerCompose(string content)
+ {
+ if (!IsToolAvailable("docker")) return;
+
+ string dir = CreateTempDir();
+ string path = Path.Combine(dir, "docker-compose.yml");
+ File.WriteAllText(path, content);
+
+ Run("docker", $"compose -f {path} config", dir);
+ }
+
+ public static void ValidateJson(string content)
+ {
+ JsonDocument.Parse(content);
+ }
+
+ public static void ValidateYaml(string content)
+ {
+ YamlStream yaml = [];
+ yaml.Load(new StringReader(content));
+ }
+
+ public static void ValidateMsBuild(string content)
+ {
+ XmlReaderSettings settings = new()
+ {
+ DtdProcessing = DtdProcessing.Prohibit
+ };
+
+ using XmlReader reader = XmlReader.Create(new StringReader(content), settings);
+ while (reader.Read()) { }
+ }
+
+ public static void ValidateShell(string content)
+ {
+ if (!IsToolAvailable("bash")) return;
+
+ string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".sh");
+ File.WriteAllText(path, content);
+
+ Run("bash", $"-n {path}", ".");
+ }
+
+ private static string CreateTempDir()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ private static void Run(string file, string args, string workingDir)
+ {
+ ProcessStartInfo psi = new()
+ {
+ FileName = file,
+ Arguments = args,
+ WorkingDirectory = workingDir,
+ RedirectStandardError = true
+ };
+
+ using Process process = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start process: {file}");
+ process.WaitForExit();
+
+ if (process.ExitCode != 0)
+ {
+ throw new Exception(process.StandardError.ReadToEnd());
+ }
+ }
+}
tests/Olav.UnitTests/Templates/OlavJsonTemplateTests.cs
new file mode 100644
@@ -0,0 +1,89 @@
+// <copyright file="OlavJsonTemplateTests.cs" company="Olav">
+// Copyright (c) Olav.
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+// </copyright>
+namespace Olav.UnitTests.Templates;
+
+using System;
+using Olav.Templates;
+using Olav.UnitTests.Templates.Helpers;
+using Xunit;
+
+public class OlavJsonTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_Json()
+ {
+ string content = OlavJsonTemplate.Generate(
+ "1.0.0",
+ "1.0",
+ new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc));
+
+ TemplateValidationHelper.ValidateJson(content);
+ }
+
+ [Fact]
+ public void Should_Contain_ToolVersion()
+ {
+ string content = OlavJsonTemplate.Generate(
+ "1.2.3",
+ "1.0",
+ DateTime.UtcNow,
+ DateTime.UtcNow);
+
+ Assert.Contains("\"toolVersion\": \"1.2.3\"", content);
+ }
+
+ [Fact]
+ public void Should_Contain_TemplateVersion()
+ {
+ string content = OlavJsonTemplate.Generate(
+ "1.0.0",
+ "1.0",
+ DateTime.UtcNow,
+ DateTime.UtcNow);
+
+ Assert.Contains("\"templateVersion\": \"1.0\"", content);
+ }
+
+ [Fact]
+ public void Should_Contain_CreatedAt_In_Iso8601()
+ {
+ DateTime createdAt = new(2026, 3, 15, 10, 30, 0, DateTimeKind.Utc);
+
+ string content = OlavJsonTemplate.Generate(
+ "1.0.0",
+ "1.0",
+ createdAt,
+ DateTime.UtcNow);
+
+ Assert.Contains("\"createdAt\": \"2026-03-15T10:30:00Z\"", content);
+ }
+
+ [Fact]
+ public void Should_Contain_UpdatedAt_In_Iso8601()
+ {
+ DateTime updatedAt = new(2026, 6, 20, 8, 0, 0, DateTimeKind.Utc);
+
+ string content = OlavJsonTemplate.Generate(
+ "1.0.0",
+ "1.0",
+ DateTime.UtcNow,
+ updatedAt);
+
+ Assert.Contains("\"updatedAt\": \"2026-06-20T08:00:00Z\"", content);
+ }
+
+ [Fact]
+ public void Should_Preserve_CreatedAt_Different_From_UpdatedAt()
+ {
+ DateTime createdAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ DateTime updatedAt = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ string content = OlavJsonTemplate.Generate("1.0.0", "1.0", createdAt, updatedAt);
+
+ Assert.Contains("2026-01-01T00:00:00Z", content);
+ Assert.Contains("2026-06-01T00:00:00Z", content);
+ }
+}
tests/Olav.UnitTests/Templates/PreCommitTemplateTests.cs
new file mode 100644
@@ -0,0 +1,15 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+
+namespace Olav.UnitTests.Templates;
+
+public class PreCommitTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_Shell()
+ {
+ string content = Olav.Templates.PreCommitTemplate.Generate();
+
+ TemplateValidationHelper.ValidateShell(content);
+ }
+}
tests/Olav.UnitTests/Templates/PrePushTemplateTests.cs
new file mode 100644
@@ -0,0 +1,15 @@
+using Xunit;
+using Olav.UnitTests.Templates.Helpers;
+
+namespace Olav.UnitTests.Templates;
+
+public class PrePushTemplateTests
+{
+ [Fact]
+ public void Should_Be_Valid_Shell()
+ {
+ string content = Olav.Templates.PrePushTemplate.Generate();
+
+ TemplateValidationHelper.ValidateShell(content);
+ }
+}