diff --git a/.github/workflows/release-plz-release.yml b/.github/workflows/release-plz-release.yml index 084abc4..bd57297 100644 --- a/.github/workflows/release-plz-release.yml +++ b/.github/workflows/release-plz-release.yml @@ -78,3 +78,39 @@ jobs: env: GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + - name: Sync README versions and features table + if: ${{ steps.release_step.outputs.releases_created == 'true' }} + env: + RELEASES_JSON: ${{ steps.release_step.outputs.releases }} + PUSH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + set -euo pipefail + + if [[ -z "${RELEASES_JSON:-}" || "$RELEASES_JSON" == "[]" ]]; then + echo "Release output is empty; skipping README sync." + exit 0 + fi + + MULTIIO_VERSION="$(echo "$RELEASES_JSON" | jq -r '.[] | select(.package_name == "multiio") | .version' | head -n1)" + + if [[ -z "${MULTIIO_VERSION:-}" || "$MULTIIO_VERSION" == "null" ]]; then + echo "multiio is not part of this release; skipping README sync." + exit 0 + fi + + python3 scripts/sync_readme.py --version "$MULTIIO_VERSION" + + if git diff --quiet -- README*.md; then + echo "README files already up to date." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git checkout -B "${GITHUB_REF_NAME}" + + git add README*.md + git commit -m "chore: sync README to v${MULTIIO_VERSION}" + git push origin "HEAD:${GITHUB_REF_NAME}" diff --git a/Cargo.toml b/Cargo.toml index 24b1c2a..e4e6215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,6 +134,7 @@ path = "examples/custom_format.rs" required-features = ["custom"] [package.metadata.release] +pre-release-hook = ["bash", "scripts/update_readme_release_metrics.sh"] pre-release-replacements = [ # Update inline-table dependency snippets like: # multiio = { version = "0.2", features = ["custom"] } diff --git a/README.md b/README.md index c909ac8..c026ba6 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -multiio = { version = "0.2.1", features = ["json"] } +multiio = { version = "0.2.2", features = ["json"] } serde = { version = "1.0", features = ["derive"] } ``` @@ -73,7 +73,7 @@ Enable the `async` feature (and the formats you use): ```toml [dependencies] -multiio = { version = "0.2.1", features = ["async", "json", "yaml"] } +multiio = { version = "0.2.2", features = ["async", "json", "yaml"] } ``` ```rust @@ -110,19 +110,20 @@ trees small. MSRV: Rust 1.86 (see `rust-version` in `Cargo.toml`). -| Feature | Description | Default | Notes | -| ----------- | --------------------------- | ------- | -------------- | -| `plaintext` | Plaintext format support | ✓ | | -| `json` | JSON format support | | | -| `yaml` | YAML format support | | | -| `toml` | TOML format support | | | -| `ini` | INI/".ini" config support | | | -| `xml` | XML format support | | | -| `csv` | CSV format support | | Enables `json` | -| `custom` | Custom formats via registry | | Enables `json` | -| `async` | Async I/O with Tokio | | | -| `miette` | Pretty error reporting | | | -| `full` | All features | | | +| Feature | Description | Default | Notes | +| ----------- | --------------------------- | ------- | ---------------- | +| `plaintext` | Plaintext format support | ✓ | | +| `json` | JSON format support | | | +| `yaml` | YAML format support | | | +| `toml` | TOML format support | | | +| `ini` | INI/".ini" config support | | | +| `xml` | XML format support | | | +| `csv` | CSV format support | | Enables `json` | +| `custom` | Custom formats via registry | | Enables `json` | +| `async` | Async I/O with Tokio | | | +| `miette` | Pretty error reporting | | | +| `sarge` | Sarge-based CLI helpers | | | +| `full` | All core features | | Excludes `sarge` | Note: Markdown is intentionally not a first-class format. If you need to ingest Markdown, use `plaintext` to read the content and then process it as needed. @@ -133,7 +134,7 @@ Custom formats are available behind the `custom` feature: ```toml [dependencies] -multiio = { version = "0.2.1", features = ["custom"] } +multiio = { version = "0.2.2", features = ["custom"] } ``` multiio allows you to register your own formats using `CustomFormat` and diff --git a/scripts/sync_readme.py b/scripts/sync_readme.py new file mode 100644 index 0000000..add81bc --- /dev/null +++ b/scripts/sync_readme.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +def run_cargo_metadata(manifest_path: Path) -> dict: + cargo = os.environ.get("CARGO", "cargo") + + cmd = [ + cargo, + "metadata", + "--no-deps", + "--format-version", + "1", + "--manifest-path", + str(manifest_path), + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError( + "cargo metadata failed.\n" + f"cmd: {' '.join(cmd)}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}\n" + ) + return json.loads(result.stdout) + + +def select_root_package(metadata: dict, manifest_path: Path) -> dict: + manifest_path = manifest_path.resolve() + packages = metadata.get("packages", []) + if not isinstance(packages, list): + raise ValueError("cargo metadata returned invalid packages list.") + for pkg in packages: + if not isinstance(pkg, dict): + continue + mp = pkg.get("manifest_path") + if not isinstance(mp, str): + continue + if Path(mp).resolve() == manifest_path: + return pkg + raise ValueError(f"Could not find package for manifest path: {manifest_path}") + + +def parse_feature_keys_from_manifest(manifest_text: str) -> list[str]: + in_features = False + keys: list[str] = [] + for raw_line in manifest_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + in_features = line == "[features]" + continue + if not in_features: + continue + # Stop when leaving the [features] section. + if line.startswith("["): + break + # Strip trailing comments (good enough for our simple keys parsing). + line = line.split("#", 1)[0].strip() + match = re.match(r"^([A-Za-z0-9_-]+)\s*=", line) + if match: + keys.append(match.group(1)) + return keys + + +def generate_features_table(feature_keys: list[str], features_map: dict) -> list[str]: + default_features = features_map.get("default", []) + if default_features is None: + default_features = [] + if not isinstance(default_features, list): + raise ValueError("cargo metadata features.default must be an array.") + default_set = {f for f in default_features if isinstance(f, str)} + + feature_names = [name for name in feature_keys if name != "default"] + preferred_order = [ + "plaintext", + "json", + "yaml", + "toml", + "ini", + "xml", + "csv", + "custom", + "async", + "miette", + "sarge", + "full", + ] + ordered: list[str] = [] + for name in preferred_order: + if name in feature_names: + ordered.append(name) + feature_names.remove(name) + ordered.extend(sorted(feature_names)) + + descriptions: dict[str, str] = { + "plaintext": "Plaintext format support", + "json": "JSON format support", + "yaml": "YAML format support", + "toml": "TOML format support", + "ini": "INI/\".ini\" config support", + "xml": "XML format support", + "csv": "CSV format support", + "custom": "Custom formats via registry", + "async": "Async I/O with Tokio", + "miette": "Pretty error reporting", + "sarge": "Sarge-based CLI helpers", + "full": "All core features", + } + + key_set = set(feature_keys) + + def implied_features(name: str) -> list[str]: + raw = features_map.get(name, []) + if not isinstance(raw, list): + return [] + implied = [] + for item in raw: + if not isinstance(item, str): + continue + if item in key_set and item != "default": + implied.append(item) + return implied + + all_non_default = {k for k in key_set if k != "default"} + full_includes = set(implied_features("full")) | {"full"} if "full" in all_non_default else set() + full_missing = sorted(all_non_default - full_includes) if full_includes else [] + + rows: list[list[str]] = [] + for name in ordered: + desc = descriptions.get(name, "Optional feature") + default_mark = "✓" if name in default_set else "" + + notes = "" + implied = implied_features(name) + if name == "full" and full_missing: + notes = "Excludes " + ", ".join(f"`{x}`" for x in full_missing) + elif name != "full" and implied: + notes = "Enables " + ", ".join(f"`{x}`" for x in implied) + + rows.append([f"`{name}`", desc, default_mark, notes]) + + header = ["Feature", "Description", "Default", "Notes"] + if not rows: + raise ValueError("No features found in Cargo.toml [features] section.") + widths = [ + max([len(header[i])] + [len(r[i]) for r in rows]) for i in range(len(header)) + ] + + lines = [] + lines.append( + "| " + + " | ".join(header[i].ljust(widths[i]) for i in range(len(header))) + + " |" + ) + lines.append( + "| " + + " | ".join("-" * widths[i] for i in range(len(header))) + + " |" + ) + for row in rows: + lines.append( + "| " + + " | ".join(row[i].ljust(widths[i]) for i in range(len(row))) + + " |" + ) + return lines + + +def replace_features_table(readme: str, new_table_lines: list[str]) -> str: + lines = readme.splitlines(keepends=True) + heading_idx = None + for idx, line in enumerate(lines): + if line.strip() == "## Features": + heading_idx = idx + break + if heading_idx is None: + raise ValueError("README.md is missing a '## Features' section.") + + table_start = None + for idx in range(heading_idx, len(lines)): + if lines[idx].lstrip().startswith("| Feature"): + table_start = idx + break + if table_start is None: + raise ValueError("README.md is missing a features table under '## Features'.") + + table_end = table_start + while table_end < len(lines) and lines[table_end].lstrip().startswith("|"): + table_end += 1 + + replacement = [line + "\n" for line in new_table_lines] + lines[table_start:table_end] = replacement + return "".join(lines) + + +def replace_readme_versions(readme: str, version: str) -> tuple[str, int]: + count = 0 + + def bump_inline(match: re.Match[str]) -> str: + nonlocal count + count += 1 + return f"{match.group(1)}{version}{match.group(3)}" + + inline_pat = re.compile( + r'(multiio\s*=\s*\{\s*version\s*=\s*\\?")' + r"([0-9]+\.[0-9]+(?:\.[0-9]+)?(?:-[0-9A-Za-z.-]+)?)" + r'(\\?")', + ) + readme = inline_pat.sub(bump_inline, readme) + + def bump_plain(match: re.Match[str]) -> str: + nonlocal count + count += 1 + return f"{match.group(1)}{version}{match.group(3)}" + + plain_pat = re.compile( + r'(multiio\s*=\s*\\?")' + r"([0-9]+\.[0-9]+(?:\.[0-9]+)?(?:-[0-9A-Za-z.-]+)?)" + r'(\\?")', + ) + readme = plain_pat.sub(bump_plain, readme) + return readme, count + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser( + description="Sync README dependency version snippets and features matrix table." + ) + parser.add_argument( + "--root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Repository root (defaults to the parent of scripts/).", + ) + parser.add_argument( + "--version", + type=str, + default=None, + help="Version to sync into README snippets (defaults to Cargo.toml [package].version).", + ) + parser.add_argument( + "--features-only", + action="store_true", + help="Only update the README features table (do not touch version snippets).", + ) + args = parser.parse_args(argv) + + root: Path = args.root + manifest_path = root / "Cargo.toml" + readme_path = root / "README.md" + + if not manifest_path.exists(): + print(f"error: missing {manifest_path}", file=sys.stderr) + return 2 + if not readme_path.exists(): + print(f"error: missing {readme_path}", file=sys.stderr) + return 2 + + manifest_text = manifest_path.read_text(encoding="utf-8") + feature_keys = parse_feature_keys_from_manifest(manifest_text) + + metadata = run_cargo_metadata(manifest_path) + pkg = select_root_package(metadata, manifest_path) + package_version = pkg.get("version") + if not isinstance(package_version, str) or not package_version: + print("error: could not read package version from cargo metadata.", file=sys.stderr) + return 2 + features_map = pkg.get("features") + if not isinstance(features_map, dict): + print("error: could not read features map from cargo metadata.", file=sys.stderr) + return 2 + + version = args.version or package_version + + readme = readme_path.read_text(encoding="utf-8") + changed = False + + if not args.features_only: + readme, replaced = replace_readme_versions(readme, version) + if replaced == 0: + print("info: no README version snippets matched; leaving version unchanged.") + else: + print(f"info: updated {replaced} README version snippet(s) to {version}.") + changed = True + + new_table_lines = generate_features_table(feature_keys, features_map) + updated = replace_features_table(readme, new_table_lines) + if updated != readme: + print("info: updated README features table.") + readme = updated + changed = True + + if changed: + readme_path.write_text(readme, encoding="utf-8") + else: + print("info: README already up to date.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/update_readme_release_metrics.sh b/scripts/update_readme_release_metrics.sh new file mode 100644 index 0000000..efcf8f7 --- /dev/null +++ b/scripts/update_readme_release_metrics.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Keep README release metrics in sync (currently the Features table). +python3 "$(dirname "$0")/sync_readme.py" --features-only diff --git a/src/tests/cli/parse_tests.rs b/src/tests/cli/parse_tests.rs index 770b368..96198c5 100644 --- a/src/tests/cli/parse_tests.rs +++ b/src/tests/cli/parse_tests.rs @@ -9,6 +9,7 @@ fn test_parse_format() { assert_eq!(parse_format("JSON"), Some(FormatKind::Json)); assert_eq!(parse_format("yaml"), Some(FormatKind::Yaml)); assert_eq!(parse_format("yml"), Some(FormatKind::Yaml)); + assert_eq!(parse_format("csv"), Some(FormatKind::Csv)); assert_eq!(parse_format("unknown"), None); }