diff options
| author | netop://ウィビ <paul@webb.page> | 2026-04-11 15:01:41 -0700 |
|---|---|---|
| committer | netop://ウィビ <paul@webb.page> | 2026-04-11 15:01:41 -0700 |
| commit | 3f7f5dc1b5ca4ba6a9acaf101d2e52b64edd2705 (patch) | |
| tree | 9c496baae8c825b548fbec43f198006856ee30c6 | |
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | README.md | 404 | ||||
| -rw-r--r-- | banner.png | bin | 0 -> 213596 bytes | |||
| -rwxr-xr-x | cli.ts | 422 | ||||
| -rw-r--r-- | deno.json | 15 | ||||
| -rw-r--r-- | justfile | 43 | ||||
| -rw-r--r-- | mod.ts | 269 | ||||
| -rw-r--r-- | test.ts | 368 | ||||
| -rw-r--r-- | version.txt | 1 |
9 files changed, 1531 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5af4d48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# OS artefacts +.DS_Store +Thumbs.db + +# directories +build/ + +# &c +*.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5f8615 --- /dev/null +++ b/README.md @@ -0,0 +1,404 @@ + + + +**[ChronVer](https://chronver.org) (Chronological Versioning) is calendar‑based versioning.** In the age of rapid software releases, knowing _when_ something released is more important than arbitrary numbers from an outdated versioning system that most people never adhere to anyway. Finally, versioning for the rest of us. + + + +## Why ChronVer? + +``` +2025.07.21 ← You know exactly when this was released +v3.2.1 ← You have no idea when this happened +``` + +Semantic versioning is great for large systems like libraries and computers. Most software would benefit from **time‑based versioning** that's immediately understandable to everyone on a team, not just the technical‑minded. + + + +## Format + +``` +YYYY.MM.DD[.CHANGESET][-FEATURE|-break] +``` + +### Examples + +| Version | Description | +|------------------------|-------------------------------| +| `2025.07.21` | Released July 21st, 2025 | +| `2025.07.21.1` | First hotfix that day | +| `2025.07.21.3` | Third change that day | +| `2025.07.21-feature` | Feature branch for that date | +| `2025.07.21.1-feature` | Feature branch with changeset | +| `2025.07.21.1-break` | Breaking change | + + + +## Installation + +### Deno + +```sh +# install +deno add jsr:@chronver/chronver + +# import in your code +import { ChronVer } from "jsr:@chronver/chronver"; + +# install CLI globally +# "resolver diagnostics" will appear when using this method but it's fine +deno install --allow-read --allow-write --global --name chronver https://raw.githubusercontent.com/ChronVer/chronver/refs/heads/primary/cli.ts --import-map https://raw.githubusercontent.com/ChronVer/chronver/refs/heads/primary/deno.json +``` + +### npm/bun + +```sh +# install via npm +npx jsr add @chronver/chronver + +# install via bun +bunx jsr add @chronver/chronver + +# import in your code +import { ChronVer } from "@chronver/chronver"; +``` + +ChronVer is especially powerful with [husky](https://typicode.github.io/husky/). Here's how I use it in my Node projects (using [bun](https://bun.sh)): + +```js +// package.json + "devDependencies": { + "del-cli": "^6.0.0", + "husky": "^9.1.7" + }, + "scripts": { + "build": "bun run clean && vite build", + "clean": "del './build'", + "increment": "chronver increment", + "pre-commit": "bun run build && bun run increment && git add -A :/", + "prepare": "husky" + } +``` + +Now every time you push a commit, the `version` in `package.json` gets updated. Please note that you'll need to install the `chronver` CLI globally using the Deno method above or the path method below (recommended). + +### macOS/Linux + +You can find releases of the CLI on the [releases page](https://github.com/ChronVer/chronver/releases) of this repo. + +```sh +# create personal bin directory if it doesn't exist +mkdir -p ~/.local/bin + +# add to PATH in your shell profile (~/.bashrc, ~/.zshrc, etc.) +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc + +# move binary there +mv chronver ~/.local/bin/ +``` + + + +## Usage + +### Basic API + +```ts +import { ChronVer } from "jsr:@chronver/chronver"; + +// create new version with today's date +const version = new ChronVer(); +console.log(version.toString()); // "2025.07.21" + +// parse existing version +const parsed = new ChronVer("2024.04.03.1"); +console.log(parsed.year); // 2024 +console.log(parsed.month); // 4 +console.log(parsed.day); // 3 +console.log(parsed.changeset); // 1 + +// compare versions +const v1 = new ChronVer("2024.04.03"); +const v2 = new ChronVer("2024.04.04"); +console.log(v1.isOlderThan(v2)); // true +console.log(v2.isNewerThan(v1)); // true + +// increment version +const incremented = version.increment(); +console.log(incremented.toString()); // "2024.07.19.1" (if same day) +``` + +### Static Methods + +```ts +// validation +ChronVer.isValid("2024.04.03"); // true +ChronVer.isValid("invalid"); // false + +// comparison +ChronVer.compare("2024.04.03", "2024.04.04"); // -1 + +// parsing +const parsed = ChronVer.parseVersion("2024.04.03.1-feature"); +// { +// changeset: 1, +// date: "2024.04.03", +// feature: "feature", +// isBreaking: false, +// version: "2024.04.03.1-feature" +// } + +// sorting +const versions = ["2024.04.05", "2024.04.03", "2024.04.04"]; +ChronVer.sort(versions); // ["2024.04.03", "2024.04.04", "2024.04.05"] + +// create from Date +const date = new Date(2024, 3, 3); // April 3, 2024 +const version = ChronVer.fromDate(date, 5); // "2024.04.03.5" +``` + +### File Operations + +```ts +// update package.json version +const newVersion = await ChronVer.incrementInFile("package.json"); +console.log(newVersion); // "2025.07.21.1" + +// works with any JSON file +await ChronVer.incrementInFile("deno.json"); +``` + + + +## CLI Usage + +```bash +# create new version +chronver create # 2024.07.19 + +# validate versions +chronver validate "2024.04.03.1" # ✅ Valid: 2024.04.03.1 + +# compare versions +chronver compare "2024.04.03" "2024.04.04" # 2024.04.03 < 2024.04.04 (-1) + +# increment package.json +chronver increment # 📦 Updated to: 2025.07.21.1 +chronver increment deno.json # 📦 Updated to: 2025.07.21.1 + +# parse version details +chronver parse "2024.04.03.1-feature" +# 📋 Version: 2024.04.03.1-feature +# 📅 Date: 2024.04.03 +# 🔢 Changeset: 1 +# 💥 Breaking: no +# 🚀 Feature: feature +# 📆 Day of week: Wednesday +# ⏪ Released 107 days ago + +# sort versions +chronver sort "2024.04.03" "2024.04.01" "2024.04.05" +# 📊 Sorted (ascending): +# 🔼 1. 2024.04.01 +# 🔼 2. 2024.04.03 +# 🔼 3. 2024.04.05 + +chronver --sort-desc "2024.04.03" "2024.04.01" "2024.04.05" +# 📊 Sorted (descending): +# 🔽 1. 2024.04.05 +# 🔽 2. 2024.04.03 +# 🔽 3. 2024.04.01 + +# create from specific date +chronver format "2024-04-03" 5 # 2024.04.03.5 + +# help +chronver --help +``` + + + +## When to Use ChronVer + +### ✅ Perfect For + +- **SaaS platforms** with regular feature rollouts +- **Mobile apps** with app store schedules +- **Enterprise software** with quarterly releases +- **Security tools** where timing matters +- **Marketing‑driven releases** tied to campaigns +- **Compliance software** with regulatory deadlines + +### ❌ Less Ideal For + +- **Libraries** consumed by other developers +- **APIs** where breaking changes need clear signaling +- **Projects** with irregular, feature‑driven releases +- **Tools** where semantic compatibility matters more than timing + + + +## Comparison with SemVer + +| Aspect | ChronVer | SemVer | +|-------------------|---------------------------------|---------------------------| +| **Clarity** | Immediately shows when released | Requires lookup | +| **Planning** | Aligns with calendar schedules | Feature‑driven | +| **Communication** | "The April release" | "Version 3.2.1" | +| **Sorting** | Chronological by default | Arbitrary without context | +| **Compatibility** | Time‑based breaking changes | API contract based | +| **Best for** | Time‑sensitive releases | Library compatibility | + + + +## Advanced Features + +### Feature Branches + +```ts +const feature = new ChronVer("2024.04.03-new-ui"); +console.log(feature.feature); // "new-ui" +console.log(feature.toString()); // "2024.04.03-new-ui" +``` + +### Breaking Changes + +```ts +const breaking = new ChronVer("2024.04.03.1-break"); +console.log(breaking.isBreaking); // true +``` + +### Date Validation + +ChronVer validates actual calendar dates: + +```ts +ChronVer.isValid("2024.02.29"); // true (2024 was a leap year) +ChronVer.isValid("2023.02.29"); // false (2023 was not a leap year) +ChronVer.isValid("2024.04.31"); // false (April has 30 days) +``` + + + +## Real-World Examples + +### `package.json` integration + +```json +{ + "name": "my-app", + "scripts": { + "version": "chronver increment" + }, + "version": "2025.07.21.3" +} +``` + +### CI/CD Pipeline + +```yaml +# GitHub Actions example +- name: Update version + run: | + chronver increment + git add package.json + git commit -m "chore: bump version to $(cat package.json | jq -r .version)" +``` + +### Release Notes + +```md +## Release 2025.07.21 - Summer Feature Drop + +### New Features + +- Dark mode support +- Mobile‑responsive dashboard +- Advanced search filters + +### Bug Fixes + +- Fixed login timeout issue +- Improved performance on large datasets + +### Breaking Changes + +None in this release. +``` + + + +## Development + +```sh +# clone project +git clone https://github.com/chronver/chronver.git && cd $_ + +# lint +deno check && deno lint + +# run tests +deno test --allow-read --allow-write --fail-fast + +# run CLI locally +deno run --allow-read --allow-write cli.ts --help +``` + +If you have [Just](https://just.systems/man/en/) installed: + +```sh +just lint +# deno check && deno lint + +just test +# deno test --allow-read --allow-write --fail-fast + +just build +# compile CLI to an executable +``` + + + +## Ports + +### Rust + +- https://github.com/dnaka91/chronver / https://crates.io/crates/chronver + + + +## License + +[Creative Commons ― CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) + + + +## FAQ + +### Why not just use dates? + +ChronVer **is** dates, but with a structured format that supports multiple releases per day, feature branches, and breaking change indicators. + +### What about semantic compatibility? + +ChronVer can indicate breaking changes with the `-break` suffix. For situations where semantic versioning is **crucial**, stick with SemVer. + +### How do I migrate from SemVer? + +1. Choose your first ChronVer date (usually next release) +2. Update your build tools to use `chronver increment` +3. Update documentation to explain the new format +4. Consider keeping a mapping in your `CHANGELOG` + +### Can I use both ChronVer and SemVer? + +Absolutely! Here's how your project could use ChronVer for releases and SemVer for API versions: + +```json +{ + "apiVersion": "v2.1.0", + "version": "2024.07.19.1" +} +``` diff --git a/banner.png b/banner.png Binary files differnew file mode 100644 index 0000000..4cc1bf5 --- /dev/null +++ b/banner.png @@ -0,0 +1,422 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write + + + +//// import + +import { parseArgs } from "@std/cli"; + +//// util + +import { ChronVer } from "./mod.ts"; + +const VERSION = await getVersion(); + +interface CliArgs { + _: string[]; + compare?: string[]; + create?: boolean; + format?: string; + help?: boolean; + increment?: string; + parse?: string; + sort?: string[]; + "sort-desc"?: boolean; + validate?: string; + version?: boolean; +} + + + +//// program + +if (import.meta.main) { + try { + await main(); + } catch(error) { + console.error(`❌ Unexpected error: ${(error as Error).message}`); + Deno.exit(1); + } +} + +async function main(): Promise<void> { + const args = parseArgs(Deno.args, { + alias: { h: "help", v: "version" }, + boolean: ["help", "version", "create", "sort-desc"], + collect: ["compare", "sort"], + string: ["validate", "increment", "parse", "format"] + }) as CliArgs; + + if (args.help) { + showHelp(); + return; + } + + if (args.version) { + showVersion(); + return; + } + + if (args.validate) { + await validateVersion(args.validate); + return; + } + + if (args.compare && args.compare.length >= 2) { + compareVersions(args.compare[0], args.compare[1]); + return; + } + + if (args.increment !== undefined) { + await incrementVersion(args.increment || undefined); + return; + } + + if (args.create) { + createVersion(); + return; + } + + if (args.parse) { + parseVersion(args.parse); + return; + } + + if (args.sort && args.sort.length > 0) { + sortVersions(args.sort, false); + return; + } + + if (args["sort-desc"]) { + const versions = args._.slice(0) as string[]; + + if (versions.length > 0) { + sortVersions(versions, args["sort-desc"]); + } else { + console.error("❌ Error: --sort-desc requires at least one version"); + Deno.exit(1); + } + + return; + } + + if (args.format) { + const changeset = args._.length > 0 ? args._[0] as string : undefined; + formatFromDate(args.format, changeset); + return; + } + + const command = args._[0] as string; + + switch(command) { + case "compare": { + if (args._[1] && args._[2]) { + compareVersions(args._[1] as string, args._[2] as string); + } else { + console.error("❌ Error: compare command requires two version strings"); + Deno.exit(1); + } + + break; + } + + case "create": { + createVersion(); + break; + } + + case "format": { + if (args._[1]) { + const changeset = args._[2] as string; + formatFromDate(args._[1] as string, changeset); + } else { + console.error("❌ Error: format command requires a date (YYYY-MM-DD)"); + Deno.exit(1); + } + + break; + } + + case "increment": { + await incrementVersion(args._[1] as string); + break; + } + + case "parse": { + if (args._[1]) { + parseVersion(args._[1] as string); + } else { + console.error("❌ Error: parse command requires a version string"); + Deno.exit(1); + } + + break; + } + + case "sort": { + if (args._.length > 1) { + const versions = args._.slice(1) as string[]; + sortVersions(versions, false); + } else { + console.error("❌ Error: sort command requires at least one version"); + Deno.exit(1); + } + + break; + } + + case "validate": { + if (args._[1]) { + await validateVersion(args._[1] as string); + } else { + console.error("❌ Error: validate command requires a version string"); + Deno.exit(1); + } + + break; + } + + default: { + if (command) { + console.error(`❌ Unknown command: ${command}`); + console.log("Run 'chronver --help' for usage information"); + Deno.exit(1); + } else { + console.log("ChronVer CLI - Versioning for the rest of us"); + console.log(`Run "chronver --help" for usage information`); + console.log(`Run "chronver create" to generate a new version`); + } + } + } +} + + + +//// helper + +function compareVersions(v1: string, v2: string): void { + try { + const result = ChronVer.compare(v1, v2); + const symbol = result === -1 ? + "<" : + result === 1 ? + ">" : + "="; + + console.log(`${v1} ${symbol} ${v2} (${result})`); + + if (result === -1) + console.log(`${v1} is older than ${v2}`); + else if (result === 1) + console.log(`${v1} is newer than ${v2}`); + else + console.log("Versions are equal"); + } catch(error) { + console.error(`❌ Error: ${(error as Error).message}`); + Deno.exit(1); + } +} + +function createVersion(): void { + try { + const version = new ChronVer(); + console.log(version.toString()); + } catch (error) { + console.error(`❌ Error: ${(error as Error).message}`); + Deno.exit(1); + } +} + +function formatFromDate(dateStr: string, changeset?: string): void { + try { + const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/); + + if (!match) + throw new Error("Date must be in YYYY-MM-DD format"); + + const [, year, month, day] = match; + const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); + const changesetNum = changeset ? parseInt(changeset) : 0; + + if (isNaN(changesetNum) || changesetNum < 0) + throw new Error("Changeset must be a non-negative number"); + + const version = ChronVer.fromDate(date, changesetNum); + console.log(version.toString()); + } catch(error) { + console.error(`❌ Error: ${(error as Error).message}`); + Deno.exit(1); + } +} + +async function getVersion() { + let version = ""; + + try { + version = await Deno.readTextFile("./version.txt"); + } catch { + /*** ignore ***/ + } + + return version.trim(); +} + +async function incrementVersion(filename?: string): Promise<void> { + try { + const file = filename || "package.json"; + console.log(`📦 Incrementing version in ${file}...`); + + const newVersion = await ChronVer.incrementInFile(file); + console.log(`✅ Updated to: ${newVersion}`); + } catch(error) { + if ((error as Error).message.includes("No such file")) { + console.error(`❌ File not found: ${filename || "package.json"}`); + console.log("💡 Create the file first or specify a different file"); + } else { + console.error(`❌ Error: ${(error as Error).message}`); + } + + Deno.exit(1); + } +} + +function parseVersion(version: string): void { + try { + const parsed = ChronVer.parseVersion(version); + + if (!parsed) { + console.log(`❌ Invalid version: ${version}`); + Deno.exit(1); + } + + console.log(`📋 Version: ${parsed.version}`); + console.log(`📅 Date: ${parsed.date}`); + console.log(`🔢 Changeset: ${parsed.changeset}`); + console.log(`💥 Breaking: ${parsed.isBreaking ? "yes" : "no"}`); + + if (parsed.feature) + console.log(`🚀 Feature: ${parsed.feature}`); + + const chronVer = new ChronVer(version); + const date = new Date(chronVer.year, chronVer.month - 1, chronVer.day); + const dayOfWeek = date.toLocaleDateString("en-US", { weekday: "long" }); + console.log(`📆 Day of week: ${dayOfWeek}`); + + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (date > now) { + if (diffDays === 1) + console.log("⏭️ Future release (in 1 day)"); + else + console.log(`⏭️ Future release (in ${diffDays} days)`); + } else if (diffDays === 0) { + console.log(`🎯 Released today`); + } else { + if (diffDays === 1) + console.log("⏪ Released 1 day ago"); + else + console.log(`⏪ Released ${diffDays} days ago`); + } + } catch(error) { + console.error(`❌ Error: ${(error as Error).message}`); + Deno.exit(1); + } +} + +function showHelp(): void { + console.log(` + __ + ____/ / _______ _____ __________ +/ __/ _ \\/ __/ _ \\/ _ | |/ / -_/ __/ +\\__/_//_/_/ \\___/_//_|___/\\__/_/ + +ChronVer CLI ${VERSION} +Versioning for the rest of us + +USAGE: + chronver [OPTIONS] [COMMAND] + +COMMANDS: + compare <v1> <v2> Compare two versions (-1, 0, 1) + create Create a new version with today's date + format <date> [changeset] Create version from date (YYYY-MM-DD format) + increment [file] Increment version in package.json (or specified file) + parse <version> Parse and display version components + sort <versions...> Sort versions in ascending order + validate <version> Check if a version string is valid + +OPTIONS: + -h, --help Show this help message + -v, --version Show CLI version + --sort-desc Sort versions in descending order + +EXAMPLES: + $ chronver compare "2024.04.03" "2024.04.04" + $ chronver create + $ chronver format "2024-04-03" 5 + $ chronver increment + $ chronver increment deno.json + $ chronver parse "2024.04.03.1-feature" + $ chronver sort "2024.04.03" "2024.04.01" "2024.04.05" + $ chronver --sort-desc "2024.04.03" "2024.04.01" "2024.04.05" + $ chronver validate "2024.04.03.1" + +MORE INFO: + ChronVer is calendar-based versioning: YYYY.MM.DD[.CHANGESET][-FEATURE|-break] + + Docs: https://chronver.org + Repo: https://github.com/chronver/chronver + `); +} + +function showVersion(): void { + console.log(`chronver ${VERSION}`); +} + +function sortVersions(versions: string[], descending = false): void { + try { + const sorted = ChronVer.sort(versions, descending); + const direction = descending ? "descending" : "ascending"; + + console.log(`📊 Sorted (${direction}):`); + + console.log(versions); + + sorted.forEach((version, index) => { + const prefix = descending ? "🔽" : "🔼"; + console.log(`${prefix} ${index + 1}. ${version}`); + }); + } catch(error) { + console.error(`❌ Error: ${(error as Error).message}`); + Deno.exit(1); + } +} + +function validateVersion(version: string): void { + try { + const isValid = ChronVer.isValid(version); + + if (isValid) { + console.log(`✅ Valid: ${version}`); + const parsed = new ChronVer(version); + console.log(` Components: ${parsed.year}-${parsed.month.toString().padStart(2, "0")}-${parsed.day.toString().padStart(2, "0")}`); + + if (parsed.changeset > 0) + console.log(` Changeset: ${parsed.changeset}`); + + if (parsed.feature) + console.log(` Feature: ${parsed.feature}`); + + if (parsed.isBreaking) + console.log(` Breaking: yes`); + } else { + console.log(`❌ Invalid: ${version}`); + Deno.exit(1); + } + } catch(error) { + console.error(`❌ Error: ${(error as Error).message}`); + Deno.exit(1); + } +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..dde5eb9 --- /dev/null +++ b/deno.json @@ -0,0 +1,15 @@ +{ + "exports": "./mod.ts", + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.13", + "@std/cli": "jsr:@std/cli@^1.0.20" + }, + "license": "MIT", + "lock": false, + "name": "@chronver/chronver", + "permissions": { + "read": true, + "write": true + }, + "version": "2.1.0" +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..3d7d92a --- /dev/null +++ b/justfile @@ -0,0 +1,43 @@ +default: + @just --list + +build: build-linux build-mac build-mac-intel build-windows + +build-linux: + @echo "[INFO] Building Linux executable…" + deno compile --allow-read --allow-write --output build/linux/chronver --target x86_64-unknown-linux-gnu cli.ts + +build-mac: + @echo "[INFO] Building macOS executable…" + deno compile --allow-read --allow-write --output build/mac/chronver --target aarch64-apple-darwin cli.ts + +build-mac-intel: + @echo "[INFO] Building macOS (Intel) executable…" + deno compile --allow-read --allow-write --output build/mac_intel/chronver --target x86_64-apple-darwin cli.ts + +build-windows: + @echo "[INFO] Building Windows executable…" + deno compile --allow-read --allow-write --output build/windows/chronver --target x86_64-pc-windows-msvc cli.ts + +clean: + rm -rf build + +lint: + deno check && deno lint + +test: + deno test --allow-read --allow-write --fail-fast + +dev: + deno run --allow-read --allow-write --watch mod.ts + +release: version clean build + @echo "[INFO] Release versioned and built" + +start: + deno run --allow-read --allow-write mod.ts + +version: + @echo "[INFO] Updating version.txt with ChronVer" + @deno eval "const now = new Date(); const version = \`\${now.getFullYear()}.\${String(now.getMonth() + 1).padStart(2, '0')}.\${String(now.getDate()).padStart(2, '0')}\`; console.log(version); await Deno.writeTextFile('version.txt', version);" + @echo "[INFO] Version updated: $(cat version.txt)" @@ -0,0 +1,269 @@ + + + +//// export + +export class ChronVer { + private static readonly DATE_REGEX = /^(\d{4})\.(\d{2})\.(\d{2})$/; + private static readonly VERSION_REGEX = /^(\d{4})\.(?:0[1-9]|1[0-2])\.(?:0[1-9]|[12]\d|3[01])(?:\.(\d+))?(?:-(break|[a-zA-Z0-9-]+)(?:\.(\d+))?)?$/; + + readonly changeset: number; /*** changeset number (0 if not specified) ***/ + readonly day: number; /*** day component (1-31) ***/ + readonly feature?: string; /*** feature name (if specified) ***/ + readonly isBreaking: boolean; /*** whether this is a breaking change ***/ + readonly month: number; /*** month component (1-12) ***/ + readonly year: number; /*** year component ***/ + + constructor(version?: string) { + if (!version) { + const now = new Date(); + + this.changeset = 0; + this.day = now.getDate(); + this.isBreaking = false; + this.month = now.getMonth() + 1; + this.year = now.getFullYear(); + + return; + } + + const parsed = this.parseVersionString(version); + + this.changeset = parsed.changeset; + this.day = parsed.day; + this.feature = parsed.feature; + this.isBreaking = parsed.isBreaking; + this.month = parsed.month; + this.year = parsed.year; + + this.validate(); + } + + //// private methods + + private getDaysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate(); + } + + private parseVersionString(version: string): { changeset: number; day: number; feature?: string; isBreaking: boolean; month: number; year: number; } { + const match = version.match(ChronVer.VERSION_REGEX); + + if (!match) + throw new ChronVerError(`Invalid ChronVer format: "${version}"`); + + const [, yearStr, changesetStr, label, featureChangeset] = match; + const parts = version.split("."); + + if (parts.length < 3) + throw new ChronVerError(`Invalid ChronVer format: "${version}"`); + + const year = parseInt(yearStr, 10); + const month = parseInt(parts[1], 10); + const day = parseInt(parts[2], 10); + const isBreaking = label === "break"; + + let changeset = changesetStr ? + parseInt(changesetStr, 10) : + 0; + + let feature: string | undefined; + + if (label && !isBreaking) { + feature = label; + + if (!changeset) { + changeset = featureChangeset ? + parseInt(featureChangeset, 10) : + 0; + } + } + + return { + changeset, + day, + feature, + isBreaking, + month, + year + }; + } + + private validate(): void { + if (this.year < 1) + throw new ChronVerError("Year must be positive"); + + if (this.month < 1 || this.month > 12) + throw new ChronVerError("Month must be between 1 and 12"); + + const maxDays = this.getDaysInMonth(this.year, this.month); + + if (this.day < 1 || this.day > maxDays) + throw new ChronVerError(`Day must be between 1 and ${maxDays} for ${this.year}-${this.month.toString().padStart(2, "0")}`); + + if (this.changeset < 0) + throw new ChronVerError("Changeset cannot be negative"); + + if (this.feature && !/^[a-zA-Z0-9-]+$/.test(this.feature)) + throw new ChronVerError("Feature name must contain only alphanumeric characters and hyphens"); + } + + //// public methods + + compare(other: ChronVer): number { + /*** compare date components ***/ + const comparisons = [ + this.year - other.year, + this.month - other.month, + this.day - other.day, + this.changeset - other.changeset + ]; + + for (const diff of comparisons) { + if (diff !== 0) + return Math.sign(diff); + } + + if (this.isBreaking !== other.isBreaking) + return this.isBreaking ? 1 : -1; /*** compare breaking changes ***/ + + /*** at this point, changeset is zero ***/ + return 0; + } + + equals(other: ChronVer): boolean { + return this.compare(other) === 0; + } + + getDateString(): string { + return `${this.year}.${this.month.toString().padStart(2, "0")}.${this.day.toString().padStart(2, "0")}`; + } + + increment(): ChronVer { + const today = new Date(); + + const isToday = this.year === today.getFullYear() && + this.month === (today.getMonth() + 1) && + this.day === today.getDate(); + + if (isToday) /*** already ran today, increment changeset ***/ + return new ChronVer(`${this.year}.${this.month.toString().padStart(2, "0")}.${this.day.toString().padStart(2, "0")}.${this.changeset + 1}`); + + return new ChronVer(); + } + + isNewerThan(other: ChronVer): boolean { + return this.compare(other) > 0; + } + + isOlderThan(other: ChronVer): boolean { + return this.compare(other) < 0; + } + + toString(): string { + const base = `${this.year}.${this.month.toString().padStart(2, "0")}.${this.day.toString().padStart(2, "0")}`; + let result = base; + + if (this.changeset > 0) + result += `.${this.changeset}`; + + if (this.feature) + result += `-${this.feature}`; + + if (this.isBreaking) + result += "-break"; + + return result; + } + + //// static methods + + static compare(v1: string, v2: string): number { + try { + const version1 = new ChronVer(v1); + const version2 = new ChronVer(v2); + + return version1.compare(version2); + } catch(error) { + throw new ChronVerError(`Failed to compare versions: ${(error as Error).message}`); + } + } + + static fromDate(date: Date, changeset: number = 0): ChronVer { + const dateStr = `${date.getFullYear()}.${(date.getMonth() + 1).toString().padStart(2, "0")}.${date.getDate().toString().padStart(2, "0")}`; + return new ChronVer(changeset > 0 ? `${dateStr}.${changeset}` : dateStr); + } + + static async incrementInFile(filename: string = "package.json"): Promise<string> { + try { + const content = await Deno.readTextFile(filename); + const json = JSON.parse(content); + + if (!json.version) { + json.version = new ChronVer().toString(); + } else { + const currentVersion = new ChronVer(json.version); + json.version = currentVersion.increment().toString(); + } + + await Deno.writeTextFile(filename, JSON.stringify(json, null, 2) + "\n"); + return json.version; + } catch(error) { + if ( /*** not found errors ***/ + error instanceof Deno.errors.NotFound || + (error as Error).message.includes("No such file") || + (error as Error).message.includes("ENOENT") || + (error as Error).message.includes("not found")) + throw new ChronVerError(`File not found: ${filename}`); + + if (error instanceof SyntaxError) /*** json parsing errors ***/ + throw new ChronVerError(`Invalid JSON in ${filename}: ${(error as Error).message}`); + + if ((error as Error).message.includes("permission") || (error as Error).message.includes("EACCES")) + throw new ChronVerError(`Permission denied accessing ${filename}`); /*** permission errors ***/ + + throw new ChronVerError(`Failed to update ${filename}: ${(error as Error).message}`); + } + } + + static isValid(version: string): boolean { + try { + new ChronVer(version); + return true; + } catch { + return false; + } + } + + static parseVersion(version: string): { changeset: number; date: string; feature?: string; isBreaking: boolean; version: string; } | null { + if (!this.isValid(version)) + return null; + + try { + const chronVer = new ChronVer(version); + + return { + changeset: chronVer.changeset, + date: chronVer.getDateString(), + feature: chronVer.feature, + isBreaking: chronVer.isBreaking, + version + }; + } catch { + return null; + } + } + + static sort(versions: string[], descending: boolean = false): string[] { + return versions.slice().sort((a, b) => { + const result = ChronVer.compare(a, b); + return descending ? -result : result; + }); + } +} + +export class ChronVerError extends Error { + constructor(message: string) { + super(message); + this.name = "ChronVerError"; + } +} @@ -0,0 +1,368 @@ + + + +//// import + +import { + assert, + assertEquals, + assertFalse, + assertThrows +} from "@std/assert"; + +//// util + +import { ChronVer, ChronVerError } from "./mod.ts"; + +const validVersions = [ + "2024.04.03", + "2024.04.03.1", + "2024.04.03.15", + "2024.12.31.999", + "2024.04.03-feature", + "2024.04.03.1-feature", + "2024.04.03-break", + "2024.04.03.1-break", + "2024.04.03-feature-name", + "2024.04.03-feature.5" +]; + +const invalidVersions = [ + "invalid", + "2024", + "2024.4.3", + "2024.04.32", + "2024.13.15", + "2024.00.15", + "2024.04.00", + "2024.04.03.-1", + "2024.04.03.1.2", + "24.04.03", + "2024.4.03", + "2024.04.3" +]; + + + +//// program + +Deno.test("ChronVer constructor", async(test) => { + await test.step("should create version with current date when no input", () => { + const version = new ChronVer(); + const now = new Date(); + + assertEquals(version.year, now.getFullYear()); + assertEquals(version.month, now.getMonth() + 1); + assertEquals(version.day, now.getDate()); + assertEquals(version.changeset, 0); + assertEquals(version.isBreaking, false); + }); + + await test.step("should parse valid version strings correctly", () => { + const version = new ChronVer("2024.04.03.5"); + + assertEquals(version.year, 2024); + assertEquals(version.month, 4); + assertEquals(version.day, 3); + assertEquals(version.changeset, 5); + assertEquals(version.isBreaking, false); + }); + + await test.step("should parse feature versions", () => { + const version = new ChronVer("2024.04.03-feature"); + + assertEquals(version.feature, "feature"); + assertEquals(version.changeset, 0); + assertEquals(version.isBreaking, false); + }); + + await test.step("should parse feature versions with changeset", () => { + const version = new ChronVer("2024.04.03-feature.5"); + + assertEquals(version.feature, "feature"); + assertEquals(version.changeset, 5); + assertEquals(version.isBreaking, false); + }); + + await test.step("should parse breaking versions", () => { + const version = new ChronVer("2024.04.03.1-break"); + + assertEquals(version.isBreaking, true); + assertEquals(version.changeset, 1); + }); + + await test.step("should throw on invalid formats", () => { + for (const invalid of invalidVersions) { + assertThrows(() => new ChronVer(invalid), ChronVerError); + } + }); +}); + +Deno.test("ChronVer validation", async(test) => { + await test.step("should validate leap years correctly", () => { + const leapYear = new ChronVer("2024.02.29"); + + assertEquals(leapYear.day, 29); + assertThrows(() => new ChronVer("2023.02.29"), ChronVerError); + }); + + await test.step("should validate month boundaries", () => { + assertThrows(() => new ChronVer("2024.13.01"), ChronVerError); + assertThrows(() => new ChronVer("2024.00.01"), ChronVerError); + }); + + await test.step("should validate day boundaries for different months", () => { + assertThrows(() => new ChronVer("2024.02.30"), ChronVerError); + assertThrows(() => new ChronVer("2024.04.31"), ChronVerError); + assertThrows(() => new ChronVer("2024.01.32"), ChronVerError); + }); + + await test.step("should validate negative changesets", () => { + assertThrows(() => new ChronVer("2024.04.03.-1"), ChronVerError); + }); +}); + +Deno.test("ChronVer comparison", async(test) => { + await test.step("should compare dates correctly", () => { + const v1 = new ChronVer("2024.04.03"); + const v2 = new ChronVer("2024.04.04"); + const v3 = new ChronVer("2024.05.03"); + const v4 = new ChronVer("2025.04.03"); + + assertEquals(v1.compare(v2), -1); + assertEquals(v2.compare(v1), 1); + assertEquals(v1.compare(v3), -1); + assertEquals(v1.compare(v4), -1); + assertEquals(v1.compare(v1), 0); + }); + + await test.step("should compare changesets correctly", () => { + const v1 = new ChronVer("2024.04.03.1"); + const v2 = new ChronVer("2024.04.03.2"); + + assertEquals(v1.compare(v2), -1); + assertEquals(v2.compare(v1), 1); + }); + + await test.step("should handle breaking changes in comparison", () => { + const normal = new ChronVer("2024.04.03.1"); + const breaking = new ChronVer("2024.04.03.1-break"); + + assertEquals(breaking.compare(normal), 1); + assertEquals(normal.compare(breaking), -1); + }); + + await test.step("should treat feature versions as equal when dates match", () => { + const v1 = new ChronVer("2024.04.03-feature1"); + const v2 = new ChronVer("2024.04.03-feature2"); + + assertEquals(v1.compare(v2), 0); + }); +}); + +Deno.test("ChronVer utility methods", async(test) => { + await test.step(`"isNewerThan" should work correctly`, () => { + const v1 = new ChronVer("2024.04.03"); + const v2 = new ChronVer("2024.04.04"); + + assert(v2.isNewerThan(v1)); + assertFalse(v1.isNewerThan(v2)); + }); + + await test.step(`"isOlderThan" should work correctly`, () => { + const v1 = new ChronVer("2024.04.03"); + const v2 = new ChronVer("2024.04.04"); + + assert(v1.isOlderThan(v2)); + assertFalse(v2.isOlderThan(v1)); + }); + + await test.step(`"equals" should work correctly`, () => { + const v1 = new ChronVer("2024.04.03.1"); + const v2 = new ChronVer("2024.04.03.1"); + const v3 = new ChronVer("2024.04.03.2"); + + assert(v1.equals(v2)); + assertFalse(v1.equals(v3)); + }); + + await test.step(`"getDateString" should return correct format`, () => { + const version = new ChronVer("2024.04.03.5"); + assertEquals(version.getDateString(), "2024.04.03"); + }); +}); + +Deno.test("ChronVer string output", async(test) => { + await test.step("should format basic versions correctly", () => { + assertEquals(new ChronVer("2024.04.03").toString(), "2024.04.03"); + assertEquals(new ChronVer("2024.04.03.1").toString(), "2024.04.03.1"); + }); + + await test.step("should format feature versions correctly", () => { + assertEquals(new ChronVer("2024.04.03-feature").toString(), "2024.04.03-feature"); + assertEquals(new ChronVer("2024.04.03-feature.5").toString(), "2024.04.03.5-feature"); + }); + + await test.step("should format breaking versions correctly", () => { + assertEquals(new ChronVer("2024.04.03-break").toString(), "2024.04.03-break"); + assertEquals(new ChronVer("2024.04.03.1-break").toString(), "2024.04.03.1-break"); + }); + + await test.step("should pad months and days correctly", () => { + assertEquals(new ChronVer("2024.01.05").toString(), "2024.01.05"); + assertEquals(new ChronVer("2024.12.25").toString(), "2024.12.25"); + }); +}); + +Deno.test("ChronVer increment", async(test) => { + await test.step("should increment changeset for same day", () => { + const today = new Date(); + const todayStr = `${today.getFullYear()}.${(today.getMonth() + 1).toString().padStart(2, "0")}.${today.getDate().toString().padStart(2, "0")}`; + const version = new ChronVer(todayStr + ".5"); + const incremented = version.increment(); + + assertEquals(incremented.toString(), todayStr + ".6"); + }); + + await test.step("should create new version for different day", () => { + const version = new ChronVer("2023.01.01.5"); + const incremented = version.increment(); + const today = new Date(); + const expectedDate = `${today.getFullYear()}.${(today.getMonth() + 1).toString().padStart(2, "0")}.${today.getDate().toString().padStart(2, "0")}`; + + assertEquals(incremented.toString(), expectedDate); + }); +}); + +Deno.test("ChronVer static methods", async(test) => { + await test.step(`"compare" should work with strings`, () => { + assertEquals(ChronVer.compare("2024.04.03", "2024.04.04"), -1); + assertEquals(ChronVer.compare("2024.04.04", "2024.04.03"), 1); + assertEquals(ChronVer.compare("2024.04.03", "2024.04.03"), 0); + }); + + await test.step(`"isValid" should validate correctly`, () => { + for (const valid of validVersions) { + assert(ChronVer.isValid(valid)); + } + + for (const invalid of invalidVersions) { + assertFalse(ChronVer.isValid(invalid)); + } + }); + + await test.step(`"parseVersion" should return correct structure`, () => { + const parsed = ChronVer.parseVersion("2024.04.03.5-feature"); + + assertEquals(parsed?.changeset, 5); + assertEquals(parsed?.date, "2024.04.03"); + assertEquals(parsed?.version, "2024.04.03.5-feature"); + assertEquals(parsed?.isBreaking, false); + assertEquals(parsed?.feature, "feature"); + }); + + await test.step(`"parseVersion" should return null for invalid versions`, () => { + assertEquals(ChronVer.parseVersion("invalid"), null); + }); + + await test.step(`"fromDate" should create version from Date object`, () => { + const date = new Date(2024, 3, 3); /*** month is 0-indexed ***/ + const version = ChronVer.fromDate(date, 5); + + assertEquals(version.year, 2024); + assertEquals(version.month, 4); + assertEquals(version.day, 3); + assertEquals(version.changeset, 5); + }); + + await test.step(`"sort" should order versions correctly`, () => { + const versions = [ + "2024.04.05", + "2024.04.03.1", + "2024.04.03", + "2024.04.04" + ]; + + const sorted = ChronVer.sort(versions); + + assertEquals(sorted, [ + "2024.04.03", + "2024.04.03.1", + "2024.04.04", + "2024.04.05" + ]); + + const sortedDesc = ChronVer.sort(versions, true); + + assertEquals(sortedDesc, [ + "2024.04.05", + "2024.04.04", + "2024.04.03.1", + "2024.04.03" + ]); + }); +}); + +Deno.test("ChronVer file operations", async(test) => { + const testFile = "test-package.json"; + + await test.step(`"incrementInFile" should create version if missing`, async() => { + const testData = { name: "test-package" }; + await Deno.writeTextFile(testFile, JSON.stringify(testData)); + + const newVersion = await ChronVer.incrementInFile(testFile); + assert(ChronVer.isValid(newVersion)); + + const content = await Deno.readTextFile(testFile); + const json = JSON.parse(content); + assertEquals(json.version, newVersion); + + await Deno.remove(testFile); + }); + + await test.step(`"incrementInFile" should increment existing version`, async() => { + const today = new Date(); + const todayStr = `${today.getFullYear()}.${(today.getMonth() + 1).toString().padStart(2, "0")}.${today.getDate().toString().padStart(2, "0")}`; + const testData = { name: "test-package", version: `${todayStr}.1` }; + + await Deno.writeTextFile(testFile, JSON.stringify(testData)); + + const newVersion = await ChronVer.incrementInFile(testFile); + assertEquals(newVersion, `${todayStr}.2`); + + await Deno.remove(testFile); + }); + + await test.step(`"incrementInFile" should handle file not found`, async() => { + try { + await ChronVer.incrementInFile("nonexistent.json"); + assert(false, "Expected ChronVerError to be thrown"); + } catch(error) { + assert(error instanceof ChronVerError, "Should throw ChronVerError"); + assert(error.message.includes("File not found"), "Should mention file not found"); + } + }); +}); + +Deno.test("ChronVer edge cases", async(test) => { + await test.step("should handle year boundaries", () => { + const version = new ChronVer("0001.01.01"); + + assertEquals(version.year, 1); + assertThrows(() => new ChronVer("0000.01.01"), ChronVerError); + }); + + await test.step("should handle complex feature names", () => { + const version = new ChronVer("2024.04.03-feature-name-123"); + assertEquals(version.feature, "feature-name-123"); + }); + + await test.step("should handle large changesets", () => { + const version = new ChronVer("2024.04.03.999999"); + assertEquals(version.changeset, 999999); + }); + + await test.step(`"compare" should throw on invalid versions`, () => { + assertThrows(() => ChronVer.compare("invalid", "2024.04.03"), ChronVerError); + }); +}); diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..9a03c90 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +2025.07.24
\ No newline at end of file |
