From 3f7f5dc1b5ca4ba6a9acaf101d2e52b64edd2705 Mon Sep 17 00:00:00 2001 From: "netop://ウィビ" Date: Sat, 11 Apr 2026 15:01:41 -0700 Subject: initial commit --- .gitignore | 9 ++ README.md | 404 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ banner.png | Bin 0 -> 213596 bytes cli.ts | 422 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ deno.json | 15 +++ justfile | 43 +++++++ mod.ts | 269 ++++++++++++++++++++++++++++++++++++++ test.ts | 368 ++++++++++++++++++++++++++++++++++++++++++++++++++++ version.txt | 1 + 9 files changed, 1531 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 banner.png create mode 100755 cli.ts create mode 100644 deno.json create mode 100644 justfile create mode 100644 mod.ts create mode 100644 test.ts create mode 100644 version.txt 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 @@ + +![](banner.png "ChronVer banner") + +**[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 new file mode 100644 index 0000000..4cc1bf5 Binary files /dev/null and b/banner.png differ diff --git a/cli.ts b/cli.ts new file mode 100755 index 0000000..24011c5 --- /dev/null +++ b/cli.ts @@ -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 { + 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 { + 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 Compare two versions (-1, 0, 1) + create Create a new version with today's date + format [changeset] Create version from date (YYYY-MM-DD format) + increment [file] Increment version in package.json (or specified file) + parse Parse and display version components + sort Sort versions in ascending order + validate 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)" diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..7d4e55c --- /dev/null +++ b/mod.ts @@ -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 { + 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"; + } +} diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..c149137 --- /dev/null +++ b/test.ts @@ -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 -- cgit v1.2.3