diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..7df84e6 --- /dev/null +++ b/.envrc @@ -0,0 +1,14 @@ +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +nvm use + +# Ensure npm is at least v11 +REQUIRED_NPM_MAJOR=11 +NPM_VERSION=$(npm --version 2>/dev/null || echo "0.0.0") +NPM_MAJOR=$(echo "$NPM_VERSION" | cut -d. -f1) +if [ "$NPM_MAJOR" -lt "$REQUIRED_NPM_MAJOR" ]; then + echo "Upgrading npm to latest (need >= v11, found $NPM_VERSION)..." + npm install -g npm@latest +fi + +export ODO_BINARY="$(pwd)/target/debug/odo" \ No newline at end of file diff --git a/.gitignore b/.gitignore index f0fe132..56c7d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +.vscode # Test fixtures (generated by Makefile) tests/fixtures/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..156ca6d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.16.0 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index cad62ab..ae73c96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,18 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + [[package]] name = "clap" version = "4.5.40" @@ -110,6 +122,34 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + [[package]] name = "hashbrown" version = "0.15.4" @@ -144,6 +184,40 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "json-patch" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "159294d661a039f7644cea7e4d844e6b25aaf71c1ffe9d73a96d768c24b0faf4" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "memchr" version = "2.7.4" @@ -156,12 +230,21 @@ version = "0.4.2" dependencies = [ "anyhow", "clap", + "json-patch", "semver", "serde", "serde_json", + "tempfile", "toml_edit", + "walkdir", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.1" @@ -186,12 +269,40 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" version = "1.0.26" @@ -247,6 +358,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -283,6 +427,34 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -364,3 +536,12 @@ checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" dependencies = [ "memchr", ] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] diff --git a/Cargo.toml b/Cargo.toml index 07c48ac..f5825b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,10 +35,15 @@ path = "src/bin/odo.rs" [dependencies] anyhow = "1.0.98" clap = { version = "4.5", features = ["derive"] } +json-patch = "4.0.0" semver = "1.0.26" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml_edit = "0.22.27" +walkdir = "2.5.0" [features] fixture-tests = [] + +[dev-dependencies] +tempfile = "3.20.0" \ No newline at end of file diff --git a/Makefile b/Makefile index eb04a70..78e401b 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ # Odometer Development Makefile # # Key targets: -# make install-tools - Install development dependencies -# make ci-docker-full - Complete CI in Docker (matches GitHub Actions) -# make ci-local - Local CI with all checks +# make install-tools - Install development dependencies +# make ci-docker-full - Complete CI in Docker (matches GitHub Actions) +# make ci-local - Local CI with all checks # make release-validation - Complete release validation -# make fixtures - Generate all test fixtures -# make test - Run unit tests (no fixtures) -# make test-integration - Run integration tests with fixtures -# make test-all - Run all tests +# make fixtures - Generate all test fixtures +# make test - Run unit tests (no fixtures) +# make test-integration - Run integration tests with fixtures +# make test-all - Run all tests # ============================================================================= # Development Setup @@ -20,6 +20,10 @@ install-tools: @which cargo >/dev/null || (echo "❌ cargo not found. Install Rust: https://rustup.rs/" && exit 1) @echo "✅ cargo found" @cargo --version + @which npm >/dev/null || (echo "❌ npm not found. Install Node.js: https://nodejs.org/" && exit 1) + @echo "✅ npm found" + @npm --version + @cargo install cargo-workspaces --force @echo "✅ Development tools ready" # ============================================================================= @@ -103,89 +107,62 @@ install-local: FIXTURES_DIR = tests/fixtures -# Clean all fixtures -.PHONY: clean-fixtures -clean-fixtures: - rm -rf $(FIXTURES_DIR) - -# Single crate fixture -$(FIXTURES_DIR)/single-crate/Cargo.toml: - @echo "Creating single-crate fixture..." - mkdir -p $(FIXTURES_DIR) - cd $(FIXTURES_DIR) && cargo new single-crate - @echo "✅ single-crate fixture ready" - -# Simple workspace fixture -$(FIXTURES_DIR)/workspace-simple/Cargo.toml: - @echo "Creating workspace-simple base..." - mkdir -p $(FIXTURES_DIR) - cd $(FIXTURES_DIR) && cargo new --name workspace-simple workspace-simple - -$(FIXTURES_DIR)/workspace-simple/lib1/Cargo.toml: $(FIXTURES_DIR)/workspace-simple/Cargo.toml - @echo "Adding lib1 to workspace-simple..." - cd $(FIXTURES_DIR)/workspace-simple && cargo new --lib lib1 - -$(FIXTURES_DIR)/workspace-simple/lib2/Cargo.toml: $(FIXTURES_DIR)/workspace-simple/Cargo.toml - @echo "Adding lib2 to workspace-simple..." - cd $(FIXTURES_DIR)/workspace-simple && cargo new --lib lib2 - -$(FIXTURES_DIR)/workspace-simple/.configured: $(FIXTURES_DIR)/workspace-simple/lib1/Cargo.toml $(FIXTURES_DIR)/workspace-simple/lib2/Cargo.toml - @echo "Configuring workspace-simple..." - echo '' >> $(FIXTURES_DIR)/workspace-simple/Cargo.toml - echo '[workspace]' >> $(FIXTURES_DIR)/workspace-simple/Cargo.toml - echo 'members = ["lib1", "lib2"]' >> $(FIXTURES_DIR)/workspace-simple/Cargo.toml - touch $(FIXTURES_DIR)/workspace-simple/.configured - @echo "✅ workspace-simple fixture ready" - -# Workspace with inheritance fixture -$(FIXTURES_DIR)/workspace-inheritance/Cargo.toml: - @echo "Creating workspace-inheritance base..." - mkdir -p $(FIXTURES_DIR) - cd $(FIXTURES_DIR) && cargo new --name workspace-root workspace-inheritance - -$(FIXTURES_DIR)/workspace-inheritance/member1/Cargo.toml: $(FIXTURES_DIR)/workspace-inheritance/Cargo.toml - @echo "Adding member1 to workspace-inheritance..." - cd $(FIXTURES_DIR)/workspace-inheritance && cargo new --lib member1 - -$(FIXTURES_DIR)/workspace-inheritance/member2/Cargo.toml: $(FIXTURES_DIR)/workspace-inheritance/Cargo.toml - @echo "Adding member2 to workspace-inheritance..." - cd $(FIXTURES_DIR)/workspace-inheritance && cargo new --lib member2 - -$(FIXTURES_DIR)/workspace-inheritance/.configured: $(FIXTURES_DIR)/workspace-inheritance/member1/Cargo.toml $(FIXTURES_DIR)/workspace-inheritance/member2/Cargo.toml - @echo "Configuring workspace-inheritance..." - # Add workspace section to root - echo '' >> $(FIXTURES_DIR)/workspace-inheritance/Cargo.toml - echo '[workspace]' >> $(FIXTURES_DIR)/workspace-inheritance/Cargo.toml - echo 'members = ["member1", "member2"]' >> $(FIXTURES_DIR)/workspace-inheritance/Cargo.toml - # Configure member1 to use workspace inheritance - perl -i -pe 's/version = "0.1.0"/version = { workspace = true }/' $(FIXTURES_DIR)/workspace-inheritance/member1/Cargo.toml - # member2 keeps its own version for testing mixed scenarios - touch $(FIXTURES_DIR)/workspace-inheritance/.configured - @echo "✅ workspace-inheritance fixture ready" - -# High-level fixture targets -.PHONY: single-crate workspace-simple workspace-inheritance fixtures -single-crate: $(FIXTURES_DIR)/single-crate/Cargo.toml -workspace-simple: $(FIXTURES_DIR)/workspace-simple/.configured -workspace-inheritance: $(FIXTURES_DIR)/workspace-inheritance/.configured -fixtures: single-crate workspace-simple workspace-inheritance +.PHONY: fixtures.clean +fixtures.clean: + @rm -rf $(FIXTURES_DIR) + +.PHONY: fixtures.dir +fixtures.dir: + @mkdir -p $(FIXTURES_DIR) + +.PHONY: fixtures +fixtures: fixtures.node fixtures.rust + +.PHONY: fixtures.node +fixtures.node: + $(MAKE) fixtures.node.basic-node-workspace + +.PHONY: fixtures.rust +fixtures.rust: + $(MAKE) fixtures.rust.basic-rust-workspace + +.PHONY: fixtures.rust.basic-rust-workspace +fixtures.rust.basic-rust-workspace: fixtures.dir + @rm -fr $(FIXTURES_DIR)/basic-rust-workspace && mkdir -p $(FIXTURES_DIR)/basic-rust-workspace + @cargo new --vcs none --frozen --bin $(FIXTURES_DIR)/basic-rust-workspace/bin1 + @cargo new --vcs none --frozen --bin $(FIXTURES_DIR)/basic-rust-workspace/bin2 + @cargo new --vcs none --frozen --lib $(FIXTURES_DIR)/basic-rust-workspace/lib1 + @cargo new --vcs none --frozen --lib $(FIXTURES_DIR)/basic-rust-workspace/lib2 + @cargo workspaces init $(FIXTURES_DIR)/basic-rust-workspace + +.PHONY: fixtures.node.basic-node-workspace +.PHONY: fixtures.node.basic-node-workspace +fixtures.node.basic-node-workspace: fixtures.dir + @rm -fr $(FIXTURES_DIR)/basic-node-workspace && mkdir -p $(FIXTURES_DIR)/basic-node-workspace + @cd $(FIXTURES_DIR)/basic-node-workspace && npm init -y + @mkdir -p $(FIXTURES_DIR)/basic-node-workspace/bin1 $(FIXTURES_DIR)/basic-node-workspace/bin2 $(FIXTURES_DIR)/basic-node-workspace/lib1 $(FIXTURES_DIR)/basic-node-workspace/lib2 + @cd $(FIXTURES_DIR)/basic-node-workspace/bin1 && npm init -y + @cd $(FIXTURES_DIR)/basic-node-workspace/bin2 && npm init -y + @cd $(FIXTURES_DIR)/basic-node-workspace/lib1 && npm init -y + @cd $(FIXTURES_DIR)/basic-node-workspace/lib2 && npm init -y + @cd $(FIXTURES_DIR)/basic-node-workspace && jq '.workspaces = ["bin1", "bin2", "lib1", "lib2"]' package.json > package.json.tmp && mv package.json.tmp package.json # ============================================================================= # Testing # ============================================================================= .PHONY: test -test: +test: build @echo "Running unit tests (no fixtures)..." cargo test --lib .PHONY: test-integration -test-integration: build clean-fixtures fixtures +test-integration: build fixtures.clean fixtures.dir @echo "Running integration tests with fresh fixtures..." - ODO_BINARY=$(shell pwd)/target/debug/odo cargo test --features fixture-tests + cargo test --features fixture-tests .PHONY: test-all -test-all: test test-integration +test-all: build test test-integration # ============================================================================= # Code Quality @@ -219,7 +196,7 @@ build: cargo build --bin odo .PHONY: clean -clean: clean-fixtures +clean: fixtures.clean cargo clean # Default target @@ -254,9 +231,7 @@ help: @echo "" @echo "Fixtures:" @echo " make fixtures Generate all test fixtures" - @echo " make single-crate Generate single-crate fixture" - @echo " make workspace-simple Generate simple workspace fixture" - @echo " make clean-fixtures Remove all fixtures" + @echo " make fixtures.clean Remove all fixtures" @echo "" @echo "Development:" @echo " make check Check code without building" diff --git a/README.md b/README.md index bde2200..11fbabf 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ A workspace version management tool that keeps package versions synchronized acr Odometer provides intuitive commands to manage versions across project workspaces, with precise control over which packages get updated. Whether you need lockstep versioning for coordinated releases or independent versioning for different packages, odometer has you covered. -**Currently supports:** Rust/Cargo workspaces -**Planned support:** Node.js/npm, Python/pip, and other package ecosystems +**Currently supports:** Rust/Cargo workspaces and Node.js/npm workspaces +**Planned support:** Python/pip and other package ecosystems ### Key Features @@ -18,7 +18,7 @@ Odometer provides intuitive commands to manage versions across project workspace - 🔄 **Flexible Version Strategies** - Independent versioning or lockstep synchronization - 🛡️ **Safe Defaults** - Operations target workspace root only unless explicitly specified - 📊 **Clear Inspection** - See current versions and validate version fields -- 🏗️ **Workspace Inheritance Support** - Handles `version = { workspace = true }` correctly (Cargo) +- 🏗️ **Workspace Inheritance Support** - Handles `version = { workspace = true }` (Cargo) and `workspace:*` (Node.js) - ⚡ **Fast & Reliable** - Written in Rust with comprehensive test coverage ## Installation @@ -207,11 +207,54 @@ odo show # Confirm all at 2.0.0 Odometer properly handles: -- **Workspace roots** with `[workspace]` sections -- **Member crates** in subdirectories -- **Workspace inheritance** (`version = { workspace = true }`) -- **Mixed scenarios** (some crates inherit, others don't) -- **Single crate projects** (no workspace) +- **Workspace roots** with `[workspace]` sections (Cargo) or `workspaces` field (Node.js) +- **Member packages** in subdirectories +- **Workspace inheritance**: + - Cargo: `version = { workspace = true }` + - Node.js: `"version": "workspace:*"` or `"version": "workspace:~"` +- **Mixed scenarios** (some packages inherit, others don't) +- **Single package projects** (no workspace) +- **Mixed ecosystems** (Rust and Node.js packages in the same workspace) + +### Node.js Workspace Example + +```bash +# Initialize a Node.js workspace +mkdir my-workspace && cd my-workspace +npm init -y +# Add workspaces to package.json +echo '{"workspaces": ["packages/*"]}' > package.json + +# Create some packages +mkdir -p packages/pkg1 packages/pkg2 +cd packages/pkg1 && npm init -y +cd ../pkg2 && npm init -y + +# Now use odometer to manage versions +odo show # See all package versions +odo roll --workspace patch # Bump all packages +odo sync 1.0.0 # Set all to same version +``` + +### Rust Workspace Example + +```bash +# Initialize a Rust workspace +cargo init --lib +# Add workspace configuration to Cargo.toml +echo '[workspace] +members = ["packages/*"]' >> Cargo.toml + +# Create some crates +mkdir -p packages/crate1 packages/crate2 +cd packages/crate1 && cargo init --lib +cd ../crate2 && cargo init --lib + +# Now use odometer to manage versions +odo show # See all crate versions +odo roll --workspace patch # Bump all crates +odo sync 1.0.0 # Set all to same version +``` ## Development diff --git a/docs/PLAN.md b/docs/PLAN.md deleted file mode 100644 index 4243728..0000000 --- a/docs/PLAN.md +++ /dev/null @@ -1,311 +0,0 @@ -# Odometer Implementation Spec & Checklist - -A Rust workspace version management tool that keeps all crate versions synchronized. - -## Core Functionality - -### Commands - -#### Core Commands -- [ ] `odo roll major` - increment workspace root major version by 1 -- [ ] `odo roll minor` - increment workspace root minor version by 1 -- [ ] `odo roll patch` - increment workspace root patch version by 1 -- [ ] `odo roll patch -1` - decrement workspace root patch version by 1 -- [ ] `odo roll patch 10` - increment workspace root patch version by 10 -- [ ] `odo set x.y.z` - set workspace root version to specific version -- [ ] `odo sync x.y.z` - set ALL workspace members to same version (lockstep) -- [ ] `odo show` - display current versions for all workspace members -- [ ] `odo lint` - check for missing/malformed version fields only - -#### Package Selection (Cargo-style semantics) -- [ ] `odo roll patch -p crate-name` - bump specific package only -- [ ] `odo roll patch --package crate-name` - long form of `-p` -- [ ] `odo roll patch -p crate-a -p crate-b` - bump multiple specific packages -- [ ] `odo roll patch --workspace` - independently bump ALL workspace members -- [ ] `odo roll patch -w` - short form of `--workspace` -- [ ] `odo set x.y.z --workspace` - set version for all workspace members independently -- [ ] `odo set x.y.z -p crate-name` - set version for specific package - -#### Advanced Package Selection -- [ ] `odo roll patch --workspace --exclude crate-name` - all except specified -- [ ] `odo roll patch --all` - alias for `--workspace` (cargo compatibility) -- [ ] `odo lint -p crate-name` - check specific package for valid version field -- [ ] `odo show -p crate-name` - show version for specific package - -### Version Detection & Parsing -- [ ] Parse semantic versions from `Cargo.toml` files -- [ ] Handle workspace inheritance (`version.workspace = true`) -- [ ] Support both workspace root and member crate versions -- [ ] Validate semver format (x.y.z, handle pre-release/build metadata) - -### Lint Functionality (Simplified) -- [ ] Check for missing `version` fields in workspace members -- [ ] Validate semver format in all version fields -- [ ] Report malformed versions with helpful error messages -- [ ] **No version consistency checking** (that's what `show` and `sync` are for) - -### File Discovery -- [ ] Find workspace root `Cargo.toml` -- [ ] Discover all workspace members from `[workspace.members]` -- [ ] Respect `.gitignore` patterns (skip ignored files by default) -- [ ] Handle glob patterns in workspace members -- [ ] Skip excluded workspace members - -## Project Setup - -### Repository Structure -- [ ] Create `odometer` repository -- [ ] Initialize Rust project with `cargo init --name odometer` -- [ ] Set up multiple binary targets in `Cargo.toml`: - ```toml - [[bin]] - name = "cargo-odometer" - path = "src/main.rs" - - [[bin]] - name = "cargo-odo" - path = "src/main.rs" - - [[bin]] - name = "odometer" - path = "src/main.rs" - - [[bin]] - name = "odo" - path = "src/main.rs" - ``` - -### Dependencies -- [ ] Add `clap` for CLI argument parsing -- [ ] Add `toml` for parsing Cargo.toml files -- [ ] Add `semver` for version manipulation -- [ ] Add `walkdir` for file system traversal -- [ ] Add `anyhow` for error handling -- [ ] Add `serde` for TOML deserialization - -### Usage Examples Section - -Add this section after "Core Functionality" for quick reference during implementation: - -## Usage Examples - -### Basic Operations (Workspace Root) -```bash -# Increment workspace root version -odo roll patch # 1.0.0 -> 1.0.1 -odo roll minor # 1.0.1 -> 1.1.0 -odo roll major # 1.1.0 -> 2.0.0 - -# Custom increments -odo roll patch 5 # 2.0.0 -> 2.0.5 -odo roll patch -2 # 2.0.5 -> 2.0.3 - -# Set specific version -odo set 3.1.4 # Set workspace root to exactly 3.1.4 -``` - -### Lockstep Versioning Workflow -```bash -# Get everything in sync first -odo sync 1.0.0 # Set ALL crates to 1.0.0 - -# Now operations work predictably across all crates -odo roll patch --workspace # All: 1.0.0 -> 1.0.1 -odo roll minor --workspace # All: 1.0.1 -> 1.1.0 -``` - -### Independent Versioning Workflow -```bash -# Work on specific packages -odo roll patch -p my-core # Just my-core gets bumped -odo roll minor --package my-utils # Just my-utils gets bumped -odo roll patch -p crate-a -p crate-b # Two specific crates - -# Bulk independent operations -odo roll patch --workspace # Each crate's patch version increments -# Example: crate-a (1.0.0->1.0.1), crate-b (0.2.5->0.2.6) -``` - -### Inspection Commands -```bash -# See current state -odo show # All workspace versions -odo show -p my-core # Specific crate version - -# Validate version fields -odo lint # Check for missing/malformed versions -odo lint -p my-core # Check specific crate -``` - -### Real-world Workflows -```bash -# Preparing a coordinated release -odo sync 2.0.0 # Get everything aligned -odo show # Verify all at 2.0.0 -odo lint # Check all version fields valid - -# Working on independent features -odo roll minor -p my-app # App gets new feature -odo roll patch -p my-utils # Utils gets bugfix -odo show # See current state - -# Bulk patch release -odo roll patch --workspace # Everyone gets patch bump -odo lint # Validate everything -``` - -### CLI Structure -- [ ] Set up clap subcommands (`roll`, `lint`, `set`) -- [ ] Handle `roll` command with bump type and optional amount -- [ ] Implement cargo-style package selection: - - [ ] `-p/--package` for specific packages (can be repeated) - - [ ] `--workspace/-w` for all workspace members - - [ ] `--exclude` to skip packages when using `--workspace` - - [ ] `--all` alias for `--workspace` -- [ ] Default behavior: operate on workspace root only (safe default) -- [ ] Handle both `cargo odo` and direct `odo` invocation -- [ ] Implement help text and examples showing package selection - -### Workspace Discovery -- [ ] Find workspace root (walk up directory tree looking for workspace `Cargo.toml`) -- [ ] Parse workspace configuration -- [ ] Resolve member paths (handle globs, relative paths) -- [ ] Build list of all `Cargo.toml` files to manage - -### Version Management -- [ ] Read current versions from all workspace members -- [ ] Apply version changes atomically (specific packages or workspace root) -- [ ] Handle workspace inheritance properly -- [ ] Preserve TOML formatting when possible -- [ ] Implement `sync` command for lockstep versioning -- [ ] Support independent version bumping with `--workspace` - -### Error Handling -- [ ] Graceful handling of malformed `Cargo.toml` files -- [ ] Clear error messages for invalid version formats -- [ ] Rollback on partial failures -- [ ] Validate workspace structure -- [ ] Handle missing package specifications gracefully - -## Advanced Features - -### Configuration -- [ ] Optional `odo.toml` configuration file -- [ ] Exclude patterns for specific crates -- [ ] Custom version constraints/rules -- [ ] Dry-run mode (`--dry-run` flag) - -### Comment Tagging (Optional) -- [ ] Add `# odo` comments to managed version lines -- [ ] Detect manually edited versions -- [ ] Warn about unmanaged changes -- [ ] `--force` flag to override warnings - -### Git Integration -- [ ] Respect `.gitignore` by default -- [ ] Optional git commit creation after version changes -- [ ] Git tag creation option -- [ ] `--include-ignored` flag to process ignored files - -## Testing - -### Unit Tests -- [ ] Version parsing and manipulation -- [ ] TOML file reading/writing -- [ ] Workspace discovery logic -- [ ] CLI argument parsing - -### Integration Tests -- [ ] Create test workspace fixtures -- [ ] Test full version bump workflows -- [ ] Test error scenarios (malformed files, etc.) -- [ ] Test workspace inheritance scenarios - -### Edge Cases -- [ ] Empty workspaces -- [ ] Nested workspaces -- [ ] Mixed workspace/non-workspace crates -- [ ] Pre-release versions -- [ ] Build metadata in versions - -## Documentation - -### User Documentation -- [ ] README with installation and usage examples -- [ ] Command help text and examples -- [ ] Common workflow documentation -- [ ] Troubleshooting guide - -### Developer Documentation -- [ ] Code documentation and examples -- [ ] Architecture overview -- [ ] Contributing guidelines - -## Distribution - -### crates.io Publishing -- [ ] Set up crate metadata (description, keywords, license) -- [ ] Create crates.io account and API token -- [ ] Test `cargo publish --dry-run` -- [ ] Publish initial version - -### Homebrew Formula -- [ ] Create `homebrew-odometer` tap repository -- [ ] Write Homebrew formula -- [ ] Set up GitHub releases with binaries -- [ ] Test homebrew installation - -### GitHub Actions CI/CD -- [ ] Set up automated testing -- [ ] Cross-platform binary builds -- [ ] Automated releases on git tags -- [ ] Security scanning - -## Quality Assurance - -### Code Quality -- [ ] Set up `clippy` linting -- [ ] Format code with `rustfmt` -- [ ] Add pre-commit hooks -- [ ] Code coverage reporting - -### User Experience -- [ ] Intuitive error messages -- [ ] Progress indicators for long operations -- [ ] Consistent command-line interface -- [ ] Shell completion scripts - -## Future Enhancements - -### Nice-to-Have Features -- [ ] Interactive mode for version selection -- [ ] Backup/restore functionality -- [ ] Integration with conventional commits -- [ ] Custom version schemes support -- [ ] Workspace dependency version updating -- [ ] JSON/YAML output formats for scripting - -### Performance Optimizations -- [ ] Parallel file processing -- [ ] Incremental change detection -- [ ] Caching for large workspaces - -## Release Checklist - -### v0.1.0 (MVP) -- [ ] Basic `roll`, `lint`, `set` commands working -- [ ] Workspace discovery and version management -- [ ] Published to crates.io -- [ ] Basic documentation - -### v0.2.0 -- [ ] Configuration file support -- [ ] Git integration features -- [ ] Homebrew distribution -- [ ] Comprehensive testing - -### v1.0.0 -- [ ] Stable API -- [ ] Full feature set -- [ ] Production-ready error handling -- [ ] Comprehensive documentation \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/src/domain.rs b/src/domain.rs index 64c6851..f659682 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -11,6 +11,13 @@ pub enum VersionBump { Patch(i32), } +#[derive(Debug, Clone, PartialEq)] +pub enum VersionField { + Absent, + Concrete(String), + Inherited, +} + #[derive(Debug, Clone, PartialEq)] pub struct PackageSelection { pub packages: Vec, @@ -49,65 +56,59 @@ impl OperationResult { } } +/// A workspace member, which can be a Rust package or a Node.js package #[derive(Debug, Clone)] pub enum WorkspaceMember { Cargo { name: String, - version: String, path: PathBuf, - has_workspace_inheritance: bool, + version: VersionField, + }, + Node { + name: String, + path: PathBuf, + version: VersionField, }, - // Node { - // name: String, - // version: String, - // path: PathBuf, - // // private: bool, - // // workspaces: Option>, - // }, - // Python { - // name: String, - // version: String, - // path: PathBuf, - // // is_pyproject_toml: bool, - // }, } impl WorkspaceMember { + /// Get the name of the package pub fn name(&self) -> &str { match self { WorkspaceMember::Cargo { name, .. } => name, - // WorkspaceMember::Node { name, .. } => name, - // WorkspaceMember::Python { name, .. } => name, + WorkspaceMember::Node { name, .. } => name, } } - pub fn version(&self) -> &str { + /// Get the path to the package + pub fn path(&self) -> &PathBuf { match self { - WorkspaceMember::Cargo { version, .. } => version, - // WorkspaceMember::Node { version, .. } => version, - // WorkspaceMember::Python { version, .. } => version, + WorkspaceMember::Cargo { path, .. } => path, + WorkspaceMember::Node { path, .. } => path, } } - pub fn set_version(&mut self, new_version: &str) { + /// Get the version of the package + pub fn version(&self) -> &VersionField { match self { - WorkspaceMember::Cargo { version, .. } => *version = new_version.to_string(), - // WorkspaceMember::Node { version, .. } => *version = new_version.to_string(), - // WorkspaceMember::Python { version, .. } => *version = new_version.to_string(), + WorkspaceMember::Cargo { version, .. } => version, + WorkspaceMember::Node { version, .. } => version, } } - pub fn path(&self) -> &PathBuf { + /// Set the version of the package + pub fn set_version(&mut self, new_version: VersionField) { match self { - WorkspaceMember::Cargo { path, .. } => path, - // WorkspaceMember::Node { path, .. } => path, - // WorkspaceMember::Python { path, .. } => path, + WorkspaceMember::Cargo { version, .. } => *version = new_version, + WorkspaceMember::Node { version, .. } => *version = new_version, } } } -#[derive(Debug)] +/// A workspace, which is a collection of packages +#[derive(Debug, Clone)] pub struct Workspace { + /// The members of the workspace pub members: Vec, } @@ -129,7 +130,12 @@ impl Workspace { let indices = self.select_member_indices(selection)?; for &index in &indices { let member = &mut self.members[index]; - let old_version = member.version().to_string(); + + let old_version = match member.version() { + VersionField::Concrete(version) => version.clone(), + _ => continue, + }; + let new_version = bump.apply_to_version(&old_version)?; if old_version != new_version { @@ -140,9 +146,10 @@ impl Workspace { path: member.path().clone(), }); - member.set_version(&new_version); + member.set_version(VersionField::Concrete(new_version)); } } + Ok(result) } @@ -154,9 +161,14 @@ impl Workspace { let mut result = OperationResult::new(format!("set {}", version)); let indices = self.select_member_indices(selection)?; + for &index in &indices { let member = &mut self.members[index]; - let old_version = member.version().to_string(); + + let old_version = match member.version() { + VersionField::Concrete(version) => version.clone(), + _ => continue, + }; if old_version != version { result.add_change(VersionChange { @@ -166,9 +178,10 @@ impl Workspace { path: member.path().clone(), }); - member.set_version(version); + member.set_version(VersionField::Concrete(version.to_string())); } } + Ok(result) } @@ -176,7 +189,10 @@ impl Workspace { let mut result = OperationResult::new(format!("sync {}", version)); for member in &mut self.members { - let old_version = member.version().to_string(); + let old_version = match member.version() { + VersionField::Concrete(version) => version.clone(), + _ => continue, + }; if old_version != version { result.add_change(VersionChange { @@ -186,7 +202,7 @@ impl Workspace { path: member.path().clone(), }); - member.set_version(version); + member.set_version(VersionField::Concrete(version.to_string())); } } Ok(result) @@ -195,7 +211,12 @@ impl Workspace { pub fn show(&self, selection: &PackageSelection) -> String { let mut output = String::new(); for member in self.selected_members(selection) { - output.push_str(&format!("{} {}\n", member.name(), member.version())); + let version = match member.version() { + VersionField::Concrete(version) => version.clone(), + _ => continue, + }; + + output.push_str(&format!("{}: {}\n", member.name(), version)); } output } @@ -203,10 +224,15 @@ impl Workspace { pub fn lint(&self, selection: &PackageSelection) -> Vec { let mut errors = Vec::new(); for member in self.selected_members(selection) { - if let Err(e) = semver::Version::parse(member.version()) { + let version = match member.version() { + VersionField::Concrete(version) => version.clone(), + _ => continue, + }; + + if let Err(e) = semver::Version::parse(&version) { errors.push(LintError { member: member.name().to_string(), - message: format!("Invalid version '{}': {}", member.version(), e), + message: format!("Invalid version '{}': {}", version, e), }); } } @@ -214,17 +240,23 @@ impl Workspace { } pub fn selected_members(&self, selection: &PackageSelection) -> Vec<&WorkspaceMember> { - match self.select_member_indices(selection) { + let members: Vec<&WorkspaceMember> = match self.select_member_indices(selection) { Ok(indices) => indices.iter().map(|&i| &self.members[i]).collect(), Err(_) => self.members.iter().collect(), // fallback to all members - } + }; + let mut sorted = members; + sorted.sort_by(|a, b| a.name().cmp(b.name())); + sorted } fn select_member_indices(&self, selection: &PackageSelection) -> anyhow::Result> { if !selection.packages.is_empty() { - // Select specific packages + // Select specific packages, excluding any in the exclude list let mut indices = Vec::new(); for package_name in &selection.packages { + if selection.exclude.iter().any(|e| e == package_name) { + continue; + } match self.members.iter().position(|m| m.name() == *package_name) { Some(index) => indices.push(index), None => anyhow::bail!("Package '{}' not found in workspace", package_name), @@ -242,8 +274,15 @@ impl Workspace { .collect()) } else { // Default: select the first member (root package in single crate, or workspace root) + // but only if it's not excluded if self.members.is_empty() { anyhow::bail!("No packages found in workspace") + } else if selection + .exclude + .iter() + .any(|e| e == self.members[0].name()) + { + Ok(vec![]) } else { Ok(vec![0]) } @@ -348,16 +387,15 @@ mod tests { use super::*; use std::path::PathBuf; - fn create_test_member(name: &str, version: &str) -> WorkspaceMember { + fn create_test_member(name: &str, version: VersionField) -> WorkspaceMember { WorkspaceMember::Cargo { name: name.to_string(), - version: version.to_string(), path: PathBuf::from(format!("{}/Cargo.toml", name)), - has_workspace_inheritance: false, + version, } } - fn create_test_workspace(members: Vec<(&str, &str)>) -> Workspace { + fn create_test_workspace(members: Vec<(&str, VersionField)>) -> Workspace { Workspace { members: members .into_iter() @@ -455,18 +493,28 @@ mod tests { #[test] fn test_workspace_member_methods() { - let mut member = create_test_member("test-crate", "1.0.0"); + let mut member = + create_test_member("test-crate", VersionField::Concrete("1.0.0".to_string())); assert_eq!(member.name(), "test-crate"); - assert_eq!(member.version(), "1.0.0"); - - member.set_version("2.0.0"); - assert_eq!(member.version(), "2.0.0"); + assert_eq!( + member.version(), + &VersionField::Concrete("1.0.0".to_string()) + ); + + member.set_version(VersionField::Concrete("2.0.0".to_string())); + assert_eq!( + member.version(), + &VersionField::Concrete("2.0.0".to_string()) + ); } #[test] fn test_workspace_roll_version_default() { - let mut workspace = create_test_workspace(vec![("app", "1.0.0"), ("lib", "0.5.0")]); + let mut workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("0.5.0".to_string())), + ]); // Default selection (first member only) let selection = PackageSelection::root_only(); @@ -474,44 +522,80 @@ mod tests { .roll_version(VersionBump::Patch(1), &selection) .unwrap(); - assert_eq!(workspace.members[0].version(), "1.0.1"); // app bumped - assert_eq!(workspace.members[1].version(), "0.5.0"); // lib unchanged + assert_eq!( + workspace.members[0].version(), + &VersionField::Concrete("1.0.1".to_string()) + ); // app bumped + assert_eq!( + workspace.members[1].version(), + &VersionField::Concrete("0.5.0".to_string()) + ); // lib unchanged } #[test] fn test_workspace_roll_version_specific_packages() { - let mut workspace = - create_test_workspace(vec![("app", "1.0.0"), ("lib", "0.5.0"), ("utils", "0.1.0")]); + let mut workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("0.5.0".to_string())), + ("utils", VersionField::Concrete("0.1.0".to_string())), + ]); let selection = PackageSelection::packages(vec!["lib".to_string(), "utils".to_string()]); + workspace .roll_version(VersionBump::Minor(1), &selection) .unwrap(); - assert_eq!(workspace.members[0].version(), "1.0.0"); // app unchanged - assert_eq!(workspace.members[1].version(), "0.6.0"); // lib bumped - assert_eq!(workspace.members[2].version(), "0.2.0"); // utils bumped + assert_eq!( + workspace.members[0].version(), + &VersionField::Concrete("1.0.0".to_string()) + ); // app unchanged + assert_eq!( + workspace.members[1].version(), + &VersionField::Concrete("0.6.0".to_string()) + ); // lib bumped + assert_eq!( + workspace.members[2].version(), + &VersionField::Concrete("0.2.0".to_string()) + ); // utils bumped } #[test] fn test_workspace_roll_version_workspace() { - let mut workspace = - create_test_workspace(vec![("app", "1.0.0"), ("lib", "0.5.0"), ("utils", "0.1.0")]); + let mut workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("0.5.0".to_string())), + ("utils", VersionField::Concrete("0.1.0".to_string())), + ]); let selection = PackageSelection::workspace(); workspace .roll_version(VersionBump::Patch(1), &selection) .unwrap(); - assert_eq!(workspace.members[0].version(), "1.0.1"); // app bumped - assert_eq!(workspace.members[1].version(), "0.5.1"); // lib bumped - assert_eq!(workspace.members[2].version(), "0.1.1"); // utils bumped + assert_eq!( + workspace.members[0].version(), + &VersionField::Concrete("1.0.1".to_string()) + ); // app bumped + + assert_eq!( + workspace.members[1].version(), + &VersionField::Concrete("0.5.1".to_string()) + ); // lib bumped + + assert_eq!( + workspace.members[2].version(), + &VersionField::Concrete("0.1.1".to_string()) + ); // utils bumped } #[test] fn test_workspace_roll_version_workspace_with_exclude() { - let mut workspace = - create_test_workspace(vec![("app", "1.0.0"), ("lib", "0.5.0"), ("utils", "0.1.0")]); + let mut workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("0.5.0".to_string())), + ("utils", VersionField::Concrete("0.1.0".to_string())), + ]); let selection = PackageSelection { packages: vec![], @@ -523,45 +607,85 @@ mod tests { .roll_version(VersionBump::Patch(1), &selection) .unwrap(); - assert_eq!(workspace.members[0].version(), "1.0.1"); // app bumped - assert_eq!(workspace.members[1].version(), "0.5.0"); // lib excluded - assert_eq!(workspace.members[2].version(), "0.1.1"); // utils bumped + assert_eq!( + workspace.members[0].version(), + &VersionField::Concrete("1.0.1".to_string()) + ); // app bumped + assert_eq!( + workspace.members[1].version(), + &VersionField::Concrete("0.5.0".to_string()) + ); // lib excluded + + assert_eq!( + workspace.members[2].version(), + &VersionField::Concrete("0.1.1".to_string()) + ); // utils bumped } #[test] fn test_workspace_set_version() { - let mut workspace = create_test_workspace(vec![("app", "1.0.0"), ("lib", "0.5.0")]); + let mut workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("0.5.0".to_string())), + ]); let selection = PackageSelection::packages(vec!["lib".to_string()]); workspace.set_version("2.0.0", &selection).unwrap(); - assert_eq!(workspace.members[0].version(), "1.0.0"); // app unchanged - assert_eq!(workspace.members[1].version(), "2.0.0"); // lib set + assert_eq!( + workspace.members[0].version(), + &VersionField::Concrete("1.0.0".to_string()) + ); // app unchanged + + assert_eq!( + workspace.members[1].version(), + &VersionField::Concrete("2.0.0".to_string()) + ); // lib set } #[test] fn test_workspace_sync_version() { - let mut workspace = - create_test_workspace(vec![("app", "1.0.0"), ("lib", "0.5.0"), ("utils", "2.1.0")]); + let mut workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("0.5.0".to_string())), + ("utils", VersionField::Concrete("2.1.0".to_string())), + ]); workspace.sync_version("1.5.0").unwrap(); - assert_eq!(workspace.members[0].version(), "1.5.0"); - assert_eq!(workspace.members[1].version(), "1.5.0"); - assert_eq!(workspace.members[2].version(), "1.5.0"); + assert_eq!( + workspace.members[0].version(), + &VersionField::Concrete("1.5.0".to_string()) + ); + + assert_eq!( + workspace.members[1].version(), + &VersionField::Concrete("1.5.0".to_string()) + ); + + assert_eq!( + workspace.members[2].version(), + &VersionField::Concrete("1.5.0".to_string()) + ); } #[test] fn test_workspace_show() { - let workspace = create_test_workspace(vec![("app", "1.0.0"), ("lib", "0.5.0")]); + let workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("0.5.0".to_string())), + ]); let output = workspace.show(&PackageSelection::workspace()); - assert_eq!(output, "app 1.0.0\nlib 0.5.0\n"); + assert_eq!(output, "app: 1.0.0\nlib: 0.5.0\n"); } #[test] fn test_workspace_lint_valid() { - let workspace = create_test_workspace(vec![("app", "1.0.0"), ("lib", "0.5.0")]); + let workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("0.5.0".to_string())), + ]); let errors = workspace.lint(&PackageSelection::root_only()); assert!(errors.is_empty()); @@ -569,7 +693,10 @@ mod tests { #[test] fn test_workspace_lint_invalid() { - let workspace = create_test_workspace(vec![("app", "1.0.0"), ("lib", "invalid-version")]); + let workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("1.0.0".to_string())), + ("lib", VersionField::Concrete("invalid-version".to_string())), + ]); let errors = workspace.lint(&PackageSelection::workspace()); assert_eq!(errors.len(), 1); @@ -581,7 +708,8 @@ mod tests { #[test] fn test_package_selection_not_found() { - let mut workspace = create_test_workspace(vec![("app", "1.0.0")]); + let mut workspace = + create_test_workspace(vec![("app", VersionField::Concrete("1.0.0".to_string()))]); let selection = PackageSelection::packages(vec!["nonexistent".to_string()]); let result = workspace.roll_version(VersionBump::Patch(1), &selection); @@ -596,7 +724,10 @@ mod tests { #[test] fn test_workspace_roll_version_invalid_operation() { // Test what happens when a roll operation would result in invalid versions - let mut workspace = create_test_workspace(vec![("app", "0.1.0"), ("lib", "0.1.0")]); + let mut workspace = create_test_workspace(vec![ + ("app", VersionField::Concrete("0.1.0".to_string())), + ("lib", VersionField::Concrete("0.1.0".to_string())), + ]); let selection = PackageSelection::workspace(); let result = workspace.roll_version(VersionBump::Patch(-2), &selection); @@ -609,8 +740,15 @@ mod tests { .contains("Cannot decrement patch version by 2 from 0.1.0")); // Versions should remain unchanged - assert_eq!(workspace.members[0].version(), "0.1.0"); - assert_eq!(workspace.members[1].version(), "0.1.0"); + assert_eq!( + workspace.members[0].version(), + &VersionField::Concrete("0.1.0".to_string()) + ); + + assert_eq!( + workspace.members[1].version(), + &VersionField::Concrete("0.1.0".to_string()) + ); } #[test] @@ -665,9 +803,9 @@ mod tests { fn test_workspace_roll_version_mixed_validity() { // Test workspace with mixed versions where some can handle rollback and others can't let mut workspace = create_test_workspace(vec![ - ("app", "1.0.2"), // Can handle patch -2 - ("lib", "0.1.0"), // Cannot handle patch -2 (will cause error) - ("utils", "2.5.3"), // Can handle patch -2 + ("app", VersionField::Concrete("1.0.2".to_string())), // Can handle patch -2 + ("lib", VersionField::Concrete("0.1.0".to_string())), // Cannot handle patch -2 (will cause error) + ("utils", VersionField::Concrete("2.5.3".to_string())), // Can handle patch -2 ]); let selection = PackageSelection::workspace(); @@ -685,9 +823,23 @@ mod tests { // so from the user's perspective, no changes occur to disk // // However, this test is only exercising domain logic, so we see the in-memory changes: - assert_eq!(workspace.members[0].version(), "1.0.0"); // app was modified in-memory - assert_eq!(workspace.members[1].version(), "0.1.0"); // lib unchanged (caused error) - assert_eq!(workspace.members[2].version(), "2.5.3"); // utils unchanged (not processed) + assert_eq!( + workspace.members[0].version(), + &VersionField::Concrete("1.0.0".to_string()), + "app was modified in-memory" + ); + + assert_eq!( + workspace.members[1].version(), + &VersionField::Concrete("0.1.0".to_string()), + "lib unchanged (caused error)" + ); + + assert_eq!( + workspace.members[2].version(), + &VersionField::Concrete("2.5.3".to_string()), + "utils unchanged (not processed)" + ); } #[test] @@ -726,4 +878,174 @@ mod tests { let bump = VersionBump::Major(-1); assert_eq!(bump.apply_to_version("2.3.5-rc.1").unwrap(), "1.0.0-rc.1"); } + + #[test] + fn test_selected_members_sorting() { + let workspace = create_test_workspace(vec![ + ("zebra", VersionField::Concrete("1.0.0".to_string())), + ("apple", VersionField::Concrete("2.0.0".to_string())), + ("banana", VersionField::Concrete("3.0.0".to_string())), + ]); + + let members = workspace.selected_members(&PackageSelection::workspace()); + assert_eq!(members[0].name(), "apple"); + assert_eq!(members[1].name(), "banana"); + assert_eq!(members[2].name(), "zebra"); + } + + #[test] + fn test_selected_members_sorting_with_selection() { + let workspace = create_test_workspace(vec![ + ("zebra", VersionField::Concrete("1.0.0".to_string())), + ("apple", VersionField::Concrete("2.0.0".to_string())), + ("banana", VersionField::Concrete("3.0.0".to_string())), + ]); + + let selection = PackageSelection::packages(vec!["zebra".to_string(), "apple".to_string()]); + let members = workspace.selected_members(&selection); + assert_eq!(members[0].name(), "apple"); + assert_eq!(members[1].name(), "zebra"); + } + + #[test] + fn test_version_bump_with_build_metadata() { + let bump = VersionBump::Patch(1); + let result = bump.apply_to_version("1.2.3+20130313144700").unwrap(); + assert_eq!(result, "1.2.4+20130313144700"); + } + + #[test] + fn test_version_bump_with_prerelease_and_build() { + let bump = VersionBump::Patch(1); + let result = bump + .apply_to_version("1.2.3-beta.1+20130313144700") + .unwrap(); + assert_eq!(result, "1.2.4-beta.1+20130313144700"); + } + + #[test] + fn test_version_bump_zero_amount() { + let bump = VersionBump::Patch(0); + let result = bump.apply_to_version("1.2.3").unwrap(); + assert_eq!(result, "1.2.3"); + } + + #[test] + fn test_workspace_roll_version_preserves_inherited() { + let mut workspace = create_test_workspace(vec![ + ("pkg1", VersionField::Concrete("1.0.0".to_string())), + ("pkg2", VersionField::Inherited), + ]); + let selection = PackageSelection::workspace(); + let result = workspace + .roll_version(VersionBump::Patch(1), &selection) + .unwrap(); + assert_eq!(result.changes.len(), 1); + assert_eq!(result.changes[0].package, "pkg1"); + assert_eq!(result.changes[0].new_version, "1.0.1"); + } + + #[test] + fn test_workspace_set_version_preserves_inherited() { + let mut workspace = create_test_workspace(vec![ + ("pkg1", VersionField::Concrete("1.0.0".to_string())), + ("pkg2", VersionField::Inherited), + ]); + let selection = PackageSelection::workspace(); + let result = workspace.set_version("2.0.0", &selection).unwrap(); + assert_eq!(result.changes.len(), 1); + assert_eq!(result.changes[0].package, "pkg1"); + assert_eq!(result.changes[0].new_version, "2.0.0"); + } + + #[test] + fn test_workspace_sync_version_preserves_inherited() { + let mut workspace = create_test_workspace(vec![ + ("pkg1", VersionField::Concrete("1.0.0".to_string())), + ("pkg2", VersionField::Inherited), + ("pkg3", VersionField::Concrete("2.0.0".to_string())), + ]); + let result = workspace.sync_version("3.0.0").unwrap(); + assert_eq!(result.changes.len(), 2); + assert!(result + .changes + .iter() + .any(|c| c.package == "pkg1" && c.new_version == "3.0.0")); + assert!(result + .changes + .iter() + .any(|c| c.package == "pkg3" && c.new_version == "3.0.0")); + } + + #[test] + fn test_workspace_show_includes_inherited() { + let workspace = create_test_workspace(vec![ + ("pkg1", VersionField::Concrete("1.0.0".to_string())), + ("pkg2", VersionField::Inherited), + ]); + let selection = PackageSelection::workspace(); + let output = workspace.show(&selection); + assert!(output.contains("pkg1: 1.0.0")); + assert!(!output.contains("pkg2")); // Inherited versions are skipped + } + + #[test] + fn test_workspace_lint_skips_inherited() { + let workspace = create_test_workspace(vec![ + ("pkg1", VersionField::Concrete("1.0.0".to_string())), + ("pkg2", VersionField::Inherited), + ("pkg3", VersionField::Concrete("invalid".to_string())), + ]); + let selection = PackageSelection::workspace(); + let errors = workspace.lint(&selection); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].member, "pkg3"); + } + + #[test] + fn test_package_selection_exclude_all() { + let workspace = create_test_workspace(vec![ + ("pkg1", VersionField::Concrete("1.0.0".to_string())), + ("pkg2", VersionField::Concrete("2.0.0".to_string())), + ("pkg3", VersionField::Concrete("3.0.0".to_string())), + ]); + let selection = PackageSelection { + packages: vec![], + workspace: false, + exclude: vec!["pkg1".to_string(), "pkg2".to_string(), "pkg3".to_string()], + }; + let members = workspace.selected_members(&selection); + assert!(members.is_empty()); + } + + #[test] + fn test_package_selection_include_and_exclude() { + let workspace = create_test_workspace(vec![ + ("pkg1", VersionField::Concrete("1.0.0".to_string())), + ("pkg2", VersionField::Concrete("2.0.0".to_string())), + ("pkg3", VersionField::Concrete("3.0.0".to_string())), + ]); + let selection = PackageSelection { + packages: vec!["pkg1".to_string(), "pkg2".to_string()], + workspace: false, + exclude: vec!["pkg2".to_string()], + }; + let members = workspace.selected_members(&selection); + assert_eq!(members.len(), 1); + assert_eq!(members[0].name(), "pkg1"); + } + + #[test] + fn test_operation_result_has_changes() { + let mut result = OperationResult::new("test".to_string()); + assert!(!result.has_changes()); + + result.add_change(VersionChange { + package: "pkg1".to_string(), + old_version: "1.0.0".to_string(), + new_version: "2.0.0".to_string(), + path: PathBuf::from("pkg1"), + }); + assert!(result.has_changes()); + } } diff --git a/src/io/cargo.rs b/src/io/cargo.rs deleted file mode 100644 index 8d92469..0000000 --- a/src/io/cargo.rs +++ /dev/null @@ -1,522 +0,0 @@ -use anyhow::{Context, Result}; -use std::fs; -use std::path::{Path, PathBuf}; -use toml_edit::{value, DocumentMut}; - -use crate::domain::{Workspace, WorkspaceMember}; - -// ============================================================================== -// CARGO.TOML PARSING & EXTRACTION -// ============================================================================== - -#[derive(Debug, Clone)] -pub struct CargoToml { - pub name: Option, - pub version: Option, - pub has_workspace_section: bool, - pub has_package_section: bool, - pub uses_workspace_version: bool, -} - -impl CargoToml { - /// Parse Cargo.toml content into structured metadata - pub fn parse(toml_content: &str) -> Result { - let doc = toml_content - .parse::() - .context("Failed to parse TOML content")?; - - Ok(Self { - name: cargo_name(&doc), - version: cargo_version(&doc), - has_workspace_section: has_workspace_section(&doc), - has_package_section: has_package_section(&doc), - uses_workspace_version: extract_uses_workspace_version(&doc), - }) - } - - /// Update version in TOML content, returning new content string - pub fn update_version(toml_content: &str, new_version: &str) -> Result { - let mut doc = toml_content - .parse::() - .context("Failed to parse TOML content")?; - - if let Some(package) = doc.get_mut("package") { - if let Some(package_table) = package.as_table_mut() { - package_table["version"] = value(new_version); - } - } - - Ok(doc.to_string()) - } -} - -// Pure extraction functions operating on parsed documents -fn cargo_version(doc: &DocumentMut) -> Option { - doc.get("package") - .and_then(|p| p.get("version")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -fn cargo_name(doc: &DocumentMut) -> Option { - doc.get("package") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) - .map(|s| s.to_string()) -} - -fn has_workspace_section(doc: &DocumentMut) -> bool { - doc.get("workspace").is_some() -} - -fn has_package_section(doc: &DocumentMut) -> bool { - doc.get("package").is_some() -} - -fn extract_uses_workspace_version(doc: &DocumentMut) -> bool { - uses_workspace_inheritance(doc, "package", "version") -} - -/// Check if a field uses workspace inheritance (field = { workspace = true }) -/// -/// Handles both regular tables and inline tables since Cargo can use either: -/// - `version = { workspace = true }` (inline table) -/// - `[package.version] workspace = true` (regular table) -fn uses_workspace_inheritance(doc: &DocumentMut, section: &str, field: &str) -> bool { - doc.get(section) - .and_then(|s| s.get(field)) - .and_then(|value| { - // Check both regular tables and inline tables for workspace = true - if let Some(table) = value.as_table() { - table.get("workspace").and_then(|w| w.as_bool()) - } else if let Some(inline_table) = value.as_inline_table() { - inline_table.get("workspace").and_then(|w| w.as_bool()) - } else { - None - } - }) - .unwrap_or(false) -} - -// ============================================================================== -// CARGO WORKSPACE OPERATIONS -// ============================================================================== - -/// Load a Cargo workspace from the file system -pub fn load_cargo_workspace() -> Result { - let project_root = find_project_root()?; - let root_manifest = project_root.join("Cargo.toml"); - - let mut members = Vec::new(); - - // Check if this is a workspace or single crate - if is_workspace_root(&root_manifest)? { - // Multi-crate workspace: discover all members - members.extend(discover_workspace_members(&project_root)?); - - // Add workspace root if it has a [package] section too - if has_package_section_file(&root_manifest)? { - let name = - read_package_name(&root_manifest)?.unwrap_or_else(|| "workspace".to_string()); - let version = read_cargo_toml_version(&root_manifest)? - .context("Workspace root package must have a version")?; - members.insert( - 0, - WorkspaceMember::Cargo { - name, - version, - path: root_manifest, - has_workspace_inheritance: false, - }, - ); - } - } else { - // Single crate project - let name = read_package_name(&root_manifest)?.context("Package must have a name")?; - let version = - read_cargo_toml_version(&root_manifest)?.context("Package must have a version")?; - members.push(WorkspaceMember::Cargo { - name, - version, - path: root_manifest, - has_workspace_inheritance: false, - }); - } - - Ok(Workspace { members }) -} - -/// Find the project root using workspace-first strategy -/// -/// Algorithm: -/// 1. Walk up from current dir looking for Cargo.toml files -/// 2. For each Cargo.toml found, check if it defines a workspace -/// 3. If workspace found, that's our project root -/// 4. If we exhaust all ancestors without finding a workspace, -/// use the first (lowest) Cargo.toml we found -pub fn find_project_root() -> Result { - let current_dir = std::env::current_dir().context("Failed to get current directory")?; - - let mut first_cargo_toml = None; - - for ancestor in current_dir.ancestors() { - let cargo_toml = ancestor.join("Cargo.toml"); - if cargo_toml.exists() { - // Remember the first Cargo.toml we find (closest to current dir) - if first_cargo_toml.is_none() { - first_cargo_toml = Some(ancestor.to_path_buf()); - } - - // Check if this Cargo.toml defines a workspace - if is_workspace_root(&cargo_toml)? { - return Ok(ancestor.to_path_buf()); - } - } - } - - // No workspace found, use the first Cargo.toml we encountered - first_cargo_toml - .ok_or_else(|| anyhow::anyhow!("No Cargo.toml found. Make sure you're in a Rust project.")) -} - -/// Check if a Cargo.toml file defines a workspace -fn is_workspace_root(cargo_toml_path: &Path) -> Result { - let content = fs::read_to_string(cargo_toml_path) - .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; - - let cargo_toml = CargoToml::parse(&content) - .with_context(|| format!("Failed to parse TOML in {}", cargo_toml_path.display()))?; - - Ok(cargo_toml.has_workspace_section) -} - -/// Discover all workspace members by walking the filesystem -/// -/// This approach is more robust than parsing \[workspace\] members because: -/// - It finds all Cargo.toml files regardless of workspace config -/// - No need to handle complex glob patterns -/// - Similar to how `git` finds all files under the repo root -pub fn discover_workspace_members(workspace_root: &Path) -> Result> { - let mut members = Vec::new(); - - // Walk the directory tree to find all Cargo.toml files - visit_cargo_tomls(workspace_root, &mut |cargo_toml_path| { - // Skip the workspace root manifest (it's handled separately) - if cargo_toml_path == workspace_root.join("Cargo.toml") { - return Ok(()); - } - - // Check if this is a valid package (has [package] section) - if let Ok(member) = load_workspace_member(cargo_toml_path) { - members.push(member); - } - - Ok(()) - })?; - - Ok(members) -} - -/// Recursively walk directory tree and call visitor for each Cargo.toml found -fn visit_cargo_tomls(dir: &Path, visitor: &mut F) -> Result<()> -where - F: FnMut(&Path) -> Result<()>, -{ - if !dir.is_dir() { - return Ok(()); - } - - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - - if path.is_dir() { - // Skip target directories and hidden directories to avoid noise - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name.starts_with('.') || name == "target" { - continue; - } - } - // Recursively visit subdirectories - visit_cargo_tomls(&path, visitor)?; - } else if path.file_name() == Some(std::ffi::OsStr::new("Cargo.toml")) { - // Found a Cargo.toml, call the visitor - visitor(&path)?; - } - } - - Ok(()) -} - -/// Load a single workspace member from its Cargo.toml -fn load_workspace_member(member_manifest: &Path) -> Result { - let content = fs::read_to_string(member_manifest) - .with_context(|| format!("Failed to read {}", member_manifest.display()))?; - - let cargo_toml = CargoToml::parse(&content) - .with_context(|| format!("Failed to parse {}", member_manifest.display()))?; - - let name = cargo_toml - .name - .context("Workspace member must have a name")?; - - let version = if cargo_toml.uses_workspace_version { - // If using workspace inheritance, we'll need to resolve the workspace version - // For now, use a placeholder - this will be resolved later - "workspace".to_string() - } else { - cargo_toml - .version - .context("Workspace member must have a version or use workspace inheritance")? - }; - - Ok(WorkspaceMember::Cargo { - name, - version, - path: member_manifest.to_path_buf(), - has_workspace_inheritance: cargo_toml.uses_workspace_version, - }) -} - -/// Update the version in a specific Cargo.toml file -pub fn update_cargo_toml_version(cargo_toml_path: &Path, new_version: &str) -> Result<()> { - let content = fs::read_to_string(cargo_toml_path) - .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; - - let updated_content = CargoToml::update_version(&content, new_version) - .with_context(|| format!("Failed to update version in {}", cargo_toml_path.display()))?; - - fs::write(cargo_toml_path, updated_content) - .with_context(|| format!("Failed to write {}", cargo_toml_path.display()))?; - - Ok(()) -} - -/// Read the current version from a Cargo.toml file -pub fn read_cargo_toml_version(cargo_toml_path: &Path) -> Result> { - let content = fs::read_to_string(cargo_toml_path) - .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; - - let cargo_toml = CargoToml::parse(&content) - .with_context(|| format!("Failed to parse TOML in {}", cargo_toml_path.display()))?; - - Ok(cargo_toml.version) -} - -/// Check if a package uses workspace inheritance for its version -pub fn uses_workspace_version(cargo_toml_path: &Path) -> Result { - let content = fs::read_to_string(cargo_toml_path) - .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; - - let cargo_toml = CargoToml::parse(&content) - .with_context(|| format!("Failed to parse TOML in {}", cargo_toml_path.display()))?; - - Ok(cargo_toml.uses_workspace_version) -} - -/// Check if a Cargo.toml has a [package] section -fn has_package_section_file(cargo_toml_path: &Path) -> Result { - let content = fs::read_to_string(cargo_toml_path) - .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; - - let cargo_toml = CargoToml::parse(&content) - .with_context(|| format!("Failed to parse TOML in {}", cargo_toml_path.display()))?; - - Ok(cargo_toml.has_package_section) -} - -/// Read the package name from a Cargo.toml file -fn read_package_name(cargo_toml_path: &Path) -> Result> { - let content = fs::read_to_string(cargo_toml_path) - .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; - - let cargo_toml = CargoToml::parse(&content) - .with_context(|| format!("Failed to parse TOML in {}", cargo_toml_path.display()))?; - - Ok(cargo_toml.name) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cargo_toml_parse_basic_package() { - let toml = r#" -[package] -name = "my-crate" -version = "1.2.3" -edition = "2021" -"#; - - let cargo_toml = CargoToml::parse(toml).unwrap(); - - assert_eq!(cargo_toml.name, Some("my-crate".to_string())); - assert_eq!(cargo_toml.version, Some("1.2.3".to_string())); - assert!(!cargo_toml.has_workspace_section); - assert!(cargo_toml.has_package_section); - assert!(!cargo_toml.uses_workspace_version); - } - - #[test] - fn test_cargo_toml_parse_workspace_root() { - let toml = r#" -[workspace] -members = ["crates/*", "tools/cli"] - -[package] -name = "workspace-root" -version = "0.1.0" -"#; - - let cargo_toml = CargoToml::parse(toml).unwrap(); - - assert_eq!(cargo_toml.name, Some("workspace-root".to_string())); - assert_eq!(cargo_toml.version, Some("0.1.0".to_string())); - assert!(cargo_toml.has_workspace_section); - assert!(cargo_toml.has_package_section); - assert!(!cargo_toml.uses_workspace_version); - } - - #[test] - fn test_cargo_toml_parse_workspace_only() { - let toml = r#" -[workspace] -members = ["packages/*"] -resolver = "2" -"#; - - let cargo_toml = CargoToml::parse(toml).unwrap(); - - assert_eq!(cargo_toml.name, None); - assert_eq!(cargo_toml.version, None); - assert!(cargo_toml.has_workspace_section); - assert!(!cargo_toml.has_package_section); - assert!(!cargo_toml.uses_workspace_version); - } - - #[test] - fn test_cargo_toml_parse_workspace_inheritance() { - let toml = r#" -[package] -name = "member-crate" -version = { workspace = true } -edition = { workspace = true } -"#; - - let cargo_toml = CargoToml::parse(toml).unwrap(); - - assert_eq!(cargo_toml.name, Some("member-crate".to_string())); - assert_eq!(cargo_toml.version, None); // workspace inheritance means no direct version - assert!(!cargo_toml.has_workspace_section); - assert!(cargo_toml.has_package_section); - assert!(cargo_toml.uses_workspace_version); - } - - #[test] - fn test_cargo_toml_parse_mixed_inheritance() { - let toml = r#" -[package] -name = "mixed-crate" -version = "1.0.0" -edition = { workspace = true } -"#; - - let cargo_toml = CargoToml::parse(toml).unwrap(); - - assert_eq!(cargo_toml.name, Some("mixed-crate".to_string())); - assert_eq!(cargo_toml.version, Some("1.0.0".to_string())); - assert!(!cargo_toml.has_workspace_section); - assert!(cargo_toml.has_package_section); - assert!(!cargo_toml.uses_workspace_version); // version is NOT inherited - } - - #[test] - fn test_cargo_toml_parse_empty() { - let toml = r#" -# Just a comment -"#; - - let cargo_toml = CargoToml::parse(toml).unwrap(); - - assert_eq!(cargo_toml.name, None); - assert_eq!(cargo_toml.version, None); - assert!(!cargo_toml.has_workspace_section); - assert!(!cargo_toml.has_package_section); - assert!(!cargo_toml.uses_workspace_version); - } - - #[test] - fn test_cargo_toml_parse_invalid_toml() { - let toml = r#" -[package -name = "broken -"#; - - let result = CargoToml::parse(toml); - assert!(result.is_err()); - } - - #[test] - fn test_cargo_toml_update_version_basic() { - let toml = r#"[package] -name = "test" -version = "1.0.0" -"#; - - let updated = CargoToml::update_version(toml, "2.0.0").unwrap(); - - assert!(updated.contains("version = \"2.0.0\"")); - assert!(updated.contains("name = \"test\"")); - // Should preserve structure - assert!(updated.contains("[package]")); - } - - #[test] - fn test_cargo_toml_update_version_preserves_formatting() { - let toml = r#"# My awesome crate -[package] -name = "test" -version = "1.0.0" # Current version -edition = "2021" - -[dependencies] -anyhow = "1.0" -"#; - - let updated = CargoToml::update_version(toml, "1.1.0").unwrap(); - - assert!(updated.contains("version = \"1.1.0\"")); - // Should preserve comments and structure - assert!(updated.contains("# My awesome crate")); - assert!(updated.contains("[dependencies]")); - assert!(updated.contains("anyhow = \"1.0\"")); - } - - #[test] - fn test_cargo_toml_update_version_no_package_section() { - let toml = r#"[workspace] -members = ["crates/*"] -"#; - - let updated = CargoToml::update_version(toml, "2.0.0").unwrap(); - - // Should not add version if no [package] section exists - assert!(!updated.contains("version = \"2.0.0\"")); - assert!(updated.contains("[workspace]")); - } - - #[test] - fn test_cargo_toml_update_version_workspace_inheritance() { - let toml = r#"[package] -name = "test" -version = { workspace = true } -"#; - - let updated = CargoToml::update_version(toml, "2.0.0").unwrap(); - - // Should overwrite workspace inheritance with concrete version - assert!(updated.contains("version = \"2.0.0\"")); - assert!(!updated.contains("workspace = true")); - } -} diff --git a/src/io/cargo_toml.rs b/src/io/cargo_toml.rs new file mode 100644 index 0000000..8b9de51 --- /dev/null +++ b/src/io/cargo_toml.rs @@ -0,0 +1,486 @@ +use anyhow::{Context, Result}; +use std::{fs, path::Path}; +use toml_edit::{DocumentMut, Formatted, Item, Value}; + +use crate::domain::VersionField; + +/// Parse a Cargo.toml file and return (name, version, has_workspace_inheritance) +pub fn parse(path: &Path) -> Result<(Option, VersionField)> { + let content = fs::read_to_string(path). //- + with_context(|| format!("Failed to read {}", path.display()))?; + + let doc = content + .parse::() + .with_context(|| format!("Failed to parse {}", path.display()))?; + + let package = get_package_section(&doc); + + let name = package + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + let version = if uses_workspace_inheritance(&doc, "package", "version") { + VersionField::Inherited + } else { + match package.and_then(|p| p.get("version")) { + None => VersionField::Absent, + Some(v) => v + .as_str() + .map(|s| VersionField::Concrete(s.to_string())) + .ok_or_else(|| anyhow::anyhow!("Version field must be a string"))?, + } + }; + + Ok((name, version)) +} + +/// Update the version in a Cargo.toml file, preserving formatting +/// +/// This function will update the version in the Cargo.toml file at the given path. +/// It will preserve the existing formatting of the version field, including comments. +/// +/// # Arguments +/// * `path` - The path to the Cargo.toml file to update. +pub fn update_version(path: &Path, new_version: &VersionField) -> Result<()> { + let new_version = match new_version { + VersionField::Concrete(version) => version, + _ => return Ok(()), + }; + + let content = fs::read_to_string(path). //- + with_context(|| format!("Failed to read {}", path.display()))?; + + let mut doc = content + .parse::() + .with_context(|| format!("Failed to parse {}", path.display()))?; + + let package = get_package_section_mut(&mut doc).ok_or_else(|| { + anyhow::anyhow!( + "No workspace or package section found in {}", + path.display() + ) + })?; + + // Get the existing decor (comments) from the version field + let decor = package + .get("version") + .and_then(|v| v.as_value()) + .map(|v| v.decor().clone()); + + // Create new value with the same decor + let mut new_value = Value::String(Formatted::new(new_version.to_string())); + if let Some(d) = decor { + if let Some(prefix_str) = d.prefix().and_then(|p| p.as_str()) { + new_value.decor_mut().set_prefix(prefix_str.to_string()); + } + if let Some(suffix_str) = d.suffix().and_then(|s| s.as_str()) { + new_value.decor_mut().set_suffix(suffix_str.to_string()); + } + } + + package["version"] = Item::Value(new_value); + + fs::write(path, doc.to_string()) + .with_context(|| format!("Failed to write {}", path.display()))?; + + Ok(()) +} + +/// Get the package section from either workspace.package or package +fn get_package_section(doc: &DocumentMut) -> Option<&Item> { + if doc.get("workspace").is_some() { + doc.get("workspace").and_then(|w| w.get("package")) + } else { + doc.get("package") + } +} + +/// Get a mutable reference to the package section from either workspace.package or package +fn get_package_section_mut(doc: &mut DocumentMut) -> Option<&mut Item> { + if doc.get("workspace").is_some() { + doc.get_mut("workspace").and_then(|w| w.get_mut("package")) + } else if doc.get("package").is_some() { + doc.get_mut("package") + } else { + None + } +} + +/// Check if a field uses workspace inheritance (field = { workspace = true }) +/// +/// Handles both regular tables and inline tables since Cargo can use either: +/// - `version = { workspace = true }` (inline table) +/// - `[package.version] workspace = true` (regular table) +fn uses_workspace_inheritance(doc: &DocumentMut, section: &str, field: &str) -> bool { + doc.get("workspace").is_none() + && doc + .get(section) + .and_then(|s| s.get(field)) + .and_then(|value| { + // Check both regular tables and inline tables for workspace = true + if let Some(table) = value.as_table() { + table.get("workspace").and_then(|w| w.as_bool()) + } else if let Some(inline_table) = value.as_inline_table() { + inline_table.get("workspace").and_then(|w| w.as_bool()) + } else { + None + } + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_temp_toml(contents: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + write!(file, "{}", contents).unwrap(); + file + } + + #[test] + fn test_parse_basic_package() { + let toml = r#" + [package] + name = "my-package" + version = "1.2.3" + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete("1.2.3".to_string())); + } + + #[test] + fn test_parse_workspace_inheritance_one() { + let toml = r#" + [package] + name = "my-package" + version = { workspace = true } + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Inherited); + } + + #[test] + fn test_parse_workspace_inheritance_two() { + let toml = r#" + [package] + name = "my-package" + version.workspace = true + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Inherited); + } + + #[test] + fn test_update_version_basic() { + let toml = r#" + [package] + name = "my-package" + version = "1.2.3" + "#; + let file = write_temp_toml(toml); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("version = \"2.0.0\"")); + } + + #[test] + fn test_update_version_workspace_inheritance() { + let toml = r#" + [package] + name = "my-package" + version = { workspace = true } + "#; + let file = write_temp_toml(toml); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("version = \"2.0.0\"")); + } + + // Workspace package tests + #[test] + fn test_parse_workspace_package() { + let toml = r#" + [workspace.package] + name = "workspace-package" + version = "1.0.0" + + [workspace] + members = ["crate1", "crate2"] + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("workspace-package".to_string())); + assert_eq!(version, VersionField::Concrete("1.0.0".to_string())); + } + + #[test] + fn test_update_workspace_package_version() { + let toml = r#" + [workspace.package] + name = "workspace-package" + version = "1.0.0" + + [workspace] + members = ["crate1"] + "#; + let file = write_temp_toml(toml); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("version = \"2.0.0\"")); + } + + // Edge cases and missing fields + #[test] + fn test_parse_package_missing_name() { + let toml = r#" + [package] + version = "1.2.3" + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, None); + assert_eq!(version, VersionField::Concrete("1.2.3".to_string())); + } + + #[test] + fn test_parse_package_missing_version() { + let toml = r#" + [package] + name = "my-package" + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Absent); + } + + // Error cases + #[test] + fn test_parse_no_package_or_workspace() { + let toml = r#" + [dependencies] + serde = "1.0" + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, None); + assert_eq!(version, VersionField::Absent); + } + + #[test] + fn test_update_version_no_package_or_workspace() { + let toml = r#" + [dependencies] + serde = "1.0" + "#; + let file = write_temp_toml(toml); + let new_version = VersionField::Concrete("2.0.0".to_string()); + let result = update_version(file.path(), &new_version); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("No workspace or package section found")); + } + + #[test] + fn test_parse_invalid_toml() { + let toml = r#" + [package + name = "invalid" + "#; + let file = write_temp_toml(toml); + let result = parse(file.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to parse")); + } + + // Workspace inheritance edge cases + #[test] + fn test_workspace_inheritance_false() { + let toml = r#" + [package] + name = "my-package" + version = "1.0.0" + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete("1.0.0".to_string())); + } + + #[test] + fn test_workspace_inheritance_with_other_fields() { + let toml = r#" + [package] + name = "my-package" + version = { workspace = true, optional = true } + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Inherited); + } + + // Formatting preservation test + #[test] + fn test_update_version_preserves_formatting() { + let toml = r#" +# This is a comment +[package] +name = "my-package" +version = "1.2.3" # inline comment +description = "A test package" + "#; + let file = write_temp_toml(toml); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + + // Check that version was updated + assert!(content.contains("version = \"2.0.0\"")); + // Check that comments are preserved + assert!(content.contains("# This is a comment")); + assert!(content.contains("# inline comment")); + // Check that other fields are preserved + assert!(content.contains("description = \"A test package\"")); + } + + // File I/O error cases (harder to test, but worth mentioning) + #[test] + fn test_parse_nonexistent_file() { + let result = parse(Path::new("/nonexistent/path/Cargo.toml")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to read")); + } + + #[test] + fn test_parse_version_with_comments() { + let toml = r#" + [package] + # This is a comment + name = "my-package" + # Version comment + version = "1.2.3" # Inline comment + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete("1.2.3".to_string())); + } + + #[test] + fn test_parse_version_with_whitespace() { + let toml = r#" + [package] + name = "my-package" + version = "1.2.3" + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete("1.2.3".to_string())); + } + + #[test] + fn test_parse_workspace_inheritance_invalid_value() { + // Test that using a string "true" instead of boolean true for workspace inheritance + // is rejected, as per Cargo.toml schema + let toml = r#" + [package] + name = "my-package" + version = { workspace = "true" } + "#; + let file = write_temp_toml(toml); + let result = parse(file.path()); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Version field must be a string")); + } + + #[test] + fn test_parse_workspace_inheritance_with_additional_fields() { + let toml = r#" + [package] + name = "my-package" + version = { workspace = true, other = "value" } + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Inherited); + } + + #[test] + fn test_parse_workspace_only_no_package() { + let toml = r#" + [workspace] + members = ["crate1"] + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, None); + assert_eq!(version, VersionField::Absent); + } + + #[test] + fn test_parse_both_workspace_and_package() { + let toml = r#" + [workspace.package] + name = "workspace-package" + version = "1.0.0" + + [package] + name = "my-package" + version = "2.0.0" + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("workspace-package".to_string())); + assert_eq!(version, VersionField::Concrete("1.0.0".to_string())); + } + + #[test] + fn test_update_version_preserves_inline_table() { + let toml = r#" + [package] + name = "my-package" + version = { workspace = true, other = "value" } + "#; + let file = write_temp_toml(toml); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("version = \"2.0.0\"")); + } + + #[test] + fn test_parse_version_invalid_semver() { + let toml = r#" + [package] + name = "my-package" + version = "not-a-version" + "#; + let file = write_temp_toml(toml); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete("not-a-version".to_string())); + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs index 974736d..b3f1799 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -1,19 +1,24 @@ -pub mod cargo; -// pub mod node; // Future: Node.js/npm support -// pub mod python; // Future: Python/pip support +mod cargo_toml; +mod package_json; use crate::domain::{Workspace, WorkspaceMember}; -use anyhow::Result; -use std::path::PathBuf; +use anyhow::{Context, Result}; +use std::path::Path; +use walkdir::WalkDir; /// Load the current workspace from the file system /// -/// This function detects the project type and delegates to the appropriate -/// ecosystem-specific loader (currently only Cargo, but designed for Node/Python/etc.) +/// This function discovers members from all supported ecosystems +/// and builds a composite workspace. pub fn load_workspace() -> Result { - // For now, we only support Cargo projects - // Future: detect project type (package.json, pyproject.toml, etc.) and delegate - cargo::load_cargo_workspace() + let current_dir = std::env::current_dir().context("Failed to get current directory")?; + let members = discover_members(¤t_dir, |_| false)?; + + if members.is_empty() { + anyhow::bail!("No supported package manifests found in current directory"); + } + + Ok(Workspace { members }) } /// Save workspace changes back to the file system @@ -24,23 +29,348 @@ pub fn save_workspace(workspace: &Workspace) -> Result<()> { for member in &workspace.members { match member { WorkspaceMember::Cargo { path, version, .. } => { - cargo::update_cargo_toml_version(path, version)?; - } // Future ecosystem support: - // WorkspaceMember::Node { path, version, .. } => { - // node::update_package_json_version(path, version)?; - // } - // WorkspaceMember::Python { path, version, .. } => { - // python::update_pyproject_version(path, version)?; - // } + cargo_toml::update_version(&path.join("Cargo.toml"), version)?; + } + WorkspaceMember::Node { path, version, .. } => { + package_json::update_version(&path.join("package.json"), version)?; + } } } Ok(()) } -/// Find the project root by walking up the directory tree -/// -/// Currently looks for Cargo.toml, but could be extended to look for -/// package.json, pyproject.toml, etc. -pub fn find_project_root() -> Result { - cargo::find_project_root() +pub fn discover_members(root: &Path, ignore: F) -> Result> +where + F: Fn(&Path) -> bool, +{ + if !root.exists() { + return Err(anyhow::anyhow!( + "Root path does not exist: {}", + root.display() + )); + } + + let mut members = Vec::new(); + for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) { + let path = entry.path(); + if ignore(path) { + continue; + } + + let parent_path = path + .parent() + .with_context(|| format!("Invalid path structure: {}", path.display()))?; + + let basename = parent_path + .file_name() + .with_context(|| format!("Cannot determine directory name for {}", path.display()))? + .to_string_lossy() + .to_string(); + + if path.file_name() == Some("Cargo.toml".as_ref()) { + let (name, version) = cargo_toml::parse(path)?; + + members.push(WorkspaceMember::Cargo { + name: name.unwrap_or(basename), + path: parent_path.to_path_buf(), + version, + }); + } else if path.file_name() == Some("package.json".as_ref()) { + let (name, version) = package_json::parse(path)?; + + members.push(WorkspaceMember::Node { + name: name.unwrap_or(basename), + path: parent_path.to_path_buf(), + version, + }); + } + } + + members.sort_by(|a, b| a.name().cmp(b.name())); + + Ok(members) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::VersionField; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::tempdir; + + fn write_file(path: &Path, contents: &str) { + let mut file = File::create(path).unwrap(); + write!(file, "{}", contents).unwrap(); + } + + #[test] + fn test_discover_members_basic() { + let dir = tempdir().unwrap(); + let rust_dir = dir.path().join("rust"); + let node_dir = dir.path().join("node"); + fs::create_dir(&rust_dir).unwrap(); + fs::create_dir(&node_dir).unwrap(); + write_file( + &rust_dir.join("Cargo.toml"), + r#"[package] +name = "rustpkg" +version = "1.0.0" +"#, + ); + write_file( + &node_dir.join("package.json"), + r#"{ + "name": "nodepkg", + "version": "2.0.0" +} +"#, + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members.len(), 2); + assert!(members.iter().any(|m| m.name() == "rustpkg")); + assert!(members.iter().any(|m| m.name() == "nodepkg")); + } + + #[test] + fn test_discover_members_with_filter() { + let dir = tempdir().unwrap(); + let skip_dir = dir.path().join("skipme"); + fs::create_dir(&skip_dir).unwrap(); + write_file( + &skip_dir.join("Cargo.toml"), + r#"[package]\nname = \"skip\"\nversion = \"0.1.0\"\n"#, + ); + let members = + discover_members(dir.path(), |p| p.to_string_lossy().contains("skipme")).unwrap(); + assert!(members.iter().all(|m| m.name() != "skip")); + } + + #[test] + fn test_discover_members_empty_ok() { + let dir = tempdir().unwrap(); + let members = discover_members(dir.path(), |_| false).unwrap(); + assert!(members.is_empty()); + } + + #[test] + fn test_discover_members_parse_error() { + let dir = tempdir().unwrap(); + let bad = dir.path().join("bad"); + fs::create_dir(&bad).unwrap(); + write_file(&bad.join("Cargo.toml"), "not toml"); + let result = discover_members(dir.path(), |_| false); + assert!(result.is_err()); + } + + #[test] + fn test_discover_members_missing_names() { + // Test packages without name fields use directory basename + let dir = tempdir().unwrap(); + let rust_dir = dir.path().join("my-rust-package"); + fs::create_dir(&rust_dir).unwrap(); + write_file( + &rust_dir.join("Cargo.toml"), + "[package]\nversion = \"1.0.0\"", + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members[0].name(), "my-rust-package"); + } + + #[test] + fn test_discover_members_workspace_inheritance() { + // Test that workspace inheritance flag is detected correctly + let dir = tempdir().unwrap(); + let pkg_dir = dir.path().join("pkg"); + fs::create_dir(&pkg_dir).unwrap(); + write_file( + &pkg_dir.join("Cargo.toml"), + "[package]\nname = \"pkg\"\nversion = { workspace = true }", + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + if let WorkspaceMember::Cargo { version, .. } = &members[0] { + assert!(matches!(version, VersionField::Inherited)); + } + } + + #[test] + fn test_discover_members_nested_manifests() { + // Test deeply nested structure + let dir = tempdir().unwrap(); + let nested = dir.path().join("a").join("b").join("c"); + fs::create_dir_all(&nested).unwrap(); + write_file( + &nested.join("Cargo.toml"), + "[package]\nname = \"nested\"\nversion = \"1.0.0\"", + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].name(), "nested"); + } + + #[test] + fn test_discover_members_mixed_ecosystems() { + // Test finding both Rust and Node.js packages in the same directory + let dir = tempdir().unwrap(); + let mixed_dir = dir.path().join("mixed"); + fs::create_dir(&mixed_dir).unwrap(); + + // Create a Rust package + write_file( + &mixed_dir.join("Cargo.toml"), + r#"[package] +name = "rust-pkg" +version = "1.0.0" +"#, + ); + + // Create a Node.js package + write_file( + &mixed_dir.join("package.json"), + r#"{ + "name": "node-pkg", + "version": "2.0.0" +} +"#, + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members.len(), 2); + assert!(members.iter().any(|m| m.name() == "rust-pkg")); + assert!(members.iter().any(|m| m.name() == "node-pkg")); + } + + #[test] + fn test_discover_members_invalid_path() { + // Test handling of invalid path structure + let result = discover_members(Path::new("/nonexistent/path"), |_| false); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Root path does not exist")); + } + + #[test] + fn test_discover_members_symlinks() { + // Test handling of symlinked directories + let dir = tempdir().unwrap(); + let real_dir = dir.path().join("real"); + let symlink_dir = dir.path().join("symlink"); + fs::create_dir(&real_dir).unwrap(); + + #[cfg(unix)] + std::os::unix::fs::symlink(&real_dir, &symlink_dir).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&real_dir, &symlink_dir).unwrap(); + + write_file( + &real_dir.join("Cargo.toml"), + r#"[package] +name = "symlinked" +version = "1.0.0" +"#, + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].name(), "symlinked"); + } + + #[test] + fn test_discover_members_duplicate_names() { + // Test handling of packages with duplicate names + let dir = tempdir().unwrap(); + let pkg1_dir = dir.path().join("pkg1"); + let pkg2_dir = dir.path().join("pkg2"); + fs::create_dir(&pkg1_dir).unwrap(); + fs::create_dir(&pkg2_dir).unwrap(); + + write_file( + &pkg1_dir.join("Cargo.toml"), + r#"[package] +name = "duplicate" +version = "1.0.0" +"#, + ); + write_file( + &pkg2_dir.join("Cargo.toml"), + r#"[package] +name = "duplicate" +version = "2.0.0" +"#, + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members.len(), 2); + assert!(members.iter().all(|m| m.name() == "duplicate")); + } + + #[test] + fn test_discover_members_case_sensitivity() { + // Test case sensitivity in file names + let dir = tempdir().unwrap(); + let pkg_dir = dir.path().join("pkg"); + fs::create_dir(&pkg_dir).unwrap(); + write_file( + &pkg_dir.join("CARGO.TOML"), // Note the uppercase + r#"[package] +name = "case-sensitive" +version = "1.0.0" +"#, + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members.len(), 0); // Should not find uppercase Cargo.toml + } + + #[test] + fn test_discover_members_empty_manifest() { + // Test handling of empty manifest files + let dir = tempdir().unwrap(); + let pkg_dir = dir.path().join("pkg"); + fs::create_dir(&pkg_dir).unwrap(); + write_file(&pkg_dir.join("Cargo.toml"), ""); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members.len(), 1); + if let WorkspaceMember::Cargo { name, version, .. } = &members[0] { + assert_eq!(name, "pkg"); + assert_eq!(version, &VersionField::Absent); + } + } + + #[test] + fn test_discover_members_sorting() { + // Test that members are properly sorted by name + let dir = tempdir().unwrap(); + let pkg1_dir = dir.path().join("z-pkg"); + let pkg2_dir = dir.path().join("a-pkg"); + fs::create_dir(&pkg1_dir).unwrap(); + fs::create_dir(&pkg2_dir).unwrap(); + + write_file( + &pkg1_dir.join("Cargo.toml"), + r#"[package] +name = "z-pkg" +version = "1.0.0" +"#, + ); + write_file( + &pkg2_dir.join("Cargo.toml"), + r#"[package] +name = "a-pkg" +version = "2.0.0" +"#, + ); + + let members = discover_members(dir.path(), |_| false).unwrap(); + assert_eq!(members.len(), 2); + assert_eq!(members[0].name(), "a-pkg"); + assert_eq!(members[1].name(), "z-pkg"); + } } diff --git a/src/io/package_json.rs b/src/io/package_json.rs new file mode 100644 index 0000000..eb2dbda --- /dev/null +++ b/src/io/package_json.rs @@ -0,0 +1,309 @@ +use anyhow::{Context, Result}; +use serde_json::Value; +use std::{fs, path::Path}; + +use crate::domain::VersionField; + +/// Parse a package.json file and return (name, version, has_workspace_inheritance) +pub fn parse(path: &Path) -> Result<(Option, VersionField)> { + let content = fs::read_to_string(path). //- + with_context(|| format!("Failed to read {}", path.display()))?; + + let value: Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + + let name = value + .get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + let version_raw = value.get("version").and_then(|v| v.as_str()); + + // Check if version uses workspace protocol (workspace:*, workspace:~, etc.) + let has_workspace_inheritance = version_raw + .map(|v| v.starts_with("workspace:")) + .unwrap_or(false); + + let version = if has_workspace_inheritance { + VersionField::Inherited + } else { + match version_raw { + Some(v) => VersionField::Concrete(v.to_string()), + None => VersionField::Absent, + } + }; + + Ok((name, version)) +} + +/// Update the version in a package.json file +pub fn update_version(path: &Path, new_version: &VersionField) -> Result<()> { + let new_version = match new_version { + VersionField::Concrete(version) => version, + _ => return Ok(()), + }; + + let content = fs::read_to_string(path). //- + with_context(|| format!("Failed to read {}", path.display()))?; + + let mut value: Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + + // Update the version field directly + value["version"] = Value::String(new_version.to_string()); + + let updated_content = serde_json::to_string_pretty(&value) + .with_context(|| format!("Failed to serialize {}", path.display()))?; + + fs::write(path, updated_content) + .with_context(|| format!("Failed to write {}", path.display()))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_temp_json(contents: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + write!(file, "{}", contents).unwrap(); + file + } + + #[test] + fn test_parse_basic_package() { + let json = r#"{ + "name": "my-package", + "version": "1.2.3" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete("1.2.3".to_string())); + } + + #[test] + fn test_parse_workspace_inheritance() { + let json = r#"{ + "name": "workspace-package", + "version": "workspace:*" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("workspace-package".to_string())); + assert_eq!(version, VersionField::Inherited); + } + + #[test] + fn test_parse_workspace_inheritance_tilde() { + let json = r#"{ + "name": "workspace-package", + "version": "workspace:~" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("workspace-package".to_string())); + assert_eq!(version, VersionField::Inherited); + } + + #[test] + fn test_parse_missing_name() { + let json = r#"{ + "version": "1.2.3" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, None); + assert_eq!(version, VersionField::Concrete("1.2.3".to_string())); + } + + #[test] + fn test_parse_missing_version() { + let json = r#"{ + "name": "my-package" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Absent); + } + + #[test] + fn test_parse_no_package_fields() { + let json = r#"{ + "dependencies": { + "lodash": "^4.17.0" + } + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, None); + assert_eq!(version, VersionField::Absent); + } + + #[test] + fn test_parse_invalid_json() { + let json = r#"{ + "name": "invalid" + "#; + let file = write_temp_json(json); + let result = parse(file.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to parse")); + } + + #[test] + fn test_update_version_basic() { + let json = r#"{ + "name": "my-package", + "version": "1.2.3" + }"#; + let file = write_temp_json(json); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("\"version\": \"2.0.0\"")); + } + + #[test] + fn test_update_version_adds_if_missing() { + let json = r#"{ + "name": "my-package" + }"#; + let file = write_temp_json(json); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("\"version\": \"2.0.0\"")); + } + + #[test] + fn test_update_version_workspace_inheritance() { + let json = r#"{ + "name": "workspace-package", + "version": "workspace:*" + }"#; + let file = write_temp_json(json); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("\"version\": \"2.0.0\"")); + } + + #[test] + fn test_update_version_preserves_other_fields() { + let json = r#"{ + "name": "my-package", + "version": "1.2.3", + "description": "A test package", + "dependencies": { + "lodash": "^4.17.0" + } + }"#; + let file = write_temp_json(json); + let new_version = VersionField::Concrete("2.0.0".to_string()); + update_version(file.path(), &new_version).unwrap(); + let content = fs::read_to_string(file.path()).unwrap(); + + // Check that version was updated + assert!(content.contains("\"version\": \"2.0.0\"")); + // Check that other fields are preserved + assert!(content.contains("\"description\": \"A test package\"")); + assert!(content.contains("\"lodash\": \"^4.17.0\"")); + } + + #[test] + fn test_parse_nonexistent_file() { + let result = parse(Path::new("/nonexistent/path/package.json")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to read")); + } + + #[test] + fn test_parse_version_with_whitespace() { + let json = r#"{ + "name": "my-package", + "version": " 1.2.3 " + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete(" 1.2.3 ".to_string())); + } + + #[test] + fn test_parse_version_invalid_semver() { + let json = r#"{ + "name": "my-package", + "version": "not-a-version" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete("not-a-version".to_string())); + } + + #[test] + fn test_parse_workspace_inheritance_with_version() { + let json = r#"{ + "name": "workspace-package", + "version": "workspace:1.2.3" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("workspace-package".to_string())); + assert_eq!(version, VersionField::Inherited); + } + + #[test] + fn test_parse_workspace_inheritance_with_range() { + let json = r#"{ + "name": "workspace-package", + "version": "workspace:^1.2.3" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("workspace-package".to_string())); + assert_eq!(version, VersionField::Inherited); + } + + #[test] + fn test_parse_version_null() { + let json = r#"{ + "name": "my-package", + "version": null + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Absent); + } + + #[test] + fn test_parse_version_number() { + let json = r#"{ + "name": "my-package", + "version": 1.2 + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Absent); + } + + #[test] + fn test_parse_version_empty_string() { + let json = r#"{ + "name": "my-package", + "version": "" + }"#; + let file = write_temp_json(json); + let (name, version) = parse(file.path()).unwrap(); + assert_eq!(name, Some("my-package".to_string())); + assert_eq!(version, VersionField::Concrete("".to_string())); + } +} diff --git a/tests/basic_node_workspace_test.rs b/tests/basic_node_workspace_test.rs new file mode 100644 index 0000000..16c69db --- /dev/null +++ b/tests/basic_node_workspace_test.rs @@ -0,0 +1,95 @@ +mod helpers; +use helpers::*; + +#[test] +fn basic_node_workspace_test() { + run_make(&["fixtures.node.basic-node-workspace"]); + + let fixture_path = build_fixture_path("basic-node-workspace"); + + let (success, stdout, stderr) = run_odo(&["show"], &fixture_path); + assert!(success, "odo show failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "basic-node-workspace: 1.0.0", + "bin1: 1.0.0", + "bin2: 1.0.0", + "lib1: 1.0.0", + "lib2: 1.0.0", + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["roll", "patch", "--workspace"], &fixture_path); + assert!(success, "odo roll patch failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "basic-node-workspace: 1.0.0 → 1.0.1", + "bin1: 1.0.0 → 1.0.1", + "bin2: 1.0.0 → 1.0.1", + "lib1: 1.0.0 → 1.0.1", + "lib2: 1.0.0 → 1.0.1", + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["roll", "minor", "--package", "bin1"], &fixture_path); + assert!(success, "odo roll patch failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "bin1: 1.0.1 → 1.1.0", // + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["show"], &fixture_path); + assert!(success, "odo show failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "basic-node-workspace: 1.0.1", + "bin1: 1.1.0", // + "bin2: 1.0.1", + "lib1: 1.0.1", + "lib2: 1.0.1", + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["set", "1.10.0", "--workspace"], &fixture_path); + assert!(success, "odo show failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "basic-node-workspace: 1.0.1 → 1.10.0", + "bin1: 1.1.0 → 1.10.0", + "bin2: 1.0.1 → 1.10.0", + "lib1: 1.0.1 → 1.10.0", + "lib2: 1.0.1 → 1.10.0", + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["roll", "minor", "-8", "--workspace"], &fixture_path); + assert!(success, "odo show failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "basic-node-workspace: 1.10.0 → 1.2.0", + "bin1: 1.10.0 → 1.2.0", // + "bin2: 1.10.0 → 1.2.0", + "lib1: 1.10.0 → 1.2.0", + "lib2: 1.10.0 → 1.2.0", + ], + "stdout:\n{}", + stdout.join("\n") + ); +} diff --git a/tests/basic_rust_workspace_test.rs b/tests/basic_rust_workspace_test.rs new file mode 100644 index 0000000..379ee88 --- /dev/null +++ b/tests/basic_rust_workspace_test.rs @@ -0,0 +1,90 @@ +mod helpers; +use helpers::*; + +#[test] +fn basic_rust_workspace_test() { + run_make(&["fixtures.rust.basic-rust-workspace"]); + + let fixture_path = build_fixture_path("basic-rust-workspace"); + + let (success, stdout, stderr) = run_odo(&["show"], &fixture_path); + assert!(success, "odo show failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "bin1: 0.1.0", // + "bin2: 0.1.0", + "lib1: 0.1.0", + "lib2: 0.1.0", + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["roll", "patch", "--workspace"], &fixture_path); + assert!(success, "odo roll patch failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "bin1: 0.1.0 → 0.1.1", // + "bin2: 0.1.0 → 0.1.1", + "lib1: 0.1.0 → 0.1.1", + "lib2: 0.1.0 → 0.1.1", + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["roll", "minor", "--package", "bin1"], &fixture_path); + assert!(success, "odo roll patch failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "bin1: 0.1.1 → 0.2.0", // + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["show"], &fixture_path); + assert!(success, "odo show failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "bin1: 0.2.0", // + "bin2: 0.1.1", + "lib1: 0.1.1", + "lib2: 0.1.1", + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["set", "0.10.0", "--workspace"], &fixture_path); + assert!(success, "odo show failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "bin1: 0.2.0 → 0.10.0", // + "bin2: 0.1.1 → 0.10.0", + "lib1: 0.1.1 → 0.10.0", + "lib2: 0.1.1 → 0.10.0", + ], + "stdout:\n{}", + stdout.join("\n") + ); + + let (success, stdout, stderr) = run_odo(&["roll", "minor", "-2", "--workspace"], &fixture_path); + assert!(success, "odo show failed:\n{}", stderr.join("\n")); + assert_eq!( + stdout, + vec![ + "bin1: 0.10.0 → 0.8.0", // + "bin2: 0.10.0 → 0.8.0", + "lib1: 0.10.0 → 0.8.0", + "lib2: 0.10.0 → 0.8.0", + ], + "stdout:\n{}", + stdout.join("\n") + ); +} diff --git a/tests/helpers.rs b/tests/helpers.rs new file mode 100644 index 0000000..d6767f9 --- /dev/null +++ b/tests/helpers.rs @@ -0,0 +1,45 @@ +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +pub fn build_fixture_path(fixture_name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(fixture_name) +} + +pub fn run_make(args: &[&str]) { + let cwd = Path::new(env!("CARGO_MANIFEST_DIR")); + + Command::new("make") + .args(args) + .current_dir(cwd) + .output() + .expect("Failed to run make command"); +} + +pub fn run_odo(args: &[&str], cwd: &Path) -> (bool, Vec, Vec) { + let default_odo_binary = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/debug/odo"); + + let odo_binary = std::env::var("ODO_BINARY").unwrap_or_else(|_| { + run_make(&["build"]); + default_odo_binary.to_string_lossy().to_string() + }); + + let output = Command::new(odo_binary) + .args(args) + .current_dir(cwd) + .output() + .expect("Failed to run odo command"); + + let success = output.status.success(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + ( + success, + stdout.split("\n").map(String::from).collect(), + stderr.split("\n").map(String::from).collect(), + ) +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs deleted file mode 100644 index 4d51982..0000000 --- a/tests/integration_tests.rs +++ /dev/null @@ -1,289 +0,0 @@ -#[cfg(feature = "fixture-tests")] -mod fixture_tests { - use std::env; - use std::path::{Path, PathBuf}; - use std::process::Command; - - fn test_fixtures_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") - } - - fn run_odo(args: &[&str], cwd: &Path) -> (String, String, bool) { - // Use the ODO_BINARY environment variable set by the Makefile - let odo_binary = env::var("ODO_BINARY") - .expect("ODO_BINARY environment variable must be set by Makefile"); - - let output = Command::new(odo_binary) - .args(args) - .current_dir(cwd) - .output() - .expect("Failed to run odo command"); - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let success = output.status.success(); - - (stdout, stderr, success) - } - - #[test] - fn integration_walkthrough() { - // NOTE: This is intentionally one comprehensive test rather than separate tests. - // - // Why? Fixture state coordination is complex: - // - Tests can run in parallel, competing for the same fixtures - // - Fresh fixtures vs reused fixtures have different initial states - // - Each test would need to set up its own known state with `odo set` - // - // The walkthrough pattern gives us: - // - Deterministic execution order - // - Complete control over fixture state transitions - // - Clear progression from basic to advanced scenarios - // - No coordination headaches between separate tests - // - // If you're tempted to split this up: remember this lesson! 🎓 - - println!("🚀 Starting odometer integration walkthrough...\n"); - - // ================================================================= - // Single Crate Tests - // ================================================================= - - let single_crate_dir = test_fixtures_dir().join("single-crate"); - assert!( - single_crate_dir.exists(), - "single-crate fixture not found. Run 'make single-crate'" - ); - - println!("📦 Testing single crate operations..."); - - // Test show command - let (stdout, stderr, success) = run_odo(&["show"], &single_crate_dir); - if !success { - eprintln!("Error: {}", stderr); - } - assert!(success, "odo show failed on single crate"); - assert!( - stdout.contains("single-crate"), - "Expected crate name in output" - ); - assert!(stdout.contains('.'), "Expected version number in output"); - println!(" ✅ show: {}", stdout.trim()); - - // Test version increment - let (stdout, _stderr, success) = run_odo(&["roll", "patch"], &single_crate_dir); - assert!(success, "odo roll patch failed"); - println!(" ✅ roll patch: {}", stdout.trim()); - - // Verify increment worked - let (stdout, _stderr, success) = run_odo(&["show"], &single_crate_dir); - assert!(success, "odo show failed after patch increment"); - println!(" ✅ after patch: {}", stdout.trim()); - - // Test setting specific version - let (stdout, _stderr, success) = run_odo(&["set", "1.0.0"], &single_crate_dir); - assert!(success, "odo set failed"); - println!(" ✅ set 1.0.0: {}", stdout.trim()); - - // ================================================================= - // Simple Workspace Tests - // ================================================================= - - let workspace_dir = test_fixtures_dir().join("workspace-simple"); - assert!( - workspace_dir.exists(), - "workspace-simple fixture not found. Run 'make workspace-simple'" - ); - - println!("\n🏗️ Testing simple workspace operations..."); - - // Test workspace show - let (stdout, _stderr, success) = run_odo(&["show"], &workspace_dir); - assert!(success, "odo show failed on workspace"); - assert!(stdout.contains("lib1"), "Expected lib1 in workspace output"); - assert!(stdout.contains("lib2"), "Expected lib2 in workspace output"); - println!(" ✅ workspace show:\n{}", stdout.trim()); - - // Test DEFAULT behavior: workspace root only (safe default) - let (stdout, _stderr, success) = run_odo(&["roll", "minor"], &workspace_dir); - assert!(success, "odo roll minor failed on workspace"); - println!(" ✅ roll minor (workspace root only): {}", stdout.trim()); - - // Verify only workspace root was updated (default behavior) - let (stdout, _stderr, success) = run_odo(&["show"], &workspace_dir); - assert!(success, "odo show failed after workspace root increment"); - println!(" ✅ after minor bump (root only):\n{}", stdout.trim()); - - // Test EXPLICIT --workspace behavior: all members - let (stdout, stderr, success) = run_odo(&["roll", "patch", "--workspace"], &workspace_dir); - if !success { - eprintln!("--workspace error: {}", stderr); - eprintln!("--workspace stdout: {}", stdout); - } - assert!(success, "odo roll patch --workspace failed"); - println!( - " ✅ roll patch --workspace (all members): {}", - stdout.trim() - ); - - // Verify all members were updated - let (stdout, _stderr, success) = run_odo(&["show"], &workspace_dir); - assert!(success, "odo show failed after workspace-wide increment"); - println!(" ✅ after patch --workspace:\n{}", stdout.trim()); - - // Test package selection - let (stdout, stderr, success) = - run_odo(&["roll", "patch", "--package", "lib1"], &workspace_dir); - if !success { - eprintln!("Package selection error: {}", stderr); - eprintln!("Package selection stdout: {}", stdout); - } - assert!(success, "odo roll with package selection failed"); - println!(" ✅ roll patch --package lib1: {}", stdout.trim()); - - // Verify only lib1 was updated (versions should now be different) - let (stdout, _stderr, success) = run_odo(&["show"], &workspace_dir); - assert!(success, "odo show failed after selective increment"); - println!(" ✅ after selective patch:\n{}", stdout.trim()); - - // Test sync to bring them back together - let (stdout, _stderr, success) = run_odo(&["sync", "1.0.0"], &workspace_dir); - assert!(success, "odo sync failed"); - println!(" ✅ sync to 1.0.0: {}", stdout.trim()); - - // Verify sync worked - all should now be 1.0.0 - let (stdout, _stderr, success) = run_odo(&["show"], &workspace_dir); - assert!(success, "odo show failed after sync"); - println!(" ✅ after sync:\n{}", stdout.trim()); - - // Test lint - let (stdout, _stderr, success) = run_odo(&["lint"], &workspace_dir); - assert!(success, "odo lint failed"); - println!(" ✅ lint: {}", stdout.trim()); - - // ================================================================= - // Atomic Behavior Test: No partial modifications on error - // ================================================================= - - println!("\n🔒 Testing atomic behavior: no partial modifications on error..."); - - // Set up a mixed state that will cause partial failure - let (_stdout, _stderr, success) = run_odo(&["set", "1.0.2"], &workspace_dir); - assert!(success, "Failed to set workspace root to 1.0.2"); - - let (_stdout, _stderr, success) = - run_odo(&["set", "0.1.0", "--package", "lib1"], &workspace_dir); - assert!(success, "Failed to set lib1 to 0.1.0"); - - let (_stdout, _stderr, success) = - run_odo(&["set", "2.5.3", "--package", "lib2"], &workspace_dir); - assert!(success, "Failed to set lib2 to 2.5.3"); - - // Verify the mixed state - let (stdout, _stderr, success) = run_odo(&["show"], &workspace_dir); - assert!(success, "Failed to show initial state"); - println!(" 📋 Mixed state setup:\n{}", stdout.trim()); - - // Record exact file contents before the failing operation - let workspace_toml_before = std::fs::read_to_string(workspace_dir.join("Cargo.toml")) - .expect("Failed to read workspace Cargo.toml"); - let lib1_toml_before = std::fs::read_to_string(workspace_dir.join("lib1/Cargo.toml")) - .expect("Failed to read lib1 Cargo.toml"); - let lib2_toml_before = std::fs::read_to_string(workspace_dir.join("lib2/Cargo.toml")) - .expect("Failed to read lib2 Cargo.toml"); - - // Attempt operation that should fail partway through: - // workspace-simple (1.0.2) -> 1.0.0 ✅ (would succeed) - // lib1 (0.1.0) -> 0.1.-2 ❌ (will fail - cannot decrement patch by 2) - // lib2 (2.5.3) -> 2.5.1 ✅ (would succeed, but never processed due to early error) - let (_stdout, stderr, success) = - run_odo(&["roll", "patch", "-2", "--workspace"], &workspace_dir); - - // Verify the operation failed with expected error - assert!(!success, "Expected operation to fail, but it succeeded"); - assert!( - stderr.contains("Cannot decrement patch version by 2 from 0.1.0"), - "Expected specific error message about lib1, got: {}", - stderr - ); - println!(" ❌ Expected error occurred: {}", stderr.trim()); - - // CRITICAL: Verify NO files were modified despite the error - let workspace_toml_after = std::fs::read_to_string(workspace_dir.join("Cargo.toml")) - .expect("Failed to read workspace Cargo.toml after error"); - let lib1_toml_after = std::fs::read_to_string(workspace_dir.join("lib1/Cargo.toml")) - .expect("Failed to read lib1 Cargo.toml after error"); - let lib2_toml_after = std::fs::read_to_string(workspace_dir.join("lib2/Cargo.toml")) - .expect("Failed to read lib2 Cargo.toml after error"); - - assert_eq!( - workspace_toml_before, workspace_toml_after, - "Workspace Cargo.toml was modified despite operation failure!" - ); - assert_eq!( - lib1_toml_before, lib1_toml_after, - "lib1 Cargo.toml was modified despite operation failure!" - ); - assert_eq!( - lib2_toml_before, lib2_toml_after, - "lib2 Cargo.toml was modified despite operation failure!" - ); - - // Double-check versions are unchanged - let (stdout, _stderr, success) = run_odo(&["show"], &workspace_dir); - assert!(success, "Failed to show state after error"); - assert!( - stdout.contains("workspace-simple 1.0.2"), - "workspace-simple version changed!" - ); - assert!(stdout.contains("lib1 0.1.0"), "lib1 version changed!"); - assert!(stdout.contains("lib2 2.5.3"), "lib2 version changed!"); - - println!(" ✅ ATOMIC BEHAVIOR VERIFIED: No files modified on error"); - println!(" ✅ This ensures users never get partially-modified workspaces"); - - // Reset to clean state for inheritance tests - let (_stdout, _stderr, success) = run_odo(&["sync", "1.0.0"], &workspace_dir); - assert!(success, "Failed to reset workspace to clean state"); - - // ================================================================= - // Workspace Inheritance Tests - // ================================================================= - - let inheritance_dir = test_fixtures_dir().join("workspace-inheritance"); - assert!( - inheritance_dir.exists(), - "workspace-inheritance fixture not found. Run 'make workspace-inheritance'" - ); - - println!("\n🔗 Testing workspace inheritance..."); - - // Test show with inheritance - let (stdout, _stderr, success) = run_odo(&["show"], &inheritance_dir); - assert!(success, "odo show failed on inheritance workspace"); - assert!( - stdout.contains("member1"), - "Expected member1 in inheritance output" - ); - assert!( - stdout.contains("member2"), - "Expected member2 in inheritance output" - ); - println!(" ✅ inheritance show:\n{}", stdout.trim()); - - // Test version operations with inheritance - let (stdout, _stderr, success) = run_odo(&["set", "2.0.0"], &inheritance_dir); - assert!(success, "odo set failed on inheritance workspace"); - println!(" ✅ set 2.0.0 with inheritance: {}", stdout.trim()); - - // Verify inheritance is handled correctly - let (stdout, _stderr, success) = run_odo(&["show"], &inheritance_dir); - assert!( - success, - "odo show failed after setting version with inheritance" - ); - println!(" ✅ after setting version:\n{}", stdout.trim()); - - println!("\n🎉 All integration tests passed! Odometer is working correctly."); - } -}