From 22f8a0b97723dc797681945060fdb850ece2b863 Mon Sep 17 00:00:00 2001 From: James Harrison Date: Fri, 27 Mar 2026 01:11:00 +0100 Subject: [PATCH 1/2] feat: add full test suite, library target, and CI pipeline improvements --- .DS_Store | Bin 0 -> 6148 bytes .github/workflows/ci.yml | 44 ++- Cargo.toml | 8 + README.md | 713 ++++++++++++++++++++------------------- src/lib.rs | 10 + tests/cache_tests.rs | 114 +++++++ tests/config_tests.rs | 80 +++++ tests/errors_tests.rs | 100 ++++++ tests/executor_tests.rs | 116 +++++++ tests/github_tests.rs | 387 +++++++++++++++++++++ tests/pipeline_tests.rs | 397 ++++++++++++++++++++++ tests/reporter_tests.rs | 200 +++++++++++ tests/scheduler_tests.rs | 194 +++++++++++ 13 files changed, 2022 insertions(+), 341 deletions(-) create mode 100644 .DS_Store create mode 100644 src/lib.rs create mode 100644 tests/cache_tests.rs create mode 100644 tests/config_tests.rs create mode 100644 tests/errors_tests.rs create mode 100644 tests/executor_tests.rs create mode 100644 tests/github_tests.rs create mode 100644 tests/pipeline_tests.rs create mode 100644 tests/reporter_tests.rs create mode 100644 tests/scheduler_tests.rs diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 **Note:** Rusty Orchestrator only runs commands you define. If your project needs packages installed, declare it as a task (as shown above) — nothing is auto-installed. + +--- + +## CLI reference + +### `run` — execute a pipeline + +```bash +rustyochestrator run [--concurrency ] [--no-tui] +``` ```bash -# Execute a pipeline rustyochestrator run pipeline.yaml -rustyochestrator run pipeline.yaml --concurrency 4 # limit worker count -rustyochestrator run .github/workflows/ci.yml # GitHub Actions format +rustyochestrator run pipeline.yaml --concurrency 4 # limit worker count +rustyochestrator run .github/workflows/ci.yml # GitHub Actions format +rustyochestrator run pipeline.yaml --no-tui # force plain log output +RUST_LOG=debug rustyochestrator run pipeline.yaml # verbose debug logging +``` -# Validate without running -rustyochestrator validate pipeline.yaml +When stdout is a TTY, the live TUI dashboard is shown automatically: -# Show execution order by stage -rustyochestrator list pipeline.yaml +``` +rustyochestrator — pipeline.yaml elapsed 00:00:12 -# Print the dependency graph -rustyochestrator graph pipeline.yaml + ✓ toolchain 0.8s [cached] + ✓ fmt 1.2s + ⠸ clippy 12s [running] + ⠸ build-debug 9s [running] + ◌ test [waiting] + ◌ build-release [waiting] + ◌ smoke-test [waiting] -# Inspect the local cache -rustyochestrator cache show + ████████░░░░░░░░░░░░░░░░ 2/7 2 done 2 running 3 pending 0 failed +``` -# Clear the local cache (forces full re-run next time) -rustyochestrator cache clean +In non-TTY environments (CI runners, `| tee`, `> file`) the TUI is suppressed automatically and plain log output is used. Use `--no-tui` to force plain output locally. -# Scaffold a new pipeline.yaml -rustyochestrator init -rustyochestrator init my-pipeline.yaml # custom filename +--- + +### `run-all` — run all workflows in a directory -# Run all workflows in a directory simultaneously +Discovers every `.yml` and `.yaml` file in the given directory and runs them all concurrently — just like GitHub Actions fires multiple workflow files in parallel. Each workflow's output is prefixed with its filename. + +```bash +rustyochestrator run-all [--concurrency ] +``` + +```bash rustyochestrator run-all .github/workflows -rustyochestrator run-all ./my-pipelines --concurrency 4 +rustyochestrator run-all ./my-pipelines +rustyochestrator run-all examples --concurrency 2 +``` + +Example output with two workflows running simultaneously: -# Debug logging -RUST_LOG=debug rustyochestrator run pipeline.yaml +``` +INFO running workflows simultaneously count=2 dir=.github/workflows +[ci] Starting task: lint__cargo_fmt___check +[release] Starting task: build__Install_cross_... +[ci] Completed task: lint__cargo_fmt___check +[release] Completed task: build__Install_cross_... ``` -### Developing from source +- All pipelines are validated before any execution starts — parse errors surface immediately +- Each workflow runs its own independent DAG scheduler with its own cache +- Exit code is non-zero if any workflow fails +- Works with both native pipeline format and GitHub Actions format files in the same directory -Clone the repo and use `cargo run` in place of the installed binary: +--- + +### `validate` — check without running + +Parses the file, resolves dependencies, checks for cycles. Exits non-zero on any error. ```bash -git clone https://github.com/yourname/rusty -cd rusty +rustyochestrator validate pipeline.yaml +``` -cargo run -- run examples/pipeline.yaml -cargo run -- run examples/pipeline.yaml --concurrency 2 -cargo run -- validate examples/pipeline.yaml -cargo run -- list examples/pipeline.yaml -cargo run -- graph examples/pipeline.yaml -cargo run -- cache show -cargo run -- cache clean -cargo run -- init -cargo run -- run-all .github/workflows +``` + 3 tasks + [ok] build + [ok] test (needs: build) + [ok] deploy (needs: test) -# Build and run the release binary directly -cargo build --release -./target/release/rustyochestrator run examples/pipeline.yaml +pipeline 'pipeline.yaml' is valid. ``` --- -### Manage the connection +### `list` — show execution order + +Groups tasks into parallel stages so you can see exactly what runs when. ```bash -rustyochestrator status # show connected dashboard and user -rustyochestrator disconnect # remove connection +rustyochestrator list pipeline.yaml ``` ---- +``` +Execution order for 'pipeline.yaml': + + Stage 0 — 2 task(s) run in parallel: + 1. toolchain + 2. fmt -## Language support + Stage 1 — 2 task(s) run in parallel: + 3. clippy (after: fmt) + 4. build-debug (after: fmt) -rustyochestrator is **completely language-agnostic**. The `command` field runs anything your shell can execute. + Stage 2 — 1 task(s) run in parallel: + 5. test (after: build-debug, clippy) +``` -### Node.js / npm +--- -```yaml -tasks: - - id: install - command: "npm install" +### `graph` — ASCII dependency graph - - id: lint - command: "npm run lint" - depends_on: [install] +```bash +rustyochestrator graph pipeline.yaml +``` - - id: test - command: "npm test" - depends_on: [install] +``` +Dependency graph for 'pipeline.yaml': - - id: build - command: "npm run build" - depends_on: [lint, test] + Stage 0 (no deps): + toolchain + fmt + + Stage 1: + clippy ◄── [fmt] + build-debug ◄── [fmt] + + Stage 2: + test ◄── [build-debug, clippy] ``` -### Python +--- -```yaml -tasks: - - id: install - command: "pip install -r requirements.txt" +### `cache show` — inspect the cache - - id: lint - command: "flake8 src/" - depends_on: [install] +```bash +rustyochestrator cache show +``` - - id: test - command: "pytest tests/" - depends_on: [install] +``` + task status hash + ───────────────────────────────────────── + build ok b3d10802f5217f42 + clippy ok 9eeaf9f8bc055df3 + fmt ok 810e75f0d3dee10e + test ok 257080d6e3e17348 - - id: docker-build - command: "docker build -t myapp:latest ." - depends_on: [lint, test] + 4 cached task(s). ``` -### Terraform / infrastructure +--- -```yaml -tasks: - - id: tf-init - command: "terraform init" +### `cache clean` — clear the cache - - id: tf-plan - command: "terraform plan -out=plan.tfplan" - depends_on: [tf-init] +Forces every task to re-run on the next `run`. - - id: tf-apply - command: "terraform apply plan.tfplan" - depends_on: [tf-plan] +```bash +rustyochestrator cache clean +# Cache cleared. ``` -### Mixed stack +--- -```yaml -tasks: - - id: backend-test - command: "cargo test" +### `init` — scaffold a new pipeline - - id: frontend-test - command: "npm test" +Creates a starter `pipeline.yaml` (or a custom filename) in the current directory. - - id: build-image - command: "docker build -t myapp ." - depends_on: [backend-test, frontend-test] +```bash +rustyochestrator init # creates pipeline.yaml +rustyochestrator init my-pipeline.yaml # custom filename +``` - - id: push-image - command: "docker push myapp:latest" - depends_on: [build-image] +--- + +### `connect` — link to dashhy dashboard + +Stream live pipeline events to a hosted monitoring UI. + +```bash +rustyochestrator connect --token --url ``` -Any command that runs in `sh -c` works — shell scripts, Python scripts, Makefiles, Docker, cloud CLIs, or anything else. +Saves the connection to `~/.rustyochestrator/connect.json`. All subsequent `run` commands will report live to the dashboard. ---- +### `disconnect` — remove dashboard connection -## Features +```bash +rustyochestrator disconnect +``` + +### `status` — show connection status -- **Parallel execution** — worker pool backed by Tokio; concurrency defaults to the number of logical CPUs -- **DAG scheduling** — dependencies are resolved at runtime; tasks run as soon as their deps finish -- **Content-addressable cache** — each task is hashed by its command + dependency IDs + env; unchanged tasks are skipped instantly -- **GitHub Actions compatibility** — parse and run `.github/workflows/*.yml` files directly -- **Parallel workflow execution** — `run-all` runs every workflow file in a directory simultaneously, with each workflow's output prefixed by its name -- **Live TUI dashboard** — colour-coded per-task progress view with spinners, elapsed time, and a summary bar; auto-detects TTY and falls back to plain log output in CI -- **Environment variables & secrets** — declare `env:` at pipeline or task level; reference shell secrets with `${{ secrets.NAME }}`; missing secrets abort before execution starts -- **Retry logic** — failed tasks are retried up to 2 times before being marked failed -- **Failure propagation** — when a task fails its entire transitive dependent subtree is cancelled immediately -- **Real-time output** — stdout and stderr from every task are streamed line-by-line as they run -- **Cycle detection** — circular dependencies are caught before execution starts -- **Live dashboard** — optional dashhy integration streams pipeline events to a hosted monitoring UI +```bash +rustyochestrator status +# Connected +# Dashboard : https://your-dashhy.vercel.app +# User : @your-github-username +``` --- ## Pipeline format -### Native YAML +### Task fields ```yaml tasks: @@ -292,14 +406,12 @@ tasks: depends_on: [lint, test] ``` -**Task fields:** - -| Field | Required | Description | -| ------------ | -------- | --------------------------------------------------- | -| `id` | yes | Unique identifier for the task | -| `command` | yes | Shell command to run (executed via `sh -c`) | -| `depends_on` | no | List of task IDs that must succeed first | -| `env` | no | Map of environment variables for this task | +| Field | Required | Description | +| --- | --- | --- | +| `id` | yes | Unique identifier for the task | +| `command` | yes | Shell command to run (executed via `sh -c`) | +| `depends_on` | no | List of task IDs that must succeed before this task starts | +| `env` | no | Map of environment variables scoped to this task | Multi-line commands work with YAML block scalars: @@ -313,9 +425,11 @@ tasks: depends_on: [build] ``` +--- + ### Environment variables & secrets -Declare environment variables at the pipeline level (applied to every task) or at the task level (overrides pipeline-level values for that task only). +Declare `env:` at the pipeline level (applied to every task) or at the task level (overrides the pipeline-level value for that task only). ```yaml env: @@ -330,18 +444,18 @@ tasks: command: "npm run deploy" env: API_URL: https://staging.example.com # overrides pipeline-level value - API_KEY: "${{ secrets.DEPLOY_KEY }}" # resolved from shell env at runtime + API_KEY: "${{ secrets.DEPLOY_KEY }}" # read from shell environment at runtime ``` -**Secret references** use the `${{ secrets.NAME }}` syntax. At runtime, `rustyochestrator` reads `NAME` from the current shell environment and passes it to the task process — the value is never written to disk. +**Secret references** use `${{ secrets.NAME }}` syntax. At runtime, the value is read from the current shell environment and passed to the task process — it is never written to disk. -**Pre-flight validation**: all secrets are resolved before any task starts. If a referenced secret is missing, the run aborts immediately with a clear error: +**Pre-flight validation:** all secrets are resolved before any task starts. If a referenced secret is missing, the run aborts immediately: ``` Error: secret 'DEPLOY_KEY' referenced by env key 'API_KEY' in task 'deploy' is not set in the environment ``` -**Debug logging** prints env keys when `RUST_LOG=debug` is set. Values for keys containing `SECRET`, `TOKEN`, `KEY`, or `PASSWORD` (case-insensitive) are redacted as `***`. +**Automatic redaction:** debug logging (`RUST_LOG=debug`) prints env keys but redacts values whose key contains `SECRET`, `TOKEN`, `KEY`, or `PASSWORD` (case-insensitive): ```bash RUST_LOG=debug rustyochestrator run pipeline.yaml @@ -349,13 +463,13 @@ RUST_LOG=debug rustyochestrator run pipeline.yaml # DEBUG task=deploy key=API_KEY value=*** ``` -**Cache invalidation**: changing any env value (including secrets) invalidates the task's cache hash, forcing a re-run. +**Cache invalidation:** changing any env value (including secrets) invalidates the task's cache hash, forcing a re-run. -In GitHub Actions workflow files, `env:` blocks at the workflow, job, and step levels are all parsed and merged. Plain values and `${{ secrets.NAME }}` references are forwarded; other `${{ }}` expressions (matrix variables, context references) are silently dropped since they require a real Actions runner. +--- ### GitHub Actions format -Rusty can run GitHub Actions workflow files directly — useful for local testing before pushing. +Rusty Orchestrator can run GitHub Actions workflow files directly — useful for local testing before pushing: ```yaml # .github/workflows/ci.yml @@ -378,255 +492,178 @@ jobs: rustyochestrator run .github/workflows/ci.yml ``` -Mapping rules: +**Mapping rules:** - Each `run:` step becomes one task -- Steps within a job are sequential -- `needs:` wires the first step of a job to the last step of each required job -- `uses:` steps (actions) are silently skipped +- Steps within a job run sequentially +- `needs:` wires the first step of a downstream job to the last step of each required job +- `uses:` steps (third-party actions) are silently skipped +- `env:` blocks at the workflow, job, and step levels are parsed and merged +- `${{ secrets.NAME }}` references are forwarded; other `${{ }}` expressions are dropped (they require a real Actions runner) --- -## CLI reference +## Language examples -### `run` — execute a pipeline +Rusty Orchestrator is **completely language-agnostic**. The `command` field runs anything your shell can execute. -```bash -rustyochestrator run [--concurrency ] [--no-tui] -``` - -```bash -rustyochestrator run pipeline.yaml -rustyochestrator run pipeline.yaml --concurrency 4 -rustyochestrator run .github/workflows/ci.yml # GitHub Actions format -rustyochestrator run pipeline.yaml --no-tui # force plain log output -RUST_LOG=debug rustyochestrator run pipeline.yaml # verbose logging -``` +### Node.js / npm -When stdout is a TTY the TUI dashboard is shown automatically: +```yaml +tasks: + - id: install + command: "npm install" -``` -rustyochestrator — pipeline.yaml elapsed 00:00:12 + - id: lint + command: "npm run lint" + depends_on: [install] - ✓ toolchain 0.8s [cached] - ✓ fmt 1.2s - ⠸ clippy 12s [running] - ⠸ build-debug 9s [running] - ◌ test [waiting] - ◌ build-release [waiting] - ◌ smoke-test [waiting] + - id: test + command: "npm test" + depends_on: [install] - ████████░░░░░░░░░░░░░░░░ 2/7 2 done 2 running 3 pending 0 failed + - id: build + command: "npm run build" + depends_on: [lint, test] ``` -Use `--no-tui` to force plain scrolling output (e.g. when piping to a file or running in CI without a pseudo-TTY). - -In non-TTY environments (CI, `| tee`, `> file`) the dashboard is suppressed automatically and the plain log format is used instead. - ---- - -### `run-all` — run all workflows in a directory simultaneously - -Discovers every `.yml` and `.yaml` file in the given directory, loads them all, then runs them concurrently — just like GitHub Actions fires multiple workflow files in parallel. Each workflow's output is prefixed with its filename so interleaved logs are always identifiable. +### Python -```bash -rustyochestrator run-all [--concurrency ] -``` +```yaml +tasks: + - id: install + command: "pip install -r requirements.txt" -```bash -rustyochestrator run-all .github/workflows # default directory -rustyochestrator run-all ./pipelines # any folder -rustyochestrator run-all examples --concurrency 2 # limit workers per workflow -``` + - id: lint + command: "flake8 src/" + depends_on: [install] -Example output with two workflows running simultaneously: + - id: test + command: "pytest tests/" + depends_on: [install] -``` -INFO running workflows simultaneously count=2 dir=.github/workflows -INFO loaded workflow=ci tasks=7 -INFO loaded workflow=release tasks=7 -[ci] [INFO] Starting task: lint__cargo_fmt___check -[release] [INFO] Starting task: build__Install_cross_... -[ci] [lint__cargo_fmt___check] ... -[release] [build__Install_cross_...|err] Compiling libc v0.2.183 -[ci] [INFO] Completed task: lint__cargo_fmt___check -[release] [INFO] Completed task: build__Install_cross_... + - id: docker-build + command: "docker build -t myapp:latest ." + depends_on: [lint, test] ``` -- All pipelines are validated before any execution starts — parse errors surface immediately -- Each workflow runs its own independent DAG scheduler with its own cache -- Exit code is non-zero if any workflow fails; the failing workflow name is reported -- Works with both native pipeline format and GitHub Actions format files in the same directory - ---- - -### `validate` — check without running - -Parses the file, checks for missing dependencies and cycles, prints each task and its deps. Exits non-zero on any error. +### Terraform / infrastructure -```bash -rustyochestrator validate pipeline.yaml -``` +```yaml +tasks: + - id: tf-init + command: "terraform init" -``` - 7 tasks - [ok] build - [ok] test (needs: build) - [ok] deploy (needs: test) + - id: tf-plan + command: "terraform plan -out=plan.tfplan" + depends_on: [tf-init] -pipeline 'pipeline.yaml' is valid. + - id: tf-apply + command: "terraform apply plan.tfplan" + depends_on: [tf-plan] ``` ---- +### Mixed stack -### `list` — show execution order +```yaml +tasks: + - id: backend-test + command: "cargo test" -Groups tasks into parallel stages so you can see exactly what runs when. + - id: frontend-test + command: "npm test" -```bash -rustyochestrator list pipeline.yaml -``` + - id: build-image + command: "docker build -t myapp ." + depends_on: [backend-test, frontend-test] + - id: push-image + command: "docker push myapp:latest" + depends_on: [build-image] ``` -Execution order for 'pipeline.yaml': - - Stage 0 — 2 task(s) run in parallel: - 1. toolchain - 2. fmt - Stage 1 — 2 task(s) run in parallel: - 3. clippy (after: fmt) - 4. build-debug (after: fmt) - - Stage 2 — 1 task(s) run in parallel: - 5. test (after: build-debug, clippy) -``` +Any command that runs in `sh -c` works — shell scripts, Python scripts, Makefiles, Docker, cloud CLIs, or anything else. --- -### `graph` — ASCII dependency graph - -```bash -rustyochestrator graph pipeline.yaml -``` - -``` -Dependency graph for 'pipeline.yaml': - - Stage 0 (no deps): - toolchain - fmt - - Stage 1: - clippy ◄── [fmt] - build-debug ◄── [fmt] - - Stage 2: - test ◄── [build-debug, clippy] -``` - ---- +## Caching -### `cache show` — inspect the cache +Cache entries are stored in `.rustyochestrator/cache.json`. -```bash -rustyochestrator cache show -``` +A task is a **cache hit** when: -``` - task status hash - ------------------------------------------------------------------------ - build ok b3d10802f5217f42 - clippy ok 9eeaf9f8bc055df3 - fmt ok 810e75f0d3dee10e - test ok 257080d6e3e17348 +1. Its SHA-256 hash of `command + dependency IDs + env key/value pairs` matches the stored entry +2. The previous run recorded `success: true` - 4 cached task(s). +```json +{ + "entries": { + "build": { + "hash": "a3f1c2...", + "success": true + } + } +} ``` ---- - -### `cache clean` — clear the cache - -Forces every task to re-run on the next `rustyochestrator run`. +To force a full re-run, clear the cache: ```bash rustyochestrator cache clean -# Cache cleared. +# or +rm -rf .rustyochestrator ``` --- -### `init` — scaffold a new pipeline - -Creates a starter `pipeline.yaml` (or a custom filename) in the current directory. +## Features -```bash -rustyochestrator init # creates pipeline.yaml -rustyochestrator init my-pipeline.yaml # custom filename -``` +| Feature | Description | +| --- | --- | +| **Parallel execution** | Worker pool backed by Tokio; concurrency defaults to the number of logical CPUs | +| **DAG scheduling** | Dependencies resolved at runtime; tasks start as soon as their deps finish | +| **Content-addressable cache** | Tasks hashed by command + deps + env; unchanged tasks skipped instantly | +| **GitHub Actions compatibility** | Parse and run `.github/workflows/*.yml` files directly | +| **Parallel workflow execution** | `run-all` runs every workflow file in a directory simultaneously | +| **Live TUI dashboard** | Colour-coded per-task progress with spinners, elapsed time, and a summary bar | +| **CI-friendly output** | Auto-detects non-TTY environments and falls back to plain log output | +| **Environment variables & secrets** | Declare `env:` at pipeline or task level; secret refs resolved from shell env | +| **Pre-flight secret validation** | All secrets validated before execution starts; missing secrets abort immediately | +| **Retry logic** | Failed tasks are retried up to 2 times before being marked failed | +| **Failure propagation** | When a task fails, its entire transitive dependent subtree is cancelled | +| **Real-time output streaming** | Stdout and stderr from every task streamed line-by-line as they run | +| **Cycle detection** | Circular dependencies caught and reported before execution starts | +| **Dashboard integration** | Optional dashhy integration streams pipeline events to a hosted monitoring UI | --- -### `connect` — link to dashhy dashboard +## Contributing -```bash -rustyochestrator connect --token --url -``` - -Saves the connection to `~/.rustyochestrator/connect.json`. All subsequent `run` commands will report live to the dashboard. - ---- - -### `disconnect` — remove dashboard connection +Contributions are welcome. To get started: ```bash -rustyochestrator disconnect +git clone https://github.com/KodeSage/rustyochestrator +cd rustyochestrator +cargo build +cargo test ``` ---- - -### `status` — show connection status +Use `cargo run -- ` in place of the installed binary during development: ```bash -rustyochestrator status -# Connected -# Dashboard : https://your-dashhy.vercel.app -# User : @your-github-username -``` - ---- - -## Caching - -Cache entries are stored in `.rustyochestrator/cache.json`. - -A task is a **cache hit** when: - -1. Its SHA-256 hash (of `command + dependency IDs + env key/value pairs`) matches the stored entry -2. The previous run recorded `success: true` - -```json -{ - "entries": { - "build": { - "hash": "a3f1c2...", - "success": true - } - } -} +cargo run -- run examples/pipeline.yaml +cargo run -- validate examples/pipeline.yaml +cargo run -- list examples/pipeline.yaml +cargo run -- graph examples/pipeline.yaml +cargo run -- cache show +cargo run -- init +cargo run -- run-all .github/workflows ``` -To force a full re-run, delete the cache: - -```bash -rustyochestrator cache clean -# or -rm -rf .rustyochestrator -``` +Please open an issue before submitting large changes so we can align on the approach. --- ## License -MIT +MIT — see [LICENSE](LICENSE). diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4c25c6f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +pub mod cache; +pub mod cli; +pub mod config; +pub mod errors; +pub mod executor; +pub mod github; +pub mod pipeline; +pub mod reporter; +pub mod scheduler; +pub mod tui; diff --git a/tests/cache_tests.rs b/tests/cache_tests.rs new file mode 100644 index 0000000..76ec549 --- /dev/null +++ b/tests/cache_tests.rs @@ -0,0 +1,114 @@ +use rustyochestrator::cache::{Cache, CacheEntry}; + +#[test] +fn test_default_cache_is_empty() { + let cache = Cache::default(); + assert!(cache.entries.is_empty()); +} + +#[test] +fn test_is_hit_unknown_task_returns_false() { + let cache = Cache::default(); + assert!(!cache.is_hit("unknown", "anyhash")); +} + +#[test] +fn test_is_hit_wrong_hash_returns_false() { + let mut cache = Cache::default(); + cache.record("task1".to_string(), "correct_hash".to_string(), true); + assert!(!cache.is_hit("task1", "wrong_hash")); +} + +#[test] +fn test_is_hit_failed_task_with_matching_hash_returns_false() { + let mut cache = Cache::default(); + cache.record("task1".to_string(), "hash123".to_string(), false); + assert!(!cache.is_hit("task1", "hash123")); +} + +#[test] +fn test_is_hit_successful_task_with_matching_hash_returns_true() { + let mut cache = Cache::default(); + cache.record("task1".to_string(), "hash123".to_string(), true); + assert!(cache.is_hit("task1", "hash123")); +} + +#[test] +fn test_record_overwrites_failed_with_success() { + let mut cache = Cache::default(); + cache.record("task1".to_string(), "hash123".to_string(), false); + assert!(!cache.is_hit("task1", "hash123")); + cache.record("task1".to_string(), "hash123".to_string(), true); + assert!(cache.is_hit("task1", "hash123")); +} + +#[test] +fn test_record_overwrites_hash() { + let mut cache = Cache::default(); + cache.record("task1".to_string(), "old_hash".to_string(), true); + cache.record("task1".to_string(), "new_hash".to_string(), true); + assert!(!cache.is_hit("task1", "old_hash")); + assert!(cache.is_hit("task1", "new_hash")); +} + +#[test] +fn test_record_multiple_tasks() { + let mut cache = Cache::default(); + cache.record("task1".to_string(), "hash1".to_string(), true); + cache.record("task2".to_string(), "hash2".to_string(), true); + cache.record("task3".to_string(), "hash3".to_string(), false); + + assert!(cache.is_hit("task1", "hash1")); + assert!(cache.is_hit("task2", "hash2")); + assert!(!cache.is_hit("task3", "hash3")); + assert_eq!(cache.entries.len(), 3); +} + +#[test] +fn test_cache_json_round_trip() { + let mut cache = Cache::default(); + cache.record("build".to_string(), "abc123".to_string(), true); + cache.record("test".to_string(), "def456".to_string(), false); + + let json = serde_json::to_string(&cache).unwrap(); + let restored: Cache = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.entries.len(), 2); + assert!(restored.is_hit("build", "abc123")); + assert!(!restored.is_hit("test", "def456")); +} + +#[test] +fn test_cache_entry_fields() { + let entry = CacheEntry { + hash: "abc123".to_string(), + success: true, + }; + assert_eq!(entry.hash, "abc123"); + assert!(entry.success); +} + +#[test] +fn test_cache_entry_failed() { + let entry = CacheEntry { + hash: "abc123".to_string(), + success: false, + }; + assert!(!entry.success); +} + +#[test] +fn test_is_hit_prefix_of_hash_is_not_a_match() { + let mut cache = Cache::default(); + let full_hash = "a".repeat(64); + cache.record("task".to_string(), full_hash.clone(), true); + let prefix = &full_hash[..32]; + assert!(!cache.is_hit("task", prefix)); +} + +#[test] +fn test_cache_json_contains_entries_key() { + let cache = Cache::default(); + let json = serde_json::to_string(&cache).unwrap(); + assert!(json.contains("entries")); +} diff --git a/tests/config_tests.rs b/tests/config_tests.rs new file mode 100644 index 0000000..0f7f174 --- /dev/null +++ b/tests/config_tests.rs @@ -0,0 +1,80 @@ +use rustyochestrator::config::ConnectConfig; + +#[test] +fn test_connect_config_json_round_trip() { + let config = ConnectConfig { + dashboard_url: "https://example.com".to_string(), + token: "my-token-123".to_string(), + user_login: "testuser".to_string(), + }; + let json = serde_json::to_string(&config).unwrap(); + let restored: ConnectConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.dashboard_url, "https://example.com"); + assert_eq!(restored.token, "my-token-123"); + assert_eq!(restored.user_login, "testuser"); +} + +#[test] +fn test_connect_config_pretty_json_contains_expected_keys() { + let config = ConnectConfig { + dashboard_url: "https://dashhy.vercel.app".to_string(), + token: "eyJhbGciOiJIUzI1NiJ9".to_string(), + user_login: "alice".to_string(), + }; + let json = serde_json::to_string_pretty(&config).unwrap(); + assert!(json.contains("dashboard_url")); + assert!(json.contains("dashhy.vercel.app")); + assert!(json.contains("user_login")); + assert!(json.contains("alice")); + assert!(json.contains("token")); +} + +#[test] +fn test_connect_config_clone_is_equal() { + let config = ConnectConfig { + dashboard_url: "https://example.com".to_string(), + token: "tok".to_string(), + user_login: "user".to_string(), + }; + let cloned = config.clone(); + assert_eq!(cloned.dashboard_url, config.dashboard_url); + assert_eq!(cloned.token, config.token); + assert_eq!(cloned.user_login, config.user_login); +} + +#[test] +fn test_connect_config_debug_format_includes_struct_name() { + let config = ConnectConfig { + dashboard_url: "https://example.com".to_string(), + token: "tok".to_string(), + user_login: "user".to_string(), + }; + let debug = format!("{:?}", config); + assert!(debug.contains("ConnectConfig")); +} + +#[test] +fn test_connect_config_from_json_string() { + let json = r#"{ + "dashboard_url": "https://ci.example.com", + "token": "super-secret-token", + "user_login": "devbot" + }"#; + let config: ConnectConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.dashboard_url, "https://ci.example.com"); + assert_eq!(config.token, "super-secret-token"); + assert_eq!(config.user_login, "devbot"); +} + +#[test] +fn test_connect_config_url_with_trailing_slash() { + let config = ConnectConfig { + dashboard_url: "https://example.com/".to_string(), + token: "tok".to_string(), + user_login: "user".to_string(), + }; + let json = serde_json::to_string(&config).unwrap(); + let restored: ConnectConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.dashboard_url, "https://example.com/"); +} diff --git a/tests/errors_tests.rs b/tests/errors_tests.rs new file mode 100644 index 0000000..708da8d --- /dev/null +++ b/tests/errors_tests.rs @@ -0,0 +1,100 @@ +use rustyochestrator::errors::{Result, RustyError}; +use std::io; + +#[test] +fn test_circular_dependency_error_contains_path() { + let err = RustyError::CircularDependency("a -> b -> a".to_string()); + let msg = err.to_string(); + assert!(msg.contains("a -> b -> a")); + assert!(msg.to_lowercase().contains("circular")); +} + +#[test] +fn test_missing_dependency_error_mentions_task_and_dep() { + let err = RustyError::MissingDependency { + task: "test_task".to_string(), + dep: "missing_dep".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("test_task"), "msg: {}", msg); + assert!(msg.contains("missing_dep"), "msg: {}", msg); +} + +#[test] +fn test_missing_secret_error_mentions_key_secret_and_task() { + let err = RustyError::MissingSecret { + key: "API_KEY".to_string(), + secret: "MY_SECRET_NAME".to_string(), + task: "deploy_task".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("API_KEY"), "msg: {}", msg); + assert!(msg.contains("MY_SECRET_NAME"), "msg: {}", msg); + assert!(msg.contains("deploy_task"), "msg: {}", msg); +} + +#[test] +fn test_io_error_converted_via_from() { + let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found"); + let err: RustyError = io_err.into(); + let msg = err.to_string(); + assert!(!msg.is_empty()); +} + +#[test] +fn test_error_debug_format_is_non_empty() { + let err = RustyError::CircularDependency("cycle".to_string()); + let debug = format!("{:?}", err); + assert!(!debug.is_empty()); + assert!(debug.contains("CircularDependency") || debug.contains("cycle")); +} + +#[test] +fn test_result_ok_variant() { + let ok: Result = Ok(42); + assert!(ok.is_ok()); + assert_eq!(ok.as_ref().ok(), Some(&42)); +} + +#[test] +fn test_result_err_variant() { + let err: Result = Err(RustyError::CircularDependency("x -> x".to_string())); + assert!(err.is_err()); +} + +#[test] +fn test_yaml_parse_error_via_from() { + let bad_yaml = "invalid: {unclosed: ["; + let result = serde_yaml::from_str::(bad_yaml); + if let Err(e) = result { + let err: RustyError = e.into(); + let msg = err.to_string(); + assert!(msg.to_lowercase().contains("yaml")); + } +} + +#[test] +fn test_json_error_via_from() { + let bad_json = "{invalid json}"; + let result = serde_json::from_str::(bad_json); + if let Err(e) = result { + let err: RustyError = e.into(); + let msg = err.to_string(); + assert!(msg.to_lowercase().contains("json") || !msg.is_empty()); + } +} + +#[test] +fn test_missing_dependency_error_mentions_not_exist() { + let err = RustyError::MissingDependency { + task: "my_task".to_string(), + dep: "ghost_dep".to_string(), + }; + let msg = err.to_string(); + // Should mention that dep does not exist + assert!( + msg.contains("does not exist") || msg.contains("missing") || msg.contains("not exist"), + "msg: {}", + msg + ); +} diff --git a/tests/executor_tests.rs b/tests/executor_tests.rs new file mode 100644 index 0000000..dc81637 --- /dev/null +++ b/tests/executor_tests.rs @@ -0,0 +1,116 @@ +use rustyochestrator::executor::execute_task; +use rustyochestrator::pipeline::Task; +use std::collections::HashMap; + +fn make_task(id: &str, command: &str) -> Task { + Task { + id: id.to_string(), + command: command.to_string(), + depends_on: vec![], + env: HashMap::new(), + hash: None, + } +} + +#[tokio::test] +async fn test_successful_echo_command_returns_true() { + let task = make_task("exec_echo", "echo hello_world"); + let result = execute_task(&task, "", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_exit_1_returns_false() { + let task = make_task("exec_fail", "exit 1"); + let result = execute_task(&task, "", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(!result.unwrap()); +} + +#[tokio::test] +async fn test_false_command_returns_false() { + let task = make_task("exec_false", "false"); + let result = execute_task(&task, "", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(!result.unwrap()); +} + +#[tokio::test] +async fn test_true_command_returns_true() { + let task = make_task("exec_true", "true"); + let result = execute_task(&task, "", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_env_variable_is_passed_to_subprocess() { + let task = make_task( + "exec_env_check", + "test \"$RUSTYTEST_EXEC_VAR\" = \"expected_value\"", + ); + let mut env = HashMap::new(); + env.insert( + "RUSTYTEST_EXEC_VAR".to_string(), + "expected_value".to_string(), + ); + let result = execute_task(&task, "", true, &env).await; + assert!(result.is_ok()); + assert!(result.unwrap(), "env var was not passed to subprocess"); +} + +#[tokio::test] +async fn test_multiple_env_variables_passed() { + let task = make_task( + "exec_multi_env", + "test \"$RUSTYTEST_A\" = alpha && test \"$RUSTYTEST_B\" = beta", + ); + let mut env = HashMap::new(); + env.insert("RUSTYTEST_A".to_string(), "alpha".to_string()); + env.insert("RUSTYTEST_B".to_string(), "beta".to_string()); + let result = execute_task(&task, "", true, &env).await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_multiline_command_all_lines_run() { + let task = make_task("exec_multi", "echo line1\necho line2\necho line3"); + let result = execute_task(&task, "", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_multiline_command_fails_if_any_line_fails() { + let task = make_task("exec_multi_fail", "echo ok\nexit 1\necho unreachable"); + let result = execute_task(&task, "", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(!result.unwrap()); +} + +#[tokio::test] +async fn test_nonzero_exit_code_returns_false() { + let task = make_task("exec_exit42", "exit 42"); + let result = execute_task(&task, "", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(!result.unwrap()); +} + +#[tokio::test] +async fn test_quiet_mode_suppresses_output() { + // quiet=true should not panic and should return result correctly + let task = make_task("exec_quiet", "echo this_should_be_quiet"); + let result = execute_task(&task, "[prefix] ", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_empty_env_map_is_fine() { + let task = make_task("exec_no_env", "echo no_env"); + let result = execute_task(&task, "", true, &HashMap::new()).await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} diff --git a/tests/github_tests.rs b/tests/github_tests.rs new file mode 100644 index 0000000..9a29d94 --- /dev/null +++ b/tests/github_tests.rs @@ -0,0 +1,387 @@ +use rustyochestrator::github::parse_github_workflow; + +#[test] +fn test_parse_simple_single_job_workflow() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: cargo build +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + assert_eq!(pipeline.tasks.len(), 1); + assert_eq!(pipeline.tasks[0].command, "cargo build"); +} + +#[test] +fn test_uses_steps_are_silently_skipped() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Build + run: cargo build +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + assert_eq!(pipeline.tasks.len(), 1); + assert_eq!(pipeline.tasks[0].command, "cargo build"); +} + +#[test] +fn test_steps_within_a_job_are_sequential() { + let yaml = r#" +on: push +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Step_A + run: echo step_a + - name: Step_B + run: echo step_b + - name: Step_C + run: echo step_c +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + assert_eq!(pipeline.tasks.len(), 3); + + let a = pipeline + .tasks + .iter() + .find(|t| t.command == "echo step_a") + .unwrap(); + let b = pipeline + .tasks + .iter() + .find(|t| t.command == "echo step_b") + .unwrap(); + let c = pipeline + .tasks + .iter() + .find(|t| t.command == "echo step_c") + .unwrap(); + + assert!(a.depends_on.is_empty()); + assert!(b.depends_on.contains(&a.id)); + assert!(c.depends_on.contains(&b.id)); +} + +#[test] +fn test_cross_job_dependency_via_needs() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: cargo build + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Test + run: cargo test +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + assert_eq!(pipeline.tasks.len(), 2); + + let build_id = pipeline + .tasks + .iter() + .find(|t| t.command == "cargo build") + .unwrap() + .id + .clone(); + let test_task = pipeline + .tasks + .iter() + .find(|t| t.command == "cargo test") + .unwrap(); + assert!(test_task.depends_on.contains(&build_id)); +} + +#[test] +fn test_needs_as_array_string() { + let yaml = r#" +on: push +jobs: + job_a: + runs-on: ubuntu-latest + steps: + - run: echo a + job_b: + runs-on: ubuntu-latest + steps: + - run: echo b + job_c: + needs: [job_a, job_b] + runs-on: ubuntu-latest + steps: + - run: echo c +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let c_task = pipeline + .tasks + .iter() + .find(|t| t.command == "echo c") + .unwrap(); + assert_eq!(c_task.depends_on.len(), 2); +} + +#[test] +fn test_needs_as_single_string() { + let yaml = r#" +on: push +jobs: + job_a: + runs-on: ubuntu-latest + steps: + - run: echo a + job_b: + needs: job_a + runs-on: ubuntu-latest + steps: + - run: echo b +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let b_task = pipeline + .tasks + .iter() + .find(|t| t.command == "echo b") + .unwrap(); + assert_eq!(b_task.depends_on.len(), 1); +} + +#[test] +fn test_secrets_env_reference_is_preserved() { + let yaml = r#" +on: push +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy + run: ./deploy.sh + env: + API_KEY: "${{ secrets.API_KEY }}" +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let task = &pipeline.tasks[0]; + assert_eq!( + task.env.get("API_KEY"), + Some(&"${{ secrets.API_KEY }}".to_string()) + ); +} + +#[test] +fn test_github_context_env_is_filtered() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: echo building + env: + PLAIN_VAR: hello + GITHUB_REF: "${{ github.ref }}" + SHA: "${{ github.sha }}" +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let task = &pipeline.tasks[0]; + assert_eq!(task.env.get("PLAIN_VAR"), Some(&"hello".to_string())); + assert!(!task.env.contains_key("GITHUB_REF")); + assert!(!task.env.contains_key("SHA")); +} + +#[test] +fn test_step_with_expression_in_run_command_is_skipped() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Matrix Step + run: echo ${{ matrix.os }} + - name: Static Step + run: cargo build +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + assert_eq!(pipeline.tasks.len(), 1); + assert_eq!(pipeline.tasks[0].command, "cargo build"); +} + +#[test] +fn test_workflow_level_env_propagated_to_tasks() { + let yaml = r#" +on: push +env: + GLOBAL_VAR: global_value +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: cargo build +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let task = &pipeline.tasks[0]; + assert_eq!( + task.env.get("GLOBAL_VAR"), + Some(&"global_value".to_string()) + ); +} + +#[test] +fn test_job_level_env_propagated_to_tasks() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + env: + JOB_VAR: job_value + steps: + - name: Build + run: cargo build +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let task = &pipeline.tasks[0]; + assert_eq!(task.env.get("JOB_VAR"), Some(&"job_value".to_string())); +} + +#[test] +fn test_step_env_overrides_job_env() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + env: + KEY: job_value + steps: + - name: Build + run: cargo build + env: + KEY: step_value +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let task = &pipeline.tasks[0]; + assert_eq!(task.env.get("KEY"), Some(&"step_value".to_string())); +} + +#[test] +fn test_step_env_overrides_workflow_env() { + let yaml = r#" +on: push +env: + KEY: workflow_value +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: cargo build + env: + KEY: step_value +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let task = &pipeline.tasks[0]; + assert_eq!(task.env.get("KEY"), Some(&"step_value".to_string())); +} + +#[test] +fn test_task_id_includes_job_and_step_name() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Run Tests + run: cargo test +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let id = &pipeline.tasks[0].id; + assert!( + id.contains("build"), + "id '{}' should contain job name 'build'", + id + ); +} + +#[test] +fn test_task_id_fallback_uses_step_index() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: cargo test +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + let id = &pipeline.tasks[0].id; + assert!(id.contains("build"), "id '{}' should contain job name", id); + assert!(id.contains("step"), "id '{}' should contain 'step'", id); +} + +#[test] +fn test_all_tasks_have_hashes_computed() { + let yaml = r#" +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: cargo build + - run: cargo test + - run: cargo clippy +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + for task in &pipeline.tasks { + let hash = task.hash.as_ref().expect("hash should be computed"); + assert_eq!(hash.len(), 64); + } +} + +#[test] +fn test_empty_jobs_map_produces_empty_pipeline() { + let yaml = r#" +on: push +jobs: {} +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + assert!(pipeline.tasks.is_empty()); +} + +#[test] +fn test_pipeline_env_is_empty_workflow_env_merged_per_task() { + let yaml = r#" +on: push +env: + GLOBAL: value +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +"#; + let pipeline = parse_github_workflow(yaml).unwrap(); + // Workflow env is merged into task env, pipeline-level env remains empty + assert!(pipeline.env.is_empty()); + assert_eq!( + pipeline.tasks[0].env.get("GLOBAL"), + Some(&"value".to_string()) + ); +} diff --git a/tests/pipeline_tests.rs b/tests/pipeline_tests.rs new file mode 100644 index 0000000..e16fd05 --- /dev/null +++ b/tests/pipeline_tests.rs @@ -0,0 +1,397 @@ +use rustyochestrator::pipeline::{Pipeline, TaskState, compute_task_hash}; +use std::collections::BTreeMap; + +// ── Parsing ────────────────────────────────────────────────────────────────── + +#[test] +fn test_parse_single_task() { + let yaml = r#" +tasks: + - id: build + command: "cargo build" +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert_eq!(pipeline.tasks.len(), 1); + assert_eq!(pipeline.tasks[0].id, "build"); + assert_eq!(pipeline.tasks[0].command, "cargo build"); + assert!(pipeline.tasks[0].depends_on.is_empty()); + assert!(pipeline.tasks[0].hash.is_some()); +} + +#[test] +fn test_parse_multiple_tasks_with_deps() { + let yaml = r#" +tasks: + - id: build + command: "cargo build" + - id: test + command: "cargo test" + depends_on: [build] + - id: deploy + command: "echo deploying" + depends_on: [test] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert_eq!(pipeline.tasks.len(), 3); + let test_task = pipeline.tasks.iter().find(|t| t.id == "test").unwrap(); + assert_eq!(test_task.depends_on, vec!["build"]); + let deploy_task = pipeline.tasks.iter().find(|t| t.id == "deploy").unwrap(); + assert_eq!(deploy_task.depends_on, vec!["test"]); +} + +#[test] +fn test_parse_pipeline_level_env() { + let yaml = r#" +env: + NODE_ENV: production + PORT: "3000" +tasks: + - id: start + command: "node app.js" +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert_eq!( + pipeline.env.get("NODE_ENV"), + Some(&"production".to_string()) + ); + assert_eq!(pipeline.env.get("PORT"), Some(&"3000".to_string())); +} + +#[test] +fn test_parse_task_level_env() { + let yaml = r#" +tasks: + - id: build + command: "cargo build" + env: + RUST_LOG: debug +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert_eq!( + pipeline.tasks[0].env.get("RUST_LOG"), + Some(&"debug".to_string()) + ); +} + +#[test] +fn test_parse_empty_tasks_list() { + let yaml = "tasks: []"; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert!(pipeline.tasks.is_empty()); +} + +#[test] +fn test_parse_invalid_yaml_returns_error() { + let yaml = "invalid: {unclosed bracket: ["; + assert!(Pipeline::from_yaml(yaml).is_err()); +} + +#[test] +fn test_parse_task_hash_is_computed() { + let yaml = r#" +tasks: + - id: build + command: "cargo build" +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let hash = pipeline.tasks[0].hash.as_ref().unwrap(); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); +} + +#[test] +fn test_parse_task_multiple_deps() { + let yaml = r#" +tasks: + - id: a + command: "echo a" + - id: b + command: "echo b" + - id: c + command: "echo c" + depends_on: [a, b] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let c = pipeline.tasks.iter().find(|t| t.id == "c").unwrap(); + assert_eq!(c.depends_on.len(), 2); + assert!(c.depends_on.contains(&"a".to_string())); + assert!(c.depends_on.contains(&"b".to_string())); +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +#[test] +fn test_validate_valid_linear_pipeline() { + let yaml = r#" +tasks: + - id: build + command: "cargo build" + - id: test + command: "cargo test" + depends_on: [build] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert!(pipeline.validate().is_ok()); +} + +#[test] +fn test_validate_valid_parallel_pipeline() { + let yaml = r#" +tasks: + - id: a + command: "echo a" + - id: b + command: "echo b" + - id: c + command: "echo c" +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert!(pipeline.validate().is_ok()); +} + +#[test] +fn test_validate_missing_dependency_returns_error() { + let yaml = r#" +tasks: + - id: test + command: "cargo test" + depends_on: [nonexistent_task] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let err = pipeline.validate().unwrap_err(); + assert!(err.to_string().contains("nonexistent_task")); +} + +#[test] +fn test_validate_circular_dependency_two_nodes() { + let yaml = r#" +tasks: + - id: a + command: "echo a" + depends_on: [b] + - id: b + command: "echo b" + depends_on: [a] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let err = pipeline.validate().unwrap_err(); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("circular")); +} + +#[test] +fn test_validate_self_dependency_returns_error() { + let yaml = r#" +tasks: + - id: a + command: "echo a" + depends_on: [a] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert!(pipeline.validate().is_err()); +} + +#[test] +fn test_validate_three_node_cycle() { + let yaml = r#" +tasks: + - id: a + command: "echo a" + depends_on: [c] + - id: b + command: "echo b" + depends_on: [a] + - id: c + command: "echo c" + depends_on: [b] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert!(pipeline.validate().is_err()); +} + +#[test] +fn test_validate_empty_pipeline() { + let yaml = "tasks: []"; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + assert!(pipeline.validate().is_ok()); +} + +// ── Levels ──────────────────────────────────────────────────────────────────── + +#[test] +fn test_levels_no_dependencies_all_in_stage_0() { + let yaml = r#" +tasks: + - id: a + command: "echo a" + - id: b + command: "echo b" + - id: c + command: "echo c" +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let levels = pipeline.levels(); + assert_eq!(levels.len(), 1); + assert_eq!(levels[0].len(), 3); +} + +#[test] +fn test_levels_linear_chain_three_stages() { + let yaml = r#" +tasks: + - id: a + command: "echo a" + - id: b + command: "echo b" + depends_on: [a] + - id: c + command: "echo c" + depends_on: [b] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let levels = pipeline.levels(); + assert_eq!(levels.len(), 3); + assert!(levels[0].contains(&"a".to_string())); + assert!(levels[1].contains(&"b".to_string())); + assert!(levels[2].contains(&"c".to_string())); +} + +#[test] +fn test_levels_diamond_shape() { + let yaml = r#" +tasks: + - id: root + command: "echo root" + - id: left + command: "echo left" + depends_on: [root] + - id: right + command: "echo right" + depends_on: [root] + - id: merge + command: "echo merge" + depends_on: [left, right] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let levels = pipeline.levels(); + assert_eq!(levels.len(), 3); + assert!(levels[0].contains(&"root".to_string())); + assert!(levels[1].contains(&"left".to_string())); + assert!(levels[1].contains(&"right".to_string())); + assert!(levels[2].contains(&"merge".to_string())); +} + +#[test] +fn test_levels_covers_all_tasks() { + let yaml = r#" +tasks: + - id: a + command: "echo a" + - id: b + command: "echo b" + depends_on: [a] + - id: c + command: "echo c" + depends_on: [a] +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let levels = pipeline.levels(); + let all: Vec<_> = levels.into_iter().flatten().collect(); + assert_eq!(all.len(), 3); + assert!(all.contains(&"a".to_string())); + assert!(all.contains(&"b".to_string())); + assert!(all.contains(&"c".to_string())); +} + +// ── Hashing ─────────────────────────────────────────────────────────────────── + +#[test] +fn test_hash_deterministic_same_inputs() { + let env: BTreeMap<&str, &str> = BTreeMap::new(); + let h1 = compute_task_hash("cargo build", &[], &env); + let h2 = compute_task_hash("cargo build", &[], &env); + assert_eq!(h1, h2); +} + +#[test] +fn test_hash_differs_on_command_change() { + let env: BTreeMap<&str, &str> = BTreeMap::new(); + let h1 = compute_task_hash("cargo build", &[], &env); + let h2 = compute_task_hash("cargo test", &[], &env); + assert_ne!(h1, h2); +} + +#[test] +fn test_hash_differs_on_deps_change() { + let env: BTreeMap<&str, &str> = BTreeMap::new(); + let h1 = compute_task_hash("echo hi", &[], &env); + let h2 = compute_task_hash("echo hi", &["dep1".to_string()], &env); + assert_ne!(h1, h2); +} + +#[test] +fn test_hash_differs_on_env_key_add() { + let env1: BTreeMap<&str, &str> = BTreeMap::new(); + let mut env2: BTreeMap<&str, &str> = BTreeMap::new(); + env2.insert("KEY", "VALUE"); + let h1 = compute_task_hash("echo hi", &[], &env1); + let h2 = compute_task_hash("echo hi", &[], &env2); + assert_ne!(h1, h2); +} + +#[test] +fn test_hash_differs_on_env_value_change() { + let mut env1: BTreeMap<&str, &str> = BTreeMap::new(); + env1.insert("KEY", "val1"); + let mut env2: BTreeMap<&str, &str> = BTreeMap::new(); + env2.insert("KEY", "val2"); + let h1 = compute_task_hash("echo hi", &[], &env1); + let h2 = compute_task_hash("echo hi", &[], &env2); + assert_ne!(h1, h2); +} + +#[test] +fn test_hash_is_64_char_hex() { + let env: BTreeMap<&str, &str> = BTreeMap::new(); + let h = compute_task_hash("cargo build", &["dep".to_string()], &env); + assert_eq!(h.len(), 64); + assert!(h.chars().all(|c| c.is_ascii_hexdigit())); +} + +#[test] +fn test_hash_dep_order_matters() { + let env: BTreeMap<&str, &str> = BTreeMap::new(); + let h1 = compute_task_hash("echo", &["a".to_string(), "b".to_string()], &env); + let h2 = compute_task_hash("echo", &["b".to_string(), "a".to_string()], &env); + // Different dep orders produce different hashes (they're fed sequentially) + assert_ne!(h1, h2); +} + +// ── TaskState ───────────────────────────────────────────────────────────────── + +#[test] +fn test_task_state_variants_are_distinct() { + let states = [ + TaskState::Pending, + TaskState::Running, + TaskState::Success, + TaskState::Failed, + TaskState::Skipped, + ]; + for (i, s1) in states.iter().enumerate() { + for (j, s2) in states.iter().enumerate() { + if i == j { + assert_eq!(s1, s2); + } else { + assert_ne!(s1, s2); + } + } + } +} + +#[test] +fn test_task_state_clone() { + let s = TaskState::Running; + let cloned = s.clone(); + assert_eq!(s, cloned); +} diff --git a/tests/reporter_tests.rs b/tests/reporter_tests.rs new file mode 100644 index 0000000..911ba1a --- /dev/null +++ b/tests/reporter_tests.rs @@ -0,0 +1,200 @@ +use rustyochestrator::reporter::{Event, PipelineCompletedArgs, Reporter}; + +#[test] +fn test_pipeline_started_event_fields() { + let event = Event::pipeline_started("pipe-abc", "my-pipeline", 7, "alice"); + match event { + Event::PipelineStarted { + pipeline_id, + pipeline_name, + total_tasks, + user_login, + started_at, + } => { + assert_eq!(pipeline_id, "pipe-abc"); + assert_eq!(pipeline_name, "my-pipeline"); + assert_eq!(total_tasks, 7); + assert_eq!(user_login, "alice"); + assert!(!started_at.is_empty()); + } + _ => panic!("expected PipelineStarted"), + } +} + +#[test] +fn test_task_completed_event_success() { + let event = Event::task_completed("pipe-abc", "build", false, 1500, true); + match event { + Event::TaskCompleted { + pipeline_id, + task_id, + cache_hit, + duration_ms, + success, + } => { + assert_eq!(pipeline_id, "pipe-abc"); + assert_eq!(task_id, "build"); + assert!(!cache_hit); + assert_eq!(duration_ms, 1500); + assert!(success); + } + _ => panic!("expected TaskCompleted"), + } +} + +#[test] +fn test_task_completed_event_cache_hit() { + let event = Event::task_completed("pipe-abc", "build", true, 0, true); + match event { + Event::TaskCompleted { + cache_hit, + duration_ms, + success, + .. + } => { + assert!(cache_hit); + assert_eq!(duration_ms, 0); + assert!(success); + } + _ => panic!("expected TaskCompleted"), + } +} + +#[test] +fn test_task_completed_event_failure() { + let event = Event::task_completed("pipe-abc", "test", false, 500, false); + match event { + Event::TaskCompleted { success, .. } => { + assert!(!success); + } + _ => panic!("expected TaskCompleted"), + } +} + +#[test] +fn test_pipeline_completed_success_status() { + let event = Event::pipeline_completed(PipelineCompletedArgs { + id: "pipe-123", + name: "ci", + success: true, + total_tasks: 5, + cached_tasks: 2, + failed_tasks: 0, + duration_ms: 12000, + user_login: "alice", + }); + match event { + Event::PipelineCompleted { + pipeline_id, + pipeline_name, + status, + total_tasks, + cached_tasks, + failed_tasks, + duration_ms, + user_login, + finished_at, + } => { + assert_eq!(pipeline_id, "pipe-123"); + assert_eq!(pipeline_name, "ci"); + assert_eq!(status, "success"); + assert_eq!(total_tasks, 5); + assert_eq!(cached_tasks, 2); + assert_eq!(failed_tasks, 0); + assert_eq!(duration_ms, 12000); + assert_eq!(user_login, "alice"); + assert!(!finished_at.is_empty()); + } + _ => panic!("expected PipelineCompleted"), + } +} + +#[test] +fn test_pipeline_completed_failed_status() { + let event = Event::pipeline_completed(PipelineCompletedArgs { + id: "pipe-456", + name: "failing", + success: false, + total_tasks: 3, + cached_tasks: 0, + failed_tasks: 2, + duration_ms: 3000, + user_login: "bob", + }); + match event { + Event::PipelineCompleted { + status, + failed_tasks, + .. + } => { + assert_eq!(status, "failed"); + assert_eq!(failed_tasks, 2); + } + _ => panic!("expected PipelineCompleted"), + } +} + +#[test] +fn test_pipeline_started_serializes_with_type_tag() { + let event = Event::pipeline_started("id", "name", 1, "user"); + let json = serde_json::to_string(&event).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["type"], "pipeline_started"); +} + +#[test] +fn test_task_completed_serializes_with_type_tag() { + let event = Event::task_completed("id", "task", false, 100, true); + let json = serde_json::to_string(&event).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["type"], "task_completed"); +} + +#[test] +fn test_pipeline_completed_serializes_with_type_tag() { + let event = Event::pipeline_completed(PipelineCompletedArgs { + id: "id", + name: "name", + success: true, + total_tasks: 1, + cached_tasks: 0, + failed_tasks: 0, + duration_ms: 100, + user_login: "user", + }); + let json = serde_json::to_string(&event).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["type"], "pipeline_completed"); +} + +#[test] +fn test_started_at_iso8601_format() { + let event = Event::pipeline_started("id", "name", 1, "user"); + if let Event::PipelineStarted { started_at, .. } = event { + // Format: YYYY-MM-DDTHH:MM:SSZ (length 20) + assert_eq!(started_at.len(), 20, "got: {}", started_at); + assert!(started_at.ends_with('Z'), "got: {}", started_at); + assert!(started_at.contains('T'), "got: {}", started_at); + assert_eq!(&started_at[4..5], "-"); + assert_eq!(&started_at[7..8], "-"); + assert_eq!(&started_at[13..14], ":"); + assert_eq!(&started_at[16..17], ":"); + } +} + +#[test] +fn test_reporter_can_be_constructed() { + let _reporter = Reporter::new("https://example.com".to_string(), "test-token".to_string()); +} + +#[test] +fn test_pipeline_started_json_has_all_required_fields() { + let event = Event::pipeline_started("pipe-1", "my-pipe", 3, "alice"); + let json = serde_json::to_string(&event).unwrap(); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(val.get("pipeline_id").is_some()); + assert!(val.get("pipeline_name").is_some()); + assert!(val.get("total_tasks").is_some()); + assert!(val.get("started_at").is_some()); + assert!(val.get("user_login").is_some()); +} diff --git a/tests/scheduler_tests.rs b/tests/scheduler_tests.rs new file mode 100644 index 0000000..aa7d3fd --- /dev/null +++ b/tests/scheduler_tests.rs @@ -0,0 +1,194 @@ +use rustyochestrator::pipeline::Pipeline; +use rustyochestrator::scheduler::Scheduler; + +fn build_scheduler(yaml: &str, workers: usize) -> Scheduler { + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + pipeline.validate().unwrap(); + Scheduler::new(pipeline, workers) +} + +#[tokio::test] +async fn test_empty_pipeline_returns_ok_true() { + let yaml = "tasks: []"; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let result = Scheduler::new(pipeline, 1).run().await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_single_successful_task() { + let yaml = r#" +tasks: + - id: sched_single_ok + command: "echo sched_single_ok" +"#; + let result = build_scheduler(yaml, 1).run().await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_single_failing_task_returns_false() { + let yaml = r#" +tasks: + - id: sched_single_fail_xyz_abc + command: "exit 1" +"#; + let result = build_scheduler(yaml, 1).run().await; + assert!(result.is_ok()); + assert!(!result.unwrap()); +} + +#[tokio::test] +async fn test_failure_propagates_to_dependent_tasks() { + let yaml = r#" +tasks: + - id: sched_fail_parent_abc + command: "exit 1" + - id: sched_skipped_child_abc + command: "echo should_not_run" + depends_on: [sched_fail_parent_abc] +"#; + let result = build_scheduler(yaml, 2).run().await; + assert!(result.is_ok()); + assert!(!result.unwrap()); +} + +#[tokio::test] +async fn test_sequential_pipeline_all_succeed() { + let yaml = r#" +tasks: + - id: sched_seq_a + command: "echo seq_a" + - id: sched_seq_b + command: "echo seq_b" + depends_on: [sched_seq_a] + - id: sched_seq_c + command: "echo seq_c" + depends_on: [sched_seq_b] +"#; + let result = build_scheduler(yaml, 1).run().await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_parallel_independent_tasks_all_succeed() { + let yaml = r#" +tasks: + - id: sched_par_a + command: "echo par_a" + - id: sched_par_b + command: "echo par_b" + - id: sched_par_c + command: "echo par_c" +"#; + let result = build_scheduler(yaml, 4).run().await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_diamond_pipeline_succeeds() { + let yaml = r#" +tasks: + - id: sched_diamond_root + command: "echo root" + - id: sched_diamond_left + command: "echo left" + depends_on: [sched_diamond_root] + - id: sched_diamond_right + command: "echo right" + depends_on: [sched_diamond_root] + - id: sched_diamond_merge + command: "echo merge" + depends_on: [sched_diamond_left, sched_diamond_right] +"#; + let result = build_scheduler(yaml, 4).run().await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_missing_secret_returns_error() { + let yaml = r#" +tasks: + - id: sched_secret_task + command: "echo secret_task" + env: + MY_KEY: "${{ secrets.RUSTYTEST_NONEXISTENT_SECRET_XYZ_999 }}" +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let result = Scheduler::new(pipeline, 1).run().await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("RUSTYTEST_NONEXISTENT_SECRET_XYZ_999") + ); +} + +#[tokio::test] +async fn test_pipeline_env_passed_to_task() { + let yaml = r#" +env: + SCHED_TEST_PIPELINE_ENV: "pipeline_value" +tasks: + - id: sched_env_check + command: "test \"$SCHED_TEST_PIPELINE_ENV\" = pipeline_value" +"#; + let result = build_scheduler(yaml, 1).run().await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_task_env_overrides_pipeline_env() { + let yaml = r#" +env: + SCHED_TEST_OVERRIDE_VAR: "pipeline" +tasks: + - id: sched_override_check + command: "test \"$SCHED_TEST_OVERRIDE_VAR\" = task" + env: + SCHED_TEST_OVERRIDE_VAR: "task" +"#; + let result = build_scheduler(yaml, 1).run().await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_with_name_does_not_break_run() { + let yaml = r#" +tasks: + - id: sched_named_task + command: "echo named" +"#; + let pipeline = Pipeline::from_yaml(yaml).unwrap(); + let result = Scheduler::new(pipeline, 1) + .with_name("custom-pipeline-name".to_string()) + .run() + .await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[tokio::test] +async fn test_concurrency_1_still_runs_all_tasks() { + let yaml = r#" +tasks: + - id: sched_c1_a + command: "echo c1_a" + - id: sched_c1_b + command: "echo c1_b" + - id: sched_c1_c + command: "echo c1_c" + depends_on: [sched_c1_a, sched_c1_b] +"#; + let result = build_scheduler(yaml, 1).run().await; + assert!(result.is_ok()); + assert!(result.unwrap()); +} From 3168592ffa47784180f45383f2daa915e4039d6c Mon Sep 17 00:00:00 2001 From: James Harrison Date: Fri, 27 Mar 2026 01:34:08 +0100 Subject: [PATCH 2/2] chores: fix tests --- .gitignore | 3 ++- src/cache.rs | 20 +++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 8921909..5b44139 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -./claude \ No newline at end of file +./claude +.DS_Store \ No newline at end of file diff --git a/src/cache.rs b/src/cache.rs index fd50992..10e2617 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -2,6 +2,7 @@ use crate::errors::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; const CACHE_DIR: &str = ".rustyochestrator"; const CACHE_FILE: &str = ".rustyochestrator/cache.json"; @@ -28,14 +29,27 @@ impl Cache { return Ok(Cache::default()); } let raw = std::fs::read_to_string(CACHE_FILE)?; - Ok(serde_json::from_str(&raw)?) + // A concurrent save may have left a partial file; treat parse errors as a cache miss + // rather than aborting the run — the cache is an optimisation, not a source of truth. + Ok(serde_json::from_str(&raw).unwrap_or_default()) } - /// Persist cache to disk. + /// Persist cache to disk using an atomic write (temp file → rename) so concurrent + /// readers never observe a truncated or partially-written file. pub fn save(&self) -> Result<()> { std::fs::create_dir_all(CACHE_DIR)?; let json = serde_json::to_string_pretty(self)?; - std::fs::write(CACHE_FILE, json)?; + // Build a unique temp path: PID + subsec_nanos avoids collisions across + // threads in the same process and across concurrent processes. + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + let tmp = format!("{}.{}-{}.tmp", CACHE_FILE, std::process::id(), nonce); + std::fs::write(&tmp, &json)?; + // rename(2) on POSIX is atomic: readers always see the old file or the new + // file, never a half-written state. + std::fs::rename(&tmp, CACHE_FILE)?; Ok(()) }